【问题标题】:Unity injection with too many constructor parameters构造函数参数过多的 Unity 注入
【发布时间】:2020-05-24 08:50:48
【问题描述】:

我有以下与 Unity 相关的问题。下面的代码存根设置了基本场景,问题在底部。

注意,[Dependency] 属性不适用于下面的示例并导致 StackoverflowException,但构造函数注入确实有效。

NOTE(2) 下面的一些 cmets 开始分配“标签”,例如代码异味、糟糕的设计等……因此,为避免混淆,这里是没有任何设计的业务设置。

这个问题似乎在一些最著名的 C# 大师中引起了激烈的争论。事实上,这个问题远远超出了 C#,它更多地属于纯计算机科学。该问题基于服务定位器模式和纯依赖注入模式之间众所周知的“战斗”:https://martinfowler.com/articles/injection.htmlhttp://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/ 以及后续更新以纠正依赖注入变得过于复杂时的情况:http://blog.ploeh.dk/2010/02/02/RefactoringtoAggregateServices/

这是一种情况,它与最后两个描述的不太吻合,但似乎完全适合第一个。

我有大量(50 多个)我称之为微服务的集合。如果您有更好的名字,请在阅读时“应用”它。它们中的每一个都对单个对象进行操作,我们称之为引用。但是,元组(上下文+引号)似乎更合适。报价是一个业务对象,它被处理并序列化到数据库中,上下文是一些支持信息,在处理报价时是必需的,但不会保存到数据库中。其中一些支持信息实际上可能来自数据库或某些第三方服务。这是无关紧要的。装配线是一个现实世界的例子:装配工人(微服务)接收一些输入(指令(上下文)+零件(报价)),处理它(根据指令对零件做某事和/或修改指令)如果成功或在出现问题时丢弃它(引发异常),则进一步传递它。微服务最终被捆绑成一小部分(大约 5 个)高级服务。这种方法将一些非常复杂的业务对象的处理线性化,并允许将每个微服务与所有其他微服务分开测试:只需给它一个输入状态并测试它是否产生预期的输出。

这就是有趣的地方。由于涉及的步骤数量众多,高级服务开始依赖于许多微服务:10+ 甚至更多。这种依赖是自然的,它只是反映了底层业务对象的复杂性。除此之外,几乎可以不断地添加/删除微服务:基本上,它们是一些业务规则,几乎像水一样流动。

这与上述 Mark 的建议严重冲突:如果我有 10 多个有效独立的规则应用于某个高级服务中的报价,那么,根据第三篇博客,我应该将它们聚合成一些逻辑组,比方说不超过 3-4,而不是通过构造函数注入所有 10+。但是没有逻辑组!虽然有些规则是松散依赖的,但它们中的大多数不是,因此人为地将它们捆绑在一起弊大于利。

规则变化频繁,这就变成了维护的噩梦:每次规则变化时,所有真实/模拟的调用都必须更新。

我什至没有提到这些规则取决于美国各州,因此理论上大约有 50 个规则集合,每个州和每个工作流都有一个集合。虽然有些规则在所有州之间共享(例如“将报价保存到数据库”),但还有很多州特定的规则。

这是一个非常简单的例子。

报价 - 保存到数据库中的业务对象。

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);
}

所有微服务都继承自这个接口。

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)
    {
        // Perform Aggregate over QuotePipeline.
        // That applies each step from workflow to a quote.
        return quote;
    }
}

高级“工作流”服务之一。

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主要有两种方式:

标准做法是通过构造函数注入所有依赖:

    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 将发挥其“魔力”并在运行时解析所有服务。问题是,目前我们有大约 30 个这样的微服务,而且预计数量还会增加。随后,一些构造函数已经注入了 10 多个服务。这不方便维护、模拟等......

当然,可以使用这里的想法: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

我该怎么办?

【问题讨论】:

  • 我的看法是,这到处都是代码味道。它也看起来像XY problem。应审查当前的设计决策。
  • 由于您指的是 Mark Seemann 的博客,我建议您阅读 Dependency Injection in .NET, second edition 的第 6.1 章,其中广泛讨论了修复构造函数注入代码气味。
  • 请不要“滥用”微服务这个词。微服务是独立运行的自治应用程序。您的微服务都在同一个进程中运行并变异同一个实体,这两个特征都与微服务非常不同。
  • 你得到一个堆栈溢出异常的事实表明你有一个循环依赖。大多数 DI 容器对此进行检查并给出有意义的异常,而不是抛出堆栈跟踪。

标签: c# dependency-injection unity-container


【解决方案1】:

我只会创建一个界面:

public interface IDoSomethingAble
{
    Quote DoSomething(Quote quote);
}

还有一个聚合:

public interface IDoSomethingAggregate : IDoSomethingAble {}

public class DoSomethingAggregate : IDoSomethingAggregate 
{
    private IEnumerable<IDoSomethingAble> somethingAbles;

    public class DoSomethingAggregate(IEnumerable<IDoSomethingAble> somethingAbles)
    {
        _somethingAbles = somethingAbles;
    }

    public Quote DoSomething(Quote quote)
    {
        foreach(var somethingAble in _somethingAbles)
        {
            somethingAble.DoSomething(quote);
        }
        return quote;
    }
}

注意:依赖注入并不意味着,你需要在任何地方使用它。

我会去工厂:

public class DoSomethingAggregateFactory
{
    public IDoSomethingAggregate Create()
    {
        return new DoSomethingAggregate(GetItems());
    }

    private IEnumerable<IDoSomethingAble> GetItems()
    {
        yield return new Service1();
        yield return new Service2();
        yield return new Service3();
        yield return new Service4();
        yield return new Service5();
    }
}

其他所有东西(不是构造函数注入的)都隐藏了显式依赖关系。


作为最后的手段,您还可以创建一些DTO 对象,通过构造函数注入所有需要的服务(但只有一次)。

这样您就可以请求ProcessorServiceScope 并让所有服务可用,而无需为每个类创建 ctor 逻辑:

public class ProcessorServiceScope
{
    public Service1 Service1 {get;};
    public ServiceN ServiceN {get;};

    public ProcessorServiceScope(Service1 service1, ServiceN serviceN)
    {
        Service1 = service1;
        ServiceN = serviceN;
    }
}

public class Processor1
{
    public Processor1(ProcessorServiceScope serviceScope)
    {
        //...
    }
}

public class ProcessorN
{
    public ProcessorN(ProcessorServiceScope serviceScope)
    {
        //...
    }
}

看起来像ServiceLocator,但它并没有隐藏依赖关系,所以我认为这还可以。

【讨论】:

  • 我们在单元测试期间模拟所有服务,这意味着我需要能够模拟 GetItems 。这将需要编写另一个“模拟”版本,然后保持同步。如果它们不同步,这可能会导致错误。上面的原始实现没有这个缺陷。
  • 你的单元测试不能看看GetItemsDefaultGetItemsMocked方法,根据返回的对象类型的唯一数量做一些检查吗?或者,您可以拥有一个Service1Factory,它可以创建一个模拟版本和一个默认版本。 @康斯坦丁康斯坦丁诺夫
  • 我觉得使用 IEnumerable 存在很大的设计问题。在我们这里,Mocked 代码不会与生产代码混在一起,所以我不能让生产级工厂实现 GetItemsMocked。当一些甚至所有对象都被模拟替换时编写单元/集成测试成为一场噩梦:如果我想在某些测试中模拟 10 多个对象中的 5 个,而在其他一些测试中模拟这 10 多个对象中的另外 3 个,该怎么办?这意味着我必须为每个测试设置创建不同的工厂,然后在规则更改时更新所有工厂。 @ChristianGollhardt
  • 如果他们是不同的测试,他们应该是不同的创建。内联或通过不同的工厂。 @康斯坦丁康斯坦丁诺夫
  • 我只针对这个“报价”对象进行了大约 100 次测试(我们对整个系统进行了大约 10K 次测试)。 “报价”测试分散在 15-20 个测试班中。 IEnumerable factory方式意味着至少会有这15-20个工厂,当规则改变时必须保持同步。他们做到了。这将是一场维护噩梦。并且编译器将无助于找出应该更改的内容:(
【解决方案2】:

考虑列出的各种接口方法:

Quote DoSomething_1(Quote quote);
Quote DoSomething_N(Quote quote);
Quote ProcessQuote(Quote quote = null)
Quote CreateQuote(Quote quote = null);

除了名称之外,它们都是相同的。为什么要把事情搞得这么复杂?考虑到Reused Abstractions Principle,我认为如果你有更少的抽象和更多的实现会更好。

因此,改为引入一个抽象:

public interface IQuoteProcessor
{
    Quote ProcessQuote(Quote quote);
}

这是一个很好的抽象,因为它是 endomorphism 上的 Quote,我们知道它是可组合的。 You can always create a Composite of an endomorphism:

public class CompositeQuoteProcessor : IQuoteProcessor
{
    private readonly IReadOnlyCollection<IQuoteProcessor> processors;

    public CompositeQuoteProcessor(params IQuoteProcessor[] processors)
    {
        this.processors = processors ?? throw new ArgumentNullException(nameof(processors));
    }

    public Quote ProcessQuote(Quote quote)
    {
        var q = quote;
        foreach (var p in processors)
            q = p.ProcessQuote(q);
        return q;
    }
}

至此,我应该认为,您基本上已经完成了。您现在可以组合各种服务(在 OP 中称为微服务)。这是一个简单的例子:

var processor = new CompositeQuoteProcessor(new Service1(), new Service2());

这样的组合应该进入应用程序的Composition Root

各种服务可以有自己的依赖关系:

var processor =
    new CompositeQuoteProcessor(
        new Service3(
            new Foo()),
        new Service4());

如果有用的话,您甚至可以嵌套 Composites:

var processor =
    new CompositeQuoteProcessor(
        new CompositeQuoteProcessor(
            new Service1(),
            new Service2()),
        new CompositeQuoteProcessor(
            new Service3(
                new Foo()),
            new Service4()));

这很好地解决了 Constructor Over-injection 代码异味,因为 CompositeQuoteProcessor 类只有一个依赖项。但是,由于该单个依赖项是一个集合,因此您可以任意组合许多其他处理器。

在这个答案中,我完全忽略了 Unity。依赖注入是软件设计的问题。如果一个 DI Container 不能轻易地组成一个好的设计,你最好使用Pure DI,我在这里暗示过。


如果您必须使用 Unity,您始终可以创建派生自 CompositeQuoteProcessor 并采用 Concrete Dependencies 的具体类:

public class SomeQuoteProcessor1 : CompositeQuoteProcessor
{
    public SomeQuoteProcessor1(Service1 service1, Service3 service3) :
        base(service1, service3)
    {
    }
}

Unity 应该能够自动连接该类,然后...

【讨论】:

  • 亲爱的@MarkSeemann 谢谢你的回答。这是一个好主意,但是,正如您所写的,它完全忽略了 Unity。让我们看看如果我们引入它会发生什么:CompositeQuoteProcessor、单元测试、集成测试等的实际用法......所有这些都必须具有相同构造的调用,其中一些或所有类都是模拟的。这变成了维护的噩梦,因为编译器无法帮助我们找出在哪里进行更改,例如,如果我们添加了一个额外的服务。因此,恐怕构造函数中的参数数组弊大于利:(
  • @KonstantinKonstantinov 查看编辑。然而,在我看来,Unity 正在阻止你取得进展,所以我建议你删除它以支持 Pure DI。
  • 这就是我已经拥有的,除了“顶级”处理器是用接口声明的,这样我就可以在测试期间模拟我需要的任何东西:public SomeQuoteProcessor1(IService1 service1, ..., IServiceN serviceN)。一切正常,但“规则”或服务的数量很大。这是一个自然的业务需求,大多数规则都是独立的。当然,我可以应用聚合服务。但是,由于规则是独立的,因此将它们捆绑在一起是不合理的。我想要的是 Unity 控制、编译器验证和可模拟结构的线性化版本。
  • @KonstantinKonstantinov 考虑到上面提到的SomeQuoteProcessor1,你为什么觉得你需要对它进行单元测试?它没有CompositeQuoteProcessor 没有的行为。如果您觉得需要,您可以轻松地对CompositeQuoteProcessor 进行单元测试。这只是您需要编写一次的几个测试用例。
  • @KonstantinKonstantinov 我可能在这里遗漏了一些东西,所以如果我的建议没有用,我深表歉意。根据我的理解,我的建议是我目前的答案。忽略 Unity 时,您可以解决 Constructor Over-injection 气味。如果您将 Unity 添加到组合中,那么是的,我可以看到我的建议将要求您向此类类添加数十个具体依赖项。这是一种仅用于满足 Unity 的解决方法。它完全是声明性的;它的配置为代码。那么,它有多少依赖项重要吗?
【解决方案3】:

我从没想过我会回答我自己的问题,虽然很大一部分功劳应该归于https://softwareengineering.stackexchange.com/users/115084/john-wu - 他是让我的思想朝着正确方向发展的人。

尽管如此,自我提出问题以来已经过去了将近两年,虽然我在提出问题后稍微构建了问题的解决方案(感谢所有回答的人),但大多数人花了一年多的时间我工作的公司的开发人员真正了解它是如何工作的以及它做了什么(是的,他们都远高于平均水平的开发人员,是的,代码是用纯 C# 编写的,没有外部库)。所以,我认为这对于可能有类似业务场景的其他人来说可能很重要。

正如问题中提到的,我们问题的根源是我们正在处理的参数空间太大。我们有大约 6-8 个我们称之为工作流的值(称之为 W),大约 30-40 个我们称之为状态配置的值(称之为 S)——尽管这是美国州代码和其他两个参数的组合并非所有三元组都是可能的(该状态配置的实际内容无关紧要),并且我们称之为风险规则的大约 30-50 个值(称为 R)-该值取决于产品,但这也无关紧要,因为不同的产品有不同的处理方式。

所以,参数空间的总维度是 N = W * S * R,大约是 10K(我不太关心精确值)。这意味着当代码运行时,我们大约需要以下内容:对于每个工作流(显然一次只运行一个,但它们都在某个时间运行)和每个状态配置(同样一次只运行一个但它们中的任何一个都可以在某个时间运行)我们需要评估与该工作流程和该状态配置相关的所有风险规则。

好吧,如果参数空间的维度大约是 N,那么覆盖整个空间所需的测试数量至少是 N 的数量级。这正是遗留代码和测试试图做到的做什么以及是什么导致了这个问题。 答案是纯数学,而不是纯计算机科学,它基于所谓的可分离空间:https://en.wikipedia.org/wiki/Separable_space,而在群论术语中称为不可约表示:https://en.wikipedia.org/wiki/Irreducible_representation。虽然我不得不承认,后者更像是一种灵感,而不是群论的实际应用。

如果你已经失去了我,那很好。请在继续之前阅读上面提到的数学。

这里的空间可分离性意味着我们可以选择这样一个空间N,使得子空间W、S和R变得独立(或可分离)。据我所知,对于我们在 CS 中处理的有限空间,总是可以做到这一点。

这意味着我们可以将 N 空间描述为例如S 列出(或集合)一些规则,而通过将一组适用的工作流分配给每个规则,每个规则都适用于某些 W 工作流。是的,如果我们有一些原本应该应用于工作流和状态配置的奇怪组合的错误规则,那么我们可以将它们拆分为多个规则,这样就可以保持可分离性。

这当然可以概括,但我将跳过细节,因为它们无关紧要。

此时,有人可能会想,这有什么意义。好吧,如果我们可以将 N 维空间(在我们的例子中 N 约为 10K)分割成独立的子空间,那么神奇的事情就会发生,而不是按照 N = W *S * R 的顺序编写测试来覆盖整个参数空间,我们只需按 W + S + R 测试的顺序编写即可覆盖整个参数空间。 在我们的例子中,差异约为 100 倍

但这还不是全部。正如我们可以在集合或列表的概念中描述子空间(取决于需要),这自然地使我们想到了无用测试的概念。

等等,我刚才说的是无用的测试吗?是的,我做到了。让我解释。一个典型的 TDD 范式是,如果代码失败,那么我们需要做的第一件事就是创建一个测试,它会发现这个错误。好吧,如果代码由静态列表或集合(== 在代码中硬编码的列表或集合)描述,并且测试将由来自该列表/集合的标识转换来描述,那么这使得这样的测试毫无用处因为它必须重复原始列表/集合……

状态配置形成了一个历史模式,例如,假设我们在 2018 年的某个时候为 CA 的状态制定了一些规则。这组规则可能会在 2019 年稍微更改为其他一些规则,并且进入 2020 年的一些规则。这些变化很小:一组规则可能会增加或丢失一些规则和/或规则可能会稍作调整,例如如果我们将某个值与某个阈值进行比较,那么该阈值的值可能会在某个时候针对某些状态配置进行更改。并且一旦规则或规则集合被改变,那么它应该保持原样,直到它再次改变。同时还有一些其他的规则可以改变,每一次这样的改变都需要引入我们所说的状态配置。因此,对于每个美国州,我们都订购了这些州配置的集合(列表),并且对于每个州配置,我们都有一组规则。大多数规则不会改变,但其中一些规则会像描述的那样偶尔改变。一种自然的 IOC 方法是使用 IOC 容器注册每个规则集合和每个状态配置的每个规则,例如Unity 使用状态配置的唯一“名称”和规则/集合名称的组合(我们实际上在工作流期间运行多个规则集合),而每个规则已经有一个应该适用的工作流集合。然后,当代码针对给定的状态配置和给定的工作流运行时,我们可以将集合从 Unity 中提取出来。然后一个集合包含应该运行的规则的名称。然后将规则名称与状态配置名称结合起来,我们可以从 Unity 中提取实际规则,过滤集合以仅保留适用于给定工作流的规则,然后应用所有规则。 这里发生的是规则名称/集合名称形成一些封闭集,并且通过以这种方式描述它们而受益匪浅。我们显然不想手动为每个状态配置注册每个规则/集合,因为这会很乏味且容易出错。所以我们使用我们所谓的“规范化器”。假设我们有一条通用规则——这条规则对于所有状态配置都是相同的。然后我们只按名称注册它,规范器将“自动”为所有状态配置注册它。历史版本也是如此。一旦我们通过规则/集合名称 + 状态配置向 Unity 注册规则/集合,那么规范器将填补空白,直到我们在以后的某个状态配置中更改规则。

因此,每条规则都变得极其简单。他们中的大多数有零个或一个注入的构造函数参数,其中一些有两个,我只知道一个具有三个注入参数的规则。由于规则是独立且非常简单的,因此对规则的测试也变得非常简单。

我们确实有一些想法可以让我在上面写的任何内容都开源,前提是它可以为社区带来一些价值......

【讨论】:

    【解决方案4】:

    Unity 支持属性注入。无需将所有这些值传递给构造函数,只需使用带有 [Dependency] 属性的公共设置器即可。这使您可以根据需要添加任意数量的注入,而无需更新构造函数。

    public class QuoteCreateService : QuoteProcessor, IQuoteCreateService
    {
        [Dependency]
        protected IService_1 Service_1 { get; public set; }
        // ...
        [Dependency]
        protected IService_N Service_N; { get; public set; }
    
        public override QuoteUpdaterList QuotePipeline =>
            new QuoteUpdaterList
            {
                Service_1.DoSomething_1,
                // ...
                Service_N.DoSomething_N
            };
    
        public Quote CreateQuote(Quote quote = null) => 
            ProcessQuote(quote);
    }
    

    【讨论】:

    • 对不起,我应该在问题中提到这一点。如果 [Dependency] 属性用于该结构,Unity 会因 StackoverflowException 而崩溃。
    • @KonstantinKonstantinov 您能否在原始问题中包含带有堆栈跟踪的异常副本?
    • StackoverflowException 没有堆栈跟踪,因为它无法再被捕获:(
    • 当它被抛出时,你应该在调试器中得到一个堆栈跟踪。确保在抛出异常并关闭“仅我的代码”时中断。
    • 我否决了这个答案,因为使用属性注入并不能解决底层设计问题,即QuoteCreateService 违反了单一责任原则。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-01-02
    相关资源
    最近更新 更多