イマイチ理解しきれていなかったDIに関して調べていところ、Google Guiceの解説がすごく分かりやすかったので、和訳してみました。
(ところどころ意訳気味です。明らかに解釈の誤った訳がありましたら、ご指摘ください)
ちなみにGoogle Guiceというのは、Googleが開発したDIライブラリです。この例ではJavaが使用されていますが、Scalaでも使用可能です。最近Play Frameworkでも採用されたので話題になっているようです。
##用語の定義
本文を読む前に目を通すことで、内容をスムーズに理解できます。
用語 | 意味 | 本文中の例 |
---|---|---|
サービス | 何らかの機能を提供するクラス。 依存される側 | CreditCardProcessor、TransactionLog |
クライアント | サービスを利用するクラス。 依存する側 | RealBillingService |
依存性解決 | サービスとクライアントの依存関係をコード上で明記すること | new演算子、Factoryクラス、DI |
モック | 単体テストにおいて、実際のサービスの代わりに用意する偽のクラス。本物のサービスと同じインターフェースを実装する | InMemoryTransactionLog、FakeCreditCardProcessor |
##本文
DIを行う動機
関係する全てのオブジェクトをひとまとめにするのは、アプリ開発の中でも退屈な作業です。サービス、データ、そしてクライアントを結合する方法には色々あります。それらの方法を比較するために、ピザのネット注文の代金を請求するコードを書いてみましょう。
public interface BillingService {
/**
* クレジットカードに注文代の課金を試みる。
* 成功しても失敗しても、トランザクションは記録される。
* @ return トランザクションの領収書(Reciptオブジェクト)を返す。課金が成功した場合、Reciptオブジェクトは正常な値を持つ。失敗した場合、Reciptオブジェクトは課金が失敗した理由を保持する。
*/
Receipt chargeOrder(PizzaOrder order, CreditCard creditCard);
}
実装に際しては、単体テストを書くことにしましょう。テストでは、本物のクレジットカードに課金してしまわないよう、偽のカード課金を表すFakeCreditCardProcessor
クラスを使う必要があります。
コンストラクタを直接呼び出す
以下のコードは、カード課金を行うクラス(CreditCardProcessor
クラス)と、トランザクションを記録するクラス(TransactionLog
クラス)を、愚直にnew演算子でインスタンス化した場合です。
public class RealBillingService implements BillingService {
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
CreditCardProcessor processor = new PaypalCreditCardProcessor();
TransactionLog transactionLog = new DatabaseTransactionLog();
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
このコードはモジュール性とテスト性に問題があります。本物のクレジット課金を行うクラス(CreditCardProcessor
クラス)にコンパイル時点で直接依存しているため、テストをするとカードに課金されてしまいます! また、課金が失敗した時やクレジットサービスが停止している時の挙動をテストするのにも骨が折れます。
###Factoryクラス
Factoryクラスは、クライアントとサービスの実装とを分離します。単純なFactoryクラスでは、インターフェースを実装したモックをgetterやsetterで操作できます。
public class CreditCardProcessorFactory {
private static CreditCardProcessor instance;
public static void setInstance(CreditCardProcessor processor) {
instance = processor;
}
public static CreditCardProcessor getInstance() {
if (instance == null) {
return new SquareCreditCardProcessor();
}
return instance;
}
}
クライアント側で行うことは、new呼び出しをFactoryメソッドに変更するだけです。
public class RealBillingService implements BillingService {
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
CreditCardProcessor processor = CreditCardProcessorFactory.getInstance();
TransactionLog transactionLog = TransactionLogFactory.getInstance();
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
Factoryを使うと、標準的な単体テストを書くことができます。
public class RealBillingServiceTest extends TestCase {
private final PizzaOrder order = new PizzaOrder(100);
private final CreditCard creditCard = new CreditCard("1234", 11, 2010);
private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();
@Override public void setUp() {
TransactionLogFactory.setInstance(transactionLog);
CreditCardProcessorFactory.setInstance(processor);
}
@Override public void tearDown() {
TransactionLogFactory.setInstance(null);
CreditCardProcessorFactory.setInstance(null);
}
public void testSuccessfulCharge() {
RealBillingService billingService = new RealBillingService();
Receipt receipt = billingService.chargeOrder(order, creditCard);
assertTrue(receipt.hasSuccessfulCharge());
assertEquals(100, receipt.getAmountOfCharge());
assertEquals(creditCard, processor.getCardOfOnlyCharge());
assertEquals(100, processor.getAmountOfOnlyCharge());
assertTrue(transactionLog.wasSuccessLogged());
}
}
このコードもスマートではありません。グローバル変数がモック実装を保持しているため、モックの用意と破棄には細心の注意を要します。tearDownメソッドが失敗すると、グローバル変数はテスト用のインスタンスを参照し続けます。こうなると別のテストに影響が出かねませんし、複数のテストを並行して進めることもできません。
しかし最大の問題は、依存性がコードの中に隠れてしまっていることです。もし、クレジットカードのなりすましを追跡するクラスを作成し、RealBillingService
がそのクラスにも依存するようになったとしましょう。テストが失敗した時、依存しているどのクラスに問題があったのかを知るには、テストをもう一回実行しなければなりません。もしFactoryを初期化するのを忘れても、テストを実行するまで気づけません。アプリが肥大化するにつれ、依存性の面倒を見ているFactoryは、生産性を落とす原因になっていきます。
品質問題は、品質保証部や受け入れテストで補足されるでしょう。それで十分かもしれませんが、もっとうまくやることができます。
###依存性注入(DI)
Factoryのように、DIもデザインパターンです。その基本的な考え方は、 振るまいと依存性解決を分離する ことです。この例で言うと、RealBillingService
は、TransactionLog
とCreditCardProcessor
を見つけてくる責任を負いません。その代わり、それらはコンストラクタの引数として渡されます。
public class RealBillingService implements BillingService {
private final CreditCardProcessor processor;
private final TransactionLog transactionLog;
public RealBillingService(CreditCardProcessor processor,
TransactionLog transactionLog) {
this.processor = processor;
this.transactionLog = transactionLog;
}
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
もはやFactoryは必要ありません。その上、決まり文句と化したsetUp
とtearDown
を取り除いたおかげでテストケースが簡潔になりました。
public class RealBillingServiceTest extends TestCase {
private final PizzaOrder order = new PizzaOrder(100);
private final CreditCard creditCard = new CreditCard("1234", 11, 2010);
private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();
public void testSuccessfulCharge() {
RealBillingService billingService
= new RealBillingService(processor, transactionLog);
Receipt receipt = billingService.chargeOrder(order, creditCard);
assertTrue(receipt.hasSuccessfulCharge());
assertEquals(100, receipt.getAmountOfCharge());
assertEquals(creditCard, processor.getCardOfOnlyCharge());
assertEquals(100, processor.getAmountOfOnlyCharge());
assertTrue(transactionLog.wasSuccessLogged());
}
}
こうなれば、いつ依存性を追加しようと削除しようと、どのテストを修正したら良いのかコンパイラが教えてくれます。依存性は、APIのシグネチャとして外出しされたのです。
ただ残念ながら、BillingService
のクライアントは、依存性を自分で見つけなければなりません。BillingService
に依存しているクラスは、コンストラクタでBillingService
を受け取ることができます。再度DIパターンを適用すれば、大方問題は解決するかもしれません。しかし最上位のクラスにとっては、フレームワークがあったほうが便利でしょう。さもないと、サービスを使用するのに再帰的に依存関係を構築するはめになります。
public static void main(String[] args) {
CreditCardProcessor processor = new PaypalCreditCardProcessor();
TransactionLog transactionLog = new DatabaseTransactionLog();
BillingService billingService
= new RealBillingService(processor, transactionLog);
...
}
###Guiceによる依存性注入
DIパターンによってコードのモジュール性とテスト性を上げられますが、Guiceを使えば同じことをもっと簡単に書くことができます。Guiceをカード請求の例に使用するには、まずインターフェースと実装の関係をGuiceに伝える必要があります。この設定は、Module
インターフェースを実装したJavaクラス(Guice Moduleクラス)で行われます。
public class BillingModule extends AbstractModule {
@Override
protected void configure() {
bind(TransactionLog.class).to(DatabaseTransactionLog.class);
bind(CreditCardProcessor.class).to(PaypalCreditCardProcessor.class);
bind(BillingService.class).to(RealBillingService.class);
}
}
この設定を使用するには、@Inject
アノテーションをRealBillingService
のコンストラクタに付与します。するとGuiceはアノテーションのついたコンストラクタを調べ、引数の値を見つけてきてくれます。
public class RealBillingService implements BillingService {
private final CreditCardProcessor processor;
private final TransactionLog transactionLog;
@Inject
public RealBillingService(CreditCardProcessor processor,
TransactionLog transactionLog) {
this.processor = processor;
this.transactionLog = transactionLog;
}
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
こうして、全部のクラスをひとまとめにすることができました。Injector
は結合したクラスのインスタンスを取得するのに使います。
public static void main(String[] args) {
Injector injector = Guice.createInjector(new BillingModule());
BillingService billingService = injector.getInstance(BillingService.class);
...
}
Getting Startedに、どのようにこのコードが動くのかの説明があります。