Loading

第六章-代码的味道

您可能已经注意到,我(Mark)对酱汁贝纳酱或酱蛋黄酱很着迷。 原因之一是它的味道好极了。另一个是做起来有点棘手。除了生产方面的挑战外,它还带来了一个完全不同的问题:必须立即送达(或者我认为是)。

当客人到达时,这曾经不理想。我不能随便问候我的客人并使他们感到宾至如归,而是疯狂地在厨房里搅打酱汁,让他们自娱自乐。经过几次重复表演后,我善于交际的妻子决定将事情交到自己手中。我们住在一家餐馆的街对面,所以有一天,她与厨师聊天,以了解是否有一种技巧可以使我提前准备真正的荷兰菜。原来有。现在,我可以为客人提供美味的调味酱,而无需先让他们处于压力和狂热的气氛中。

每件手工艺品都有自己的技巧。 通常,对于软件开发,尤其对于DI,也是如此。挑战不断涌现。 在许多情况下,有众所周知的处理方式。 多年来,我们已经看到人们在学习DI时会遇到困难,并且许多问题本质上是相似的。 在本章中,我们将介绍将DI应用于代码库时出现的最常见的代码的味道(Code smells)以及如何解决它们。完成后,您应该能够更好地识别和处理这些情况。

与本书本部分的前两章相似,本章以目录的形式组织—这次是问题和解决方案的目录(或重构)。您可以根据需要独立或按顺序阅读每个部分。 每个部分的目的是使您熟悉常见问题的解决方案,以便您更好地进行处理。 但首先,让我们定义代码的味道。

定义 代码的味道(Code smells)是暗示某些东西可能是错误的,而不是确定性的。完美的习惯用法可能被认为是代码的味道(Code smells),因为它经常被滥用,或者因为有一种更简单的替代方法在大多数情况下效果更好。调用某种代码的味道(Code smells)不是攻击。 这表明必须仔细观察。 (http://wiki.c2.com/?CodeSmell)

反模式(anti-pattern)是对通常会产生肯定负面影响的问题的解决方案的描述,而代码的味道(Code smells)则是可能导致问题的代码构造。代码的味道(Code smells)仅需进一步调查。

处理构造函数过度注入代码的味道(Dealing with the Constructor Over-injection code smell)

除非您有特殊要求,否则构造函数注入(Constructor Injection)(我们将在第4章中对此进行了介绍)应该是您的首选注入方式。尽管构造函数注入(Constructor Injection)易于实现和使用,但是当开发人员的构造函数看起来像下面所示时,它会使开发人员感到不舒服。

清单6.1 具有许多依赖关系的构造函数(坏代码)

public OrderService(
    IOrderRepository orderRepository,
    IMessageService messageService,
    IBillingSystem billingSystem,
    ILocationService locationService,
    IInventoryManagement inventoryManagement)  <---OrderService依赖关系
{
    if (orderRepository == null)
        throw new ArgumentNullException("orderRepository");
    if (messageService == null)
        throw new ArgumentNullException("messageService");
    if (billingSystem == null)
        throw new ArgumentNullException("billingSystem");
    if (locationService == null)
        throw new ArgumentNullException("locationService");
    if (inventoryManagement == null)
        throw new ArgumentNullException("inventoryManagement");
    
    this.orderRepository = orderRepository;
    this.messageService = messageService;
    this.billingSystem = billingSystem;
    this.locationService = locationService;
    this.inventoryManagement = inventoryManagement;
}

具有许多依赖关系表明存在违反单一责任原则(Single Responsibility Principle)(SRP)的情况。违反SRP导致代码难以维护。

在本节中,我们将探讨构造函数参数越来越多的明显问题,以及为什么构造函数注入(Constructor Injection)是好事而不是坏事。如您所见,这并不意味着您应该在构造函数中接受较长的参数列表,因此我们还将回顾您可以采取哪些措施。您可以通过多种方法来重构构造函数,而我们也将讨论两种可以用来重构这些异常的常见方法,即外观服务(Facade Services)和领域事件(domain events):

  • 外观服务(Facade Services)是与参数对象(Parameter Objects)相关的抽象外观(Abstract Facades)。 但是,除了组合组件并将其作为参数公开之外,外观服务(Facade Services)仅公开封装的行为,同时隐藏了组成部分。
  • 利用领域事件(domain events),您可以捕获可能触发正在开发的应用程序状态更改的操作。

认识到构造函数的过度注入(Recognizing Constructor Over-injection)

当构造函数的参数列表过大时,我们将现象称为构造函数过度注入(Constructor Over-injection),并将其视为代码的味道(Code smells)。 这是一个与DI无关但又被DI放大的普遍问题。尽管您最初的反应可能是由于构造器注入过多而取消了构造器注入,但是我们应该感谢向我们揭示了一个一般性的设计问题。

我们不能说我们责怪任何人不喜欢清单6.1中所示的构造函数,但不要怪罪构造函数注入(Constructor Injection)。 我们可以同意具有五个参数的构造函数是代码的味道,但这表明它违反了SRP,而不是与DI有关的问题。

注意 使用构造函数注入(Constructor Injection)可以轻松发现违反SRP的行为。不要为构造函数注入(Constructor Injection)感到不安,而应将其作为构造函数注入(Constructor Injection)的幸运副作用。当类承担太多责任时,这是一个警告您的信号.

我们的个人门槛取决于四个构造函数参数。当我们添加第三个参数时,我们已经开始考虑是否可以以不同的方式设计事物,但是对于少数几个类,我们可以使用四个参数。您的限额可能会有所不同,但是当您越过限额时,该进行检查了。

如何重构已经变得太大的特定类取决于特定的情况:已经存在的对象模型,领域模型,业务逻辑等。 按照众所周知的设计模式将新兴的上帝课堂划分为更小,更专注的类总是一个不错的选择。不过,在某些情况下,业务需求会迫使您同时做许多不同的事情。在应用程序边界通常是这种情况。 考虑一下触发许多业务事件的粗粒度Web服务操作。

注意 通过引入属性注入(Property Injection),甚至通过将那些属性移入基类,来解决构造函数过度注入(Constructor Over-injection)的诱人但错误的尝试。尽管可以通过用属性替换依赖项来减少构造函数依赖项的数量,但是这种更改并不会降低类的复杂性,这应该是您的主要重点。

您可以设计和实施协作者,以使他们不违反SRP。 在第9章中,我们将讨论装饰者(Decorator)设计模式如何帮助您堆叠横切关注点(Cross-Cutting Concerns)问题,而不是将其作为服务注入消费者。这样可以消除许多构造函数参数。在某些情况下,单个入口点需要协调许多依赖关系。一个示例是Web服务操作,该操作触发许多不同服务的复杂交互。计划的批处理作业的入口点可能会遇到相同的问题。

我们不时查看的示例电子商务应用程序需要能够接收订单。 这通常最好由单独的应用程序或子系统来完成,因为在那一点上,事务的语义会发生变化。 只要您查看购物篮,就可以动态计算单价,汇率和折扣。 但是,当客户下订单时,必须按照客户批准该订单时显示的所有值来捕获并冻结这些值。表6.1概述了订购过程。

表6.1 订单子系统批准订单时,它必须执行许多不同的操作。

行动 必需的依存关系
更新订单 IOrderRepository
发送收据电子邮件给客户 IMessageService
通知会计系统有关发票金额 IBillingSystem
根据购买的物品以及靠近送货地址的位置选择最佳仓库来拣选和运送订单 ILocationService,
IInventoryManagement
要求选定的仓库挑选并装运整个订单或部分订单 IInventoryManagement

批准订单仅需要五个不同的依赖关系。 想象一下您需要处理其他与订单相关的其他依赖项!

您在此页面上看到的大多数示例都有防御性语句(Guard Clause)。 到目前为止,我们认为我们已经充分强调了防御性语句(Guard Clause)的重要性。 为了简洁起见,从清单6.2开始,我们将省略大部分的防御性语句(Guard Clause)。

让我们回顾一下,如果使用OrderService类直接导入所有这些依赖项,则外观会如何。 以下清单提供了该类内部的快速概述。

清单6.2 具有许多依赖关系的原始OrderService类 (坏代码)

public class OrderService : IOrderService
{
    private readonly IOrderRepository orderRepository;
    private readonly IMessageService messageService;
    private readonly IBillingSystem billingSystem;
    private readonly ILocationService locationService;
    private readonly IInventoryManagement inventoryManagement;
    
    public OrderService(
        IOrderRepository orderRepository,
        IMessageService messageService,
        IBillingSystem billingSystem,
        ILocationService locationService,
        IInventoryManagement inventoryManagement)
    {
        this.orderRepository = orderRepository;
        this.messageService = messageService;
        this.billingSystem = billingSystem;
        this.locationService = locationService;
        this.inventoryManagement = inventoryManagement;
    }
    
    public void ApproveOrder(Order order)
    {
        this.UpdateOrder(order);   <----使用订单的新状态更新数据库
        this.Notify(order);               <----通知其他系统订单
    }
    
    private void UpdateOrder(Order order)
    {
        order.Approve();
        this.orderRepository.Save(order);
    }
    
    private void Notify(Order order)
    {
        this.messageService.SendReceipt(new OrderReceipt { ... });  
        this.billingSystem.NotifyAccounting(...);
        this.Fulfill(order);
    }
    
    private void Fulfill(Order order)
    {
        this.locationService.FindWarehouses(...);                 <---查找最近的仓库
        this.inventoryManagement.NotifyWarehouses(...);  <---通知仓库有关订单
    }
}

为了使示例易于管理,我们省略了该类的大多数细节。 但是,不难想象这样的班级相当庞大和复杂。 如果让OrderService直接使用所有五个依赖关系,则会得到许多细粒度的依赖关系。结构如图6.1所示。

图6.1 OrderService具有五个直接依赖项,这表明违反了SRP。
image

如果对OrderService类使用构造函数注入(Constructor Injection)(应该使用),则您将拥有一个带有五个参数的构造函数。 这太多了,表明OrderService职责过多。另一方面,所有这些依赖关系都是必需的,因为OrderService类在收到新订单时必须实现所有所需的功能。 您可以通过使用外观服务(Facade Services)重构重新设计OrderService来解决此问题。 在下一部分中,我们将向您展示如何执行此操作。

从构造函数的过度注入到外观服务(Facade Services)的重构(Refactoring from Constructor Over-injection to Facade Services)

重新设计OrderService时,您需要做的第一件事是寻找自然的互动集群。ILocationServiceIInventoryManagement之间的交互应立即引起您的注意,因为您使用它们来查找可以完成订单的最近的仓库。这可能是一个复杂的算法。

选择仓库后,您需要将订单通知他们。 如果再仔细考虑一下,ILocationService是一个实现细节,用于通知适当的仓库有关订单。 整个交互可以隐藏在IOrderFulfillment接口的后面,如下所示:

public interface IOrderFulfillment
{
    void Fulfill(Order order);
}

清单6.3 OrderFulfillment类 (好代码)

public class OrderFulfillment : IOrderFulfillment
{
    private readonly ILocationService locationService;
    private readonly IInventoryManagement inventoryManagement;
    
    public OrderFulfillment(
        ILocationService locationService,
        IInventoryManagement inventoryManagement)
    {
        this.locationService = locationService;
        this.inventoryManagement = inventoryManagement;
    }
    
    public void Fulfill(Order order)
    {
        this.locationService.FindWarehouses(...);
        this.inventoryManagement.NotifyWarehouses(...);
    }
}

有趣的是,订单履行听起来很像一个领域概念。您很可能发现了隐式领域的概念,并将其明确化。

IOrderFulfillment的默认实现消耗了两个原始的依赖项,因此它有一个带有两个参数的构造函数,这很好。 作为进一步的好处,您已将用于查找给定订单的最佳仓库的算法封装到可重复使用的组件中。 新的IOrderFulfillment抽象是外观服务(Facade Services),因为它隐藏了两个相互依赖的依赖关系及其行为。

定义 外观服务(Facade Services)将交互依赖关系及其行为的自然群集隐藏在单个抽象后面。

这种重构将两个依赖项合并为一个,但是在OrderService类上留下了四个依赖项,如图6.2所示。 您还需要寻找其他机会来将依赖项汇总到外观中。

OrderService类只有四个依赖项,而OrderFulfillment类包含两个依赖项。 这不是一个不好的开始,但是您可以进一步简化OrderService。您可能会注意到的下一件事是,所有要求都涉及将订单通知给其他系统。 这表明您可以定义一个通用的抽象来对通知进行建模,也许是这样的:

public interface INotificationService
{
    void OrderApproved(Order order);
}
图6.2 聚集在外观服务(Facade Services)后面的OrderService的两个依赖关系
image

可以使用此接口来实现对外部系统的每次通知。但是您可能想知道这有什么用,因为您已经将每依赖项包装在一个新界面中。依存关系的数量没有减少,所以您有收获吗?

是的,你做到了。因为所有三个通知实现相同的接口,所以可以将它们包装在组合模式中,如清单6.4所示。 这显示了INotificationService的另一种实现,该实现包装INotificationService实例的集合并在所有实例上调用OrderAccepted方法。

清单6.4 组合包含的INotificationService实例 (好代码)

public class CompositeNotificationService
    : INotificationService    <-----实现INotificationService
    {
        IEnumerable<INotificationService> services;
        
        public CompositeNotificationService(
            IEnumerable<INotificationService> services)  <---包装一系列INotificationService实例
        {
            this.services = services;
        }
        
        public void OrderApproved(Order order)
        {
            foreach (var service in this.services)
            {
                service.OrderApproved(order);   <---将来电转接到所有包装的实例
            }
        }
    }

CompositeNotificationService实现INotificationService并将传入的调用转发到其包装的实现。 这避免了消费者必须处理多个实现,这是一个实现细节。 这意味着您可以让OrderService依赖于一个INotificationService,而该INotificationService仅留下两个依赖项,如下所示。

清单6.5 具有两个依赖关系的重构OrderService (好代码)

public class OrderService : IOrderService
{
    private readonly IOrderRepository orderRepository;
    private readonly INotificationService notificationService;
    
    public OrderService(
        IOrderRepository orderRepository,
        INotificationService notificationService)
    {
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }
    public void ApproveOrder(Order order)
    {
        this.UpdateOrder(order);
        this.notificationService.OrderApproved(order);
    }
    private void UpdateOrder(Order order)
    {
        order.Approve();
        this.orderRepository.Save(order);
    }
}

从概念上讲,这也是有道理的。 从高层次上讲,您不需要关心OrderService如何通知其他系统的细节,但是您确实需要注意。这将OrderService减少为仅两个依赖关系,这是一个更合理的数字。

从消费者的角度来看,OrderService在功能上没有改变,这使其成为真正的重构。另一方面,在概念级别上,OrderService被更改。 现在,它的职责是接收订单,保存订单并通知其他系统。通知了哪些系统以及如何实现的详细信息已被推到更详细的级别。图6.3显示了OrderService的最终依赖项。

图6.3 具有重构依赖关系的最终OrderService
image

使用CompositeNotificationService,您现在可以创建具有依赖关系的OrderService

清单6.6 使用外观服务(Facade Services)重构的组合根(Composition Root)

var repository = new SqlOrderRepository(connectionString);

var notificationService = new CompositeNotificationService(
    new INotificationService[]
    {
        new OrderApprovedReceiptSender(messageService),
        new AccountingNotifier(billingSystem),
        new OrderFulfillment(locationService, inventoryManagement)
    });

var orderServive = new OrderService(repository, notificationService);

即使您在整个过程中始终使用构造函数注入(Constructor Injection),也没有哪个类的构造函数最终需要两个以上的参数。 CompositeNotificationServiceIEnumerable<INotificationService>作为单个参数。

提示 重构到外观服务(Facade Services)不仅仅是摆脱过多依赖关系的一种技巧。关键是确定相互作用的自然簇。

一个有益的副作用是,发现这些自然集群会把以前未发现的关系和领域概念公开化。在此过程中,您会将隐含的概念转换为显式的概念。每个聚合都成为一种服务,可以在更高级别上捕获此交互,而消费者的单一责任就变成了协调这些更高级别的服务。如果您有一个复杂的应用程序,使使用者最终对外观服务(Facade Services)的依赖性过多,则可以重复此重构。创建外观服务(Facade Services)是一件非常明智的事情。

外观服务(Facade Services)重构是处理系统复杂性的好方法。但是对于OrderService示例,我们甚至可以更进一步,将我们带入领域事件。

从构造函数过度注入到域事件的重构(Refactoring from Constructor Over-injection to domain events)

清单6.5显示,所有通知都是在批准订单时触发的动作。 以下代码再次显示了此相关部分:

this.notificationService.OrderApproved(order);

我们可以说,批准订单的行为对企业很重要。这些事件称为领域事件(domain events),在您的应用程序中更明确地建模它们可能很有价值。

定义 领域事件(domain event)的本质是您可以使用它捕获可触发正在开发的应用程序状态更改的操作(https://martinfowler.com/eaaDev/DomainEvent.html)。

尽管INotificationService的引入是对OrderService的重大改进,但它只能在OrderService及其直接依赖级别解决此问题。 将相同的重构技术应用于系统中的其他类时,可以轻松地想象INotificationService如何朝着类似于以下清单的方向发展。

清单6.7 带有越来越多方法的INotificationService (坏代码)

public interface INotificationService
{
    void OrderApproved(Order order);
    void OrderCancelled(Order order);
    void OrderShipped(Order order);
    void OrderDelivered(Order order);
    void CustomerCreated(Customer customer);
    void CustomerMadePreferred(Customer customer);
} <--每种方法都代表一个领域事件。 但是,包含许多成员的抽象通常会违反接口隔离原则,我们将在6.2.1节中进行讨论。

在任何大小合理且复杂的系统中,您很容易会收到许多此类领域事件,这将导致INotificationService接口不断变化。 随着对该接口的每次更改,该接口的所有实现也必须更新。另外,接口的不断增长也导致实现的不断增长。但是,如果您将领域事件提升为实际类型并使其成为领域的一部分,如图6.4所示,则将有一个有趣的机会来进一步推广。

图6.4 领域事件提升为实际类型。 这些类型仅包含数据,没有任何行为。
image

以下清单显示了图6.4中所示的领域事件代码。

清单6.8 OrderApprovedOrderCancelled领域事件类 (好代码)

public class OrderApproved
{
    public readonly Guid OrderId;
    
    public OrderApproved(Guid orderId)
    {
        this.OrderId = orderId;
    }
}

public class OrderCancelled
{
    public readonly Guid OrderId;
    
    public OrderCancelled(Guid orderId)
    {
        this.OrderId = orderId;
    }
}

尽管OrderApprovedOrderCancelled类都具有相同的结构,并且与相同的Entity相关,但是围绕它们自己的类对它们进行建模可以使创建响应此类特定事件的代码变得更加容易。 当系统中的每个领域事件都有其自己的类型时,它使您可以使用单个方法将INotificationService更改为通用接口,如下面的清单所示。

清单6.9 通用IEventHandler <TEvent>仅有一个方法

public interface IEventHandler<TEvent>
{
    void Handle(TEvent e);
} <--- 我们将名称从INotificationService更改为IEventHandler,以使该接口具有比通知其他系统更大的范围。

泛型(Generics)

泛型(Generics)引入了类型参数的概念,该概念允许接口,类和方法的设计推迟其类型的规范,直到由客户端代码声明和实例化它们为止。 使用泛型,这样的接口,类或方法将成为模板。

.NET Framework包含许多通用的类型和方法,您很可能已经使用了许多类型和方法。 实际上,在本书的整个过程中,我们已经展示了几个示例:

  • 第2章和第3章中的几个清单(例如清单2.3)中的IEnumerable<T>接口
  • 清单2.2和3.11中的DbSet<T>
  • 清单4.3中的AddSingleton<T>()方法
  • 清单5.7中的Dictionary<TKey,TValue>

清单6.9的IEventHandler<TEvent>接口与那些通用框架类型和方法没有任何不同。 如果您不熟悉泛型概念,建议您阅读C#编程指南中的主题。

对于IEventHandler<TEvent>,从接口派生的类必须在类声明中指定TEvent类型(对于OrderCancelled实例)。然后,此类型将用作该类的Handle方法的参数类型。尽管类型不同,这允许一个接口统一多个类。此外,它还允许对每个实现进行强类型化,专门处理指定为TEvent的任何类型。

现在,基于此接口,您可以构建响应域事件的类,例如之前看到的OrderFulfillment类。基于新的IEventHandler<TEvent>接口,原始的OrderFulfillment类(如清单6.3所示)将更改为以下清单中显示的类。

清单6.10 实现IEventHandler<TEvent>OrderFulfillment类 (好代码)

public class OrderFulfillment
    : IEventHandler<OrderApproved>  <---实现IEventHandler<OrderApproved>
    {
        private readonly ILocationService locationService;
        private readonly IInventoryManagement inventoryManagement;
        
        public OrderFulfillment(
            ILocationService locationService,
            IInventoryManagement inventoryManagement)
        {
            this.locationService = locationService;
            this.inventoryManagement = inventoryManagement;
        }
        
        public void Handle(OrderApproved e)   <---Handle方法中的逻辑与清单6.3中的逻辑相同。
        {
            this.locationService.FindWarehouses(...);
            this.inventoryManagement.NotifyWarehouses(...);
        }
    }

OrderFulfillment类实现IEventHandler<OrderApproved>,这意味着它对OrderApproved事件起作用。 然后,OrderService使用新的IEventHandler<TEvent>接口,如图6.5所示。

图6.5 OrderService类依赖于IEventHandler <OrderApproved>接口,而不是INotificationService
image

清单6.11 显示了一个依赖于IEventHandler<OrderApproved>OrderService。与清单6.5相比,OrderService逻辑将几乎保持不变。

清单6.11 取决于IEventHandler<OrderApproved>OrderService (好代码)

public class OrderService : IOrderService
{
    private readonly IOrderRepository orderRepository;
    private readonly IEventHandler<OrderApproved> handler;
    
    public OrderService(
        IOrderRepository orderRepository,
        IEventHandler<OrderApproved> handler)  <---现在,OrderService依赖于IEventHandler<OrderApproved>而不是INotificationService。
    {
        this.orderRepository = orderRepository;
        this.handler = handler;
    }
    
    public void ApproveOrder(Order order)
    {
        this.UpdateOrder(order);
        this.handler.Handle(
            new OrderApproved(order.Id));  <---批准订单意味着您创建一个OrderApproved域事件,并将其发送到适当的处理程序进行处理。
    }
    ...
}

与非泛型INotificationService一样,您仍然需要一个组合(Composite)来负责将信息分发到可用处理程序列表中。这使您可以向应用程序添加新的处理程序,而无需更改OrderService。清单6.12显示了此组合(Composite)。如您所见,它类似于清单6.4中的CompositeNotificationService

清单6.12 组合包装IEventHandler<TEvent>实例

public class CompositeEventHandler<TEvent> : IEventHandler<TEvent>
{
    private readonly IEnumerable<IEventHandler<TEvent>> handlers;
    
    public CompositeEventHandler(
        IEnumerable<IEventHandler<TEvent>> handlers)  <--包装IEventHandler<TEvent>实例的集合
    {
        this.handlers = handlers;
    }
    
    public void Handle(TEvent e)
    {
        foreach (var handler in this.handlers)
        {
            handler.Handle(e);
        }
    }
}

CompositeEventHandler<TEvent>一样,包装IEventHandler<TEvent>实例的集合,使您可以向系统添加任意事件处理程序实现,而不必对IEventHandler<TEvent>的使用者进行任何更改。 使用新的CompositeEventHandler<TEvent>,可以创建具有依赖关系的OrderService

清单6.13 使用事件重构的OrderService的组合根(Composition Root)

var orderRepository = new SqlOrderRepository(connectionString);
var orderApprovedHandler = new CompositeEventHandler<OrderApproved>(
    new IEventHandler<OrderApproved>[]
    {
        new OrderApprovedReceiptSender(messageService),
        new AccountingNotifier(billingSystem),
        new OrderFulfillment(locationService, inventoryManagement)
    });
var orderService = new OrderService(orderRepository, orderApprovedHandler);

同样,组合根(Composition Root)将包含其他域事件的处理程序的配置。以下代码显示了OrderCancelledCustomerCreated的更多事件处理程序。 我们将其留给读者从中推断出来。

var orderCancelledHandler = new CompositeEventHandler<OrderCancelled>(
    new IEventHandler<OrderCancelled>[]
    {
        new AccountingNotifier(billingSystem),
        new RefundSender(orderRepository),
    });

var customerCreatedHandler = new CompositeEventHandler<CustomerCreated>(
    new IEventHandler<CustomerCreated>[]
    {
        new CrmNotifier(crmSystem),
        new TermsAndConditionsSender(messageService, termsRepository),
    });

var orderService = new OrderService( orderRepository, orderApprovedHandler, orderCancelledHandler);

var customerService = new CustomerService( customerRepository, customerCreatedHandler);

IEventHandler<TEvent>这样的通用接口的优点在于,新功能的添加不会对接口或任何现有的实现产生任何更改。 如果您需要为批准的订单生成发票,则只需添加一个实现IEventHandler<OrderApproved>的新实现。创建新的领域事件时,不需要更改CompositeEventHandler<TEvent>

从某种意义上说,IEventHandler<TEvent>成为应用程序所依赖的常见构建块的模板。每个构件块都对特定事件做出响应。如您所见,您可以有多个构建块来响应同一事件。无需更改任何现有业务逻辑即可插入新的构建基块。

提示 DI容器(DI Container)的自动注册功能是简化组合根(Composition Root)的一种好方法。第13、14和15章显示了如何使用DI容器(DI Container)注册IEventHandler<TEvent>实现。

尽管IEventHandler<TEvent>的引入避免了INotificationService不断增长的问题,但并不能防止OrderService类不断增长的问题。 这是我们将在第10章中详细介绍的内容。

可靠的消息传递(Reliable messaging)

将域事件提升为系统中的类型的好处不仅仅在于改善应用程序的可维护性。 请考虑以下情形。

在高峰时间,仓库的Web服务可能会超时。但是在那个时间点,收据已经被发送,并且计费系统已经被通知。 尽管可以回滚数据库更新,但是却不能回滚通知-客户已经被邮寄了。

不幸的是,计费系统并不是唯一的问题。最近,运行订单批准过程的Web服务器之一崩溃了。客户的确认邮件是在飞机坠毁前发送的,但未通知开票系统或仓库。 客户从未收到订单。 您应如何解决这些问题?

尽管有多种方法可以处理这种情况,但域事件可以提供帮助:可以对它们进行序列化并将其放在持久消息队列中,例如MSMQ,Azure Queue或数据库表。 这样做允许您让OrderService仅执行以下操作:

  • 开始交易
  • 在事务中更新数据库中的订单
  • OrderAccepted事件发布到持久队列中作为事务的一部分
  • 提交交易

仅在将OrderAccepted事件提交到队列后,该事件才可用于进一步处理。 此时,您可以将其传递给该特定事件的每个可用处理程序。 每个处理程序都可以在其自己的隔离事务中运行。如果其中一个处理程序失败,则可以重试该特定处理程序,而不会影响其他处理程序。 您甚至可以并行执行多个处理程序。

使用持久队列处理消息是可靠消息传递的一种形式。可靠的消息传递为消息的成功传输提供了一定的保证。对于上述情况,这是一种有效的解决方案,在这种情况下服务器可能崩溃并且外部系统可能不可用。您可以想象,但是,如何实现这些可靠的消息传递模式不在本书的讨论范围之内。

我们发现使用域事件是一种有效的模型。它允许在更概念性的级别上定义代码,同时使您可以构建功能更强大的软件,尤其是在必须与不属于数据库事务的外部系统进行通信的情况下。 但是,无论您选择哪种重构方法,无论是装饰者模式,外观服务(Facade Services),领域事件还是其他重构方法,这里的重要要点都是,构造函数的过度注入是代码发出臭味的明显标志。 不要忽略这样的迹象,而要采取相应的行动。

由于构造函数过度注入是一种经常重复出现的代码味道,因此下一节将讨论一个更微妙的问题,乍一看,它看起来像是解决一系列重复出现的问题的好方法。 但是,是吗?

滥用抽象工厂(Abuse of Abstract Factories)

当您开始应用DI时,您可能会遇到的第一个困难是抽象依赖于运行时值。 例如,在线地图站点可能会提供计算两个位置之间的路线的信息,您可以选择如何计算路线。 您要最短的路线吗? 基于已知流量模式的最快路线? 最风景秀丽的路线?

在这种情况下,许多开发人员的第一个响应将是使用抽象工厂(Abstract Factory)。 尽管抽象工厂(Abstract Factory)确实在软件中占有一席之地,但在直接投资方面(当工厂在应用程序组件中用作依赖时),它们经常被过度使用。 在许多情况下,存在更好的替代方案。

在本节中,我们将讨论两种存在抽象工厂(Abstract Factory)的更好替代方案的情况。 在第一种情况下,我们将讨论为什么不应使用抽象工厂来创建具有较短生命周期的有状态依赖关系。 之后,我们将讨论为什么最好不要使用抽象工厂(Abstract Factory)根据运行时数据选择依赖项。

滥用抽象工厂来克服生命周期问题(Abusing Abstract Factories to overcome lifetime problems)

当涉及到抽象工厂的滥用时,常见的代码气味是看到无参数的工厂方法以返回类型为依赖项,如下面的清单所示。

清单6.14 使用无参数Create方法的抽象工厂 (代码的味道)

public interface IProductRepositoryFactory
{
    IProductRepository Create();  <--一个无参数的工厂方法,返回一个新的过度性依赖(Volatile Dependency)实例
}

摘要具有无参数Create方法的工厂通常用于允许消费者控制其依赖项的生存期。 在下面的清单中,HomeController通过从工厂请求IProductRepository的寿命来控制IProductRepository的生存期,并在其使用完毕后对其进行处置。

清单6.15 一个HomeController显式管理其依赖项的生命周期 (代码的味道)

public class HomeController : Controller
{
    private readonly IProductRepositoryFactory factory;
    
    public HomeController(
        IProductRepositoryFactory factory)  <--向消费者注入抽象工厂
    {
        this.factory = factory;
    }
    
    public ViewResult Index()
    {
        using (IProductRepository repository =this.factory.Create())  <--抽象工厂创建存储库实例,必须明确管理其生存期。
        {
            var products = repository.GetFeaturedProducts();  <--使用存储库。
            return this.View(products);
        }  <--由于IProductRepository实现了IDisposable,因此在使用方完成后应丢弃创建的实例。 这将使IProductRepository成为泄漏抽象,我们将在稍后进行讨论。
    }
}

图6.6显示了HomeController及其依赖项之间的通信顺序。

图6.6 使用类HomeController控制其IProductRepository依赖项的生存期。 通过从IProductRepositoryFactory依赖关系请求一个Repository实例,并在完成后在IProductRepository实例上调用Dispose来实现。
image

当使用的实现保留应以确定性方式关闭的资源(例如数据库连接)时,需要处置依赖项。尽管实施可能需要确定性的清理,但这并不意味着确保适当清理应该由用户负责。这将我们带到泄漏抽象(Leaky Abstraction)的概念。

泄漏抽象类的代码的味道(The Leaky Abstraction code smell)

正如测试驱动开发(TDD)确保可测试性一样,最安全的方法是先定义接口,然后对它们进行编程。即使这样,在某些情况下您已经有了具体的类型,现在想提取一个接口。执行此操作时,必须注意基础实现不会泄漏。发生这种情况的一种方法是,如果您仅从给定的具体类型中提取接口,但是某些参数或返回类型仍然是您要从中抽象的库中定义的具体类型。 以下接口定义提供了一个示例:(cede Smell)

public interface IRequestContext  <--应用程序界面尝试从ASP.NET运行时环境中抽象出来
{
    HttpContext Context { get; }  <--该接口仍在公开HttpContext,它是ASP.NET的一部分。 这是一个泄漏抽象。
}

如果需要提取接口,则需要以递归的方式进行操作,确保根接口公开的所有类型本身都是接口。 我们将其称为深度提取(Deep Extraction),其结果是深层接口(Deep Interfaces)。

这并不意味着接口不能公开任何具体的类。通常可以公开无行为的数据对象,例如参数对象,视图模型和数据传输对象(DTO)。它们在与接口相同的库中定义,而不是要从中抽象的库。这些数据对象是抽象的一部分。

请注意深度提取(Deep Extraction):它不一定总能带来最佳解决方案。拿前面的例子。考虑以下深度提取的IHttpContext接口的可疑实现:(code Smell)

public interface IHttpContext   <--应用程序定义的接口,用于从ASP.NET的HttpContext中进行抽象
{
    IHttpRequest Request { get; }
    IHttpResponse Response { get; }
    IHttpSession Session { get; }
    IPrincipal User { get; }   <--3-6rows 该接口的成员公开了其他应用程序定义的接口,这些接口从HttpContext公开的成员中抽象出来。 这可以深入很多层次。
}

尽管您可能一直在使用接口,但仍然很明显HTTP模式正在泄漏。换句话说,IHttpContext仍然是泄漏抽象(Leaky Abstractions)-子接口也是如此。

您应该如何为IRequestContext建模?为了弄清楚这一点,您必须查看其消费者想要实现的目标。例如,如果消费者需要找出发送当前Web请求的用户的角色,那么您可能最终会看到我们在第3章中讨论过的IUserContext:(好代码)

public interface IUserContext
{
    bool IsInRole(Role role);
}

IUserContext接口不会向用户显示它正在作为ASP.NET Web应用程序的一部分运行。 实际上,此抽象使您可以将相同的使用者作为Windows 服务或桌面应用程序的一部分运行。 它可能需要创建一个不同的IUserContext实现,但是它的使用者却没有这样做。

始终考虑给定的抽象是否对您所想到的实现以外的其他实现有意义。 如果不是这样,则应重新考虑您的设计。这使我们回到了无参数工厂方法。

无参数工厂方法是泄漏抽象 (Parameterless factory methods are Leaky Abstractions)

尽管抽象工厂模式很有用,但您必须小心地加以区别对待。 从概念上讲,由抽象工厂创建的依赖项应该需要运行时值,并且从运行时值到抽象的转换应该是有意义的。 如果您因为有特定的实现而迫切需要引入抽象工厂(Abstract Factory),则可能手头有泄漏抽象(Leaky Abstractions)。

依赖IProductRepository的消费者(例如清单6.15中的HomeController)不必理会他们获得的实例。 在运行时,您可能需要创建多个实例,但是就使用者而言,只有一个。

重要 从概念上讲,只有一个服务抽象实例。在使用者的整个生命周期中,不必担心可能存在多个依赖关系实例。否则,任何事情都会给消费者带来不必要的麻烦,这意味着抽象并不是为他们的利益而设计的。

通过使用无参数的Create方法指定IProductRepositoryFactory抽象,您可以使使用者知道给定服务的更多实例,并且必须处理该实例。 由于IProductRepository的另一种实现可能根本不需要多个实例或确定性处理,因此,您会通过Abstract Factory及其无参数的Create方法泄漏实现细节。 换句话说,您已经创建了泄漏抽象类。

实现IDisposable的抽象是泄漏抽象

应用程序代码不应该负责对象生命周期的管理。将这种责任放在应用程序代码中意味着您增加了该特定类的复杂性,并使测试和维护变得更加复杂。 我们经常看到,整个应用程序中都重复了生命周期管理逻辑,而不是将其集中在组合根(Composition Root)中。

DI不能作为编写有内存泄漏的应用程序的借口,因此您必须能够尽快明确关闭连接和其他资源.另一方面,任何依赖关系都可能代表或可能不代表进程外资源,因此,如果您要对一个抽象进行建模以包括一个DisposeClose方法,那将是一个泄漏抽象。

通常,抽象不应是一次性的,因为无法预见其所有可能的实现。 实际上,任何抽象可能最终都需要在某个时间点实现一次性实现,而同一抽象的其他实现则继续完全依赖于托管代码。

这并不意味着类不应实现IDisposable。但是,这意味着抽象不应实现IDisposable。由于客户端仅了解抽象,因此它不负责管理该实例的生存期。 我们将此责任移回组合根(Composition Root)。 我们将在第8章中讨论生命周期管理(Lifetime Management)。

接下来,我们将讨论如何防止泄漏抽象(Leaky Abstractions)代码的气味。

重构以寻求更好的解决方案(Refactoring toward a better solution)

消费代码不应该涉及一个IProductRepository实例是否可能存在的可能性。 因此,您应该完全摆脱IProductRepositoryFactory,而让使用者完全依赖IProductRepository,他们应该使用构造函数注入(Constructor Injection)功能将其注入。 以下列表中反映了该建议。

清单6.16 HomeController而不管理其依赖项的生命周期 (Good Code)

public class HomeController : Controller
{
    private readonly IProductRepository repository;
    
    public HomeController(
        IProductRepository repository)  <---IProductRepository本身没有注入抽象工厂,而是直接注入到使用中的HomeController中。
    {
        this.repository = repository;
    }
    public ViewResult Index()
    {
        var products =
            this.repository.GetFeaturedProducts();
        return this.View(products);
    }  <---HomeController并没有通过从抽象工厂请求并处置IProductRepository的寿命来对其进行管理,而只是使用了它。 IProductRepository不再实现IDisposable。
}

这段代码简化了HomeController及其唯一的IProductRepository依赖关系之间的交互序列,如图6.7所示。

与图6.6相比,图6.7与图6.6相比,取消了管理IProductRepository生存期的责任以及删除了IProductRepositoryFactory依赖关系,从而大大简化了与HomeController的依赖关系的交互。
image

尽管删除生命周期管理(Lifetime Management)可以简化HomeController,但是您必须在应用程序中的某个位置管理存储库的生命周期。 解决此问题的常见模式是代理(Proxy)模式,下一个清单中给出了一个示例。

清单6.17 使用代理(Proxy)模式延迟创建SqlProductRepository (Good Code)

public class SqlProductRepositoryProxy : IProductRepository
{
    private readonly string connectionString;
    
    public SqlProductRepositoryProxy(string connectionString)
    {
        this.connectionString = connectionString;
    }
    
    public IEnumerable<Product> GetFeaturedProducts()
    {
        using (var repository = this.Create()) <--仅当调用其GetFeaturedProducts方法时,代理才会在内部创建并调用SqlProductRepository。 这样的代理服务器通常应该是组合根(Composition Root)的一部分,以防止Control Freak (控制怪胎)反模式。
        {
            return repository.GetFeaturedProducts(); <---代理将调用转发到真实的IProductRepository实现。
        }
    }
    
    private SqlProductRepository Create()
    {
        return new SqlProductRepository(
            this.connectionString);
    }
}

代理设计模式(Proxy design pattern)

代理设计模式(Proxy design pattern)为另一个对象提供代理或占位符,以控制对它的访问。它允许将其创建和初始化的全部成本推迟到您需要使用它之前。 代理实现与其代理对象相同的接口。 它使消费者相信他们正在谈论真正的实施。

请注意,SqlProductRepositoryProxy如何通过其私有Create方法在内部包含类似于工厂的行为。 但是,与将IProductRepository从其定义中公开的IProductRepositoryFactory抽象工厂相比,此行为被封装在代理(Proxy)中,并且不会泄漏出去。

注意 通常不可避免地具有工厂式的行为(如清单6.17的Create方法)。 但是,应怀疑整个应用程序的工厂抽象。

SqlProductRepositoryProxySqlProductRepository紧密耦合。 如果在域层中定义了SqlProductRepositoryProxy,则这将是Control Freak (控制怪胎)反模式(anti-pattern)的实现(第5.1节)。 相反,您应该在包含SqlProductRepository的数据访问层中定义此代理(Proxy),或者更可能是在组合根(Composition Root)中定义此代理(Proxy)。

因为Create方法构成对象图的一部分,所以组合根(Composition Root)是放置此代理(Proxy)类的合适位置。 下一个清单显示了使用SqlProductRepositoryProxy的组合根(Composition Root)的结构。

清单6.18 使用新的SqlProductRepositoryProxy的对象图

new HomeController(
    new SqlProductRepositoryProxy(  <--实例化代理(Proxy)类而不是SqlProductRepository
        connectionString));

在一个抽象有很多成员的情况下,创建代理实现变得非常麻烦。 但是,具有许多成员的抽象通常违反接口隔离原则。 使抽象更加集中可解决许多问题,例如创建代理,装饰器和双重测试(Test Doubles)的复杂性。 我们将在6.3节中对此进行更详细的讨论,然后在第10章中再次讨论该主题。

接口隔离原理(Interface Segregation Principle)

接口隔离原则(ISP)指出:“不应强迫任何客户端依赖其不使用的方法。”

这意味着接口的使用者应使用消耗的依赖项的所有方法。 如果该消费者上没有使用该抽象上的方法,则该接口太大,根据ISP的说法,该接口应该分开。 这样可以使系统保持脱钩状态,并易于重构,更改和重新部署。 因此,接口应设计为特定的。 您不想将太多的职责放在一起集中到一个界面中,因为它变得太笨重而难以实施。

ISP可以被视为单一责任原则(SRP)的概念基础。 ISP指出,接口应仅对单个概念建模,而SRP指出,实现应仅承担一个责任。

最初,ISP似乎与DI有着很深的联系。 这很重要,因为建模过多的界面会将您引向特定实施的方向。 它通常带有泄漏抽象的味道,使替换依赖项变得更加困难。 这是因为某些接口成员在与推动初始设计的环境不同的环境中可能没有任何意义。但是,在第10章中,您将了解到ISP在有效地应用DI和Aspect方面至关重要。 面向程序设计。

但这并不意味着在实现和抽象之间应该始终存在一对一的关系。 有时您希望使接口小于其实现,这意味着实现可能实现更多接口。

下一部分将讨论基于提供的运行时数据的抽象工厂滥用,以选择要返回的依赖项。

滥用抽象工厂基于运行时数据选择依赖项(Abusing Abstract Factories to select Dependencies based on runtime data)

在上一节中,您了解了抽象工厂(Abstract Factory)通常应接受运行时值作为输入。 没有它们,您会将有关实施的实施详细信息泄露给使用者。 这并不意味着接受运行时数据的抽象工厂(Abstract Factory)是每种情况的正确解决方案。 通常不是这样。

在本节中,我们将研究专门接受运行时数据以确定要返回哪个依赖关系的抽象工厂(Abstract Factory)。 我们将以在线地图站点为例,该站点提供了计算两个位置之间的路线的功能,这是我们在6.2节开头介绍的。

要计算路线,应用程序需要一种路由算法,但并不在意哪个算法。 每个选项代表一个不同的算法,应用程序可以将每个路由算法作为一个抽象来处理,以将它们平等地对待。 您必须告诉应用程序使用哪种算法,但是直到运行时您才知道这一点,因为它取决于用户的选择。

在Web应用程序中,您只能将原始类型从浏览器传输到服务器。 当用户从下拉框中选择路由算法时,必须用数字或字符串表示。 枚举是一个数字,因此在服务器上,您可以使用以下RouteType表示选择:

public enum RouteType { Shortest, Fastest, Scenic }

您需要的是IRouteAlgorithm实例,可以为您计算路线:

public interface IRouteAlgorithm
{
    RouteResult CalculateRoute(RouteSpecification specification);
}

现在,您遇到了一个问题。RouteType是基于用户选择的运行时数据。 它随请求一起发送到服务器。

清单6.19 RouteController及其GetRoute方法

public class RouteController : Controller
{
    public ViewResult GetRoute(
        RouteSpecification spec, RouteType routeType)
    {
        IRouteAlgorithm algorithm = ...  <--获取相应RouteType的IRouteAlgorithm。 但是如何?
        var route = algorithm.CalculateRoute(spec); <-- 调用选定的IRouteAlgorithm
        var vm = new RouteViewModel
        {
            ...
        };  <--将返回的路线数据映射到可由视图使用的RouteViewModel
        return this.View(vm);   <-----使用MVC的View helper方法将视图模型包装在MVC ViewResult对象中
    }
}

现在的问题是,如何获得合适的算法? 如果您还没有读过本章,那么您对这个挑战的下意识的反应可能就是引入一个抽象工厂(Abstract Factory),如下所示:

public interface IRouteAlgorithmFactory
{
    IRouteAlgorithm CreateAlgorithm(RouteType routeType);
}

这使您可以通过注入IRouteAlgorithmFactory并将其用于将运行时值转换为所需的IRouteAlgorithm依赖关系,从而为RouteController实现GetRoute方法。 下面的清单演示了交互。

清单6.20 在RouteController中使用IRouteAlgorithmFactory ( 代码的味道)

public class RouteController : Controller
{
    private readonly IRouteAlgorithmFactory factory;
    
    public RouteController(IRouteAlgorithmFactory factory)
    {
        this.factory = factory;
    }
    
    public ViewResult GetRoute(
        RouteSpecification spec, RouteType routeType)
    {
        IRouteAlgorithm algorithm =
            this.factory.CreateAlgorithm(routeType);  <--使用工厂将routeType参数的运行时值映射到IRouteAlgorithm
        var route = algorithm.CalculateRoute(spec);   <--当您拥有该算法时,可以使用它来计算路线并返回结果。
        var vm = new RouteViewModel
        {
            ...
        };
        return this.View(vm);
    }
}

RouteController类的职责是处理Web请求。 GetRoute方法接收用户的出发地和目的地说明以及所选的RouteType。使用抽象工厂(Abstract Factory),您可以将运行时RouteType值映射到IRouteAlgorithm实例,因此您可以使用构造函数注入(Constructor Injection)请求IRouteAlgorithmFactory的实例。RouteController及其依赖项之间的交互顺序如图6.8所示。

IRouteAlgorithmFactory的最简单实现将涉及switch语句,并根据输入返回IRouteAlgorithm的三种不同实现。 但是,我们将其留给读者练习。

到现在为止,您可能会想:“有什么收获? 为什么这是代码的味道(Code smells)?” 为了能够看到问题,我们需要回到依赖倒置原则。

图6.8 RouteController将路由类型运行时值提供给IRouteAlgorithmFactory。工厂返回一个IRouteAlgorithm实现,RouteController通过调用CalculateRoute请求一条路由。 交互作用与图6.6相似。
image
分析代码的味道(Code smells)(Analysis of the code smell)

在第3章(第3.1.2节)中,我们讨论了依赖倒置原则。我们讨论了如何使用抽象来声明抽象应归该层所有。我们解释说,应该由抽象的使用者来决定其形状并以最适合其需求的方式定义抽象。当我们回到RouteController并问自己这是否是最适合RouteController的设计时,我们会认为该设计不适合RouteController

一种看待这种情况的方法是评估RouteController具有的依赖项数量,这可以告诉您有关类的复杂性的信息。正如您在6.1节中所看到的,具有大量的依赖关系是一种代码的味道(Code smells),一种典型的解决方案是应用外观服务(Facade Services)重构。

引入抽象工厂时,总是会增加使用者具有的依赖项数量。 如果仅查看RouteController的构造函数,则可能会导致相信该控制器仅具有一个依赖关系。 但是,即使IRouteAlgorithm没有注入到其构造函数中,它也是RouteController的依赖项。

起初,这种增加的复杂性可能并不明显,但是当您开始对RouteController进行单元测试(Unit testing)时,可以立即感觉到。这不仅迫使您测试RouteControllerIRouteAlgorithm的交互,还必须测试与IRouteAlgorithmFactory的交互。

重构以寻求更好的解决方案(Refactoring toward a better solution)

您可以通过将IRouteAlgorithmFactoryIRouteAlgorithm合并在一起来减少依赖项的数量,就像在6.1节中的外观服务(Facade Services)重构中所看到的一样。 理想情况下,您希望使用在6.2.1节中应用代理模式的方式。 但是,仅在为抽象提供了选择适当依赖项所需的所有数据的情况下,代理才适用。 不幸的是,此先决条件不适用于IRouteAlgorithm,因为它仅随RouteSpecification一起提供,而没有随RouteType提供。

在放弃代理(Proxy)模式之前,请务必从概念层面验证将RouteType传递给IRouteAlgorithm是否有意义。 如果是这样,则意味着CalculateRoute实现包含选择正确算法和算法计算路由所需的运行时值所需的所有信息。 但是,在这种情况下,将RouteType传递给IRouteAlgorithm在概念上是很奇怪的。 算法实现永远不需要使用RouteType。 相反,为了降低控制器的复杂性,您可以定义一个适配器(Adapter),该适配器(Adapter)在内部调度到适当的路由算法:

public interface IRouteCalculator
{
    RouteResult Calculate(RouteSpecification spec, RouteType routeType);
}

以下清单显示了当RouteController依赖于IRouteCalculator而不是IRouteAlgorithmFactory时如何简化。

清单6.21 在RouteController中使用IRouteCalculator (Good code)

public class RouteController : Controller
{
    private readonly IRouteCalculator calculator;
    public RouteController(IRouteCalculator calculator) <--IRouteCalculator的使用减少了依赖项的数量。 现在只剩下一个依赖关系。
    {
        this.calculator = calculator;
    }
    public ViewResult GetRoute(RouteSpecification spec, RouteType routeType)
    {
        var route = this.calculator.Calculate(spec, routeType);
        var vm = new RouteViewModel { ... };
        return this.View(vm);
    }
}

图6.9显示了RouteController及其唯一的依赖之间的简化交互。 如图6.7所示,交互被简化为单个方法调用。

图6.9与图6.8相比,通过将IRouteAlgorithmFactoryIRouteAlgorithm隐藏在单个IRouteCalculator抽象后面,可以简化RouteController及其依赖项(现在是单个)之间的交互。
image

您可以通过多种方式实现IRouteCalculator。一种方法是将IRouteAlgorithmFactory注入此RouteCalculator。不过,这不是我们的偏爱,因为IRouteAlgorithmFactory将是无用的额外的间接层,您可以轻松地做到这一点。 相反,您将IRouteAlgorithm实现注入到RouteCalculator构造函数中。

清单6.22 IRouteCalculator包装了一个IRouteAlgorithms字典

public class RouteCalculator : IRouteCalculator
{
    private readonly IDictionary<RouteType, IRouteAlgorithm> algorithms;
    public RouteCalculator(
        IDictionary<RouteType, IRouteAlgorithm> algorithms)
    {
        this.algorithms = algorithms;
    }
    public RouteResult Calculate(RouteSpecification spec, RouteType type)
    {
        return this.algorithms[type].CalculateRoute(spec);
    }
}

使用新定义的RouteCalculator,现在可以像下面这样构造RouteController

var algorithms = new Dictionary<RouteType, IRouteAlgorithm>
{
    { RouteType.Shortest, new ShortestRouteAlgorithm() },
    { RouteType.Fastest, new FastestRouteAlgorithm() },
    { RouteType.Scenic, new ScenicRouteAlgorithm() }
};
new RouteController( new RouteCalculator(algorithms));

通过将抽象工厂(Abstract Factory)重构为适配器(Adapter),可以有效减少组件之间的依赖关系数。图6.10显示了使用工厂(Factory)的初始解决方案的依赖图,而图6.11显示了重构后的对象图。

图6.10 具有IRouteAlgorithmFactoryRouteController的初始依赖图
image
图6.11 改为依赖IRouteCalculatorRouteController的依赖关系图
image

当您经常使用抽象工厂(Abstract Factory)根据提供的运行时数据选择依赖项时,可以通过对不像抽象工厂(Abstract Factory)那样公开底层依赖的适配器(Adapter)进行重构来降低复杂性。 但是,这仅在与抽象工厂打交道时才适用。 我们想概括一下这一点。

通常,服务抽象不应在其定义中公开其他服务抽象。这意味着服务抽象不应接受其他服务抽象作为输入,也不应将服务抽象作为输出参数或返回类型。 依赖于其他应用程序服务的应用程序服务迫使其客户端了解这两个依赖关系。

注意 前者更多是一个准则,而不是严格的规则。当然,在某些情况下,返回抽象最有意义,但请注意,在使用它们来解决依赖关系时,这些情况并不常见。 因此,我们将其视为代码气味而不是反模式(anti-pattern)。

下一个代码的味道(Code smells)是一种更奇特的气味,因此您可能不会经常遇到它。尽管前面讨论的代码气味可能会被忽略,但是下一个气味很难忽略-您的代码要么停止编译,要么在运行时中断。

修复循环依赖性(Fixing cyclic Dependencies)

有时,依赖关系实现是循环的。一个实现需要另一个依赖项,其实现需要第一个抽象。这样的依赖图无法满足。图6.12显示了此问题。

图6.12 IChickenIEgg之间的依赖周期
image

下面显示了一个简单的示例,其中包含图6.12的循环依赖关系:

public class Chicken : IChicken
{
    public Chicken(IEgg egg) { ... } <--鸡肉依赖于IEgg。
    public void HatchEgg() { ... }
}

public class Egg : IEgg  <---Egg实现IEgg
{
    public Egg(IChicken chicken) { ... }  <--Egg依赖于IChicken,它由Chicken实现。
}

牢记前面的示例,如何构造由这些类组成的对象图?

new Chicken(  <---为了能够构造一个新的Chicken实例,应该为其构造函数提供一个现有的Egg实例。
    new Egg(
        ???  <--为了能够构造一个新的Egg实例,应该为其构造函数提供一个现有的Chicken,但是我们尚未创建以前的Chicken,因为它需要一个现有的Egg实例。
    )
);

我们在这里遇到的是典型的鸡肉或鸡蛋因果关系难题。简短的答案是,您不能像这样构造对象图,因为这两个类都要求另一个对象在构造之前就存在。 只要周期仍然存在,您就不可能满足所有依赖关系,并且您的应用程序将无法运行。显然,必须做些什么,但是呢?

在本节中,我们将研究与循环依赖有关的问题,包括一个示例。 完成后,您的第一反应应该是尝试重新设计依赖项,因为问题通常是由应用程序的设计引起的。因此,本节的主要内容是:依赖关系周期通常是由违反SRP引起的(Dependency cycles are typically caused by an SRP violation)。

如果无法重新设计依赖关系,则可以通过从构造函数注入(Constructor Injection)到属属性注入(Property Injection)的重构来打破循环。 这代表了类的不变式的松动,因此您不应该轻描淡写。

示例:由于违反SRP导致的依赖关系周期(Example: Dependency cycle caused by an SRP violation)

Mary Rowan(我们第2章的开发人员)已经开发了一段时间的电子商务应用程序,并且在生产中取得了很大的成功。 然而,有一天,玛丽的老板在门口突然弹出来请求一项新功能。 抱怨是,当生产中出现问题时,很难确定谁在处理系统中的某些数据。 一种解决方案是将更改存储在审计表中,该表记录系统中每个用户所做的每个更改。

考虑了一段时间后,Mary提出了IAuditTrailAppender抽象的定义,如清单6.23所示。(请注意,为了在现实的环境中演示此代码的味道,我们需要一个稍微复杂的示例。下面的示例由三个类组成,在进行代码分析之前,我们将花几页说明代码。)

清单6.23 IAuditTrailAppender抽象

public interface IAuditTrailAppender  <---通过传入要更改的域实体,允许将条目追加到审核表
{
    void Append(Entity changedEntity); <--所有实体都源自的基类
}

玛丽使用SQL Server Management Studio创建AuditEntries表,她可以使用该表存储审核项。 该表的定义如表6.2所示。

列名 数据类型 允许为空 主键
Id uniqueidentifier No Yes
UserId uniqueidentifier No No
TimeOfChange DateTime No No
EntityId uniqueidentifier No No
EntityType varchar(100) No No

创建数据库表后,Mary继续执行IAuditTrailAppender实现,如下清单所示。

清单6.24 SqlAuditTrailAppender将条目追加到SQL数据库表

public class SqlAuditTrailAppender : IAuditTrailAppender
{
    private readonly IUserContext userContext;
    private readonly CommerceContext context;
    private readonly ITimeProvider timeProvider;  <--回想一下,这是清单5.10中的ITimeProvider接口。
        
    public SqlAuditTrailAppender(
        IUserContext userContext,
        CommerceContext context,
        ITimeProvider timeProvider)
    {
        this.userContext = userContext;
        this.context = context;
        this.timeProvider = timeProvider;
    }
    
    public void Append(Entity changedEntity)
    {
        AuditEntry entry = new AuditEntry
        {
            UserId = this.userContext.CurrentUser.Id,
            TimeOfChange = this.timeProvider.Now,
            EntityId = entity.Id,
            EntityType = entity.GetType().Name
        };  <--构造一个新的AuditEntry对象,该对象将插入到AuditEntries表中。 使用当前系统时间,所提供实体的特定信息以及执行请求的用户的身份来构造此条目。
        this.context.AuditEntries.Add(entry);
    }
}

审核跟踪的重要部分是将更改与用户相关联。为此,SqlAuditTrailAppender需要IUserContext依赖关系。这允许SqlAuditTrailAppender使用IUserContext上的CurrentUser属性构造条目。 这是Mary不久前为另一项功能添加的属性。

清单6.25显示了Mary的AspNetUserContextAdapter的当前版本(有关初始版本,请参见清单3.12)。

清单6.25 添加了CurrentUser属性的AspNetUserContextAdapter

public class AspNetUserContextAdapter : IUserContext
{
    private static HttpContextAccessor Accessor = new HttpContextAccessor();
    private readonly IUserRepository repository;  <--Mary添加了IUserRepository依赖关系,以允许从数据库中检索用户信息。
        
    public AspNetUserContextAdapter(
        IUserRepository repository)
    {
        this.repository = repository;
    }
    public User CurrentUser  <-- 新属性
    {
        get
        {
            var user = Accessor.HttpContext.User;
            string userName = user.Identity.Name;
            return this.repository.GetByName(userName);
        }  <---从HttpContext获取登录用户的名称,并使用它从IUserRepository请求一个User实例。
    }
    ...
}

当您忙于阅读有关DI模式和反模式(anti-pattern)的内容时,Mary也很忙。 IUserRepository是她在此期间添加的抽象之一。我们将在不久之后讨论她的IUserRepository实现。

Mary的下一步是更新需要添加到审核跟踪中的类。SqlUserRepository是需要更新的类之一。它实现了IUserRepository,所以这是一个窥视它的好时机。 以下清单显示了该类的相关部分。

代码清单6.26 SqlUserRepository需要附加到审计线索

public class SqlUserRepository : IUserRepository
{
    public SqlUserRepository(
        CommerceContext context,
        IAuditTrailAppender appender)  <---对于新的审核跟踪功能,Mary将IAuditTrailAppender的依赖项添加到SqlUserRepository的构造函数中。
    {
        this.appender = appender;
        this.context = context;
    }
    
    public void Update(User user) 
    {
        this.appender.Append(user); <---通过调用IAuditTrailAppender.Append修改Update方法。 这允许将条目追加到审核跟踪。
        ...
    }
    public User GetById(Guid id) { ... }
    public User GetByName(string name) { ... }  <--前面讨论过的AspNetUserContextAdapter的CurrentUser属性使用此方法。
}

玛丽几乎完成了她的角色。 由于她在SqlUserRepository方法中添加了一个构造函数参数,因此可以继续更新组合根(Composition Root)。 当前,组成AspNetUserContextAdapter的组合根(Composition Root)部分如下所示:

var userRepository = new SqlUserRepository(context); 
IUserContext userContext = new AspNetUserContextAdapter(userRepository);

由于IAuditTrailAppender已作为依赖项添加到SqlUserRepository构造函数中,因此Mary尝试将其添加到组合根(Composition Root):

var appender = new SqlAuditTrailAppender(
    userContext,   <---哎哟! Mary在此行上收到编译错误。
    context,
    timeProvider);

var userRepository = new SqlUserRepository(context, appender);
IUserContext userContext = new AspNetUserContextAdapter(userRepository);

不幸的是,玛丽的更改无法编译。 C#编译器抱怨:“在声明之前不能使用局部变量'userContext'。”

由于SqlAuditTrailAppender依赖于IUserContext,因此Mary尝试为SqlAuditTrailAppender提供她定义的userContext变量。 C#编译器不接受此变量,因为必须在使用前定义此类变量。 Mary尝试通过上移userContext变量的定义和分配来解决该问题,但这立即导致C#编译器抱怨userRepository变量。 但是,当她将userRepository变量上移时,编译器会抱怨在声明之前使用过的appender变量。

Mary开始意识到自己遇到了严重的麻烦-她的依存关系图中存在一个循环。 让我们分析出了什么问题。

Mary的依赖周期分析(Analysis of Mary's Dependency cycle)

一旦将IAuditTrailAppender依赖项添加到SqlUserRepository类中,Mary的对象图中的循环就会出现。 图6.13显示了这个依赖周期。

图6.13 涉及AspNetUserContextAdapterSqlUserRepositorySqlAuditTrailAppender的依赖关系周期
image

该图在对象图中显示了循环。 但是,对象图是故事的一部分。 我们可以用来分析问题的另一个视图是方法调用图,如下所示:

image-20210511102654977

此调用图显示了调用将如何从UserServiceUpdateMailAddress方法开始,该方法将调用SqlUserRepository类的Update方法。 从那里进入SqlAuditTrailAppender,然后进入AspNetUserContextAdapter,最后进入SqlUserRepositoryGetByName方法。

注意 我们没有讨论UserService类,因为在此讨论中它并不那么有趣。

该方法调用图显示的是,尽管对象图是循环的,但该方法调用图不是递归的。 如果GetByName再次调用SqlAuditTrailAppender,它将变成递归的。例如追加。 这将导致无休止地调用其他方法,直到进程耗尽堆栈空间,从而导致StackOverflowException。 对玛丽而言,幸运的是,调用图不是递归的,因为这将要求她重写方法。 问题的原因在其他地方-违反了SRP。

当我们看一下先前声明的类AspNetUserContextAdapterSqlUserRepositorySqlAuditTrailAppender时,您可能会发现很难发现可能的SRP违规。 如表6.3所示,所有这三个类似乎都集中在一个特定的领域。

表6.3 抽象类及其在应用程序中的作用

抽象对象类 角色 方法
IAuditTrailAppender 可以记录用户所做的重要更改 1 method
IUserContext 向消费者提供有关代表其执行当前请求的用户的信息 2 methods
IUserRepository 针对给定的持久性技术提供围绕用户的检索,查询和存储的操作 3 methods

如果您仔细查看IUserRepository,可以看到该类中的功能主要围绕用户的概念进行分组。 这是一个相当广泛的概念。 如果坚持将与用户相关的方法分组在一个类中的方法,则会看到IUserRepositorySqlUserRepository都经常更改。

不断变化的抽象强烈表明违反了SRP。这也与第4章中讨论的开放/封闭原则(OCP)有关,该原则指出您应该能够添加功能而不必更改现有类。

当我们从内聚性的角度看待SRP时,我们会问自己,IUserRepository中的方法是否真的具有如此高的内聚性。 将类拆分为多个较窄的接口和类有多容易?

从违反SRP的问题进行重构以解决依赖关系周期(Refactoring from SRP violations to resolve the Dependency cycle)

修复SRP违规问题可能并不总是那么容易,因为这可能会导致抽象用户的涟漪变化。 但是,对于我们的小型商务应用程序来说,进行更改非常容易,如以下清单所示。

清单6.27 GetByName移到IUserByNameRetriever中 (好代码)

public interface IUserByNameRetriever
{
    User GetByName(string name); <---GetByName方法从IUserRepository移到了这个新的IUserByNameRetriever接口
}

public class SqlUserByNameRetriever : IUserByNameRetriever
{
    public SqlUserByNameRetriever(CommerceContext context)
    {
        this.context = context;
    }
    public User GetByName(string name) { ... }
}

在清单中,GetByName方法从IUserRepositorySqlUserRepository中提取到名为IUserByNameRetrieverSqlUserByNameRetriever的新抽象实现对中。 新的SqlUserByNameRetriever实现不依赖于IAuditTrailAppender。 接下来显示SqlUserRepository的其余部分。

清单6.28 IUserRepository的其余部分及其实现 (Good code)

public interface IUserRepository  <-----删除GetByName方法
{
    void Update(User user);
    User GetById(Guid id);
}

public class SqlUserRepository : IUserRepository
{
    public SqlUserRepository(   <---删除IUserContext依赖项
        CommerceContext context,
        IAuditTrailAppender appender
        {
            this.context = context;
            this.appender = appender;
        }
        public void Update(User user) { ... }
        public User GetById(Guid id) { ... }
 }

注意 一个类拥有的方法越多,违反单一责任原则(Single Responsibility Principle)的机会就越大。 这也与接口隔离原则(Interface Segregation Principle)有关,后者更喜欢窄接口。

Mary从这个部门获得了一些好处。 首先,新类较小,更易于理解。 接下来,它降低了进入Mary将不断更新现有代码的情况的机会。 最后但并非最不重要的一点是,拆分SqlUserRepository类将打破依赖关系周期,因为新的SqlUserByNameRetriever不依赖于IAuditTrailAppender。 图6.14显示了依赖关系周期是如何中断的。

图6.14 将IUserRepository分成两个接口打破了依赖关系周期。
image

以下代码显示了将所有内容联系在一起的新的组合根(Composition Root):

var userContext = new AspNetUserContextAdapter(
    new SqlUserByNameRetriever(context));  <---AspNetUserContextAdapter现在依赖IUserByNameRetriever,因为它需要按用户名检索用户。
    
var appender = new
    SqlAuditTrailAppender(
    userContext,
    context,
    timeProvider);
var repository = new SqlUserRepository(context, appender);

实施审核跟踪(Implementing an audit trail)

选择本节中的特定审计跟踪实现是为了解释依赖关系周期。 但是,通常这不是我们自己实现这种功能的方式。 审核跟踪是一个横切关注点(Cross-Cutting Concerns)。Mary实施它的方式在系统的许多类中引起了广泛的变化。 这既麻烦又容易出错,并且违反了OCP和SRP。

一个更简单的解决方案是重写CommerceContext内部的DbContext.SaveChanges方法。 DbContext允许使用其ChangeTracker属性查询更改。 这样可以避免进行大范围的更改,并使您免于在任何单独的类上实现此要求并为此编写适当的测试。 但是,我们的首选方法是应用第6.1节中讨论的域事件。 在前一种方法将每个更改的实体作为条目存储在审计跟踪中的情况下,域事件为您提供了更高功能级别的跟踪。

让我们以前面的示例为例,您尝试在其中更新用户的邮件地址。 发布UserMailAddressChanged事件时,可以将该事件附加到跟踪中。 使用它,您可以存储其邮件地址已更改的用户,发生时间以及进行更改的用户的ID。 这将产生一个审计跟踪,使您可以很好地了解每个时间点发生的情况。 当在电子商务Web应用程序中可视化给定订单的域事件时,您可能最终得到下表中显示的视图。

给定订单的时间表

Date User Description
2018-11-21 15:21 Mary Rowan Order created
2018-11-21 15:26 Mary Rowan Order approved
2018-11-21 15:27 Mary Rowan Order paid
2018-11-22 08:10 [system] Order shipped
2018-11-23 15:48 [system] Order delivered

在第10章中,我们将展示另一种实现审计跟踪的解决方案。

依赖周期最常见的原因是违反SRP。 通过将类分为更小,更集中的类来解决违规问题,通常是一个很好的解决方案,但是还有其他一些可以打破依赖关系周期的策略。

打破依赖周期的常用策略(Common strategies for breaking Dependency cycles)

当我们遇到一个依赖周期时,我们的第一个问题是:“我在哪里失败了?” 依赖周期应立即触发对根本原因的全面评估。任何循环都是设计的味道,因此您的第一反应应该是重新设计所涉及的零件,以防止循环首先发生。 表6.4显示了您可以采取的一些一般指导。

表6.4 打破依赖关系循环的一些重新设计策略,从最高到最低的顺序排列

战略 描述
Split classes 正如您在审计跟踪示例中看到的,在大多数情况下,可以将使用太多方法的类拆分为较小的类,以打破周期。
.NET events 您通常可以通过更改其中一个抽象来引发事件来打破一个循环,而不必显式调用依赖项来通知依赖项发生了某些事情。 如果一方仅在其依赖项上调用void方法,则事件特别合适。
Property Injection 如果所有其他方法均失败,则可以通过将一个类从构造方法注入重构为属性注入来中断周期。 这应该是最后的努力,因为它只能治疗症状。

毫无疑问:依赖周期是一种设计的味道。您的首要任务应该是分析代码,以了解出现此循环的原因。不过,即使您了解周期的根本原因,有时仍无法更改设计。

不得已:通过属性注入打破周期(Last resort: Breaking the cycle with Property Injection)

在某些情况下,设计错误是无法控制的,但是您仍然需要打破循环。在这种情况下,即使是临时解决方案,也可以使用属性注入(Property Injection)来实现。

警告 仅通过使用属性注入(Property Injection)作为最后的努力来求助于解决周期。 它只治疗症状而不治愈疾病。

要打破循环,您必须对其进行分析以找出可以进行切割的位置。 由于使用属性注入(Property Injection)建议使用可选的依赖关系,而不是必需的依赖关系,因此,请务必仔细检查所有依赖关系,以确定在哪处切割对您的伤害最小,这一点很重要。

在我们的审计跟踪示例中,您可以通过将SqlAuditTrailAppender的依赖项从构造函数注入(Constructor Injection)更改为属性注入(Property Injection)来解决周期。 这意味着您可以首先创建SqlAuditTrailAppender,将其注入SqlUserRepository,然后再将AspNetUserContextAdapter分配给SqlAuditTrailAppender,如清单所示。

清单6.29 用属性注入(Property Injection)打破依赖周期 (code Smell)

var appender =
    new SqlAuditTrailAppender(context, timeProvider);  <--创建没有IUserContext实例的追加程序。 这将导致部分初始化的实例。
    
var repository =
    new SqlUserRepository(context, appender);  <--将部分初始化的追加程序注入到存储库中。
    
var userContext = new
    AspNetUserContextAdapter(
    new SqlUserByNameRetriever(context));

appender.UserContext = userContext;  <--使用属性注入通过注入IUserContext来完成附加程序的初始化。

以这种方式使用属性注入(Property Injection)会增加SqlAuditTrailAppender的复杂性,因为它现在必须能够处理尚不可用的依赖项。 如4.3.2节所述,这导致了时间耦合(Temporal Coupling)。

正如我们之前在4.2.1节中所指出的那样,类不应在其构造函数中执行涉及依赖项的工作。 除了使对象构造缓慢且不可靠之外,使用注入的依赖项可能会失败,因为它可能尚未完全初始化。

如果您不希望以这种方式放松任何原始类,则一种密切相关的方法是引入虚拟代理(Virtual Proxy),这将使SqlAuditTrailAppender保持完整:

清单6.30 打破虚拟代理(Virtual Proxy)的依赖周期 (code Smell)

var lazyAppender = new LazyAuditTrailAppender();  <--创建一个虚拟代理
    
var repository =
    new SqlUserRepository(context, lazyAppender);

var userContext = new
    AspNetUserContextAdapter(
    new SqlUserByNameRetriever(context));

lazyAppender.Appender =   <--将真正的追加程序注入到虚拟代理的属性中
    new SqlAuditTrailAppender(
    userContext, context, timeProvider);

LazyAuditTrailAppenderSqlAuditTrailAppender一样实现IAuditTrailAppender。 但是,它通过属性注入而不是构造函数注入(Constructor Injection)来获取其IAuditTrailAppender依赖关系,从而使您可以在不违反原始类不变性的情况下中断循环。 下一个清单显示了LazyAuditTrailAppender虚拟代理。

清单6.31 LazyAuditTrailAppender虚拟代理实现

public class LazyAuditTrailAppender : IAuditTrailAppender
{
    public IAuditTrailAppender Appender { get; set; }  <---允许打破依赖关系周期的属性
        
    public void Append(Entity changedEntity)
    {
        if (this.Appender == null)  <---防御性语句(Guard Clause)
        {
            throw new InvalidOperationException("Appender was not set.");
        }
        this.Appender.Append(changedEntity);
    }
}

始终牢记,解决周期的最佳方法是重新设计API,以使周期消除。但是在极少数情况下,这是不可能的或非常不希望的,您必须通过在至少一个地方使用属性注入来打破循环。这样,您就可以构成对象图的其余部分,而不是与该属性关联的依赖关系。当对象图的其余部分完全填充后,您可以通过属性注入(Property Injection)适当的实例。 属性注入(Property Injection)表示依赖关系是可选的,因此您不应轻易进行更改。

当您了解一些基本原理时,DI并不是特别困难。 但是,在您学习的过程中,一定会遇到一些可能使您感到困惑的问题。本章介绍了人们遇到的一些最常见的问题。 它与前两章一起构成了模式,反模式(anti-pattern)和代码气味的目录。 该目录构成本书的第2部分。 在第3部分中,我们将转向DI的三个维度:对象组合(Object Composition),生命周期管理(Lifetime Management)和拦截(Interception)。

总结

  • 不断变化的抽象是违反单一责任原则(SRP)的明确标志。 这也与开放/封闭原则(Open/Closed Principle)有关,该原则指出您应该能够添加要素而不必更改现有类。
  • 类拥有的方法越多,违反SRP的机会就越大。这也与接口隔离原则有关,该原则指出,不应强迫任何客户端依赖其不使用的方法。
  • 使抽象类更薄可解决许多问题,例如创建代理模式,装饰器模式和双重测试(Test Doubles)的复杂性。
  • 构造函数注入(Constructor Injection)的一个好处是,当您违反SRP时,它会变得更加明显。 如果单个类的依赖性过多,则表明您应该重新设计它。
  • 当构造函数的参数列表过大时,我们将现象称为构造函数过度注入(Constructor Over-injection),并将其视为代码的味道(Code smells)。 这是一种普通的代码的味道(Code smells),与DI无关,但被DI放大了。
  • 您可以通过多种方式从构造函数过度注入(Constructor Over-injection)进行重新设计,但是按照众所周知的设计模式将大型类分成较小的,更集中的类始终是一个好举动。
  • 您可以通过应用外观服务(Facade Services)重构来避免构造函数过度注入。 外观服务(Facade Services)将交互依赖关系的自然簇隐藏在单个抽象后面。
  • 外观服务(Facade Services)重构允许发现这些自然集群,并公开地公开以前未发现的关系和域概念。外观服务与参数对象有关,但是与其合并和公开组件,不如将其公开,而是在隐藏组成部分的同时仅公开封装的行为。
  • 您可以通过在应用程序中引入领域事件来重构构造函数过度注入(Constructor Over-injection)。 利用领域事件,您可以捕获可能触发正在开发的应用程序状态更改的操作。
  • 泄漏抽象(Leaky Abstraction)是接口之类的抽象,它泄漏实现细节,例如特定于层的类型或特定于实现的行为。
  • 实现IDisposable的抽象是泄漏抽象(Leaky Abstractions)。IDisposable应该在实现中生效。
  • 从概念上讲,只有一个服务抽象实例。 并非将这些知识泄漏给消费者的抽象设计是针对这些消费者的。
  • 服务抽象通常不应在其定义中公开其他服务抽象。 依赖于其他抽象的抽象迫使其客户了解这两个抽象。
  • 在应用DI时,经常会过度使用抽象工厂(Abstract Factory)。 在许多情况下,存在更好的替代方案。
  • 从概念上讲,抽象工厂(Abstract Factory创建的依赖项应要求一个运行时值。 从运行时值到抽象的转换应该在概念上有意义。 如果您迫切希望引入一个抽象工厂(Abstract Factory)以便能够创建具体实现的实例,则可能手头上有泄漏抽象(Leaky Abstractions)。 相反,代理模式为您提供了更好的解决方案。
  • 在某些类中具有工厂式的行为通常是不可避免的。 但是,应怀疑所有应用程序范围的抽象工厂(Abstract Factory)。
  • 抽象工厂(Abstract Factory)总是增加使用者的依赖项数量,同时增加其复杂性。
  • 当您使用抽象工厂(Abstract Factory)根据提供的运行时数据选择依赖项时,通常可以通过重构不暴露底层依赖项的外观来降低复杂性。
  • 依赖循环通常是由SRP冲突引起的。
  • 改进包含依赖循环的应用程序部分的设计应该是您的首选选项。在大多数情况下,这意味着将类划分为更小、更集中的类。
  • 使用属性注入(Property Injection)可以打破依赖循环。您应该仅通过使用属性注入(Property Injection)作为最后的努力来解决循环。它只治标不治病。
  • 类永远不应该执行涉及其构造函数中的依赖项的工作,因为注入的依赖项可能尚未完全初始化。
posted @ 2022-09-03 23:52  F(x)_King  阅读(160)  评论(0编辑  收藏  举报