NHN Cloud Meetup 編集部
持続可能なソフトウェアのコーディング(5)べき等性
2020.03.05
650
べき等性(Idempotent)
コンピュータサイエンスや数学で使われる用語で、演算を何度適用しても結果が変わらない性質を意味します。たとえば、関数f(x)で次のような等式が成立します。つまり、メソッドが何度実行されても結果は同じなので、安全に使用できる性質でもあります。
f(f(x)) ≡ f(x)
たとえば、次のようなメソッドがべき等性を持っている場合、当該メソッドはメールアドレスからブラケットを除去する働きをします。何度適用しても、ブラケットが除去されたメールアドレスは常に同じ値を持つため、使用する立場では、何度でも安全に使用することができます。
pubic class MailAddressUtils { public static String removeBrackets(String bracketMailAddress) { if (StringUtils.isEmpty(bracketMailAddress)) return bracketMailAddress; if (bracketMailAddress.startsWith("<")) bracketMailAddress = bracketMailAddress.substring(1); if (!StringUtils.isEmpty(bracketMailAddress) && bracketMailAddress.endsWith(">") ) bracketMailAddress = bracketMailAddress.substring(0, bracketMailAddress.length() - 1); return bracketMailAddress; } } @Test public void testRemoveBrackets() { String mailAddress = "<byungboo.kim@nhn.com"; String expected = MailAddressUtils.removeBrackets(mailAddress); //3回実行 String actual = MailAddressUtils.removeBrackets( MailAddressUtils.removeBrackets( MailAddressUtils.removeBrackets( mailAddress ) ) ); // removeBrackets関数を3回実行した値と1回実行した値が同じ Assertions.assertEquals(expected, actual); }
上のtestRemoveBrackets()テストケースを見ると、removeBrackets関数を3回実行した値と、1回実行した値が等しいことがわかります。関数がべき等性を持つようにコーディングするのは、それほど難しいことではありません。
REST APIのべき等性
REST APIにおいても、べき等性は重要です。REST APIで最も多く使われるHTTPメソッドは、GET、PUT、DELETE、POSTです。このうちPOSTを除いた残りのHTTPメソッドを使用するAPI(GET、PUT、DELETE)は、このべき等性を維持する必要があります。次のような状況から、べき等性がなぜ重要なのかを確認してみましょう。
このような状況は、productIdが123である商品を削除する過程で十分に発生する可能性があります。基本的にネットワークは100%信頼してはならない媒体であると認識しましょう。最初のリクエストに対して「Server1」は通常処理を行いますが、応答がネットワークの問題により返却されませんでした。そこで、クライアントは2回目のリクエストを行いますが、べき等性が保証されていない削除REST APIは、失敗の応答を返します。結果として、サーバーではすでにその製品が削除されているにも関わらず、クライアントはproductId=123である商品を削除するのに失敗しています。このように、クライアントとサーバー間でデータが一致しない状況が発生する可能性があります。
Spring Data JPAでよくあるミス
多くの開発者がSpring Data JPAを使用していますが、このべき等性に関連したミスを気づかないうちに発生させています。次のコードから、べき等性がどのように関係するかを確認してみましょう。
@RestController public class ProductController { @Autowired private ProductRepository productRepository; @DeleteMapping("/products/{productId}") public ResponseEntity deleteProduct(@RequestParam Long productId){ // deleteByIdを覚えてください。 productRepository.deleteById(productId); return ResponseEntity.ok(); } } @Repository // JpaRepositoryインターフェースを継承しています。 public interface ProductRepository extends JpaRepository<ProductEntity, Long> { }
注意すべきは、ProductRepositoryは、org.springframework.data.jpa.repository.JpaRepositoryインタフェースを継承しているという点です。したがって、ユーザーは親が提供するdeleteById()メソッドを、簡単に使用することができます。JpaRepositoryの階層は下図のとおりです。
SpringにおけるCrudRepositoryのデフォルト実装クラスは、SimpleJpaRepositoryであり、deleteById()の実装は次のとおりです。
@Transactional public void deleteById(ID id) { Assert.notNull(id, ID_MUST_NOT_BE_NULL); // この部分に注目しましょう。 delete(findById(id).orElseThrow(() -> new EmptyResultDataAccessException( String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1))); }
deleteメソッドの内部から、findById(id). orElseThrow(()…)が見つかりましたか?最初に呼び出されると、findByIdが正常にデータを探しますが、2回目に呼び出されるとデータを見つけることができないため、Exceptionが発生します。そのため、上記のSequence Diagramのような現象が発生することがあります。このようなケースを防止するには、次のような方法で回避する必要があります。
– deleteById()メソッドを呼び出す前に、findById()のようなメソッドでデータの有無を確認する方法
テストケース
これまで持続可能なソフトウェア開発について紹介してきましたが、ここまでの内容はオブジェクト指向についての話ばかりでした。持続可能なソフトウェアには、機能拡張とエラー管理が必ず求められます。機能が増えて複雑になれば、それに比例してコード間の複雑さが増加します。先ほど説明した原則を用いてリファクタリングしても、それとエラーを管理することは別の問題です。テストケースこそが、私たちが信頼できる最後の砦となります。
通常、テストといえば、単体テスト(unit test)、統合テスト(integration test)、承認テスト(acceptance test)、回帰テスト(regression test)などが挙げられます。持続可能なソフトウェアのためには、承認テスト以外の3つのテストすべてが重要だと思われます。まず、単体テストと統合テストを区別する方法は、議論の余地がありますが、大体は次のとおりです。
- 単体テストは、開発したクラスで構成されたユースケースをテストすること
- 統合テストは、クラスだけでなく、データベースやRedisのような他コンポーネントと一緒にテストすること
単体テストはMockを使ってテスト環境を構成し、統合テストはSpringBootの機能を用いて、H2DBあるいはEmbedded Redisを利用します。この2つのテストに回帰テストがあればエラー管理が可能となります。つまり、エラーが発生した状況を構成して、結果値が正しいかどうかを検証します。このような回帰テストが蓄積されると、同じエラーが発生する確率が減少します。この他にも、回帰テストは次のような状況で役立ちます。
- assert構文が正常に動作するかを確認
- 不変クラスの状態を維持するかを確認
* 契約による設計先入条件など
* 完璧でないソフトウェアを適切に維持するのに役立つ
* リファクタリングに役立つ
テストケースを作成したら、JenkinsのようなCI/CDツールで常にテストをしましょう。もちろんパッケージ段階や配布段階でのテストは必ず行わなければなりません。
また、主なライブラリのテストケースも作成しておくとよいでしょう。バージョン別に変化が激しいJacksonライブラリや、みなさんが作成しているユーティリティ性クラスのことですね。特にユーティリティ性クラスは、必ずテストケースが必要です。複数の開発者が広範囲に使用するので、後でリファクタリングや新機能を開発する際に、信頼性のあるコードを書くことができます。
完全なソフトウェアはない
私たちがOOP開発を進めつつ、最も大変なことは、自ら完璧さを追求することではないでしょうか。
- このメソッドをどの程度抽象化すべきか?
- このクラスは、DRY原則で開発されたものか?
- このValueオブジェクトは、Class Invariant、Immutable Classか?
- シャイコーディング、デメテルの法則で開発されているか?
実用主義プログラマーで最も感銘深かったのは、適度によいソフトウェアという言葉でした。もちろん、適度とは非常に難しく、測定しにくい単語でもあります。プログラムがいくら整然とコーディングされていても、有機的に動作しなければ意味がありません。
常にリファクタリングを通して、よりよいソフトウェアへと発展させるのが現実的でしょう。メソッドを分割したり、結合させたりして、適切な抽象化の程度を探してみましょう。または変数名にメタファーを入れるのも効果的です。
実際のところ、完全なオブジェクト指向を追跡することは困難であり、場合によっては、コードの内容が非常に冗長化されることがあります。たとえば、calculateFinalPriceByVendorClassAndOrderQuantity(…)のような冗長メソッドを書いてしまったり、コード内にさまざまな表明文(assert)が飛び交っていたりすることはありませんか?さらには、メソッドを呼び出す前に引数の値を検証すべきか、メソッド内部で検証すべきか、あらゆる考えに囚われてしまうこともあります。後でリファクタリングすればよいので、自らを苦しめることなく、適切なレベルで妥協されることをお勧めします。
適度によいソフトウェアだと考えると、先ほどの長いメソッドは、次のようにコーディングすることもできますね。
@Service pubic class FinalPriceService { public FinalPrice calculate(Vendor vendor, Order order){ assertNotNull(vendor); assertNotNull(order); ///.... } }
結論
オブジェクト指向に関連した記事や本を読んでいると、共通するものが1つあります。ビジネスに係る(あるいはドメインとも表現されます)ソリューションに対して共通した言語を作成する、ということです。DDDで共通言語(ユビキタス言語)を作成することにも相通じる部分があります。共通言語があれば、開発者は簡単にユースケースを作成することができ、ユースケースを分解しながら、適切なレベルの抽象化されたクラスを設計することができます。そして、このクラスを有機的に組み合わせながら、1つの機能、すなわちユースケースが作成されます。プランナーやプロジェクトオーナーとたくさんの物語を共有する、抽象化もそこから出発するようです。
そして、最後にコードレビューを行いましょう。コードレビューによって、相互にビジネスロジックを理解することができ、お互いのコードを使って(DRYのために)、自分が作成したコードが間違っていないか(シャイコーディング、デメテルの法則)確認することができます。ぜひ、仲間たちと楽しみながらコーディングしてみてください。