Help us understand the problem. What is going on with this article?

DIコンテナのつらみを補うミドルウェア "Deptorygen"

C#向けのAnalyzer+CodeFixライブラリを作ってみました。

https://github.com/NumAniCloud/Deptorygen

リポジトリ内にあるマニュアルを基にした紹介をしたいと思います。

対象読者

DIコンテナを使用してある程度大きなソフトウェアを開発したことのある方に向けています。

DeptorygenはC#用のアナライザーなので、C#を使用している方に向けています。

既存のDIコンテナとしてGenericHostを例に挙げているので、GenericHostを知っていると理解がスムーズかもしれません。

Deptorygenとは

Deptorygen(でぷとりじぇん)はC#向けのAnalyzer+CodeFixライブラリです。現在、Nuget経由で入手することができます。

Deptorygenの主な役割は、従来のDIコンテナの弱点を補うことです。従来のDIコンテナとはGenericHostのDIコンテナ機能のようなものを指しています。こういったミドルウェアは、インスタンスの依存関係を解決するために動的な処理を用います。それは時に動的コード生成だったり、リフレクションだったりします。

GenericHostにおける注入の設定の書き方
var services = new ServiceCollection();
services.AddSingleton<IService, ServiceGold>();
services.AddTransient<Client>();

一方で、Deptorygenはいわば静的なDIコンテナであり、コンストラクタインジェクションは本当にコンストラクタにインスタンスを注入するようなnew式としてコード生成されます。

静的コード生成で作られるのはファクトリーパターン的な振る舞いをするクラスで、そのクラスの機能はDeptorygenが「ファクトリー定義」と読んでいるような形式のインターフェースとして書かれます。

Deptorygenにおける注入の設定の書き方
[Factory]
interface IFactory
{
    [Resolution(typeof(ServiceGold))]
    IService ResolveService();
    Client ResolveClient();
}

依存関係を静的に解決するDeptorygenは万能ではなく、コンパイル時に型の判明しているものにしか使えません。かといって、依存関係を動的に解決する方法は時に強力すぎで、それを制御するコストが高くつくこともあります。Deptorygenと動的なDIコンテナを組み合わせて使うことがベストな使い方であると考えています。

静的コード生成の利点

不透明さの解消

動的に依存関係を判断してインスタンスを生成する場合、その手順は実行時に決まります。こうなると、プログラマーはインスタンスが実際にどのような手順で生成されているのかを知ることができません。

// この2つのクラス Service, Client の生成をDIで行いたい
class Service {}
class Client
{
    public Client(Service service){}
}

class Program
{
    public static void Main()
    {
        // 生成したい型の情報を登録する
        var services = new ServiceCollection();
        services.AddSingleton<Service>();
        services.AddTransient<Client>();
        var provider = services.BuildServiceProvider();

        // 利用側
        var client = provider.GetService<Client>();

        // →それで、どんな手順でインスタンスを生成しているのだろう?
        // 動的に依存関係が解決されるので分からない
    }
}

Deptorygenでは、インスタンスを生成するコードが静的にコード生成されるため、そのコードを見ればどのような手順で生成されているのかを理解することができます。

ファクトリーを生成してみよう

以下はユーザーの書くコードです。

using System;
using Deptorygen.Annotations;
using UseDeptorygen.Infra;

namespace UseDeptorygen.Samples.BasicDependency
{
    // newしたいクラスたち
    class Service
    {
        public void Show()
        {
            Console.WriteLine("This is Service!");
        }
    }

    class Client
    {
        private readonly Service _service;

        public Client(Service service)
        {
            _service = service;
        }

        public void Execute()
        {
            Console.WriteLine("# Client");
            _service.Show();
        }
    }

    // インターフェースに Factory 属性をつけたものが「ファクトリー定義」
    [Factory]
    interface IFactory
    {
        Service ResolveService();
        Client ResolveClient();
    }

    class Program
    {
        public static void Main()
        {
            var factory = new Factory();
            factory.ResolveClient().Execute();
        }
    }
}

IFactoryの部分にVisual Studioからクイックアクションが提供され、Deptorygenによるコード生成コマンドを実行することができます。

以下は生成されるコードです。

// <autogenerated />
#nullable enable
using System;
using System.Collections.Generic;

namespace UseDeptorygen.Samples.BasicDependency
{
    internal partial class Factory : IFactory
        , IDisposable
    {
        private Service? _ResolveServiceCache;
        private Client? _ResolveClientCache;

        public Factory()
        {
        }

        public Service ResolveService()
        {
            return _ResolveServiceCache ??= new Service();
        }

        public Client ResolveClient()
        {
            return _ResolveClientCache ??= new Client(ResolveService());
        }

        public void Dispose()
        {
        }
    }
}

生成されたFactoryクラスではいくつかの機能をサポートしています。

  • ServiceクラスをnewするResolveServiceメソッド
  • ClientクラスをnewするResolveClientメソッド
  • newしたService,Clientインスタンスはキャッシュする
  • キャッシュしたインスタンスがIDisposableなら、それをまとめてDisposeできる機能
  • ファクトリー定義として使用したインターフェースを実装している
  • 必要な名前空間があればusingする

ガイド:基本的な使い方
↑話題に関連するマニュアルのページへのリンクを張っておきますので参考にしてください。

無効な設定にコンパイルエラーを出す

動的に依存関係を判断してインスタンスを生成する場合、依存関係を解決する手段が実行時に決まるということなので、実際には依存関係を解決できないような設定でDIコンテナが使用されている場合にコンパイルエラーを出すことができません。

ここからはMainメソッドを省略した簡易的なコードで紹介します。トップレベルに処理が書かれていたら、それはMainメソッドの内部です。

class Service { }

var services = new ServiceCollection();

// DIコンテナに Service を登録するのを忘れちゃった
// services.AddSingleton<Service>();
var provider = services.BuildServiceProvider();

// 利用側
// ここが実行時エラーになる(コンパイルエラーにならない)
var service = provider.GetService<Service>();

Deptorygenを用いてファクトリークラスを生成すると、依存関係の解決が不可能であった型に対しては無効なコードが生成され、コンパイルエラーとなります。

class Service { }

[Factory]
interface IFactory
{
    // Service 型に対するメソッドを書き忘れちゃった
}

// こんな感じのファクトリーが生成される(機能がからっぽ)
public class Factory : IFactory, IDisposable
{
    public void Dispose()
    {
    }
}

// 利用側
var factory = new Factory();
// ResolveServiceなるメソッドは存在しないのでコンパイルエラーが出る
var service = factory.ResolveService();

ただし、依存先のインスタンスを生成することができないことにより依存関係の解決が不可能であった場合はファクトリークラス自体は有効なコードが生成されます。

その代わり、足りない依存先がコンストラクタの引数でもって利用者に対して要求されます。この引数にインスタンスを渡したくない場合はファクトリーも生成できないことになるため、プログラマーはファクトリークラスに対して十分に型の情報を伝える必要があることに気づくことができます。

class Service { }
class Client
{
    public Client(Service service) { }
}

[Factory]
interface IFactory
{
    // Service 型に対するメソッドを書き忘れちゃった
//  Service ResolveService();
    Client ResolveClientAsTransient();
}

// 生成されるコード
internal class Factory : IFactory, IDisposable
{
    private readonly Service _service;

    // 足りない依存先はコンストラクタで外部に要求する
    public Factory(Service service)
    {
        _service = service;
    }

    public Client ResolveClientAsTransient()
    {
        // Service に対するメソッドが無いので、仕方なくフィールドに持ってるインスタンスを使う
        // フィールドの中身は、コンストラクタを通じて渡される前提
        return new Client(_service);
    }

    public void Dispose()
    {
    }
}

ガイド:コンストラクタで意外な引数を要求されたら

追加の引数を与える

動的に依存関係を判断してインスタンスを生成するDIコンテナでは、実際にインスタンスを生成するタイミングで初めて得られるような情報を追加で引数に渡して、適切に設定されたインスタンスを生成できるものもあります。しかし、こうして与える追加の引数についてコンパイル時に型チェックをしてもらうことは困難です。

// これは今からnewしたいクラス
class Service
{
    public Service(string message) { }
}

var services = new ServiceCollection();
services.AddSingleton<Service>();   // ここでは引数に関する情報を伝えない

var provider = services.BuildServiceProvider();

// 生成時に引数を渡せる。ただしprovider.GetService<Service>() という書き方はできない
// 引数が (ServiceProvider, params object[]) なのでIntellisenseも効かない
var instance = ActivatorUtilities.CreateInstance<Service>(provider, "SomethingMessage");

// 型チェックがないので、stringを渡すべき場所に何でも渡せてしまう
// これは実行時エラーになる
var invalid = ActivatorUtilities.CreateInstance<Service>(provider, DateTime.Now);

DeptorygenでもそうしたDIコンテナと同様に、インスタンスを生成するときに追加の引数を渡すことができます。ただし、依存関係を解決するコードは静的に生成されているため、追加で渡す引数も必ず型チェックの対象となります。

// これは今からnewしたいクラス
class Service
{
    public Service(string message) { }
}

[Factory]
interface IFactory
{
    // ファクトリー定義の時点で引数の情報を伝えておく
    Service ResolveService(string message);
}

// 生成されるクラスは以下のような感じ
internal partial class Factory : IFactory
    , IDisposable
{
    private Service? _ResolveServiceCache;

    public Factory()
    {
    }

    public Service ResolveService(String message)
    {
        return _ResolveServiceCache ??= new Service(message);
    }

    public void Dispose()
    {
    }
}

var factory = new Factory();

// 生成時に引数を渡せる。静的コード生成なのでIntellisenseも効く
var instance = factory.ResolveService("SomethingMessage");

// 型チェックが効くので、これはコンパイルエラーになる
var invalid = factory.ResolveService(DateTime.Now);

サンプル:解決メソッドに直接オブジェクトを渡す

インスタンスの自由な寿命管理

DIコンテナにおいてインスタンスの寿命を直感的に管理するのは難しい課題です。筆者の利用したものの多くは、インスタンスの寿命はSingleton, Scope, Transient といった3つ程度の区分に分かれ、あとはDIコンテナ独自のクラス構造を駆使してスコープや寿命を管理します。

例えばGenericHostのDIコンテナであれば、ServiceProviderのインスタンス1つが1つのスコープに対応しています。

Deptorygenでは、インスタンスの寿命はそのインスタンスをキャッシュしているファクトリーが基準となります。ファクトリークラスはstaticなものではないし、DIコンテナとしての特別な機能が備わっているクラスでもないので、依存関係を注入する対象のクラスたちと同様に取りまわすことができます。もちろん、ファクトリーがファクトリーを生成することも可能です。

Deptorygenでのインスタンスの寿命は2種類です。Cached……つまりファクトリーそのものと同じ寿命か、Transient……生成するたびに違うインスタンスか、です。

ファクトリー自体をシングルトンにするのも自由です。その場合、寿命がCachedであるインスタンスもシングルトンな寿命を持つことになります。ファクトリーのコンストラクタがprivateになることをファクトリー定義で指示することが現状ではできないので、シングルトンにするには別のクラスに包含させる、あるいはファクトリー自体をDIコンテナに生成させるなどの工夫は要りそうです。

他にも、ファクトリークラスを生成する種となる複数のインターフェース定義のあいだに継承や包含の関係を持たせれば、複数のファクトリー間でキャッシュを共有したり、特定のインスタンスを生成する権利を持つクラスを限定するなどの使い方ができたりなど、DIコンテナを使わない場合と同じくらいに寿命とスコープを柔軟に管理することができます。

ガイド:ファクトリーを別のアセンブリに提供する

サンプル:依存関係の解決に別のファクトリーも利用する(キャプチャ)

動的な依存解決との組み合わせ

プラグインで拡張のできるアプリなどを開発していると、外部からどのようなクラスが供給されるのか不明な場合があります。

特に、2つのプラグイン間で依存関係が存在する場合は困難な問題になります。どのようなクラスが供給されるのかだけでなく、どのようなクラスが要求されるのかすら不明なため、静的に依存関係を解決できる可能性は絶望的です。

こうなった場合、動的な依存解決の出番です。

Deptorygenは現在GenericHostのDIコンテナと連携する機能があり、Deptorygenの生成したファクトリークラスをGenericHostが依存解決する際に利用するよう登録できます。

以下はユーザーの書くコードです。

// newしたいクラス Service, Service2, Client
class Service { }

class Service2 { }

class Client
{
    public Client(Service service, Service2 service2) { }
    public void Work() { /* service, service2 を使って何かする */ }
}

// ConfigureGenericHost 属性をつけると、GenericHostで使えるようになる
[Factory]
[ConfigureGenericHost]
interface IFactory
{
    Service ResolveService();
    Service2 ResolveService2();
    Client ResolveClient();
}

class GenericHostSample
{
    public void Run()
    {
        var services = new ServiceCollection();

        // GenericHost の ServiceCollection インスタンスに、ファクトリーのインスタンスを登録する
        services.UseDeptorygenFactory(new Factory());

        var serviceProvider = services.BuildServiceProvider();

        // Factory クラスで解決できる依存関係が、ServiceProvider からも解決できるようになる
        serviceProvider.GetService<Client>().Work();
    }
}

以下のようなコードが生成されます。

// <autogenerated />
#nullable enable
using System;
using System.Collections.Generic;
using Deptorygen.GenericHost;
using Microsoft.Extensions.DependencyInjection;

namespace UseDeptorygen.Samples.GenericHost
{
    internal partial class Factory : IFactory
        , IDisposable
        , IDeptorygenFactory
    {
        private Service? _ResolveServiceCache;
        private Service2? _ResolveService2Cache;
        private Client? _ResolveClientCache;

        public Factory()
        {
        }

        public Service ResolveService()
        {
            return _ResolveServiceCache ??= new Service();
        }

        public Service2 ResolveService2()
        {
            return _ResolveService2Cache ??= new Service2();
        }

        public Client ResolveClient()
        {
            return _ResolveClientCache ??= new Client(ResolveService(), ResolveService2());
        }

        // GenericHostと連携するためのメソッド
        public void ConfigureServices(IServiceCollection services)
        {
            // キャッシュはファクトリー側が管理するので、すべてTransient
            services.AddTransient<IFactory>(provider => this);
            services.AddTransient<Service>(provider => ResolveService());
            services.AddTransient<Service2>(provider => ResolveService2());
            services.AddTransient<Client>(provider => ResolveClient());
        }

        public void Dispose()
        {
        }
    }
}

この Factory クラスは IDeptorygenFactoryを実装しています。クラスがIDeptorygenFactory を実装していると、UseDeptorygenFactory拡張メソッドに渡すことができます。

ConfigureServicesメソッドがIDeptorygenFactoryの実装に必要なAPIです。

サンプル:GenericHostと連携する

まとめ

Deptorygenのコンセプトは、「静的に解決できる部分だけでも静的に解決する」です。依存関係を静的に解決することで生まれるいかなる潜在能力にぼくが期待しているかは、この記事には書ききれません。もういくつか紹介の記事を書くかもしれませんが、おそらくリポジトリに用意したマニュアルと同程度の紹介になると思います。

興味のある方はDeptorygenを使ってみてください。Twitterなどで感想・要望をもらえると嬉しいです。GitHubのissueを通じて要望を受け付ける予定はありませんが、issueを立ててもらってもそれほど困らないのでどうぞ。

Deptorygenにはまだ細かい問題が残っており、使いづらいこともあるかもしれません。反響があればディスカッションの類はSlackを立てて、そこでしたいかなと思っています。

それと……依存関係を静的に解決するというアイデアは別の言語にも適用できるだろうし、Deptorygenとは違った実装をC#に与えることもできると考えています。同じアイデアのミドルウェアがあれば教えてください。そして、皆さんもこのようなミドルウェアを作ってみると面白い挑戦になるかもしれません。

NumAniCloud
ゲーム制作に興味があります。
http://numani.info/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away