matarillo.com

The best days are ahead of us.

ドメインオブジェクトはIoCコンテナ(DIコンテナ)に預けるな

2012-07-17 19:45:12

Eric Leeの"GenesisEngine: Don’t Get Domain Objects From The Container“の翻訳。

ドメインオブジェクトはIoCコンテナ(DIコンテナ)に預けるな

IoCコンテナは素晴らしいので、重要なプロジェクトではいつも使っている。しかし、IoCコンテナを使うにあたっては興味深い注意点がある。この注意点はそれほど明白なものではない。コンテナから直接ドメイン・エンティティを取り出してはいけない。

驚いたことに、このルールは出典がたくさんあるようには思えない。IoCの専門家にしてみれば割と知られていることなのに。(MLや掲示板では「やるな」というコメントを見かけるかもしれない。) しかし、コンテナからドメインオブジェクトを取り出すのがアンチパターンであるという、簡潔ではっきりした説明を見つけるのには非常に苦労した。検索力が足りないせいかもしれないけど、それはさておき。この投稿はやっと見つけたまともな説明だが、そのルールの裏側にある理由について掘り下げてはいない。もっとましなソースが見つかったら教えて欲しい。

すまないが、決定的な説明を書こうとしているわけではない。ただ、ルールを無視するとどうなるかを示すことはできる。私はGenesisEngineを書く前から、ドメインオブジェクトはコンテナに属さないことを知っていたが、なぜかPlanetクラス(とそれに含まれるクラス)はドメインオブジェクトだと考えていなかった。理由は分からない。たぶんPlanetはたった1つしかないという仮定をもっていたからだろう。しかし、もちろん実際にはドメインオブジェクトだったのだ。IDとなる情報(場所と半径)を持っているので。

で、どうなったか。

当初

MainPresenterはコンテナによって作成され、コンテナから注入されたIPlanetを受けとっていた。以下のように。

public MainPresenter(IPlanet planet, ICamera camera, IWindowManager windowManager,
    Statistics statistics, ISettings settings)
{
    _planet = planet;
    _planet.Initialize(DoubleVector3.Zero, PhysicalConstants.RadiusOfEarth);
    _camera = camera;
    _windowManager = windowManager;
    _statistics = statistics;
    _windowManager.ShowAllWindows();
 
    _settings = settings;
    _settings.ShouldUpdate = true;
}

コンテナはPlanetオブジェクトを作成するが、一意に識別する情報を与えることができていなかった。私が作成したい惑星がどれなのかについては、コンテナは何も知らなかったからだ。だから、MainPresenterのコンストラクタで IPlanet::Initialize() を呼び出し、固有の属性を渡す必要があった。この2段階のインスタンス生成は明確なアンチパターンだ。IPlanetの利用側が、注入された依存関係を初期化するのを忘れてしまうということを防ぐためのセーフティネットが存在しないからだ。

今回は惑星クラスのインスタンスを生成してから初期化しているので、それが2段階のインスタンス生成になっていることがわかる。“注入してから初期化する"というコンセプト は、PlanetRendererのようなPlanetと依存関係があるクラスにもカスケードされたことに注意する。

public Planet(IPlanetRenderer renderer, ITerrainFactory terrainFactory,
    IHeightfieldGenerator generator, Statistics statistics)
{
    _renderer = renderer;
    _terrainFactory = terrainFactory;
    _generator = generator;
    _statistics = statistics;
 
    _clippingPlanes = new ClippingPlanes();
}
 
public void Initialize(DoubleVector3 location, double radius)
{
    _location = location;
    _radius = radius;
 
    CreateTerrain();
 
    _renderer.Initialize(_radius);
}

破綻しそうな鈍くさいコードは別として、その時の主な問題は、プログラムで1つしか惑星を持てなかったことだった。惑星の軌道上に月を置きたくなったらどうすればよかっただろうか?あるいは太陽系をモデリングしたい場合は?惑星をコンテナからMainPresenterに注入するのが正しいアプローチではないのは明らかだった。MainPresenterが他の惑星をコンテナから直接取りだすようにすることもできたと思うが、ブートストラップ/セットアップ コード以外でコンテナを参照するのは「コードのにおい」がする。

面白いことに、このまずい設計が、一見無関係な領域のコードで奇妙な問題を間接的に引き起こしていた。例えば、メインのGenesisプログラム クラスの初期化メソッドで、私はこんなコメントを残していた。

protected override void Initialize()
{
    this.IsMouseVisible = true;
 
    _mainPresenter = ObjectFactory.GetInstance<MainPresenter>();
    // TODO: ここでカメラコントローラを参照する必要はないのだが、
    // 他のところでもカメラコントローラを参照する必要がないので、
    // しかたなく生成している。そうしないと、イベント集約機構がうまく動かない。
    // これってもっとまともな実装方法はないのか?
    _cameraController = ObjectFactory.GetInstance<ICameraController>();
    _inputState = ObjectFactory.GetInstance<IInputState>();
    _inputMapper = ObjectFactory.GetInstance<IInputMapper>();
 
    SetInputBindings();
 
    base.Initialize();
}

CameraController オブジェクトは存在させる必要があったが、アプリケーションから参照しているところはどこにもないようだった。オブジェクトを作成して存在させておくのを強制するためだけに、メインプログラムクラスで参照するというハックをする必要があった。結局のところ、私がドメインオブジェクトをコンテナから引っ張り出していたことによってその問題が生じていたのだ。IPlanetはCameraController に直接注入されていたが、それは別のルートによるものであるべきだったのだ。

修正方法

この類のことを修正するには、普通はファクトリを導入する。

MainPresenter には、コンテナからIPlanet を注入するのではなく、IPlanetFactory を注入した。そうすれば、MainPresenterは、惑星ファクトリを使い、必要な数だけの惑星を作成できる。こうすることで、 MainPresenter が「2段階のインスタンス生成」のようになっていることがわかるだろうか。作成してから表示する。しかし、それはPresenterにしてみれば論理的に意味があることだし、ほかのウィンドウを表示したりする流れを整理するのに実際に役立つ。

public MainPresenter(IPlanetFactory planetFactory, ICamera camera, ICameraController cameraController, IWindowManager windowManager, Statistics statistics, ISettings settings)
{
    _planetFactory = planetFactory;
    _camera = camera;
    _cameraController = cameraController;
    _windowManager = windowManager;
    _statistics = statistics;
 
    _settings = settings;
    _settings.ShouldUpdate = true;
}
 
public void Show()
{
    _planet = _planetFactory.Create(DoubleVector3.Zero, PhysicalConstants.RadiusOfEarth);
    _cameraController.AttachToPlanet(_planet);
    _windowManager.ShowAllWindows();
}

CameraControllerがMainPresenter に注入され、特定の惑星に明示的に関連付けられ、それによって CameraControllerの亡霊問題が修正されていることに注意すること。コンテナは実際にそれを作成する必要があるし、 MainPresenter はそれへの参照を保持しているのだ。Genesisプログラムクラスに存在した奇妙な参照はなくなり、MainPresenter の取り扱いはより論理的だ (コンテナから取得して表示する)。

protected override void Initialize()
{
    this.IsMouseVisible = true;
 
    _mainPresenter = ObjectFactory.GetInstance<MainPresenter>();
    _mainPresenter.Show();
 
    _inputState = ObjectFactory.GetInstance<IInputState>();
    _inputMapper = ObjectFactory.GetInstance<IInputMapper>();
 
    SetInputBindings();
 
    base.Initialize();
}

最後に、Planet クラスはより単純になり、レンダラには初期化呼び出しのカスケードがもうない。

public Planet(DoubleVector3 location, double radius, ITerrain terrain,
    IPlanetRenderer renderer, IHeightfieldGenerator generator, Statistics statistics)
{
    _location = location;
    _radius = radius;
 
    _terrain = terrain;
    _renderer = renderer;
    _generator = generator;
    _statistics = statistics;
 
    _clippingPlanes = new ClippingPlanes();
}

ここでGitHubの比較表示を見れば、変更をすべて確認できる。