第十章-通过设计进行面向方面的编程
在家做饭和在专业厨房工作之间有很大的区别。在家里,您可以花所有时间准备菜,但是在商用厨房中,效率是关键。Mise到位是此方面的重要方面。 这不仅仅是提前准备原料;这是关于设置所有必需的设备的信息,包括您的锅,平底锅,砧板,品尝勺以及工作空间中必不可少的任何内容。
厨房的人体工程学和布局也是影响厨房效率的主要因素。布置不当的厨房可能会导致夹伤,严重干扰以及员工的环境切换。专用站和相关的专用设备之类的功能有助于最大程度地减少人员流动,避免(不必要的)多任务处理并鼓励专注于手头的任务。 如果做得好,它将有助于提高整个厨房的效率。
在软件开发中,代码库是我们的厨房。团队在同一个厨房中共同工作多年,正确的体系结构对于高效和一致,将代码重复保持在最低限度至关重要。 您的“客人”取决于您成功的厨房策略。
可以用于改善软件人体工程学的关键体系结构策略之一是面向方面的编程(AOP
)。这可以采用设备(工具)或实体布局(软件设计)的形式。 AOP
与拦截(Interception)功能密切相关。要充分了解拦截(Interception)的潜力,您必须学习AOP的概念以及诸如SOLID之类的软件设计原理。
本章首先介绍AOP
。因为应用AOP
的最有效方法之一是通过众所周知的设计模式和面向对象的原理,所以本章将继续对SOLID的五项原则进行回顾,在整本书的前几章中已对此进行了讨论。
一个常见的误解是AOP
需要工具。 在本章中,我们将说明情况并非如此:我们将展示如何将SOLID软件设计用作AOP
的驱动程序以及有效,一致且可维护的代码库的实现程序。 在下一章中,我们将讨论两种需要特殊工具的众所周知的AOP
形式。但是,这两种形式都比本章讨论的纯设计驱动的AOP
形式表现出明显的缺点。
如果您已经熟悉SOLID和AOP
的基础知识,则可以直接跳到10.3节,其中包含本章的内容。 否则,您可以继续我们对面向方面的编程的介绍。
介绍AOP
AOP于1997年在Xerox Palo Alto研究中心(PARC)发明,Xerox工程师在那里设计了AspectJ
,这是Java语言的AOP
扩展。 AOP
是一种范式,它围绕有效且可维护地应用横切关注点(Cross-Cutting Concerns)这一概念。这是一个相当抽象的概念,带有它自己的一组术语,而大多数术语与本讨论无关。
定义 面向方面的编程(Aspect-Oriented Programming)旨在减少实现横切关注点(Cross-Cutting Concerns)问题和其他编码模式所需的样板代码。 它通过在单个位置实现此类模式并将其以声明方式或基于约定的方式应用于代码库来实现,而无需修改代码本身。
9.1.2和9.2.1节中的审计和断路器示例仅显示了几种代表性方法,因为所有方法都以相同的方式实现。 我们不想将几页几乎完全相同的代码添加到我们的讨论中,因为这样做会分散我们的观点。
以下清单再次显示了CircuitBreakerProductRepositoryDecorator
的Delete
方法。
清单10.1
CircuitBreakerProductRepositoryDecorator
的Delete
方法
public void Delete(Product product)
{
this.breaker.Guard();
try
{
this.decoratee.Delete(product);
this.breaker.Succeed();
}
catch (Exception ex)
{
this.breaker.Trip(ex);
throw;
}
}
清单10.2显示了CircuitBreakerProductRepositoryDecorator
的方法有多相似。此清单仅显示Insert
方法,但是我们相信您可以推断其余实施的外观。
清单10.2 通过复制断路器(Circuit Breaker)逻辑来违反DRY原理 (代码的味道)
public void Insert(Product product)
{
this.breaker.Guard();
try
{
this.decoratee.Insert(product); <--此行是此清单与清单10.1之间的唯一区别。 该行不调用Delete,而是调用Insert。
this.breaker.Succeed();
}
catch (Exception ex)
{
this.breaker.Trip(ex);
throw;
}
}
此清单的目的是说明在当前设计中用作装饰器的装饰物的重复性质。Delete
和Insert
方法之间的唯一区别是,它们每个都在修饰的存储库上调用自己的对应方法。
即使我们已经通过ICircuitBreaker
接口成功地将断路器(Circuit Breaker)实现委派给了单独的类,但该管道代码违反了DRY原理。 它趋于合理地保持不变,但仍然是一个责任。 每次要向装饰类型添加新成员时,或者要将断路器应用于新抽象时,都必须应用相同的管道代码。 如果您要维护这样的应用程序,那么这种重复性可能会成为问题。
注 AOP作为范例,重点在于解决重复问题。
遵循第9章中的审核示例,我们已经确定您不想将审核代码放入SqlProductRepository
实现中,因为这将违反单一职责原则(SRP)。 但是,您也不希望系统中的每个存储库抽象都有数十个审核装饰器。 这也将导致严重的代码重复,并且有可能导致全面的更改,这是违反开放式/封闭式原则(OCP)的。 相反,您要声明性地声明要对系统中所有存储库抽象的特定方法集应用审核方面,并一次实施此审核方面。
您会发现启用AOP
的工具,框架和体系结构样式。 在本章中,我们将讨论最理想的AOP
形式。 下一章将讨论动态拦截(Interception)和编译时编织作为基于工具的AOP
形式。 这是AOP
的三种主要方法。表10.1列出了我们将要讨论的方法,以及每种方法的主要优点和缺点。
表10.1常用的
AOP
方法
方法 | 描述 | 优点 | 缺点 |
---|---|---|---|
SOLID | 将装饰器应用于可重用抽象周围的方面,该抽象可根据类的行为为类组定义。 | 不需要任何工具。 方面很容易实现。 专注于设计 使系统更易于维护。 |
在遗留系统中并不总是那么容易应用。 |
动态拦截(Interception) | 根据应用程序的抽象导致装饰器的运行时生成。 这些装饰器注入了特定于工具的方面,称为“拦截(Interception)器”。 | 假设应用程序已经编程为接口,则只需很少的更改即可轻松添加到现有或遗留应用程序中。 使已编译的应用程序与使用的动态Interception库分离 免费提供良好的工具。 |
使方面与AOP工具紧密耦合。 失去编译时支持。 使约定易碎且容易出错。 |
编译时编织 | 方面是在后编译过程中添加到应用程序的。 最常见的形式是IL编织,其中外部工具读取已编译的程序集,通过应用方面对其进行修改,然后将原始程序集替换为修改后的程序集。 | 即使应用程序未编程为界面,也只需更改很少的操作即可轻松将其添加到现有或旧版应用程序中。 | 向各个方面注入易失性依赖关系会导致时间耦合或相互依赖的测试。 方面是在编译时编织的,因此如果不应用方面,就无法调用代码。 这使测试复杂化并降低了灵活性。 编译时编织是DI的对立面。 |
如前所述,我们将在下一章中回到动态拦截(Interception)和编译时编织中。 但是,在深入研究将SOLID用作AOP的驱动程序之前,让我们先简要回顾一下SOLID原理。
SOLID原则
您可能已经注意到在第9章和上一节中,诸如单一责任原则,开放/封闭原则和里氏替换原则之类的术语比平常更为密集。 它们与接口隔离原理(ISP)和依赖关系反转原理(DIP)一起构成SOLID的首字母缩写。 在本书的整个过程中,我们已经对所有这五个问题进行了单独讨论,但是本节提供了一个简短的摘要来使您耳目一新,因为理解这些原理对于本章的其余部分很重要。
注 谁不想编写可靠的软件? 可以经受时间考验并为用户提供价值的软件听起来像是一个值得追求的目标,所以我们引入SOLID作为首字母缩写词,因为构建高质量的软件才有意义。
所有这些模式和原则都被认为是编写干净代码的宝贵指导。 本节的一般目的是将已建立的指南与DI关联起来,强调DI只是达到目的的一种手段。 因此,我们将DI用作可维护代码的使能器。
SOLID封装的原理均不代表绝对原则。它们是可以帮助您编写简洁代码的准则。对我们而言,它们代表了有助于我们确定应朝哪个方向发展的目标。 当我们成功时,我们总是很高兴; 但是有时候我们没有。
以下各节介绍了SOLID原理,并总结了我们在本书整个过程中已对它们进行的解释。每个部分都是简要概述-我们在这些部分中省略了示例。 我们将在第10.3节中返回至此,我们将通过一个真实的示例来说明,从可维护性的角度来看,为什么违反SOLID原则会成为问题。现在,我们将回顾五项SOLID原则。
单一责任原则(SRP)
在2.1.3节中,我们描述了SRP如何声明每个类都应具有更改的单个原因。 违反此原则会使类变得更加复杂,并且难以测试和维护。
但是,查看一个班级是否有多种改变的理由通常会很有挑战性。 在这方面有帮助的是从凝聚力的角度看待SRP
。 内聚性定义为类或模块的元素的功能相关性。 关联度越低,内聚性越低; 凝聚力越低,则类违反SRP
的可能性越大。 在第10.3节中,我们将通过一个具体示例来讨论内聚性。
可能很难坚持,但是如果您练习DI,那么构造函数注入(Constructor Injection)的许多好处之一就是,当您违反SRP时,它会变得更加明显。 在第9.1.2节的审核示例中,您可以通过将职责划分为不同的类型来遵守SRP
:SqlUserRepository
仅处理存储和检索产品数据,而AuditingUserRepositoryDecorator
专注于将审核跟踪持久化在数据库中。 AuditingUserRepositoryDecorator
类的唯一职责是协调IUserRepository
和IAuditTrailAppender
的操作。
开放/封闭原则(OCP)
正如我们在4.4.2节中讨论的那样,OCP规定了一种应用程序设计,可以防止您在整个代码库中进行大范围更改。 或者,在OCP的词汇表中,应该打开一个类以进行扩展,但是关闭该类以进行修改。 开发人员应该能够扩展系统的功能,而无需修改任何现有类的源代码。
因为它们都试图防止大范围的更改,所以OCP原则与“不要重复自己的”(DRY)原则之间有着密切的关系。 但是,OCP侧重于代码,而DRY侧重于知识。
不要重复自己(DRY)
在他们的《实用程序员》一书中,安迪·亨特(Andy Hunt)和戴夫·托马斯(Dave Thomas)创造了首字母缩写词DRY,是“不要重复自己”的缩写,他们是这样写的:
每条知识都必须在系统中具有单一,明确,权威的表示形式。
我们的开发人员在知识不稳定的系统中工作。 复制这些知识将使保持所有内容同步变得困难。 我们对系统和需求的理解通常会迅速变化。 DRY指出,我们应该努力将所有知识集中在一个地方。 DRY不仅限于代码:它还适用于文档。
您可以通过多种方法使类可扩展,包括虚拟方法,策略注入和装饰器的应用。3但是,无论细节如何,DI都使您能够构成对象,从而使这成为可能。
里氏替换原则(LSP)
在8.1.1节中,我们描述了所有依赖项的使用者在调用其依赖项时都应遵守LSP,因为每个依赖项的行为均应符合其抽象定义。 这样,您就可以用同一抽象类的另一个实现替换最初打算的实现,而不必担心破坏使用者。 因为装饰者(Decorator)实现与其包装的类相同的抽象类,所以可以用装饰者(Decorator)替换原始的装饰者(Decorator),但前提是该装饰者(Decorator)遵守其抽象类赋予的约定。
这正是我们在清单9.3中用AuditingUserRepositoryDecorator
替换了原来的SqlUserRepository
时所做的事情。 您可以在不更改使用ProductService
的代码的情况下执行此操作,因为任何实现都应遵守LSP。 ProductService
需要一个IUserRepository
实例,并且只要它专门与该接口对话,任何实现都可以。
LSP是DI的基础。 当消费者不注意时,注入依赖项几乎没有优势,因为您不能随意替换它们,并且会失去DI的很多(如果不是全部)好处。
接口隔离原理(ISP)
在6.2.1节中,您了解到ISP提倡使用细粒度抽象,而不是宽泛的抽象。 消费者在任何时候都依赖未使用其某些成员的抽象时,就会违反ISP。
最初,ISP似乎与DI有着密切的关系,但这可能是因为我们在本书的大部分内容中都忽略了这一原理。 这将在10.3节中发生变化,您将了解到ISP在有效地应用面向方面的编程方面至关重要。
依赖倒置原则(DIP)
当我们在3.1.2节中讨论DIP时,您了解到我们用DI尝试完成的许多事情都与DIP有关。 该原理指出,您应该针对抽象进行编程,并且使用层应控制使用的抽象的形状。 消费者应该能够以最有利于自己的方式定义抽象。 如果您发现自己在界面中添加了成员以满足其他特定实现的需求(包括将来可能的实现),那么您几乎肯定会违反DIP。
SOLID原理和拦截(Interception)
设计模式(例如Decorator)和准则(例如SOLID原则)已经存在很多年了,通常被认为是有益的。 在这些部分中,我们提供了它们与DI的关系的指示。
SOLID原则在本书的各个章节中都具有重要意义。 但是,当我们开始谈论拦截(Interception)及其与装饰者之间的关系时,遵守SOLID原则的好处就凸显出来了。 有些比其他的要好,但是通过使用装饰器来添加行为(例如审计)是OCP
和SRP
的明确应用,后者使我们能够创建具有特定定义范围的实现。
在前面的部分中,我们简要介绍了常见的模式和原理,以了解DI与其他既定准则之间的关系。 有了这些知识,现在让我们将注意力转移到本章的目标上,即面对不一致或不断变化的要求以及解决横切关注点(Cross-Cutting Concerns)的需求时,编写干净且可维护的代码。
SOLID作为AOP的驱动程序
在第10.1节中,您了解到AOP的主要目的是保持跨切关注点保持干爽。正如我们在10.2节中讨论的那样,OCP和DRY原则之间有着密切的关系。 他们都追求相同的目标,即最大程度地减少重复并防止重大变化。
从这个角度来看,您在第9章(清单9.2、9.4和9.7)中使用AuditingUserRepositoryDecorator
,CircuitBreakerProductRepositoryDecorator
和SecureProductRepositoryDecorator
见证的代码重复强烈表明我们在违反OCP
。AOP
试图通过将可扩展行为(方面)分离为可轻松应用于各种实现的单独组件来解决此问题。
但是,一个常见的误解是AOP
需要工具。 AOP
工具供应商都渴望保留这种谬论。 我们的首选方法是通过设计实践AOP
,这意味着您将首先应用模式和原理,然后再恢复到专用的AOP
工具,例如动态侦听库。
在本节中,我们将这样做。我们将通过仔细研究我们在第3章中介绍的IProductService
抽象类,从设计的角度看待AOP
。 我们将分析我们违反了哪些SOLID原则,以及为什么这样的违反行为会引起问题。之后,我们将逐步解决这些违规问题,以期使应用程序更具可维护性,从而避免将来进行大范围更改的需求。准备好应对一些精神上的不适,甚至认知失调,因为我们无视您对如何设计软件的信念。系好安全带,为骑行做好准备。
示例:使用IProductService
实施与产品相关的功能
让我们深入了解一下您在第3章中构建的IProductService
抽象,它是示例电子商务应用程序域层的一部分。 以下清单显示了清单3.5中最初定义的此接口。
清单10.3 第3章的
IProductService
接口
public interface IProductService {
IEnumerable<DiscountedProduct> GetFeaturedProducts();
}
从SOLID原则(尤其是OCP
)的角度来看应用程序的设计时,重要的是要考虑到应用程序随时间的变化,并据此预测未来的变化。考虑到这一点,您可以确定是否关闭应用程序以修改将来最有可能发生的更改。
注 通常,您在应用程序领域和软件开发方面拥有的经验越多,就越有可能对未来的变化做出良好的预测。这就是为什么在开始一个项目时通常很难立即正确地进行设计。
需要特别注意的是,即使采用SOLID设计,有时也可能会发生巨大变化。100%关闭以进行修改是不可能的,也是不希望的。 此外,符合OCP
的价格昂贵。 尽管过多的抽象可能会对应用程序的复杂性产生负面影响,但是要找到并设计适当的抽象需要花费大量的精力。 您的工作是平衡风险和成本,并提出全球最佳方案。
因为您应该查看应用程序的发展方式,所以在单个时间点评估IProductService
并没有帮助。幸运的是,Mary Rowan(我们第2章的开发人员)已经从事她的电子商务应用程序已有一段时间了,自从我们上次看过她的肩膀以来,已经实现了许多功能。下一个清单显示了Mary的进步情况。
清单10. 4 改进的
IProductService
接口 (坏代码)
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
void DeleteProduct(Guid productId);
Product GetProductById(Guid productId);
void InsertProduct(Product product);
void UpdateProduct(Product product);
Paged<Product> SearchProducts(
int pageIndex, int pageSize,
Guid? manufacturerId, string searchText);
void UpdateProductReviewTotals( Guid productId, ProductReview[] reviews);
void AdjustInventory( Guid productId, bool decrease, int quantity);
void UpdateHasTierPricesProperty(Product product);
void UpdateHasDiscountsApplied(Guid productId, string discountDescription);
<--Mary在前几章的过程中添加了新功能。 我们将在稍后讨论,这个小代码段显示了三种违反SOLID的行为。
}
如您所见,应用程序中已经添加了许多新功能。一些是典型的CRUD操作,例如UpdateProduct
,而其他一些则处理更复杂的用例,例如UpdateHasTierPricesProperty
。还有一些用于检索数据,例如SearchProducts
和GetProductById
。
注 如果您不清楚这些新方法的功能,请不要担心; 该界面的详细信息以及每种方法的作用与本次讨论无关。
尽管Mary在清单10.3中定义IProductService
的第一个版本时怀着良好的意愿开始,但每次实现与产品相关的新功能时都需要更新此接口这一事实清楚地表明出了点问题。
如果将其推断以做出预测,您是否可以期望此接口很快再次更新? 该问题的答案是明确的“是!” 实际上,Mary的待办事项中已经具有多个功能,涉及交叉销售,产品图片和产品评论,这些都会导致IProductService
发生变化。
这告诉我们的是,在此特定应用程序中,定期添加有关产品的新功能。因为这是一个电子商务应用程序,所以这不是一个震惊世界的观察。但是由于这既是代码库的核心部分,又是经常更改的部分,因此需要改进设计。让我们考虑一下SOLID原则来分析当前的设计。
从SOLID角度分析IProductService
关于第10.2节中讨论的五项SOLID原则,Mary的设计违反了五项SOLID原则中的三项,即ISP
,SRP
和OCP
。 我们从第一个开始:IProductService
违反了ISP
。
IProductService
违反了ISP
有一个明显的违规行为-IProductService
违反了ISP
。 如第10.2.4节所述,ISP
规定了在广泛的抽象上使用细粒度的抽象。 从ISP
的角度来看,IProductService
相当广泛。考虑到清单10.4,很容易相信不会有任何一个IProductService
使用者会使用其所有方法。 大多数消费者通常只会使用一种方法或最多使用一种方法。但是这种违规怎么会成为问题呢?
在测试期间,宽接口直接导致问题的代码库的一部分。 例如,HomeController
的单元测试(Unit testing)将定义一个IProductService
双重测试(Test Double)实现,但是即使HomeController
本身仅使用一种方法,也需要一个双重测试(Test Double)来实现其所有成员。即使您可以创建可重用的双重测试(Test Double),通常 仍然想断言HomeController
不会调用IProductService
的无关方法。以下清单显示了Mock IProductService
实现,该实现断言未调用意外方法。
清单10.5一个可重用的Mock
IProductService
基类 (坏代码)
public abstract class MockProductService : IProductService
{
public virtual void DeleteProduct(Guid productId)
{
Assert.True(false, "Should not be called."); <--所有方法实现均失败。
}
public virtual Product GetProductById(Guid id)
{
Assert.True(false, "Should not be called."); <--所有方法实现均失败。
return null;
}
public virtual void InsertProduct(Product product)
{
Assert.True(false, "Should not be called."); <--所有方法实现均失败。
}
... <--方法列表不胜枚举。 您需要实现所有10种方法。
}
通过使用false
值调用Assert.True
来实现所有方法均失败。Assert.True
方法是xUnit
测试框架的一部分。通过传递false
,断言将失败,并且当前正在运行的测试也将失败。
为了保护珍贵的树木,清单10.5仅显示了MockProductService
的几种方法,但我们认为您可以理解。 如果接口是特定于HomeController
的需求的,则无需实施大量失败的方法; 在这种情况下,希望HomeController
调用其所有依赖项的方法,而您不必进行此检查。
IProductService
违反了SRP
因为ISP
是SRP
的概念基础,所以ISP
违规通常表示在其实现中违反了SRP
,如此处的情况。 有时很难检测到违反SRP
的情况,并且您可能会争辩说ProductService
实现有一个责任,即处理与产品相关的用例。
但是,与产品相关的用例的概念非常模糊和广泛。而是,您只需要更改一个原因的类。ProductService
肯定有多个更改原因。例如,以下任何原因都会导致ProductService
发生更改:
- 折扣方式的变更
- 更改库存调整的方式
- 添加产品的搜索条件
- 添加与产品相关的新功能
ProductService
不仅有很多更改的理由,而且其方法很可能没有凝聚力。发现低内聚性的一种简单方法是检查将某些班级的功能转移到新班级的难易程度。 这越容易,两个部分的相关性越低,违反SRP
的可能性就越大。
也许UpdateHasTierPricesProperty
和UpdateHasDiscountsApplied
共享相同的依赖项,但是就此而已;他们没有凝聚力。 结果,该类可能很复杂,这可能导致可维护性问题。 因此,应将ProductService
分为多个类。 但这引发了一个问题:应该将几个类和哪些方法分组在一起(如果有的话)? 在开始讨论之前,我们首先检查一下IProductService
周围的设计如何违反OCP
。
要测试代码是否违反了OCP
,您首先必须确定可以对应用程序的这一部分进行什么样的更改。之后,您可以问一个问题:“在进行预期的更改时,此设计是否会引起全面的更改?”
您可以预期在电子商务应用程序的整个生命周期中会发生两个很可能发生的变化。首先,将需要添加新功能(Mary的待办事项中已有这些功能)。其次,Mary可能还需要运用横切关注点(Cross-Cutting Concerns)。有了这些预期的更改,该问题的明显答案是:“是的,当前的设计确实会引起全面的更改。”在添加新功能和添加新方面时,都会发生彻底的变化。
添加与产品相关的新功能时,所有IProductService
实现(主要的ProductService
实现)以及所有装饰器和双重测试的变化都会波及到所有产品。当添加新的横切关注点(Cross-Cutting Concerns)时,系统也会发生涟漪变化,因为,除了为IProductService
添加新的装饰器(Decorator)之外,您还将为ICustomerService
,IOrderService
和所有其他添加装饰器(Decorator)的服务抽象。正如我们在10.1节中讨论的那样,由于每个抽象可能包含数十种方法,因此该方面的代码将重复多次。
注 您需要对现有装饰器(Decorator)进行的更改量与系统中功能部件的数量成比例地增长。随着时间的推移,这使得添加新的方面和功能变得更加昂贵,以至于添加功能变得太昂贵了。
在表9.1中,我们总结了您可能需要实现的许多可能方面。在项目开始时,您可能不知道需要哪些。但是,即使您可能不确切知道您可能需要添加哪些横切关注点(Cross-Cutting Concerns)问题,也可以像Mary一样,在项目过程中确实需要添加一些横切关注点(Cross-Cutting Concerns)训练,这是一个受过良好教育的猜测。
结束我们对IProductService
的分析
从前面的分析中,您可以得出结论,清单10.4及其实现违反了SOLID五项原则中的三项。 尽管从AOP
的角度来看,您可能会倾向于使用动态拦截(Interception)(第11.1节)或编译时编织工具(第11.2节)来应用方面,但我们认为这只能解决部分问题; 也就是说,如何以可维护的方式有效地应用“跨领域关注点”。 使用工具并不能解决潜在的设计问题,从长远来看,这些问题仍然会导致可维护性问题。
提示 尽管您不应该立即拒绝基于工具的AOP
方法,但是您的首要本能应该是改善应用程序的设计。 只有在不能解决可维护性问题的情况下,才应诉诸使用工具。
正如我们将在11.1.2和11.2.2节中讨论的那样,两种AOP
方法都有其各自的缺点。但是,让我们看一下是否可以使用Mary的应用程序获得更坚固和可维护的设计。
通过应用SOLID原理改进设计
在本部分中,我们将通过执行以下操作逐步改进应用程序的设计:
- 将读取与写入分开
- 通过拆分接口和实现来修复
ISP
和SRP
违规问题 - 通过引入参数对象和用于实现的通用接口来修复
OCP
违规问题 - 通过定义通用抽象来修复意外引入的
LSP
违规
步骤1:将读取与写入分开
Mary当前设计的问题之一是,应用于IProductService
的大多数方面仅是其方法的子集所必需的。 尽管诸如安全性之类的方面通常适用于所有功能,但是诸如审核,验证和容错之类的方面通常仅在更改状态的应用程序部分周围才需要。另一方面,诸如缓存之类的方面仅对读取数据而不更改状态的方法有意义。 您可以通过将IProductService
拆分为只读和只写接口来简化装饰器(Decorator)的创建,如图10.1所示。
图10.1 将IProductService 分为只读IProductQueryServices 抽象和只读IProductCommandServices 抽象 |
---|
注 我们使用查询一词来表示仅读取状态但不更改系统状态的操作,并使用命令来表示用于更改系统状态但不产生任何结果的操作。 此术语源于命令查询分离(
CQS
)原理。 Mary已经在方法级别将IQService
与IProductService
一起应用,但是通过拆分接口,她现在将CQS
传播到接口级别。
命令查询分离
命令查询分隔(
CQS
)由Bertrand Meyer在面向对象的软件构造(ISE Inc.,1988)中提出。CQS
已成为一种有影响力的面向对象的原则,它提倡一种思想,即每种方法都应
- 返回结果,但不更改系统的可观察状态
- 更改状态,但不产生任何价值
Meyer称值产生方法查询和状态改变方法命令。 这种分离背后的想法是,使方法更容易推断何时是查询或命令,而不是两者。
这种划分的优点是新接口的粒度比以前更细。 这降低了您不得不依赖不需要的方法的风险。 例如,当您创建将事务应用于已执行代码的装饰器(Decorator)时,仅需要修饰IProductCommandServices
,从而无需实现IProductQueryServices
的方法。 这也使实现更小,更容易推理。
尽管此拆分是对原始IProductService
接口的改进,但此新设计仍会引起重大变化。与以前一样,实现与产品相关的新功能会导致应用程序中许多类的更改。尽管您将类更改的可能性降低了一半,但是更改仍然会导致触摸大约相同数量的类。这将我们带到第二步。
步骤2:通过拆分接口和实现来修复ISP
和SRP
由于拆分宽界面将我们推向正确的方向,因此让我们更进一步。 我们将注意力集中在IProductCommandServices
上,而忽略IProductQueryServices
。
让我们在这里尝试一些激进的方法。 让我们将IProductCommandServices
分解为多个单成员接口。 图10.2显示了如何将ProductCommandServices
实现分为七个类,每个类都有自己的一个成员接口。
在图10.2中,将IProductCommandServices
接口的每个方法移到了单独的接口中,并为每个接口提供了自己的类。 清单10.6显示了其中一些接口定义。
图10.2 将包含七个成员的IProductCommandServices 接口替换为七个一个成员的接口。 每个接口都有自己的对应实现。 |
---|
清单10.6 将大接口分离为一个成员接口
public interface IAdjustInventoryService
{
void AdjustInventory(Guid productId, bool decrease, int quantity);
}
public interface IUpdateProductReviewTotalsService
{
void UpdateProductReviewTotals(Guid productId, ProductReview[] reviews);
}
public interface IUpdateHasDiscountsAppliedService
{
void UpdateHasDiscountsApplied(Guid productId, string description);
}
... <--为了简洁起见,省略了其他四个接口。
这可能会吓到你活活的日光,但它可能并不像看起来那样糟糕。 以下是此更改的一些引人注目的优势:
- 每个接口都是隔离的。没有客户会被迫依赖不使用的方法。
- 当您创建从接口到实现的一对一映射时,应用程序中的每个用例都有自己的类。 这使班级变得小而集中-他们负有单一责任。
- 添加新功能意味着添加新的接口实现对。 无需更改实现其他用例的现有类。
尽管此新设计符合ISP
和SRP
,但在创建装饰器时仍会引起重大变化。就是这样:
- 将
IProductCommandServices
接口拆分为七个单成员接口,每个方面将有七个装饰器(Decorator)实现。 以10个方面为例,这意味着70个装饰器。 - 对现有方面进行更改会导致对整个类进行全面更改,因为每个方面都分布在许多装饰器(Decorator)上。
这种新设计使应用程序中的每个类都集中于一个特定的用例,这从SRP
和ISP
的角度来看非常有用。 但是,由于这些类没有可应用方面的共性,因此您不得不创建许多具有几乎相同的实现的装饰器(Decorator)。 如果您能够为代码库中的所有命令操作定义一个界面,那就太好了。这将大大减少围绕方面的代码重复,并将每个方面的装饰器(Decorator)类的数量减少到一个装饰器(Decorator)。
当您查看清单10.6时,可能很难看到这些接口之间有何相似之处。 它们都返回void
,但是都具有不同命名的方法,并且每个方法具有不同的参数集。 没有共同点可以提取出来吗?或者是吗?
步骤3:使用参数对象修复OCP
如果将每个命令方法的方法参数提取到参数对象中怎么办? 大多数重构工具都可以通过一些简单的击键就可以进行这种重构。
定义 参数对象是自然地在一起的一组参数。
下一个清单显示了此重构的结果。
清单10.7 在参数对象中包装方法参数
public interface IAdjustInventoryService
{
void Execute(AdjustInventory command); <--现在,IAdjustInventoryService不再接受参数列表,而是接受新的AdjustInventory参数对象的一个参数。 该类将方法的所有参数分组。 它的方法被重命名为一个更通用的名称Execute。
}
public class AdjustInventory <--AdjustInventory包含IAdjustInventoryService的分组方法参数。 这是一个参数对象; 它不包含任何行为。
{
public Guid ProductId { get; set; }
public bool Decrease { get; set; }
public int Quantity { get; set; }
}
public interface IUpdateProductReviewTotalsService
{
void Execute(UpdateProductReviewTotals command); <--相同的重构应用于此接口。 现在,它接受UpdateProductReviewTotals作为其唯一参数。
}
public class UpdateProductReviewTotals <--现在,在新的UpdateProductReviewTotals参数对象中,将IUpdateProductReviewTotalsService的两个方法参数组合在一起。
{
public Guid ProductId { get; set; }
public ProductReview[] Reviews { get; set; }
}
请务必注意,即使AdjustInventory
和UpdateProductReviewTotals
参数对象都是具体对象,它们仍然是其抽象的一部分。正如我们在3.1.1节中提到的那样,由于它们只是没有行为的数据对象,因此将它们的值隐藏在抽象类后面将是毫无用处的。如果将实现移动到其他程序集中,则参数对象将与它们的抽象保持在同一程序集中。同样,这些提取的参数对象成为命令操作的定义。因此,我们通常将这些对象本身称为命令。
提示 命令参数对象只有一个参数-甚至根本没有参数是非常好的选择。
InsertProduct
和UpdateHasTierPricesProperty
命令都将具有Product类型的单个参数。但是,插入产品与更新产品的HasTierPrices
属性完全不同。 同样,命令类型本身成为命令操作的定义。
通过这些重构,您可以有效地将代码从1个接口和使用7种方法实现的代码更改为7个接口和14个类。 在这一点上,您可能会认为我们确实是疯了,也许您已经准备好将这本书扔到窗外。 这可能是我们在本节开始时警告的精神不适。 忍受我们,因为增加系统中类的数量可能并不像乍看起来那样糟糕,并且这种重构将使我们进入某个位置。这就是承诺。
重要 尽管这种重构会增加项目中的文件数量,但假设每个类和接口在项目中都有自己的文件,则无需更改可执行代码。 每个方法仍包含与以前相同数量的代码。 您为每个用例提供了自己的数据对象,并且每个类现在都处理一个用例。
通过先前的重构,出现了一种模式:
- 每个抽象都包含一个方法。
- 每个方法都被命名为
Execute
。 - 每个方法都返回
void
。 - 每种方法都有一个单独的输入参数。
现在,您可以从此模式中提取公共接口。 就是这样:
public interface ICommandService { // <------------------------ 一个界面来统治他们!
void Execute(object command);
}
如果使用此新ICommandService
接口实现命令服务,则代码清单10.8中的代码将生成。 请注意,这个新的接口定义也可以用来替代其他I ... Service
抽象。
清单10.8 实现
ICommandService
的AdjustInventoryService
public class AdjustInventoryService : ICommandService <--实现ICommandService而不是IAdjustInventoryService
{
readonly IInventoryRepository repository;
public AdjustInventoryService( <--使用构造函数注入来注入类的依赖项
IInventoryRepository repository)
{
this.repository = repository;
}
public void Execute(object cmd) <--Execute接受类型对象的值,但是由于您知道AdjustInventoryService随AdjustInventory命令消息一起提供,因此可以执行强制转换。
{
var command = (AdjustInventory)cmd;
Guid id = command.ProductId;
bool decrease = command.Decrease;
int quantity = command.Quantity;
... <--访问命令的参数并执行适当的代码。 这是最初放在ProductService类的AdjustInventory方法中的代码。
}
}
图10.3显示了如何将接口数量从7个减少到1个。 但是,现在,您可以将每个服务的方法参数提取到参数对象(Parameter Object)中。
图10.3 通过将方法参数提取到参数对象中,接口的数量从7个ICommandService 减少到1个。 |
---|
如前所述,参数对象(Parameter Object)是抽象的一部分。 将所有接口折叠为一个接口使这一点更加明显。 参数对象已成为用例的定义-它已成为合同。 消费者可以将此ICommandService
注入到其构造函数中,并通过提供适当的参数对象(Parameter Object)来调用其Execute
方法。
清单10.9 取决于
ICommandService
的InventoryController
public class InventoryController : Controller
{
private readonly ICommandService service;
public InventoryController(ICommandService service) <--将ICommandService注入MVC控制器类
{
this.service = service;
}
[HttpPost]
public ActionResult AdjustInventory(
AdjustInventoryViewModel viewModel)
{
if (!this.ModelState.IsValid)
{
return this.View(viewModel);
}
AdjustInventory command = viewModel.Command; <--AdjustInventoryViewModel将AdjustInventory命令包装为属性。
this.service.Execute(command); <--如果发布的数据有效,则将命令传递给ICommandService以便执行
return this.RedirectToAction("Index");
}
}
AdjustInventoryViewModel
将AdjustInventory
命令包装为属性。 这很方便,因为AdjustInventory
是抽象类的一部分,并且仅包含特定于用例的数据。当用户回发请求时,MVC
框架及其周围的AdjustInventoryViewModel
将与AdjustInventory
绑定在一起。
使用ICommandService
实施交叉关注
在代码库中为所有命令服务调用提供单个接口可提供巨大的优势。 由于所有应用程序状态更改用例现在都实现了该单一接口,因此您现在可以为每个方面创建一个装饰器(Decorator),并将其包装在每个实现中。 为了证明这一点,下面的清单显示了作为ICommandService
的装饰器(Decorator)的事务方面的实现。
清单10.10 基于
ICommandService
实现事务方面
public class TransactionCommandServiceDecorator
: ICommandService
{
private readonly ICommandService decoratee;
public TransactionCommandServiceDecorator(
ICommandService decoratee)
{
this.decoratee = decoratee;
}
public void Execute(object command)
{
using (var scope = new TransactionScope())
{
this.decoratee.Execute(command);
scope.Complete();
}
}
}
因为该Decorator就像您在第9章中多次看到的那样,所以我们认为它不需要解释,除了TransactionScope
类。
TransactionScope
System.Transactions.dll
的System.Transactions.TransactionScope
类使您可以在事务中包装任意代码段。 在该作用域的生存期内创建的任何DbTransaction
都会自动加入同一事务中。 这是一个强大的概念,可以将事务应用于属于同一业务操作的多个代码段,而不必通过调用堆栈传递事务。与完整的.NET框架相比,.NET Core不支持分布式事务,因为这需要Microsoft分布式事务协调器(MSDTC)服务,该服务在Windows以外的平台上均不具备。 这是一个优势,因为我们认为,总的来说,无论如何都应避免分布式事务。 但是,使用.NET Core,您仍然可以使用
TransactionScope
将事务中的操作注册到单个数据源。
使用这个新的装饰器(Decorator),您现在可以通过注入一个新的AdjustInventoryService
来构成一个InventoryController
,该交易被TransactionCommandServiceDecorator
拦截(Interception):
ICommandService service = new TransactionCommandServiceDecorator( new AdjustInventoryService(repository));
new InventoryController(service);
这种设计有效地防止了在添加新功能和需要应用新的“横切关注点”时发生的大范围更改。 现在,此设计已真正关闭以进行修改,因为
- 添加新的(命令)功能意味着创建新的命令参数对象和支持的
ICommandService
实现。 无需更改现有的类。 - 添加新功能不会强制创建新的装饰器,也不会强制更改现有的装饰器。
- 可以通过添加单个装饰器来向应用程序添加新的“横切关注点”。
- 更改“横切关注点”会导致更改单个班级。
重要 即使您从清单10.4的情况开始,那里有2种类型(
IProductService
接口及其实现),也进入图10.3所示的情况,那里有15种类型(1个接口,7个参数对象和7个服务实现) ),应用程序的可维护性得到了极大的改善,因为全面的更改将很少见。 这导致了一个重要的认识,即类数本身对于衡量可维护性是一个不好的指标。
一些开发人员反对在他们的系统中使用这么多的类,因为他们认为这样做会使在项目中浏览变得复杂。 但是,只有在您的项目结构不正确时,才会发生这种情况。 在此示例中,所有与产品相关的操作都可以放置在名为MyApp.Services.Products
的命名空间中,从而将这些操作有效地分组在一起,类似于Mary的IProductService
所做的。 现在,您无需在类级别进行分组,而是在项目级别进行分组,这是一个很大的好处,因为项目结构会立即向您显示应用程序的行为。
现在,您已经修复了先前分析的SOLID违规问题,您可能会认为重构已经完成。 但是,不幸的是,这些更改无意中引入了新的SOLID违规行为。 接下来让我们看看。
分析新的意外LSP
违规
如前所述,ICommandService
的定义不小心引入了新的SOLID违规行为,即LSP。 清单10.9的InventoryController
表现出此冲突。
正如我们在10.2.3节中讨论的那样,LSP
指出,您必须能够在不更改客户端正确性的情况下,用抽象类替代同一抽象类的任意实现。根据LSP,由于AdjustInventoryService
实现了ICommandService
,因此您应该能够在不破坏InventoryController
的情况下将其替换为其他实现。下面的清单显示了更改后的InventoryController
的对象组成。
清单10.11 替代
AdjustInventoryService
( bad code)
ICommandService service =
new TransactionCommandServiceDecorator(
new UpdateProductReviewTotalsService( <--而不是注入AdjustInventoryService,而是注入UpdateProductReviewTotalsService。 这样可以编译,但完全破坏了InventoryController —违反LSP的行为。
repository));
new InventoryController(service);
The following shows the Execute method for UpdateProductReviewTotalsService:
public void Execute(object cmd)
{
var command = (UpdateProductReviewTotals)cmd; <--当为Execute提供类型为AdjustInventory的命令时,此强制转换失败。
...
}
InventoryController
将ICommandService
注入其构造函数。 它将AdjustInventory
命令传递给注入的ICommandService
。 由于注入的ICommandService
是UpdateProductReviewTotalsService
,因此它将尝试将传入的命令强制转换为UpdateProductReviewTotals
。 由于无法将AdjustInventory
强制转换为UpdateProductReviewTotals
,因此强制转换失败。 这破坏了InventoryController
,因此违反了LSP。
注 DI容器(DI Container)根据从类型的构造函数参数中检索到的类型信息来组成对象图。 因为它们的对象组合的主要方法基于此,所以DI容器(DI Container)不利于处理模棱两可的抽象。因此,在使用DI容器(DI Container)时,违反LSP
的情况会使您的成分根复杂化。 或者换句话说,使用DI容器(DI Container)会使LSP
违规更加明显,就像构造函数注入(Constructor Injection)使SRP
违规更加明显一样。
尽管有人可能认为应由组合根(Composition Root)提供正确的实现,但ICommandService
接口仍然会造成歧义,并且会阻止编译器验证对象图的组成是否有意义。 违反LSP往往会使系统易碎。此外,Execute
方法使用的无类型命令方法参数要求每个ICommandService
实现都包含强制类型转换,而强制类型转换本身可以被视为代码异味。让我们解决此违规问题。
步骤4:使用通用抽象来修复LSP
对于这种看似棘手的设计僵局,这是一个相当优雅的解决方案。 解决此问题所需要做的就是重新定义ICommandService
。
清单10.12 一个通用的
ICommandService
实现 (好代码)
public interface ICommandService<TCommand> <--TCommand是泛型类型参数。 它指定实现将执行的命令的类型。
{
void Execute(TCommand command);
}
您可能对使接口通用有何帮助感到困惑。 为了帮助澄清这一点,下面的清单显示了如何实现ICommandService <TCommand>
。
清单10.13 实现
ICommandService <TCommand>
的AdjustInventoryService
(好代码)
public class AdjustInventoryService
: ICommandService<AdjustInventory> <--实现一个ICommandService <TCommand>,指示该类处理AdjustInventory消息
{
private readonly IInventoryRepository repository;
public AdjustInventoryService(
IInventoryRepository repository)
{
this.repository = repository;
}
public void Execute(AdjustInventory command) <--因为该类实现ICommandService <AdjustInventory>,所以其Execute方法现在接受AdjustInventory而不是对象。
{
var productId = command.ProductId;
...
} <--由于Execute现在直接接受AdjustInventory,因此可以直接使用parameter命令,而无需进行任何强制转换。
}
重要 您还记得我们如何定义清单6.9中的
IEventHandler<TEvent>
抽象吗? 此新ICommandService<TCommand>
的签名与IEventHandler <TEvent>
的签名相同,这不是巧合。 当您将SOLID原理应用于代码库时,这种结构就会经常出现。也就是说,一个成员的通用接口会根据其通用类型接受和/或返回消息。
与先前的示例类似,许多框架和在线参考体系结构示例的接口名称也不同。 它们可能被命名为IHandler<T>
,ICommandHandler<T>
,IMessageHandler<T>
或IHandleMessages<T>
。 一些抽象是异步的,并返回一个Task
,而其他一些则添加CancellationToken
作为方法参数。 有时,该方法称为Handle
或HandleAsync
。尽管命名不同,但是其思想及其对应用程序可维护性的影响是相同的。
尽管实现中的附加编译时支持当然是一个不错的选择,但是通用ICommandService <TCommand>
的主要原因是为了防止违反其客户端中的LSP
。 以下清单显示了如何将ICommandService <TCommand>
注入InventoryController
来修复LSP
。
清单10.14 取决于
ICommandService <TCommand>
的InventoryController
(好代码)
public class InventoryController : Controller
{
readonly ICommandService<AdjustInventory> service;
public InventoryController(
ICommandService<AdjustInventory> service)
{
this.service = service;
}
public ActionResult AdjustInventory(
AdjustInventoryViewModel viewModel)
{
...
AdjustInventory command = viewModel.Command;
this.service.Execute(command);
return this.RedirectToAction("Index");
}
}
提示 如果您的编程语言不支持泛型,则可能会发现将非泛型
ICommandService
接口与Mediator设计模式混合使用是可以接受的解决方法。在这种情况下,您将引入一个附加的抽象,即Mediator
,它可以接受任意命令 并注入到消费者中。中介者的工作是将提供的命令调度到正确的ICommandService
实现。
将非通用ICommandService
更改为通用ICommandService <TCommand>
可修复我们上一次违反SOLID的问题。这将是收获我们新设计优势的好时机。
使用通用抽象应用事务处理
尽管通用的一员抽象不仅具有横切关注点(Cross-Cutting Concerns),但以不引起重大变化的方式应用方面的能力是这种设计的最大优点之一。 与非通用ICommandService
接口一样,ICommandService <TCommand>
仍允许每个方面创建单个装饰器。 清单10.15显示了使用新的通用ICommandService <TCommand>
抽象对清单10.10的事务装饰器的重写。
清单10.15 实现通用交易方面 (好代码)
public class TransactionCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private readonly ICommandService<TCommand> decoratee;
public TransactionCommandServiceDecorator(
ICommandService<TCommand> decoratee)
{
this.decoratee = decoratee;
}
public void Execute(TCommand command)
{
using (var scope = new TransactionScope())
{
this.decoratee.Execute(command);
scope.Complete();
}
}
}
使用ICommandService <TCommand>
接口和TransactionCommandServiceDecorator <TCommand>
装饰器,您的组合根(Composition Root)目录将变为以下内容:
new InventoryController( new TransactionCommandServiceDecorator<AdjustInventory>( new AdjustInventoryService(repository)));
这样一来,这个由一员成员组成的通用抽象就开始抢走展览。这是您开始添加更多横切关注点(Cross-Cutting Concerns)的时候。
添加更多的横切关注点(Cross-Cutting Concerns)
我们在9.2节中讨论的横切关注点(Cross-Cutting Concerns)示例都集中于在存储库边界(例如清单9.4和9.7)中应用方面。 但是,在本部分中,我们将焦点从数据访问库的存储库转移到域库的IProductService
,将层次结构上移了一层。
这一转变是有意为之的,因为您会发现存储库不是有效应用许多横切关注点(Cross-Cutting Concerns)的正确粒度。 域层中定义的单个业务操作可能会调用多个存储库,或多次调用同一存储库。 例如,如果您要在存储库级别应用事务,则仍然意味着业务操作可能会在数十个事务中运行,这将危及系统的正确性。
单个业务操作通常应在单个事务中运行。 这种粒度级别不仅适用于事务,而且适用于其他类型的操作。
域库实现了业务运营,您通常希望在此边界上应用许多横切关注点(Cross-Cutting Concerns)。 下面列出了一些示例。 它不是一个完整的清单,但可以让您大致了解在该级别上可以应用的内容:
- Auditing-尽管您可以像清单9.1的
AuditingUserRepositoryDecorator
一样对存储库实施审核,但这会显示单个实体的更改列表,并且您会失去整体情况—这就是更改发生的原因。 报告单个实体的更改可能适用于基于CRUD的应用程序,但是如果该应用程序实现影响比单个实体更多的更复杂的用例,则提高审核级别并存储有关已执行命令的信息将变得非常有益。接下来,我们将展示一个审核示例。 - Logging-正如我们在5.3.2节中提到的,良好的应用程序设计可以防止不必要的日志记录语句散布在整个代码库中。 记录任何已执行的业务操作及其数据可为您提供有关调用的详细信息,这通常消除了在每种方法开始时进行记录的需要。
- 性能监视-由于通常将执行请求的时间的99%用于运行业务操作本身,因此
ICommandService <TCommand>
成为插入性能监视的理想边界。 - 安全性-尽管您可能尝试在存储库级别上限制访问,但是通常这样做的粒度太细,因为您更可能希望在业务操作级别上限制访问。 您可以使用许可角色或许可来标记命令,这使得使用单个装饰器对所有业务操作应用安全性考虑变得很简单。 我们将很快显示一个示例。
- 容错能力-如清单10.15所示,由于您希望在业务运营中应用事务,因此通常应在同一级别上应用其他容错方面。 例如,实现数据库死锁重试方面就是一个很好的例子。 这种机制应始终围绕交易方面应用。
- Validation(验证)-如清单10.9和10.14所示,该命令可以成为Web请求提交数据的一部分。通过使用“数据注释”的属性丰富命令,该命令的数据也将由
MVC
进行验证。作为一种额外的安全措施,您可以创建一个装饰器,使用“数据注释”的静态Validator
类来验证传入的命令.
以下各节介绍如何在ICommandService <TCommand>
之上实现这两个方面。
示例:实施审核方面
清单9.1和9.2定义了IUserRepository
的审核装饰器,同时重用了清单6.23中的IAuditTrailAppender
。 如果改为在ICommandService <TCommand>
上应用审核,那么您处于理想的粒度级别,因为该命令包含您可能想要记录的所有特定于用例的有趣数据。 如果您通过一些上下文信息(例如用户名和当前系统时间)丰富了这些数据和元数据,那么您的工作就差不多完成了。下一个清单在ICommandService<TCommand>
的顶部显示了一个审核装饰器。
清单10.16 为业务操作实现通用审计方面 (好代码)
public class AuditingCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private readonly IUserContext userContext;
private readonly ITimeProvider timeProvider;
private readonly CommerceContext context;
private readonly ICommandService<TCommand> decoratee;
public AuditingCommandServiceDecorator(
IUserContext userContext,
ITimeProvider timeProvider, <--回想一下,这是清单5.10中的ITimeProvider接口。
CommerceContext context,
ICommandService<TCommand> decoratee)
{
this.userContext = userContext;
this.timeProvider = timeProvider;
this.context = context;
this.decoratee = decoratee;
}
public void Execute(TCommand command)
{
this.decoratee.Execute(command);
this.AppendToAuditTrail(command);
}
private void AppendToAuditTrail(TCommand command)
{
var entry = new AuditEntry
{
UserId = this.userContext.CurrentUser.Id,
TimeOfExecution = this.timeProvider.Now,
Operation = command.GetType().Name,
Data = Newtonsoft.Json.JsonConvert
.SerializeObject(command)
}; <--除了将用户和执行时间附加到审计跟踪之外,Decorator还存储命令的名称及其数据的序列化表示形式。 此信息是使用反射收集的,在这种情况下,您使用众所周知的JSON.NET序列化库(https://www.newtonsoft.com/ json)将命令数据转换为可读的JSON格式。
this.context.AuditEntries.Add(entry);
this.context.SaveChanges();
}
}
注 此装饰器结合了审核逻辑和装饰逻辑。这是否是一个好习惯,取决于装饰器(Decorator)内部的逻辑量,是否需要其他类重用此审核逻辑以及将装饰器(Decorator)放在哪个模块中。因为您现在可以将此装饰器(Decorator)应用于所有业务运营,所以我们认为没有理由与其他类共享此逻辑。 因此,我们将两个类合并在一起。但是,由于依赖于
CommerceContext
,因此该Decorator应该放置在数据访问层或组合根(Composition Root)中。
当Mary使用AuditingCommandServiceDecorator <TCommand>
运行应用程序时,Decorator在审核表中生成信息,如表10.2所示。
表10.2 审计跟踪示例
用户 | 时间 | 操作 | 时间 |
---|---|---|---|
Mary | 2018-12-24 11:20 | AdjustInventory | { ProductId: "ae361...00bc", Decrease: false, Quantity: 2 } |
Mary | 2018-12-24 11:21 | UpdateHasTierPricesProperty | { Product: { Id: "ae361...00bc", Name: "Gruyère", UnitPrice: 48.50, IsFeatured: true } } |
Mary | 2018-12-24 11:25 | UpdateHasDiscountsApplied | |
Mary | 2018-12-24 15:11 | AdjustInventory | |
Mary | 2018-12-24 15:12 | UpdateProductReviewTotals | { ProductId: "5435...a845",Reviews: [{ Rating: 5, Text: "nice!" }] } |
如前所述,AuditingCommandServiceDecorator<TCommand>
使用反射来获取命令的名称并将该命令转换为JSON
格式。 尽管JSON
是人类可读的,但您可能不想将其显示给最终用户。不过,这是用于后端审核的一种好格式。利用这些信息,您将能够有效地查看系统中发生的情况,由谁以及何时发生。 如果由于某种原因操作失败,它甚至可以让您重播该操作,或者使用此信息对系统执行实际的压力测试。您可以将该表中的信息反序列化回命令,然后在系统中运行它们。
正如我们在6.3.2节中所述,领域事件是另一种非常适合的技术,也可以用于审计。但是,此审核方面仅记录用户的成功操作。尽管审计师可能对失败不感兴趣,但作为开发人员,我们当然对此感兴趣。不难想象,当操作失败时,您将如何使用相同的机制来记录相同的数据并包括堆栈跟踪。
重要 应用程序设计从类似
RPC
的方法调用模型转变为消息传递模型。这些消息可以序列化,排队,记录和重播-使用方法调用模型很难实现的所有功能,例如清单10.4中的初始IProductService
实现。
设计良好的应用程序几乎没有代码行可以记录
如
ICommandService <TCommand>
那样,当您使用环绕业务事务的抽象时,方法参数将成为易于序列化的数据包,如清单10.16所示。 因此,单个Decorator可让您跨应用程序中的多种方法应用日志记录。这可能无法解决您所有的日志记录需求,但是当应用程序变得更加复杂时,我们已经体验到,精心设计的SOLID应用程序的抽象允许定义一些装饰器,这些装饰器可以为我们提供98%的日志记录需求 应用程序。但是,您还需要采用其他一些实践来避免必须在应用程序中的太多位置进行登录:
- 抛出异常,而不是在继续执行时记录意外情况。
- 与其捕获意外的异常,进行记录并在操作过程中继续进行,不如不处理异常并使它们冒泡增加调用堆栈,而不是捕获异常。 让操作快速失败会使异常记录在调用堆栈顶部的单个位置,并避免使用户产生幻想其请求已成功完成的错觉。
- 使方法变小。这不仅提高了代码的可读性,而且在引发异常的情况下,堆栈跟踪为您提供了有关异常发生时应用程序经过的路径的更多信息。
同样,您可以通过相同的方式将此信息用于性能监控,在时间和操作详细信息旁边存储一个额外的时间跨度。这很容易让您监视随着时间的推移哪些操作变慢。 在向您展示应用了AuditingCommandServiceDecorator<TCommand>
的新组合根(Composition Root)的示例之前,我们将首先介绍如何使用被动属性来实现安全性。
示例:实现安全方面
在第9.2节中有关横切关注点(Cross-Cutting Concerns)的讨论中,您在清单9.7中实现了SecureProductRepositoryDecorator
。因为该装饰器(Decorator)特定于IProductRepository
,所以很明显,装饰器(Decorator)应该授予访问哪个角色。在该示例中,对IProductRepository
的写入方法的访问仅限于管理员角色。
使用这种新的通用模型,单个装饰器将包装在所有业务操作中,而不仅仅是产品CRUD操作。某些操作还需要其他角色可执行,这使得硬编码的Administrator
角色不适合此通用模型。您可以通过多种方法在通用抽象之上实现这种安全检查,但是一种引人注目的方法是使用被动属性。
定义 被动属性提供元数据而不是行为。被动属性会阻止Control Freak反模式(anti-pattern),因为包含行为的方面属性通常是易失性依赖项。
当您将基于角色的安全性作为授权示例时,可以指定PermittedRoleAttribute
。
清单10.17一个被动的
PermittedRoleAttribute
public class PermittedRoleAttribute : Attribute <--此被动属性允许类使用有关允许角色的元数据来丰富。
{
public readonly Role Role;
public PermittedRoleAttribute(Role role) <-- 包装应用程序的角色枚举,该枚举定义应用程序的固定角色集
{
this.Role = role;
}
}
public enum Role <--包含应用程序已知角色的角色枚举。 您首先在3.1.2节中看到了该枚举。
{
PreferredCustomer,
Administrator,
InventoryManager
}
您可以使用此属性来使命令带有元数据,以允许有关执行哪个角色的操作。
清单10.18 通过与安全相关的元数据来丰富命令 (好代码)
[PermittedRole(Role.InventoryManager)]
public class AdjustInventory
{
public Guid ProductId { get; set; }
public bool Decrease { get; set; }
public int Quantity { get; set; }
}
[PermittedRole(Role.Administrator)]
public class UpdateProductReviewTotals
{
public Guid ProductId { get; set; }
public ProductReview[] Reviews { get; set; }
}
<---1,8row 指定允许的角色时,用PermittedRoleAttribute标记命令。 在这种情况下,尽管只有管理员有权执行UpdateProductReviewTotals,但具有InventoryManager角色的用户可以执行AdjustInventory。
重要 注意清单10.18中的允许角色如何成为命令定义的一部分。
正如我们将在11.2节中讨论的那样,应用方面属性与被动属性(例如PermittedRoleAttribute
)之间存在很大的差异。 与方面属性相比,被动属性与使用它们的值的方面是分离的,这是编译时编织的主要问题之一,您将在第11章中看到。 方面。 这允许元数据可能以不同的方式被多个方面重用。
提示 与重用绑定到特定框架的属性相比,更喜欢创建特定于域的属性。 例如,如果您使用
ASP.NET Core
的[Authorize]
属性,则会将依赖项拖至Microsoft .AspNetCore.Authorization.dll
,如果要在例如 Windows服务应用程序。
如您先前所见,添加安全行为只需创建装饰器(Decorator),然后将其包装在实际实现中即可。清单10.19显示了这样的一个装饰器(Decorator)。 如清单10.18所示,它利用了提供给命令的PermittedRoleAttribute
。
清单10.19
SecureCommandServiceDecorator <TCommand>
(好代码)
public class SecureCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private static readonly Role PermittedRole = GetPermittedRole(); <--获取允许执行此命令的角色
private readonly IUserContext userContext;
private readonly ICommandService<TCommand> decoratee;
public SecureCommandServiceDecorator(
IUserContext userContext, <--装饰器依赖于IUserContext,它允许它检查当前用户的角色。
ICommandService<TCommand> decoratee)
{
this.decoratee = decoratee;
this.userContext = userContext;
}
public void Execute(TCommand command)
{
this.CheckAuthorization(); <--在将呼叫委托给被装饰者之前,请验证是否允许用户执行此操作
this.decoratee.Execute(command);
}
private void CheckAuthorization()
{
if (!this.userContext.IsInRole(PermittedRole)) <--如果用户不属于指定角色,则抛出异常。 这样可以使操作快速失败。 可以在调用堆栈的更高位置记录异常。
{
throw new SecurityException();
}
}
private static Role GetPermittedRole()
{
var attribute = typeof(TCommand) <--使用反射获取在命令类型上指定的PermittedRoleAttribute
.GetCustomAttribute<PermittedRoleAttribute>();
if (attribute == null)
{
throw new InvalidOperationException(
"[PermittedRole] missing."); <--如果在命令类型上未定义任何属性,则可以假定允许每个用户执行该命令,但这将带来安全风险。 而是,它引发异常,强制每个命令都应用属性。
}
return attribute.Role;
}
}
授权方式
您可以通过多种方式在命令和其他消息类型上指定授权。 这里有一些想法:
- 如果命令始终可由单个角色访问,请考虑将命令放在以其角色命名的命名空间中,而不要应用属性。例如,您可以将管理命令放在名称空间
MyApp .Domain.Commands.Administrator
中,并让装饰器(Decorator)分析该名称空间。 这也为您提供了一个很好的直观项目结构,因为命令是按其允许的角色分组的。 - 通用模型不是使用角色,而是使用权限。权限允许以更细粒度的方式配置访问。可以将命令标记为特定的权限。这将对应用程序权限列表(而不是角色)进行硬编码,并允许管理员管理用户,角色和权限之间的链接
- 除了基于角色的安全性之外,您的应用程序可能还需要基于行的安全性。在电子商务应用程序的上下文中,这可能意味着某些产品组只能由位于某些地区的用户来管理。换句话说,即使多个用户可能处于同一角色中,基于行的安全性仍可以使某个角色中的某些用户可以访问特定产品,但同一角色中的其他用户则无法访问特定产品
我们可以为您提供大量可以装饰在业务交易中的装饰器示例,但是一本书可以容纳的页数是有限的。此外,到现在为止,我们认为您已经开始在ICommandService<TCommand>
之上开始了解如何应用抽象类了。让我们将所有内容组合到组合根(Composition Root)目录中。
使用通用装饰器组成对象图
在前面的部分中,您声明了三个装饰器来实现安全性,事务管理和审计。您需要围绕组合根(Composition Root)中的实际实现应用这些装饰器。图10.4显示了装饰器如何像一组俄罗斯嵌套娃娃一样围绕命令服务包裹。
图10.4 通过审计,事务和安全性方面丰富真实的命令服务 |
---|
如果将所有三个先前定义的装饰器都应用到组合根(Composition Root),则会得到下面显示的代码。
清单10.20装饰
AdjustInventoryService
ICommandService<AdjustInventory> service = new SecureCommandServiceDecorator<AdjustInventory>(
this.userContext,
new TransactionCommandServiceDecorator<AdjustInventory>(
new AuditingCommandServiceDecorator<AdjustInventory>(
this.userContext,
this.timeProvider,
context,
new AdjustInventoryService(repository)
)
)
);
return new InventoryController(service);
因为应用程序有望获得许多ICommandService<TCommand>
实现,所以大多数实现将需要相同的装饰器。因此,清单10.20将导致Composition Root内部大量代码重复。通过将重复的装饰器创建提取到其自己的方法中,可以轻松解决此问题。
清单10.21 将装饰器的成分提取为可重用的方法
private ICommandService<TCommand> Decorate<TCommand>(
ICommandService<TCommand> decoratee,
CommerceContext context)
{
return
new SecureCommandServiceDecorator<TCommand>(
this.userContext,
new TransactionCommandServiceDecorator<TCommand>(
AuditingCommandServiceDecorator<TCommand>(
this.userContext,
this.timeProvider,
context,
decoratee)))); <--包装装饰器列表中的decoratee参数
}
将装饰器提取到装饰器(Decorator)方法中,可以使组合根(Composition Root)完全干燥。AdjustInventoryService
的创建简化为简单的一列式:
var service = Decorate(new AdjustInventoryService(repository), context);
return new InventoryController(service);
第12章演示了如何使用DI容器(DI Container)自动注册ICommandService<TCommand>
实现并应用装饰器。因为这几乎使我们到了本节末尾,即使用SOLID原理作为AOP
的驱动程序,所以让我们回顾一下我们所取得的成就以及它与应用程序设计的更广阔前景之间的关系。
结论
在本章中,您将域层的大型IProductService
(包含多个命令方法)重构为单个ICommandService <TCommand>
抽象,其中每个命令都有自己的消息以及用于处理该消息的关联实现。 这种重构并没有改变任何原始的应用程序逻辑。但是,您确实使命令的概念明确了。
一个重要的观察结果是,这些域命令现在在系统中以清晰的工件形式公开,并且它们的处理程序带有单个接口标记。此方法类似于使用ASP.NET Core MVC
之类的应用程序框架时隐式实践的方法。MVC
控制器通常是通过继承Controller
抽象类来定义的。 这使MVC
可以使用反射来找到它们,并且提供了用于与它们进行交互的通用API
。正如您在这些命令中看到的那样,您为他们的处理程序提供了通用的API
(单个Execute
方法),这种做法在应用程序的设计中具有更大的价值。这样可以有效地应用各个方面,而无需重复代码。
除了命令之外,系统中还有一些其他构件可能需要以类似的方式进行设计,以便能够应用横切关注点(Cross-Cutting Concerns)。值得一看的常见工件是查询的工件。在10.3.3节的开头,将IProductService
拆分为读写接口之后,我们将注意力集中在IProductCommandServices
上,而忽略了IProductQueryServices
。查询应该自己抽象。但是,由于篇幅所限,对此的讨论不在本书的讨论范围之内。
但是,我们的观点是,在许多类型的应用程序中,可以像本章所述确定一组相关组件之间的通用性。 这可能有助于更有效地应用横切关注点,并且还为您提供了经过编译器验证的显式编码约定。
但是,本章的目的并不是要说明ICommandService <TCommand>
抽象是设计应用程序的方式。 本章的重要内容应该是根据SOLID设计应用程序是保持应用程序可维护性的方法。 正如我们所展示的,在大多数情况下,无需使用专门的AOP
工具即可实现这一点。这很重要,因为这些工具都有其自身的局限性和问题,这将在下一章中深入探讨。 但是,我们发现一组特定的设计结构适用于许多业务线(LOB)应用程序-其中一个类似于ICommandService
的抽象。
这并不意味着应用SOLID原理总是很容易。相反,这可能很困难。如前所述,这需要时间,而且您永远不会100%稳定。作为软件开发人员,您的工作是找到最佳位置。 在适当的时机应用DI和SOLID绝对可以提高您接近这一点的机会。
当应用公认的面向对象原理(例如SOLID)时,DI会大放异彩。特别是,DI的松散耦合(Loose Coupling)特性使您可以使用装饰者模式来遵循OCP和SRP
。这在很多情况下都很有价值,因为它使您能够保持代码的清洁和井井有条,特别是在解决跨切问题时。
但是,我们不要在灌木丛中挣扎。即使您尝试应用SOLID原则,也很难编写可维护的软件。此外,您经常从事的项目并非经得起时间的考验。进行较大的体系结构更改可能不可行或很危险。那时,即使AOP
工具为您提供了一个临时解决方案,也可能是您唯一可行的选择。在决定使用这些工具之前,重要的是要了解它们的工作原理以及它们的弱点,尤其是与本章所述的设计原理相比。这将是下一章的主题。
总结
- 单一责任原则(SRP)规定,每个班级只有一个改变的理由。 这可以从凝聚力的角度来看。 内聚性定义为类或模块的元素的功能相关性。 关联度越低,内聚性越低; 凝聚力越低,班级违反SRP的机会就越大。
- 开放/封闭原则(OCP)规定了一种应用程序设计,该应用程序设计使您不必在整个代码库中进行全面更改。OCP和DRY原则之间的密切关系是它们都为同一目标而奋斗。
- “不要重蹈覆辙”(DRY)原则指出,每条知识在系统中都必须具有单一,明确,权威的表示形式。
- 里氏替换原则(LSP)指出,每个实现都应按照其抽象定义的方式运行。 这样,您就可以用同一抽象类的另一个实现替换最初打算的实现,而不必担心破坏使用者。 这是DI的基础。 当消费者不注意时,注入依赖项几乎没有优势,因为您不能随意替换依赖项,并且会失去许多(如果不是全部)DI的好处。
- 接口隔离原则(ISP)提倡使用细粒度的抽象,而不是宽泛的抽象。消费者只要依赖某些成员未使用的抽象,就违反了该原则。 当有效地应用面向方面的编程时,此原则至关重要。
- 依赖倒置原则(DIP)指出您应针对抽象进行编程,并且使用层应控制使用的抽象的形状。 消费者应该能够以最有利于自己的方式定义抽象。
- 这五个原则共同构成SOLID的首字母缩写。SOLID原则均不代表绝对原则。 它们是可以帮助您编写简洁代码的准则。
- 面向方面的编程(AOP)是一种范式,其重点是有效且可维护地应用交叉切割关注点的概念。
- 最引人注目的AOP技术是SOLID。SOLID应用程序可防止在常规应用程序代码和交叉切割问题的实现期间重复代码。 使用SOLID技术还可以帮助开发人员避免使用特定的AOP工具。
- 即使采用SOLID设计,也可能会出现一次变化变得巨大的时代。 100%关闭以进行修改是不可能的,也是不希望的。 查找和设计适当的抽象时,遵循OCP会花费大量的精力,尽管太多的抽象可能会对应用程序的复杂性产生负面影响。
- 命令查询分离(CQS)是一种有影响力的面向对象原则,该原则指出每种方法应返回结果但不更改系统的可观察状态,或更改状态但不产生任何值。
- 将命令方法和查询方法放在不同的抽象中可简化横切关注点(Cross-Cutting Concerns)问题的应用,因为大多数方面都需要应用于命令或查询,但不能同时应用于两者。
- 参数对象是自然地在一起的一组参数。通过提取参数对象,可以定义可重用的抽象,该抽象可通过大量组件来实现。 这样可以类似地处理这些组件,并有效地应用横切关注点(Cross-Cutting Concerns)。
- 这些提取的参数对象而不是组件的抽象,而是成为系统中不同的操作或用例的定义。
- 尽管使用参数对象将较大的类划分为许多较小的类可以极大地增加系统中的类数,但它也可以显着提高系统的可维护性。 系统中的类数对于衡量可维护性是一个不好的指标。
- 横切关注点(Cross-Cutting Concerns)问题应在应用程序中以正确的粒度级别应用。 对于除最简单的CRUD应用程序以外的所有应用程序而言,对于大多数横切关注点(Cross-Cutting Concerns)而言,存储库都不是正确的粒度级别。 随着SOLID原则的应用,可重复使用的一元抽象通常作为需要应用交叉切割关注点的级别而出现。