2011.06.05
■[Ruby][設計] 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() { ... } }
図にするとこうなります。
具象クラスが別の具象クラスに依存しています。そのため、次の問題があります。
- テストしにくい
- 再利用しにくい
Dependency Inversion Principle に従えば、以下のように設計すべきです。
これで先程挙げた問題が解決します。
コードは以下のようになるでしょうか。
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)
同じ資料に対する、当時の僕より格上な反応をみつけました


