VContainer v0.0.1 - Unity用の速くて軽いDIライブラリをリリースしました

github.com

Unity (ゲームエンジン) で動作する自作DIライブラリを公開しました。

特徴は、

  • 速くて薄くて軽い
  • GCゴミがとても少ない
  • コードファーストでスコープを切れる機能 (Autofac の影響)
  • MonoBehaviourに依存しないカスタムクラスをUnityのライフサイクルに割り込ませる (Zenjectの影響甚大)
    • 独自の PlayerLoopSystem のサブシステムを利用しているので、不特定タイミングでつくったスコープの IInitializable とかも動作する。
  • コンテナがイミュータブル

なのです。

Zenject と比較すると、メソッドチェインをつなげるとなんでも宣言できる(Fluent API) は最低限に、使い方の迷いようがなく透明度の高いAPI を心掛けています。
APIの比較表をつくりました

Scopeの親子関係はある程度(ていうかかなり) Zenject を踏襲していますが、できるだけ規約的な決まりごとから解放し、フレキシブルに親子関係を組める自由度を提供しようとしています。 (スコープを表現するGameObjectに挿す型は一種類です) 専用の Scene Loader のようなものはなく、アプリケーション側で選択決定すべき機能は一切入れないように気をつけました。

APIAutofac を参考に、実装や最適化は neuecc/MicroResolver を参考にしました。m(_ _)m

Zenjectよりオーバヘッドがない最大の理由は GCゴミの少なさですが、その他の最適化もやってます。

型をキーにしたマッピングneue cc - C#におけるTypeをキーにした非ジェネリック関数の最適化法 を参照しました。 最初は Hashtable を使っていましたが、neueccさんの FixedTypeKeyHashtable は、

> 使えるケースがありそうな人は是非どうぞ。ハヤイデス

と書いてあったので拝借させてもらてます。

また、VContainer ,は、Il2CPP で c++ 化した場合のコードのサイズもちょっと気を配っており、 現状、ライブラリ部分のみの c++状態を比較すると、 Zenject の 1/9 弱です。 アプリケーション側で使用されたジェネリクス実装の展開も考慮すると さらにビルドサイズのインパクトが小さくなる見込みです。(未検証)

f:id:hadashia:20200630013343p:plain

上はUnityのDIライブラリとして事実上標準なZenjectとのベンチマークです。 Unity以外での使用を想定していないので、Unity Performance Testing Extension でil2cpp上で走らせてみてます。(BenchmarkDotNet とかで計測するとまた違う結果になるかもしれません)

Resolveにだけ注目すると、GCゴミゼロを達成してます。

(Resolve対象のインスタンスを除く)

f:id:hadashia:20200630015850p:plain

f:id:hadashia:20200630015852p:plain

追記: v0.9.0 にて、コンパイル時 IL生成によるさらなる高速化が入りました。 (詳細は別の記事に書く予定です)

C#メタプログラミング所感

DIコンテナのボトルネックは、インスタンス生成を自動的に行う部分です。C#の一般用のDIコンテナ実装は、この魔法を Expression Tree でインスタンス生成の式オブジェクトを実行時に生成したり、 ILジェネレータ で実行時にインスタンス生成のバイトコードをつくるなどの非常にかっこいい手法で実現してます。

一方、ZenjectやVContainer は、実行時に リフレクションで コンストラクタを呼び出す方法をとっています。これはIl2CPP環境の制限のためなのですが、上の手法よりも遥かに遅いです。

一般的にはメタプログラミングをする方法としては、以下のような手法があると思います。

C# はマクロがない代わりに実はその他全部の芸が使え、リフレクションがもっとも扱う難易度が低いものの、パフォーマンスが必要な場合には普段は鞘におさまっている刀を抜くというオプションが本当はあります。

でも IL2CPP ではそこが動作しません 。(そのかわり Unity は ECS や Burst で独自路線をいく

というわけで、VContainerは、ボトルネックがリフレクション経由のメソッド呼び出しである点がZenjectと共通しています。 そのため、単純なケースだと速度差が縮まっていますが、 実は Zenject には リフレクション以外のオーバヘッドが多々あり、コンテナへ登録するオブジェクトが増えれば増えるほど差を出せる傾向があります。

最小限のDIライブラリ

VContainer は、DIをやるための必要最小限(と僕が考える)機能しか入れていません。

僕はDIはけっこう好きな方で、Zenjectもいくつかのプロジェクトで使ってきたのですが、 あくまでパターンorテクニックとしてのDIがしたい場合、もっともっとごく薄いライブラリか、自分でComposition Root をつくる(Poor Man's DI) で十分だと思ってます。

DIそれ自体は、アプリケーションフレームワークではありません。
Unity はプロジェクトごとにつくるものも違えば、UniRx/UniTask 等の レイヤ低めのところの超強力ユーティリティがあるかどうかもプロジェクトによってまちまちです。
そんな環境の中、DIライブラリによってアプリケーションレイヤの色々がなんでもかんでも規定されてしてしまうととても使いずらいと思ってます。

Zenject は、オブジェクトプーリングやシグナル、シーンローダなど、DIの範疇を越えて、アプリケーションごとに選択すべき機能にも干渉しているほか、 あらゆるオブジェクト生成を DIコンテナを介してできるようにすることを目標にしているようにすら見えます。 そのクラスが責任をもって内側に隠蔽/所有する ようなもの、または実行時の引数やstate、それらとにかく全てを DIで解決しようとしても、それはそれでカプセル化が壊れていて設計が劣化してると思うし、なによりも設定が複雑すぎて読解困難です。DI の快適さってそこではないと思うんですよね。

Zenject がノリノリで筆が走った感じで色々な機能を取り入れているのは、それはそれでめっちゃおもろいしアイデアの豊富さは大尊敬なのですが、その柔軟すぎるAPIも相俟って、ユーザ間では濫用や混乱も見られると思います。

もちろん、Zenject は単に機能を提供するだけではなくて、直径10メートルのREADMEによって、正しいDI way を隙あらば語るといった 偉大な仕事も同時にしていていて、コミュニティへDIを啓蒙した影響は甚大。ZenjectによってDIを学んだ人もたぶんいそうです。 (VContainer もできる限りその善行を継承しようと、READMEにDIについても説明もいれました)

しかし、個人的には、オブジェクト指向(とくにオブジェクト指向です)の名前のついた原則やパターンとかは、世の中の議論が、実体より過度に複雑すぎる気がしてます。 だいたいどんなパターンもフレームワークも、そのいっこ前の時代の慣習について一言物申したり克服するために逆張りとして登場しているものが多いと思います。それを唯一の正解としてみたり商売のタネにすることは、過度な期待や誤解やミスマッチが生まれます。そもそも唯一の正解とかなくてすべてのことはトレードオフだと思います。

VContainer に最小限の機能しかない理由は、けっこう意識的にやっていて、正しいDIを使うにはむしろ機能が少ない方がうまくいくという主張でもあります。

APIについて

APIの良いところをすこし紹介してみようと思います。

Zenject は、Fluent API を多用しているため、メソッドチェインのチェインのチェインになにがあるか、チェインを途中で止めたときにどんな挙動になるかについてあまり透明性がないと思っています。
また、メソッドチェインのチェインの果ての不正なチェインをコンパイル時に防ぐため、たくさんのビルダー型が内部にあり、実装を読む難易度が高く、コードサイズも大きくなってます。

VContainer では、初手の builder.Register* のメソッドでどれを選ぶかによって、有効な選択肢をとり終わるようにしています。

// 一例

builder.Register<ActorController>(Lifetime.Scoped)
    .AsImplementedInterfaces();

builder.RegisterInstance(ground);

型を指定した場合は、Lifetimeの指定が必須ですが、 インスタンスを登録した場合、Transientとして登録することはまじで無意味なので、そもそも指定ができないようになってます。 これはメソッドの引数を見れば自明で、どんなチェインが有効化を調べる必要がないです。

メソッドチェインがないわけではなく、 As で登録する型をインターフェイスに変えたりできますが、これはどの種類の Register* でも有効で、状況によって出たり消えたりしないため一貫性があります。

また、Zenject のスコープは Singleton / Cached / Transient の3つですが、この Cached の意味するところが初見ではわかりにくいと思ってます。

VContainer は、 Singleton / Scoped / Transient です。

VContainerでは、 Containerがあるところには必ずLifetimeScope という名前のオブジェクトがあり、Scoped の寿命はこれと完全に一致するようにしているのでより自明です。

他、Zenjectと考え方が違っているところは、スコープの生成と破棄に関してです。

Zenjectは、子スコープがいつ作成されるかについても、あらかじめ DI の宣言によって決めておく、という方針になっていますが、
VContainer はスコープの作成を いつでもどこでも ユーザが呼び出せるメソッドとして公開しています。

通常のアプリケーションであれば、まずDIでインスタンスの初期化をしてからアプリケーションが動き出す、という順番が素直ですが、Unityの場合は、どう足掻いてもシーンの初期化が何よりも先にきてしまうため、DIライブラリをいれたとしてもシーンに従属した存在になってしまいます。
シーン自体の切り替えや追加は、アプリケーション側のコードで好きなように管理することになりますが、これはシーンの内部のコードの管理の範疇の外です。 そのため、アプリケーション側でスコープをフレキシブルに追加/破棄を行える方が使いやすいとおもいます。

たとえば、VContainer では、たとえば以下のような子スコープを作成するAPIが公開されています。

lifetimeScope.CreateChild(builder => ...)

また、シーン間の関連づけもサポートしてますが、アプリケーション側のシーンローディングの実装は自由に決めることができるようにしてます。

var current = LifetimeScope.FindDefault();

// 次に読むシーンの親を設定する
using (LifetimeScope.PushParent(current))
{
    // コルーチンを使う場合
    var loading = SceneManager.LoadSceneAsync("...", LoadSceneMode.Additive);
    while (!loading.isDone)
    {
        yield return null;
    }
}

using (LifetimeScope.PushParent(current))
{
    // UniTask 使う場合
    await SceneManager.LoadSceneAsync("...", LoadSceneMode.Additive);
}

中身

実装で工夫したところは、コンテナを完全にイミュータブル(不変)にしているところです。

いちど Registerし終わったコンテナは、新しくなにかを追加することができません。(その代わり子どもはいつでもつくれる)

これによって次のような利点があります。

  • コンテナは参照のみなので、なにもしなくてもスレッドセーフ。最適化もしやすい。
  • コンテナが何かをResolveしはじめる前のステージでは、 Unityの機能に依存しないので裏スレッドでも実行できる。
  • Register / Resolve のそれぞれのメソッド一覧がわかりやすい。(コンテナにはRegisterできないので)
  • DIコンテナの不正な使い方が不可能

FAQ

  • Q BindFactory ないの?
    • ないです。
    • 自前で Factoryクラスを作って登録すれば十分だと思ってます。
    • 将来的にはなにか考えるかも
  • Q 読み込み先のシーンに Injectしたいデータを渡すには?
  • Q NonLazy ないの?
    • ないです。
    • どこからもResolveされない、かつコンストラクタで副作用を実装するおとを推奨していません。IInitializable などを使ってください。
  • Q とはいえとはいえ、どこからも Resolve されない MonoBehaviour へ Injectしたいときに NonLazy ほしいです
    • IInitializable他があるので、処理のエントリポイントMonoBehaviour にしないことを推奨してます
  • Q Inject Id ないの?
    • ないです。
    • 同じ型を重複して登録すること自体を推奨してません。 代わりに .WithParameter() はあります。
  • Q Optional ないの?
    • ないです
  • Q Signalないの?
    • ないです。
    • アプリケーションにあった実装を入れよう
  • Q MemoryPool ないの?

    • ないです。
    • アプリケーションにあった実装を入れよう
  • Q Container自体をResolver できないの?

    • builder.RegisterContainer() というのがあります。(裏メニュー)

そんなこんなで

もっと薄いDIライブラリが欲しかった方は使ってみてください。 Unity依存の機能については、まだ使い込めていないので、もしバグとかあれば報告もらえたら対応します。

Github の ☆ とかをもらえると、開発への愛が増すので気に入ったら押してね