MicroResolver - C#最速のDIコンテナライブラリと、最速を支えるメタプログラミングテクニック

MicroResolver、というDIコンテナを作りました。Microといいつつ、フルフルではないですがそれなりにフルセットな機能もあります。DIの意義とか使い方とかは割とどうでもいい話なので、何をやったら最速にできるのかってところを中心に説明しますので、DIに興味ない人もどうぞ。

例によってインストールはNuGetからで、.NET 4.6 から .NET Standard 1.4 で使えます。

DIコンテナはIoC Performanceという、存在するDIライブラリは全部突っ込んだ総合ベンチマークがあるので、そこで好成績を出せれば勝ったといえるでしょう。

Container Singleton Transient Combined Complex
No 53
50
58
51
71
73
87
67
abioc 0.6.0 46
47
67
55
72
66
86
65
Autofac 4.6.0 562
477
545
488
1408
1252
4726
4350
DryIoc 2.10.4 49
37
47
47
62
60
69
57
fFastInjector 1.0.1 21
27
61
52
145
108
373
223
Mef2 1.0.30.0 187
119
199
133
274
159
447
266
MicroResolver 2.0.0 26
33
31
39
50
55
72
63
Ninject 3.2.2.0 3978
2444
12567
7963
34620
19315
95859
60936
SimpleInjector 4.0.8 58
44
82
59
93
76
109
80
Unity 4.0.1 1992
1042
2745
1523
7161
3843
19892
10586

てわけで、TransientとCombinedで勝ってます。フル結果はでっかいのでこちら。ただし、これはジェネリクス版に書き換えて比較しているので、ノンジェネリクスで統一している場合は若干異なる結果になります。つまり、MicroResolverにやや有利になってます。その辺どうしていきましょうかってのは要議論。

使い方イメージ

高速化の説明の前に、さすがに簡単な使い方がわからないとイメージつかないと思うので、使い方の方を軽く。

// Create a new container
var resolver = ObjectResolver.Create();
 
// Register interface->type map, default is transient(instantiate every request)
resolver.Register<IUserRepository, SqlUserRepository>();
 
// You can configure lifestyle - Transient, Singleton or Scoped
resolver.Register<ILogger, MailLogger>(Lifestyle.Singleton);
 
// Compile and Verify container(this is required step)
resolver.Compile();
 
// Get instance from container
var userRepository = resolver.Resolve<IUserRepository>();
var logger = resolver.Resolve<ILogger>();

というわけで、ObjectResolver.Create でコンテナを作って、そこにRegisterでインターフェイス-具象型の関連をマップしていって、Compileで検証とコード生成。あとはResolveで取り出せる。みたいなイメージです。普通のDIコンテナです。APIは私が一番触り心地が楽なように、かつ、一般的なものとは外れないように選んでいきました。Bind().To()とかいうような Fluent Syntax でやらせるやつは最低の触り心地なので、ナイですね。ナイ。まじでナイ。

IL生成時インライン化

単発のパフォーマンスは普通に動的コード生成やれば普通に出るのでいいんですが、少し複雑な依存関係を解決する、ネストの深い生成時にパフォーマンスの違いが大きく現れます。↑のベンチマークも、見方がわからないと漠然と速いとか遅いとかしかわからないと思うんですが、ぶっちゃけSingletonはどうでもよくて(というのも、別にDI使う時にSingletonで生成するものってあんま多くないよね?)大事なのはTransientとCombined、あるいはComplexです。Transientは単発の生成、Combinedは依存関係のある複数生成、ComplexはCombinedよりも多くの複数生成になってます。ようはこういうことです。

// こんなクラスが色々あるとして
public class ForPropertyInjection : IForPropertyInjection
{
    [Inject]
    public void OnCreate()
    {
    }
}
 
public class ForConstructorInjection : IForConsturctorInjection
{
    [Inject]
    public IForFieldInjection MyField;
}
 
public class ComplexType : IComplexType
{
    [Inject]
    public IForPropertyInjection MyProperty { get; set; }
 
    public ComplexType(IForConsturctorInjection instance1)
    {
 
    }
 
    [Inject]
    public void Initialize()
    {
    }
}
 
// このComplexTypeをどのようにライブラリは生成するか想像しましょう?
var v = resolver.Resolve<IComplexType>();

で、最初に、私はこういう実装にしたんですね。

static IComplexType ResolveComplexType(IObjectResolver resolver)
{
    var a = resolver.Resolve<IForConsturctorInjection>();
    var b = resolver.Resolve<IForPropertyInjection>();
 
    var result = new ComplexType(a);
    result.MyProperty = b;
    result.Initialize();
 
    return result;
}

まぁ別におかしくはない、素直なコード生成の実装だったんですが、これでベンチマーク走らせたら見事に負けたんですね。負けた!マジか!どういうことだ!ってことでよーく考えたんですが、中で多段にResolve<T>してるとこがネックっぽい。それなりに、というかかなり気を使って単発Resolve速度は上げてるんですが、とはいえ、多段呼び出しは多段呼び出しで、恐らくそれのせいで負けてるわけです。というか、もはやここを削る以外にやれることないし。というわけで、考えた手法はインライン化です、依存を解決した生成コードは全部フラットにインライン化してIL埋め込みます。

static ComplexType ResolveComposite()
{
    var a = new ForConstructorInjection();
    a.MyField = new ForFieldInjection();
    var b = new ForPropertyInjection();
    b.OnCreate();
 
    var result = new ComplexType(a);
    result.MyProperty = b;
    result.Initialize();
 
    return result;
}

↑のようなイメージのコードが型毎に生成されてます。これの効果は絶大で、Transientでは勝ってるのにCombinedでは負けたー、という状況もなくなり、他をきちんとなぎ倒せるようになりました。めでたしめでたし。実装的にもIL Emitの分割点を適切に切って足すだけなので、実はそんな難しくない。コスパ良い。

Dynamic Generic Type Caching

コード生成ってようするにデリゲートを作ることなんですが、それを型で分類してキャッシュするわけですが、それをどうやって保持して取り出しましょうか、という問題が古くからあります。普通はDictionary<Type, T>とか、ConcurrentDictionary<Type, T>とか使うんですが、ジェネリクスを活用すればもう少し速くできるんですね。ようするに

static Cache<T>
{
    // ここに保持すればいいんじゃもん
    public Func<T> factory;
}

こういうことです。これは別に珍しくなく、 EqualityComparer<T>.Default とかで割と日常的に使ってるはずです。しかしコンテナって複数作ったりするので、staticクラスにはできないんですよねー、ということで困ってしまうわけですが、私はこういうふうに解決しました。まず、これがObjectResolver(コンテナ)のシグネチャ(一部)です。

public abstract class ObjectResolver
{
    public abstract T Resolve<T>();
}

で、ObjectResolver.Createで新しいコンテナを作成する際に、こういう型を動的生成しています(とにかくなんでも生成するのです!)

public class ObjectResolver_Generated1 : ObjectResolver
{
    public override T Resolve<T>()
    {
        // 余計なものが一切ない超絶シンプルなコードパスにまで落とし込んでいるので、当然最強に速い
        return Cache<T>.factory();
    }
 
    Cache<T>
    {
        // IL生成時インライン化のとこで説明したコードがここに代入されてる
        public Func<T> factory;
    }
}

さすがにもはや文句のつけようもなく、これ以上速くするのは難しいでしょう。しいていえばTransientとSingletonが共通化されているので(Singletonの場合はfactory()を呼ぶと中でLazy.Valueを返すようになってる)、もしSingletonなら.Valueで取れたほうが速くなります。ただ、そうなるとTransientとSingletonで分岐コード書かなきゃいけなくなって、Transientの速度が犠牲になるんですよね。明らかにTransientを優先すべきなので、分岐なしのTransientを最速にする実装にしています。

ところで、これやるとコンテナを解放することはできません。作った型は消せません。あと、やっぱコンテナ生成速度はそれなりに犠牲になってます。ただまぁ、コンテナ山のように作ることって普通ないと思うんで(生成速度が遅いといっても、ユニットテストとかでテストメソッド毎に作るぐらいなら別に許せるレベルですよ)いいでしょう。山のように作らなければ、解放できないことによるメモリ云々カンヌンも大したことないはずなので。

非ジェネリック用の特化ハッシュテーブル

いくらジェネリクスを最速にしても、フレームワークから使われる時って object Resolve(Type type) を要求することが多いんですよね。なので、そっちのほうも最適化してやらなきゃいけません。んで、デザインとしてMicroResolverは事前Compileで、以後追加はない、完全に中身が固定化されるという仕様にしたので、マルチスレッドは考えなくていい。つまりConcurrentDictionaryはサヨナラ。そしてDictionaryも、さようなら。エクストリームな領域では汎用コンテナを使ったら負けです。中身が完全に固定されていて追加がない状態なら、固定配列を使ってもう少しパフォーマンスを稼げるはずだし、実装も簡単。

// こんな構造体を定義しておいて
struct HashTuple
{
    public Type type;
    public Func<object> factory;
}
 
// これがハッシュテーブルの中身、基本的に固定配列が最強です
private HashTuple[][] table;
 
// Register<T> は、つまりFunc<T> なわけですが、これはFuncの共変を使って直接 Func<object> に変換できます
// ExpressionTree経由で上からデリゲートを生成して変換する、という手が一般に使われますが、
// それは関数呼び出しが一つ増えるオーバーヘッドですからね!
// というわけで、MicroResolverのRegister<T>のTにはclass制約がかかってます
table[hash][index] = new Func<object>(Resolve<T>);
 
// で実際に呼び出すばやい
public object Resolve(Type type)
{
    var hashCode = type.GetHashCode();
    var buckets = table[hashCode % table.Length];
 
    // チェイン法によるハッシュテーブルの配列は、拡縮を考えなくていいので連結リストではなく固定サイズの配列
    // 当然これがループ的には最速だし、ついでに.Lengthで回せるので配列の境界チェックも削れる
    for (int i = 0; i < buckets.Length; i++)
    {
        if (buckets[i].type == type)
        {
            return buckets[i].factory();
        }
    }
 
    throw new MicroResolverException("Type was not dound, Type: " + type.FullName);
}

実装は別に難しくなくて、難しいのは汎用コンテナを捨てる、という決断だけですね。捨ててもいいんだ、という発想を持てること。が何気に大事です。当たり前ですが一般論はDictionaryを使えってことですが、使わないという選択を完全に捨て去ってしまうのは間違いです。そこの塩梅を持てるようになると、一歩ステップアップできるんじゃないでしょうか?杓子定規の綺麗事ばかり言ってると人間進歩しないですしね。むしろ世の中の本質は汚いところにある。

さて、とはいえ、ジェネリック版が優先で、非ジェネリックはサブなんですが、実装によっては非ジェネリックを優先で、ジェネリックはフォールバックにする実装もあります。というか普通はそっちです。ので、ベンチマークではどっち優先のものかで差が出ちゃうんですよね。今回私が計測したのはジェネリック優先のベンチマークにしましたが、非ジェネリック優先のベンチマークだと、そのものが非ジェネリック優先で作られたものに負けてしまったりします。きわどい勝負をしてるので、むつかしいところですね。

DIとしての機能

一応DIとしてはちゃんと機能あって、コンストラクタインジェクション、プロパティインジェクション、フィールドインジェクション、メソッドインジェクションをサポートしてます。インジェクト対象は明示的に[Inject]をつけてください。かわりに、プライベートでも問答無用で差し込めます。

public class MyType : IMyType
{
    // field injection
 
    [Inject]
    public IInjectTarget PublicField;
 
    [Inject]
    IInjectTarget PrivateField;
 
    // property injection
 
    [Inject]
    public IInjectTarget PublicProperty { get; set; }
 
    [Inject]
    IInjectTarget PrivateProperty { get; set; }
 
    // constructor injection
    // if not marked [Inject], the constructor with the most parameters is used.
    [Inject]
    public MyType(IInjectTarget x, IInjectTarget y, IInjectTarget z)
    {
 
    }
 
    // method injection
 
    [Inject]
    public void Initialize1()
    {
    }
 
    [Inject]
    public void Initialize2()
    {
    }
}
 
// and resolve it
var v = resolver.Resolve<IMyType>();

お行儀が良いのはコンストラクタインジェクションで、お行儀が一番悪いのはプライベートフィールドインジェクションなんですが、ぶっちけコンストラクタインジェクションに拘る必要はないでしょうね。プライベートフィールドインジェクションとかするとDIコンテナ以外から生成できないじゃん!とかいうけど、どうせDIコンテナ使ったらアプリケーション全体でDIコンテナ依存するので、コンストラクタインジェクションならDIコンテナなしでもDependency Injection Patternとしてキレイにおさまるからいいよね、とかクソどうでもいいので無視でいいでしょう。むしろライブラリ使うんなら諦めてライブラリと心中するぐらいの覚悟のほうが、いい結果残せるでしょう。

まぁプライベートフィールドインジェクションすると警告出て(未初期化のフィールドを触ってます的なあれそれ)ウザかったりもしますが。

そういう意味ではService Locator is an Anti-Patternもどうでもよくて、Service Locatorの何が悪い(どうせキレイに作ってもなんらかのライブラリに依存するんだから、Service Locatorなしでメンテナンスビリティ云々とかないでしょふし、どうせそもそも深い依存関係をDIコンテナから生成するならコンストラクタで依存を表明とか実質ないんでどうでもよろし)。ってのはありますね。でも普通にService Locatorでやるよりも依存のトップからMicroResolverでResolveしたほうがパフォーマンスが良いので、そういう観点から適当に判断しましょう:)

まぁあと、RegisterCollectionで登録しておくと T[]とかで取り出したりできます。大事大事。

// Register type -> many types
resolver.RegisterCollection<IMyType>(typeof(T1), typeof(T2), typeof(T3));
 
resolver.Compile();
 
// can resolve by IEnumerbale<T> or T[] or IReadOnlyList<T>.
resolver.Resolve<IEnumerable<IMyType>>();
resolver.Resolve<IMyType[]>();
resolver.Resolve<IReadOnlyList<IMyType>>();
 
// can resolve other type's inject target.
public class AnotherType
{
    public AnotherType(IMyType[] targets)
    {
    }
}

Lifetime.Scopedとかもありますが、その辺はReadMe見てください。この辺までカバーしておけば、別にパフォーマンス特化で機能犠牲、ってわけでもなく、ちゃんとDIライブラリとしての機能は満たしているといえるでしょう。実際満たしてる。

まとめ

テストのための設計、というのがすごく好きじゃなくて、テスタビリティのためにシンプルなプロダクトの設計を、大なり小なり歪めるでしょうね。そして、どうしてもDependency Injection Patternのようになっていくわけですが、ライブラリなしでそのパターンやると、相当キツいってのが間違いなくあるんですねー。ライブラリのチョイスとか利用ってものすごく大事だと思っていて、何も考えずテスト最高!とかいってるのはあまりにもお花畑なんで、一歩引いて考えたい、と。とはいえ、さすがに無策なのはそれはそれでしょーもないんで、改めてDIパターンとは、サービスロケーターとは、そしてDIライブラリとは、っていうところから見つめ直してみました。

DIライブラリのパフォーマンスは、まぁそこまで大事ではないと思います、少なくともシリアライザよりは。なので、さすがにベンチマークであからさまに遅いのは正直使う気起きなくなると思いますが(Ninject!)、そこそこのなら別にいいんじゃないかと。SimpleInjectorは速度と機能、そしてコミュニティの成熟度からバランスは良さそうだなーって印象ありますね。AutofacやUnity(DIライブラリの)は、基幹的な設計が(パフォーマンス的な意味で)古いというところもあってベンチ結果は一歩遅いんですが、とはいえこれがネックになるかどうかでいうと、なんともってところです。とはいえあえて古臭いものを使いたいかって話はある。

DIライブラリ全体の印象としては、雨後の筍のように山のようにあるだけに、上位のものはみんなかなりパフォーマンス的に競っていて、それぞれ良いアプローチをしていて、「ランキング一位を目指す」的なプログラミング芸としては中々楽しかった!それじゃただの趣味プロですね。いい加減さすがにC#メタプログラミングは極めた感ある。というか2~3日腰据えて書いただけで一位取れちゃうってのもどうなのかね、うーん。

まぁ、それなりにいい感じにまとまってるとは思うんで、MicroResolverも、よければ使ってみてくださいな。ちなみにUnity(ゲームエンジン)版はありません(今回の目的がハナからベンチマークで一位を取る、というところにフォーカスしてるんでIL生成芸以外のことはやる気なし)

Comment (0)

Name
WebSite(option)
Comment

Trackback(0) | http://neue.cc/2017/07/09_554.html/trackback

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for Visual Studio and Development Technologies(C#)

April 2011
|
July 2018

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc