「なぜDI(依存性注入)が必要なのか?」についてGoogleが解説しているページを翻訳した 

  • 1184
    いいね
  • 4
    コメント

イマイチ理解しきれていなかった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は、TransactionLogCreditCardProcessorを見つけてくる責任を負いません。その代わり、それらはコンストラクタの引数として渡されます。

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は必要ありません。その上、決まり文句と化したsetUptearDownを取り除いたおかげでテストケースが簡潔になりました。

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に、どのようにこのコードが動くのかの説明があります。