web-dev-qa-db-ja.com

コンストラクターパラメーターが多すぎるUnityインジェクション

問題は、以下で説明するシナリオに適した設計を選択することです。これは https://stackoverflow.com/questions/51940180/unity-injection-with-too-many-constructor-parameters からの再投稿であり、ここに質問を置くことが提案されました。

この質問は、いくつかの最も有名なC#の教祖の間でも深刻な論争を引き起こしているようです。実際、これはC#をはるかに超えており、純粋なコンピュータサイエンスに当てはまります。質問は、サービスロケータパターンと純粋な依存関係注入パターンの間のよく知られている「戦い」に基づいています。 https://martinfowler.com/articles/injection.html vs http: //blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/ と、依存関係の注入が複雑になりすぎた場合の状況を改善するための後続の更新: http://blog.ploeh.dk/2010/02/02/RefactoringtoAggregateServices /

私はこのテーマについて見つけることができるすべてを読み、いくつかの有名なC#達人に直接連絡さえしましたが、それでも最良の選択が何であるかはまだ不明です。

開発のセットアップと要件

  1. UnityはDI/IOCに使用します。そのため、クラスを直接作成することはなく、コンストラクタを介してインターフェイスのみを注入します。
  2. すべての単体テストでMoqとMockBehavior.Strictを使用して、予期した動作が常に得られ、予期せぬ驚きがないことを確認します。
  3. すべての外部サービスが何らかの方法でモックされ、すべて(またはほとんどすべて)の内部サービスが実際のものである統合テストでは、部分モックを使用します。
  4. すべてのインターフェースが登録されている単一の構成ルートを使用します。
  5. 設計を簡素化し、メンテナンス時間/コストを削減したいと考えています。

ビジネス設定

各ルールは単一のサービスにラップされているため、マイクロサービスと呼ぶ「ルール」の大規模なコレクション(50〜100+)があります。良い名前がある場合は、読んでそれを「適用」してください。それぞれが単一のオブジェクトを操作するので、それを引用と呼びましょう。ただし、タプル(コンテキスト+引用)の方が適切と思われます。見積もりはビジネスオブジェクトであり、処理されてデータベースにシリアル化されます。コンテキストはサポート情報であり、見積もりの​​処理中に必要ですが、データベースには保存されません。そのサポート情報の一部は、実際にはデータベースまたは一部のサードパーティサービスからのものである可能性があります。これは無関係です。組立ラインは実際の例として頭に浮かびます:組立作業員(マイクロサービス)は何らかの入力(指示(コンテキスト)+パーツ(引用))を受け取り、それを処理します(指示に従ってパーツで何かを行うか、または指示を変更します)。成功した場合はさらに渡しますOR問題がある場合は破棄します(例外が発生します)。マイクロサービスは最終的に少数(約5)の高レベルサービスにバンドルされます。このアプローチ非常に複雑なビジネスオブジェクトの処理を線形化し、各マイクロサービスを他のすべてのサービスから個別にテストできるようにします。入力状態を指定し、期待される出力を生成することをテストします。

ここが面白いところです。必要なステップ数が多いため、高レベルのサービスは多くのマイクロサービスに依存し始めます(10〜15以上)。この依存関係は自然であり、基礎となるビジネスオブジェクトの複雑さを反映するだけです。その上、マイクロサービスはほぼ一定の基準で追加/削除できます。基本的に、これらはいくつかのビジネスルールであり、ほとんど水と同じくらい流動的です。そのような高レベルのサービスは単一責任原則に違反すると誰かが述べるかもしれません。まあ、私が適用しなければならない場合、たとえば、見積もりを作成するために15以上の「ルール」がある場合、IQuoteCreateService.CreateQuoteの実装はそれらすべてを適用する必要がありますが、単一のタスクのみを実行して見積もりを作成します。

これは、上記のマークの推奨事項と激しくぶつかります。高レベルのサービスの見積もりに15以上の実質的に独立したルールを適用している場合、3番目のブログによると、それらをいくつかの論理グループに集約する必要があります。 15+をすべてコンストラクタで注入する代わりに3-4。しかし、論理的なグループはありません!一部のルールは緩やかに依存していますが、ほとんどは依存していないため、それらを人為的にバンドルすることは良いことよりも害をもたらします。

規則は頻繁に変更され、メンテナンスの悪夢になります。規則が変更されるたびに、すべての実際の呼び出し/模擬呼び出しを更新する必要があります。

また、ルールが米国の州に依存していることについても触れていません。したがって、理論的には、約50のルールのコレクションがあり、各州および各ワークフローごとに1つのコレクションがあります。また、一部のルールはすべての州で共有されていますが(「データベースに見積もりを保存」など)、州固有のルールは多数あります。

これは非常に簡略化した例です

見積もり-データベースに保存されるビジネスオブジェクト。

public class Quote
{
    public string SomeQuoteData { get; set; }
    // ...
}

マイクロサービス。それらのそれぞれは、引用するためにいくつかの小さな更新を実行します。上位レベルのサービスは、一部の下位レベルのマイクロサービスからも構築できます。

public interface IService_1
{
    Quote DoSomething_1(Quote quote);
}
// ...

public interface IService_N
{
    Quote DoSomething_N(Quote quote);
}

すべての高レベルサービスとマイクロサービスは、このインターフェイスを継承します。低レベルの実装:QuoteProcessorは、データ検証の呼び出し、ファイナライザータスクの実行(必要な場合)など、いくつかの一般的なタスクを提供するので便利です。これは質問とは無関係ですが、マイクロサービスの理由を説明しますこのインターフェースからも継承します。

public interface IQuoteProcessor
{
    List<Func<Quote, Quote>> QuotePipeline { get; }
    Quote ProcessQuote(Quote quote = null);
}

// Low level quote processor. It does all workflow related work.
public abstract class QuoteProcessor : IQuoteProcessor
{
    public abstract List<Func<Quote, Quote>> QuotePipeline { get; }

    public Quote ProcessQuote(Quote quote = null)
    {
        // The real code performs Aggregate over QuotePipeline.
        // That applies each step from workflow to a quote.
        return quote;
    }
}

高レベルの「ワークフロー」サービスの1つ:

public interface IQuoteCreateService
{
    Quote CreateQuote(Quote quote = null);
}

低レベルのマイクロサービスの多くを使用する実際の実装。

public class QuoteCreateService : QuoteProcessor, IQuoteCreateService
{
    protected IService_1 Service_1;
    // ...
    protected IService_N Service_N;

    public override List<Func<Quote, Quote>> QuotePipeline =>
        new List<Func<Quote, Quote>>
        {
            Service_1.DoSomething_1,
            // ...
            Service_N.DoSomething_N
        };

    public Quote CreateQuote(Quote quote = null) => 
        ProcessQuote(quote);
}

問題

DIを達成するには、主に2つの方法があります。

標準的なアプローチは、コンストラクタを通じてすべての依存関係を注入することです。

    public QuoteCreateService(
        IService_1 service_1,
        // ...
        IService_N service_N
        )
    {
        Service_1 = service_1;
        // ...
        Service_N = service_N;
    }

そして、すべてのタイプをUnityに登録します。

public static class UnityHelper
{
    public static void RegisterTypes(this IUnityContainer container)
    {
        container.RegisterType<IService_1, Service_1>(
            new ContainerControlledLifetimeManager());
        // ...
        container.RegisterType<IService_N, Service_N>(
            new ContainerControlledLifetimeManager());

        container.RegisterType<IQuoteCreateService, QuoteCreateService>(
            new ContainerControlledLifetimeManager());
    }
}

次に、Unityはその「魔法」を実行し、実行時にすべてのサービスを解決します。問題は、現在、このようなマイクロサービスが約50〜100あり、その数は増加すると予想されていることです。その後、いくつかのコンストラクタはすでに10-15以上のサービスが注入されています。これは、メンテナンス、モックなどに不便です...

確かに、ここからアイデアを使用することは可能です: http://blog.ploeh.dk/2010/02/02/RefactoringtoAggregateServices/ ただし、マイクロサービスは実際には互いに関連しておらず、それらを一緒にバンドルすることは、正当な理由のない人工的なプロセスです。さらに、ワークフロー全体を直線的で独立させる目的も無効になります(マイクロサービスは現在の "状態"を取り、その後、見積もりを使用していくつかのアクションを実行し、次に進むだけです)。それらのいずれも、それらの前または後の他のマイクロサービスを気にしません。

別のアイデアは、単一の「サービスリポジトリ」またはサービスロケータを作成するようです。

public interface IServiceRepository
{
    IService_1 Service_1 { get; set; }
    // ...
    IService_N Service_N { get; set; }

    IQuoteCreateService QuoteCreateService { get; set; }
}

public class ServiceRepository : IServiceRepository
{
    protected IUnityContainer Container { get; }

    public ServiceRepository(IUnityContainer container)
    {
        Container = container;
    }

    private IService_1 _service_1;

    public IService_1 Service_1
    {
        get => _service_1 ?? (_service_1 = Container.Resolve<IService_1>());
        set => _service_1 = value;
    }
    // ...
}

それをUnityに登録し、関連するすべてのサービスのコンストラクターを次のように変更します。

    public QuoteCreateService(IServiceRepository repo)
    {
        Service_1 = repo.Service_1;
        // ...
        Service_N = repo.Service_N;
    }

このアプローチの利点(前のものと比較して)は次のとおりです。

すべてのマイクロサービスと上位レベルのサービスは、統一された形式で作成できます。サービスとすべての単体テストのコンストラクター呼び出しを修正する必要なく、新しいマイクロサービスを簡単に追加/削除できます。その後、メンテナンスと複雑さが減少します。

インターフェースIServiceRepositoryにより、すべてのプロパティを反復処理し、すべてのサービスをインスタンス化できることを検証する、自動化された単体テストを簡単に作成できます。つまり、実行時に予期せぬ事態が発生することはありません。

このアプローチの問題は、サービスロケーターのように見え始めることです。これは、一部の人々がアンチパターンと見なしているものです。 http://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern / そして、人々はServiceRepositoryのようにすべての依存関係を明示的にして隠さないようにする必要があると主張し始めます。

ただし、元の質問への回答には、さらにいくつかのアイデアが含まれていました(上記のリンク)。それらはすべて、IEnumerableを使用してパラメーターの配列として、または適用されるルールのコレクションとして渡すというアイデアを中心としていました。私は個人的に、パラメーター配列を使用してサービスをコンストラクターに渡すと、各コンストラクター呼び出しを同期して維持し、コンパイラーがレスキューを使用しないようにする必要があるため、害があると考えています。

質問

コンストラクターを介してパラメーターを注入しすぎることに関連する複雑さを軽減したいルール(またはパラメーター)はかなり独立しているので、トップクラスのパラメーターの数を減らすためにそれらをバンドルする論理的な理由はありません。反対に、ビジネスのセットアップでは、多くのほぼ独立したルールを上位のビジネスオブジェクト(引用など)のレベルで適用する必要があります。

このコードの断片が私の注意を引いた:

_public override List<Func<Quote, Quote>> QuotePipeline =>
    new List<Func<Quote, Quote>>
    {
        Service_1.DoSomething_1,
        // ...
        Service_N.DoSomething_N
    };
_

個別の要素のリストを取得してすぐにリストに入れるという事実は、クラスに組み込まれていない暗黙のビジネスコンセプトがここにあることを示唆しています。それが私なら、パイプラインを表すクラスがあり、パイプライン自体か、呼び出し元がパイプラインを作成できるようにするファクトリーを注入します。

依存関係と寿命の管理はファクトリーの単一の責任の一部であるため、ファクトリーを作成している場合、Unityコンテナー自体を渡すことは「OK」です。このようにして、IoCのルールを大幅に破ることなく、サービスロケーターパターンのメリットを享受できます。

単体テストが必要な場合は、モックを返す別のパイプラインファクトリに置き換えることができます。

したがって、最初にパイプラインを定義します。

_public class QuotePipeline : List<Func<Quote,Quote>>
{
    public Quote Execute(Quote quote)
    {
        foreach (var f in this) quote = f(quote);
        return quote;
    }
}
_

次に、ファクトリを記述します。クラスにコンストラクタ引数としてIUnityContainerがある場合、Unityは常に自動的に自身を挿入します。

_class QuotePipelineFactory : IQuotePipelineFactory
{
    protected readonly IUnityContainer _container;

    public QuotePipelineFactory(IUnityContainer container)
    {
        _container = container;
    }

    public QuotePipeline GetPipeline()
    {
        var p = new QuotePipeline();

        var d1 = _container.Resolve<Service_1>();
        p.Add( q => d1.DoSomething_1(q) );

        var d2 = _container.Resolve<Service_2>();
        p.Add( q => d2.DoSomething_2(q) );

        return p;
    }
}
_

次に、ファクトリを注入し、パイプライン自体を取得します。

_public class QuoteCreateService : IQuoteProcessor
{
    protected readonly IQuotePipelineFactory _quotePipelineFactory;

    public QuoteCreateService(IQuotePipelineFactory quotePipelineFactory)
    {
        _quotePipelineFactory = quotePipelineFactory;
    }

    public Quote CreateQuote(Quote quote)
    {
        var p = _quotePipelineFactory.GetPipeline();
        return p.Execute(quote);
    }
}
_

現在、依存関係は1つしかなく、任意の数の引用プロセッサをサポートできます。

見積もりパイプラインが常に同じである場合は、もちろんそれをファクトリ内の単一のインスタンスとして保存できます。これは、Unityによって適切にライフタイムスコープになります。

パイプラインが異なる場合は、常にGetPipeline()に入力引数を追加し、 戦略パターン を使用して、パイプラインに含めるプロセッサを選択することができます。

1
John Wu

個人的には、コンストラクタを介した注入はまったく好きではありません。それは、クラスには他の誰も気にしない注入されたクラスが必要になるためですが、クラスのインスタンスを作成する人は、これらの注入を実行する必要があるため、コンストラクターにも注入する必要があるなどの理由によります。

各クラスが自分自身の面倒を見れば、はるかに良くなります。いくつかのファクトリーを用意するか、必要に応じて代替品を返すシングルトンを用意し、必要な注入オブジェクトを全員が収集します。真剣に、なぜ7レベル離れた他のインスタンスによって使用されるコンストラクターに注入されたクラスが必要なのですか?

1
gnasher729