Hatena::ブログ(Diary)

わからん

2011.06.05

[][] Jim Weirich さんから学ぶ DI(Dependency Injection)

PHP コミュニティでは今、DI コンテナが花盛りです。良い機会と捉え、勉強しています。このブログ記事では、Jim Weirich さんの O’Reilly Open Source Convention August 1-5, 2005 での Vitally Important or Totally Irrelevant? というタイトルのプレゼン資料を紹介します。完全な翻訳ではなく、省略したりおぎなったりしています。間違いはコメント欄などでご指摘下さい。

この資料では、動的型付け言語である Ruby にとって DI は重要な設計方針なのかを、静的型付け言語である Java のサンプルコードを引き合いに出し論じています。これを読むことで、DI とは何かを(実際に動く)コードレベルから理解することができました。また、Ruby ならではの実装を知ることができました。しかし、Ruby にとって DI は重要なのかに関しては歯切れの悪い結論だという印象を持ちました。DI の詳細はマーチンファウラーの Inversion of Control コンテナと Dependency Injection パターン を読むとよいと思います。



コーヒーメーカーの設計を想定しています。コーヒーメーカーのサブシステム Warmer クラスを中心に考えます。Warmer クラスでは内部で Heater クラス、PotSensor クラスを利用しています。

public class Warmer {
  private PotSensor myPotSensor;
  private Heater myHeater;
  public Warmer() { ... }
  public void trigger() {
    if (myPotSensor.isCoffeePresent()) {
      myHeater.on();
    }
    else {
      myHeater.off();
    }
  }
}

public class Heater {
  public void on() { ... }
  public void off() { ... }
}

public class PotSensor {
  public boolean isCoffeePresent() { ... }
}

図にするとこうなります。


f:id:kitokitoki:20110605135803p:image


具象クラスが別の具象クラスに依存しています。そのため、次の問題があります。

  • テストしにくい
  • 再利用しにくい

Dependency Inversion Principle に従えば、以下のように設計すべきです。


f:id:kitokitoki:20110605135802p:image


これで先程挙げた問題が解決します。

コードは以下のようになるでしょうか。

public interface OnOffDevice {  //インターフェースを作成
  void on();
  void off();
}

public interface SensorDevice {
  boolean isCoffeePresent();
}

public class Warmer {
  private SensorDevice myPotSensor; // PotSensor から変更
  private OnOffDevice myHeater;     // Heater から変更
  public Warmer() { ... }           // コンストラクタはあとで考える
  public void trigger() {
    if (myPotSensor.isCoffeePresent()) {
      myHeater.on();
    }
    else {
      myHeater.off();
    }
  }
}

コンストラクタはどうしましょうか。

class Warmer {
  ...
  public Warmer() {
    myPotSensor = new PotSensor();
    myHeater = new Heater();
  }
  ...
}

これでは駄目です。Warmer クラスから、具象クラスである PotSensor、Heater は取り除いたのでした。


修正するとこうなります。

  public Warmer(SensorDevice sensor, OnOffDevice heater) {
    myPotSensor = sensor;
    myHeater = heater;
  }

あるいは、次のようなセッターを用意してもよいでしょう。

  public Warmer() { ... }
  public void setPotSensor (SensorDevice sensor) { ... }
  public void setHeater (OnOffDevice heater) { ... }

しかしながら、これは問題を移動させただけです。どこかで次のようなコードが必要になります。

  OnOffDevice heater = new Heater();
  SensorDevice sensor = new PotSensor();
  Warmer warmer = new Warmer(sensor, heater);

どこが良いでしょうか。



とりあえず無視して、Ruby の場合を考えましょう。


まずは、強い依存のあるコードです。

class PotSensor
  def coffee_present?
    ...
  end
end

class Heater
  def on() ... end
  def off() ... end
end


class Warmer

  def initialize
    @sensor = PotSensor.new
    @heater = Heater.new
  end

  def trigger
    if @pot_sensor.coffee_present?
      @heater.on
    else
      @heater.off
    end
  end
end

Java 同様の変更を行ってみます。


コンストラクタを変更するなら、次のようになります。

class Warmer
  def initialize(pot_sensor, heater)
    @pot_sensor, @heater = pot_sensor, heater
  end
end

または、セッターを用意する方法もあります。

class Warmer
  attr_accessor :pot_sensor, :heater
end

先程の Java と同様の問題に辿りつきました。解法は、Factories、Service Locators、Dependency Injection の3つです。順に見ていきましょう。


まずは、Factories です。Heater、PotSensor を new するファクトリメソッドを持つファクトリクラスを作成し、そのオブジェクトを Warmer のコンストラクタに渡します。

class Warmer

  def initialize(factory)
    @sensor = factory.make_pot_sensor
    @heater = factory.make_heater
  end

  def trigger
    if @pot_sensor.coffee_present?
      @heater.on
    else
      @heater.off
    end
  end
end

class Factory
  def make_heater
    Heater.new
  end
  def make_pot_sensor
    PotSensor.new
  end
end

factory = Factory.new
warmer = Warmer.new(factory)


Service Locators ではこうなります。

class Warmer

  def initialize(locator)
    @sensor = locator[:pot_sensor]
    @heater = locator[:heater]
  end

  def trigger
    if @pot_sensor.coffee_present?
      @heater.on
    else
      @heater.off
    end
  end
end

locator = Hash.new
locator[:heater] = Heater.new
locator[:pot_sensor] = PotSensor.new
locator[:warmer] = Warmer.new(locator)
...
coffee_maker = CoffeeMaker.new(locator)

最後は Dependency Injection です。


次のような特徴を持つ、基本的な指示でコーヒーメーカーを組み立てるコンテナを作ってみます。

  • コンテナに関する情報をもたないオブジェクトが用いられる
  • ことなるシナリオに対し容易に調整できる柔軟さがある

呼び出し側はおおよそこんな感じのコードになります。モジュール DIM の中身については後述します。

  magic_lamp = DIM::Container.new
  magic_lamp.register(:warmer) { |c|
    Warmer.new(c.pot_sensor, c.heater) //pot_sensor, heater はあとで解説
  }

  coffee_maker = magic_lamp.coffee_maker

pot_sensor と heater もコンテナの中で定義します。

  magic_lamp.register(:pot_sensor) { |c|
    PotSensor.new(c.pot_sensor_io_port)
  }

  magic_lamp.register(:heater) { |c|
    Heater.new(c.heater_io_port)
  }

オブジェクトはすべて、数値や文字列さえ、コンテナに登録し、そこから利用します。

  magic_lamp.register(:pot_sensor_io_port) { 0x08F0 }
  magic_lamp.register(:heater_io_port)     { 0x08F1 }

コーヒーメーカーのトップレベルの設定はこうなります。ここまでで登場している Warmer はコーヒーメーカーのサブシステムです。

  magic_lamp.register(:coffee_maker) { |c|
    MarkIV::CoffeeMaker.new(c.boiler, c.warmer)
  }

おおよそ上のように設定したコンテナを使って次のように呼び出されます。

  coffee_maker = magic_lamp.coffee_maker

ここで利用した DI を載せておきます。30行程度の小さなものです。

#!/usr/bin/env ruby
#--
# Copyright 2004, 2005 by Jim Weirich (jim@weirichhouse.org).
# All rights reserved.
#
# Permission is granted for use, copying, modification, distribution,
# and distribution of modified versions of this work as long as the
# above copyright notice is included.
#++
#
# = Dependency Injection - Minimal (DIM)
#
# The DIM module provides a minimal dependency injection framework for
# Ruby programs.
#
# Example:
#
#   require 'dim'
#
#   container = DIM::Container.new
#   container.register(:log_file) { "logfile.log" }
#   container.register(:logger) { |c| FileLogger.new(c.log_file) }
#   container.register(:application) { |c|
#     app = Application.new
#     app.logger = c.logger
#     app
#   }
#
#   c.application.run
#
module DIM

  class MissingServiceError < StandardError; end
  class DuplicateServiceError < StandardError; end

  class Container

    def initialize(parent=nil)
      @services = {}
      @cache = {}
      @parent = parent || Container
    end

    # サービスの登録
    def register(name, &block)
      if @services[name]
        fail DuplicateServiceError, "Duplicate Service Name '#{name}'"
      end
      @services[name] = block
    end

    # サービスの名前によるサービスの検索
    def [](name)
      @cache[name] ||= service_block(name).call(self)
    end

    # message selector によるサービスの検索
    def method_missing(sym, *args, &block)
      self[sym]
    end

    # サービス名からそのサービスの処理(ブロック)を返す
    def service_block(name)
      @services[name] || @parent.service_block(name)
    end

    # コンテナクラスからサービスの処理(ブロック)を検索して、なかった場合
    # のエラーを返す処理
    def self.service_block(name)
      fail(MissingServiceError, "Unknown Service '#{name}'")
    end
  end
end

呼び出し側の動作するサンプルです。ここでは、コーヒーメーカーではなく、サブシステムの Warmer を呼び出しています。この Warmer クラスの定義は、PotSensor, Heater に依存していません。

class PotSensor
  def coffee_present?
    true
  end
end

class Heater
  def on()
    'on'
  end
  def off()
    'off'
  end
end


class Warmer

  def initialize(pot_sensor, heater)
    @pot_sensor = pot_sensor
    @heater = heater
  end

  def trigger
    if @pot_sensor.coffee_present?
      @heater.on
    else
      @heater.off
    end
  end
end


magic_lamp = DIM::Container.new

magic_lamp.register(:pot_sensor) { |c|
  PotSensor.new
}

magic_lamp.register(:heater) { |c|
  Heater.new
}

magic_lamp.register(:warmer) { |c|
  warmer = Warmer.new(c.pot_sensor, c.heater)
}

warmer = magic_lamp.warmer
warmer          # => #<Warmer:0xb77675d4 @pot_sensor=#<PotSensor:0xb7767624>, @heater=#<Heater:0xb77675c0>>
warmer.trigger  # => "on"


Ruby での DI について見てきました。ところで、Java での実装を安直に Ruby に翻訳するのは間違っています。以下では両者の違いを挙げていきます。

  def initialize(factory)
    @sensor = factory.make_pot_sensor
    @heater = factory.make_heater
  end

  class Factory
    def make_heater
      Heater.new
    end
    def make_pot_sensor
      PotSensor.new
    end
  end
  factory = Factory.new
  warmer = Warmer.new(factory)

これは次のように書くべきです。

  def initialize(sensor_class,
                 heater_class)
    @sensor = sensor_class.new
    @heater = heater_class.new
  end

あるいはこうです。

  factory = OpenStruct.new
  factory.heater = Heater
  factory.sensor = Sensor

  def initialize(factory)
    @sensor = factory.sensor.new
    @heater = factory.heater.new
  end

Ruby はオープンクラスなので、あとから定義を追加できます。

require 'models/payment_gateway'  # PaymentGateway クラスを require して

class PaymentGateway              # PaymentGateway クラスを再オープンして
  def commit                      # メソッドを追加
    SuccessfullSubmission.new
  end
end

オブジェクト単位で振る舞いを追加できます。これはモックで使えます。

require 'models/payment_gateway'

def test_handle_payment
  gateway = PaymentGateway.new
  def gateway.commit            #gatewayオブジェクトの特異クラスにメソッドを定義
    SuccessfullSubmission.new
  end
  # 変更した gateway オブジェクトを用いたテストが続く
end

クラス名はたんなる定数なので、ラベルのようなものです。

if we_are_testing?
  Heater = Mocking::Heater
else
  Heater = Hardware::Heater
end

class Warmer
  def initialize
    @heater = Heater.new
    ...
  end
end

クラスもオブジェクトです。プロキシクラスオブジェクトを作り、元のクラスの前後に処理を差し込むことができます。

  def test_warmer
    Warmer.use_class(:Heater, MockHeater) do
      # 何かしらの処理
    end
    # 元々の処理
  end

以下はプロキシクラスの汎用的な実装です。

class ClassProxy
  attr_accessor :proxied_class
  def initialize(default_class)
    @proxied_class = default_class
  end
  def new(*args, &block)
    @proxied_class.new(*args, &block)
  end
end

class Class
  def use_class(class_name, class_ref)
    proxy = const_get(class_name)
    unless ClassProxy === proxy
      self.const_set(class_name, ClassProxy.new(proxy))
      proxy = const_get(class_name)
    end
    if ! block_given?
      proxy.proxied_class = class_ref
    else
      old_proxied = proxy.proxied_class
      proxy.proxied_class = class_ref
      begin
	yield
      ensure
	proxy.proxied_class = old_proxied
      end
    end
  end
end

Ruby では Dependency Injection がそれほど使われていません。言語の動的な性質で十分まにあっているからです。それから、DI は大きなプロジェクトでとくに役に立つのですが、大きな Ruby プロジェクトがそれほどないというのもあります。

この状況は変わるでしょうか。DIは、DIを使わずにテストを容易にするアプローチと競合しています。しかし、DI には Flexible Building、Centralized Build Control、AOP Features といった特徴があります。The future will reveal all …


 追記(2012/5/13)

同じ資料に対する、当時の僕より格上な反応をみつけました

はてなユーザーのみコメントできます。はてなへログインもしくは新規登録をおこなってください。

Google