NHN Cloud Meetup 編集部
持続可能なソフトウェアのコーディング DRY原則
2020.02.13
1,418
はじめに
ソフトウェアシステムのすべての関数とメソッドには、どんなものがあるでしょうか。8ビットファミコン時代には関数1つで画面に*を出力させるソフトウェアがありました。しかし、今ではRPGや商品レコメンドのような複雑な機能が提供され、1つの関数でソフトウェアを作成することができないほど、要件が複雑化しています。様々な関数やメソッドが有機的に絡み合ってソフトウェアが完成され、また複数の開発者が有機的に同時に様々な機能を開発しています。
人気のあるソフトウェアは機能がますます複雑になり、より多くの開発者が投入される傾向があります。そのため市場における開発者の需要は常に絶えることがありません。しかし実際には、プロジェクトに投入される開発者が増加しても、ソフトウェアの開発速度と機能の追加速度が比例するとは限りません。むしろ最初よりも速度が遅くなる可能性があります。それだけソフトウェアのサイズが大きくなっているからだと考えられます。さらに悪いことに、新しい機能を追加したり、バグを修正すると、他の機能で新しいバグが発生してしまうことがあります。最悪の場合、開発者がバグを管理できないことでユーザーからの信頼性を失い、最終的にそのソフトウェアは人々から忘れ去られてしまうでしょう。
そこで、ソフトウェアの信頼性を高めるために、開発者はソースコードを理解しやすく作成しようと努力します。分かりやすいコードはどこにどんな問題があるか把握しやすく、当然メンテナンスも簡単です。
コンピュータプログラミングの歴史は100年程度しかありません。世界に存在するプログラミングパラダイムは、手続き型プログラミング、オブジェクト指向プログラミング、関数型プログラミングの3つがあり、一般的に手続き型プログラミングは非効率的で、非生産的であると言われています。ソフトウェアの機能が複雑になり、その規模も大きくなって、メンテナンスや機能拡張、信頼性において、もはや役に立たないからでしょう。この記事では、いくつかの原則と事例を用いて持続可能なソフトウェアのコーディング方法を紹介したいと思います。
DRY原則
1つの機能が複数の場所に散らばっているとメンテナンスが困難です。例えば、その機能にバグがあり直したところ、別の重複コードを調整し忘れることがあるでしょう。他の人が開発したものだったり、または開発してから時間が長く経っていると他の重複コードを忘れてしまうことがあります。 このようなことから、重複コードを作らず他人が作成したもの(既に作成されたもの)を書くようにします。しかし、この原則を誤って理解すると機能までも細かく分解し、次のようにコーディングしてしまうことがあります。
- クラスがあまりにも多く作成されクラス間の依存関係が高くなっていたり、メソッドまで細かく分けてコードの理解を妨げるもの。メソッドの抽象化の程度が一定でないために発生します。
- 下記のコードでPurchaseServiceクラスのpurchaseメソッドのコードを分析すると仮定しましょう。
- purchaseメソッドの機能を分析するには、多くのメタベースを読み取り確認しなければなりません。
- 特に下記コメントにあるように、コード分析をして戻ってくると、既存のcontextが維持されていないでしょう。
public class PurchaseService { public ApiResponse purchase(Long productId){ // このメソッドを探してみよう。またここに戻ってきたらcontextは維持できるか? Product product = this.getProduct(productId); //.... ....// return ApiResponse.of(result); } private Product getProduct(Long productId){ return productService.getProduct(product); } } public class ProductService { public Product getProduct(Long productId){ Product product = getValidProduct(product); return product; } public Product getValidProduct(Long productId){ ProductEntity productEntity = productRepository.findById(productId).orElseThrow(...); this.convert(productEntity); return product; } private Product convert(ProductEntity productEntity){ Product product = new Product(); product.productId = productEntity.productId; /// get. set.. return product } }
- メソッドの名前が曖昧な場合。コードを分析したり使用するとき、常に機能に対して疑問を抱く状況に陥ります。
- 次のコードのpriceService.process()などのメソッドが代表的です。メソッド名の抽象化がひどくなっています。
public PriceEntity getPriceEntityById(Long priceId){ PriceEntity priceEntity = priceRepository.findById(priceId); // どのように処理するかメソッドクラスで内容を理解できない。 return priceService.process(priceEntity); }
- メソッド内部でパラメータの値を変更し、メソッドを使用するクラスでは、これを認知しにくい状況が発生します。
- 下記のprocess内部で、priceEntity引数の値を変更します。
public void process(PriceEntity priceEntity){ BigDecimal listPrice = priceEntity.calculateListPrice(); if (listPrice != null){ // 値の変更 priceEntity.setDiscountPrice(listPrice.multiply(...)); } /... .../ }
これらはDRY原則が悪いのでしょうか?あるいはDRY原則を放棄すべきでしょうか?そうではありません。オブジェクト指向プログラミングをする場合、DRY原則をよく守って開発する必要があります。
1つのメソッドに1つの行為を表します。したがって、あまりにも小さな単位のメソッドを作成するのは良くありません。メソッドの抽象化サイズが大きすぎても小さすぎてもいけません。メソッドの抽象化サイズは一定であることをお勧めします。特にPurchaseServiceクラスのgetProduct()などのメソッドは、実際には何もしない意味のないメソッドです。有意義な作業をするメソッドを作りましょう。そしてメソッドの機能はメソッドの名前と一致させる必要があります。もし次のようなメソッドを発見したら、皆さんはすべてのコードを疑う必要があります。
- メソッド名はupdateShippingStatus()だが、行為はshippingStatusを更新せず削除するメソッド
- メソッド名がprocess()で、何をするのか正確な行為が認識できないメソッド
- メソッド名がupdateShippingStatus()だが、配送情報を更新しながら配送情報が更新されたと通知も送信するメソッド
- 議論の余地がありますが、通知を送信する行為は別途に分離した方がよいでしょう。通知を送信する行為と配送情報を更新する行為が違うためです。
メソッド名を意味のあるもので作成すると、下記のように名前が長くなる場合があります。このような場合にはリファクタリングしましょう。
- updateShippingStatusAndSendNotificationToVendor() Q1. updateShippingStatusとsendNotificationToVendorという2つの行為をメソッド名に表現するほど重要か? A1-1. はい。(Q2に移動) A1-2. いいえ。updateShippingStatusのみが重要な行為なので、メソッド名をupdateShippingStatus()に変更する。 Q2. 両方の行為が重要であれば、行為を分離してメソッドを分離してはどうか? A2. updateShippingStatus()とsendNotificationToVendor()に分離し、この2つを呼び出すメソッドを1つ作る。
ほかにも、このような場合、ケント・ベックが提案したメタファー(隠喩)を使用する方法もあります。ケント・ベックは、クラス名を作成するときにメタファーを使用した経験を共有しています。図面フレームワークのクラスを本(book)に対するメタファーとして使用しました。例えば、図面フレームワークのDrowingObjectを本の絵にあたるFigureとして新たに命名しました。メソッド名を作成するときメタファーを使ってみてはどうでしょうか?次のようにリファクタリングしてみましょう。
- デリバリーのメタファーを使う。 - ドライバーは品物を配達した後、システムに送信完了を入力をして受信者に通知を送る。 - デリバリーのメタファーを利用して、updateShippingStatus()をdeliver()に、sendNotificationToVendor()をalertDelivered()にしてはどうか? public class PostMan() { public void afterDelivery(){ //.. package.delivered(); vendor.alertDelivered(); //.. } }
メタファーを使用するには、同僚と相互共感が形成されている必要があります。そしてその表現を使うのに非常に慣れている必要があります。自分一人が使用するメタファーはむしろ可読性を妨げます。同僚と相互に共感を得られるように、文書にメタファーを定義するのも1つの方法です。さて、名前をつけることはある程度解決されたようです。次にどの程度の大きさでメソッドを分離して開発する必要があるか、また作成したメソッドはどのクラスに宣言すればよいか考えてみましょう。
直交性(Orthogonality)
直交性の数学的な定義は次のとおりです。
ベクトルの両方の内角が90度(互いに直角を成すとき)であれば、この2つのベクトルは互いに直交するといいます。
2つのベクトルが1つの空間で互いに出会うのは一地点しかありません。2つのベクトルの性質が異なるため、互いの共通点を見つけるのは難しいでしょう。クラスのデザインも同様に、それぞれのクラスは互いに共通する特性があってはいけません。共通点がないという性質は、前述のDRY原則と似ています。
Art of UNIX programmingは直交性を次のように説明しています。
もう一度整理してみましょう。関数やメソッドは他の関数やメソッドに影響があってはなりません。そのため、凝集性があり、独立的なコードを作成する必要があります。この2つの条件(凝集、独立)を満たすとき直交性があるといい、コードを分離して簡単に保守することができます。コードが分離されたということは、特定の機能をアップグレードして開発する場合にも都合が良いと言えます。
では、どのようにすれば直交性のあるコードを書くことができるでしょうか?
特定のメソッドのシグネチャ(signature)を変更してみましょう。例えばパラメータを1つ追加したり、リターンオブジェクトのクラスタイプを変えてみるとよいでしょう。
では、前述したProductService.javaのconvert()メソッドに引数Long makerIdをもう1つ追加してみましょう。データモデルによって異なりますが、非常に多くのメソッドを変更する必要があります。機能が複雑で複数のクラスが絡み合う直交性がないものでは、かなり多くのクラスが変更される必要があり、変更されたコードでバグが新たに発生するリスクもあります。
直交性とSpring @Transactional
この直交性を満足させる代表的なコードがSpringの@Transactionalです。TransactionalアナテーションはAOP(Aspect Object Programming)に実装された代表的なJDBCのトランザクションモジュールです。JDBC Connectionのbegin、commit、rollbackの機能をビジネスコードと分離してAOPプログラミングされたコードです。@Transactionalは次のようにSpring Beanのメソッドやクラスに宣言します。
@Service public class PlanService { /** * @param createPlanRequest * @return created plan id */ @Override @Transactional(readOnly = false, isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public Long createPlan(CreatePlanRequest createPlanRequest) { ///.... ..../// } }
開発者は、@Transactionalというアナテーションだけを宣言し、コード内部ではトランザクション関連の機能を気にしません。トランザクションコードとビジネスコードの接点は、@Trasnactionalアナテーションのみで、互いの機能も完全に分離されています。ビジネスコードの内容を変更してもトランザクションコードは変更する必要がなく、また逆の場合も同様です。両方のコードは直交性を持っています。
参考までに、@Transactionalで宣言されたcreatePlanメソッドは次のように動作します。
UserTransaction utx = entityManager.getTransaction(); try { utx.begin(); planService.createPlan(...); // @Transactionalで宣言されたcreatePlan() utx.commit(); } catch(Exception ex) { utx.rollback(); throw ex; }
より詳しい動作をご覧になりたい方は、org.springframework.transaction.interceptor.TransactionIntercep torとorg.springframework.transaction.interceptor.TransactionAspectSu pportをご参照ください。
JDBC Transaction処理ロジックはビジネスコードと完全に分離され、TransactionInterceptorに凝集されており、独立して動作します。そのためPlanSerivceと直交性を持っています。AOPでプログラムされたコードを例に挙げて説明しましたが、クラス間のメソッドを呼び出すのも同様です。