实用模式
聚合和耦合
很多软件设计一直都存在一个问题:这段代码应放置在哪里?我一直在寻找编排代码的最佳方法,以便能够更轻松地编写、理解代码,并在以后更方便地进行更改。如果我的代码构造很漂亮,我将可名扬四海,无限荣光。如果构造得很糟糕,那些追随我的开发人员会一直对我埋怨不停。
我特别想在我的代码结构方面实现三个具体目标:
大体上说,这些目标都与聚合和耦合这两个典型代码质量紧密相关。我通过达到更高程度的聚合和更松散的耦合实现这些目标。当然,我们首先需要了解这些质量的含义以及为何聚合和耦合是有用的概念。然后我想讨论一些我将称之为“设计矢量”的内容,它们可以帮助我们实现更佳的结构,并帮助我们认识到何时需要丢弃那些已经不知不觉在我们代码中形成的错误结构。
补充一点,我是名为 StructureMap 的开源控制反转 (IOC) 工具的主要开发人员。我将使用源自 StructureMap 的一些现实示例来说明这些矢量所针对的设计问题。换言之,千万不要再犯我已犯过的错误。
降低耦合
在软件设计的几乎所有讨论场合,你都会随时听到“松散耦合”或“紧密耦合”这样的术语。类或子系统之间的耦合是这些类或子系统之间互联程度的量度标准。紧密耦合表示相关的类必须了解彼此的内部细节、更改将波及整个系统,并且系统可能更难以了解。
图 1 显示了一个精心设计的业务处理模块示例,该模块与除业务逻辑之外的其他各种关注问题紧密耦合。
图 1 紧密耦合的代码
第一个问题是该代码会因侧重点失偏而有些难以理解。我将在有关聚合的下一节中深入讨论这一点。
第三,我们不能独立于特定的数据库结构或在没有 AppSettings 键的情况下重用该业务逻辑代码。我们也不能重用在 BusinessLogicClass 中内嵌的数据访问功能。数据访问与业务逻辑之间的耦合可能不是问题,但如果我们希望改变此业务逻辑的用途,以对照由分析人员直接输入到 Excel 电子表格中的数据来使用它会怎样呢?如果我们要单独测试或调试该业务逻辑又会怎样呢?我们无法实现上述的任何操作,因为该业务逻辑与数据访问代码是紧密耦合的。如果我们能将业务逻辑从其他关注问题中隔离出来,则对它进行更改就会变得轻松得多。
总之,在类与模块之间实现松散耦合的实际目标是为了:
提高聚合
聚合的理论定义是类的所有职责、数据和方法彼此关联紧密程度的度量标准。我更倾向于将聚合视为是判断某个类在系统内是否有明确定义的角色的度量标准。我们通常认为高度聚合是件好事,并且将“高度聚合”视为魔咒一样反复叨念。但其中的原因是什么?
让我们将编码看作是与计算机进行的一次对话。更准确地讲,我们在与计算机同时进行多个对话。我们的对话内容是有关如何实现安全性、基础结构方面的问题应如何表现以及业务规则是什么。
如果您处于一个正同时进行多个不同对话的喧闹聚会中,则很难将注意力集中在某一个您正设法进行的对话。在一个只进行一个对话的安静环境中进行对话就会容易得多。
对聚合的一个简单测试就是观察某个类并判断该类的所有内容是否与类的名称直接相关并由该名称描述,含糊的类名称(例如 InvoiceManager)不算在内。如果该类的职责不与其名称相关,则这些职责有可能属于其他类。如果您发现一些方法和字段的子集可轻松地在另一个类名称下单独分组,则您可能应将这些方法和字段提取到一个新类中。
举例说明,如果您在 TrainStation、Train 和 Conductor 类中发现一些方法和数据看起来与 TrainSchedule 类的主题最精确匹配,可将这些方法和数据移至 TrainSchedule 中。我最喜爱的有关设计的一句熟语在此处比较适用:将代码放置在您认为应该能找到它的位置。对我而言,将涉及列车时刻表的功能放置在 TrainSchedule 类中是最符合逻辑的。
延续我之前的对话比喻,由聚合类和子系统组成的系统就像是一个设计合理的在线讨论组。在线组中的每个区域都仅仅集中在一个特定主题,这样比较容易跟踪讨论内容,如果您正在寻找有关某特定主题的对话,则只能访问一个房间。
消除不适当的亲密
不适当的亲密指的是某个类中某个方法对另一个类过于熟知。不适当的亲密是两个类之间存在有负面影响的紧密耦合的迹象。假设我们有一个业务逻辑类,该类调用类 DataServer1 的实例以获取其进行业务逻辑处理所需的数据。
图 2 显示了一个示例。在这种情况下,Process 方法需要了解 DataServer1 的大量内部工作,并对 sqlDataReader 类略知一二。
图 2 不适当的亲密
现在让我们重新编写
图 2 中的代码以消除不适当的亲密:
public void Process() { DataServer2 server = new DataServer2(); foreach (DataItem item in server.GetWorkItemData(5)) { processItem(item); } }
正如您在此版本代码中看到的那样,我已将所有 sqlConnection 和 sqlDataReader 对象操作封装在 DataServer2 类内。DataServer2 也被假定为负责其自身的配置,因此新的 Process 方法无需了解任何有关设置 DataServer2 的内容。从 GetWorkItemData 返回的 DataItem 对象也是强类型化的对象。
现在,我们对照松散耦合的某些目标分析一下两个版本的 Process 方法。首先,在使代码易于阅读的方面效果如何?第一个版本和第二个版本的 Process 都执行了相同的基本任务,但哪一个更易于阅读和理解呢?就个人而言,我无需费力地理解数据访问代码就可以更轻松地阅读和理解业务逻辑处理。
在使类易于使用的方面效果如何?DataServer1 的使用者需要了解如何创建 sqlConnection 对象、了解返回的 DataReader 的结构,以及迭代并整理 DataReader。DataServer2 的使用者只需调用无参数的构造函数,然后调用可返回一系列强类型化的对象的单个方法即可。DataServer2 将负责自身的 ADO.NET 连接设置并整理打开的 DataReaders。
在隔离对较小区域代码的可能更改的方面效果如何?在第一个版本的代码中,对 DataServer 工作方式的几乎所有更改都会影响 Process 方法。在封装性更好的第二个版本的 DataServer 中,您可将数据存储切换到 Oracle 数据库或 XML 文件,而不会对 Process 方法产生任何影响。
Demeter 定律
Demeter 定律是一种设计经验法则。该定律的简要定义为:仅与您的直接伙伴交谈。Demeter 定律是有关代码潜在威胁的警告,如
图 3 所示。
图 3 违背定律
ClassThatNeedsInsuranceClaim 类需要获取 InsuranceClaim 数据。它有一个对 Repository 类的引用,Repository 类本身包含一个 DataService 对象。ClassThatNeedsInsuranceClaim 到达 Repository 内部以获取内部 DataService 对象,然后调用 Repository.FindClaims 获得其数据。请注意,对 _repository.InnerService.FindClaims(customer) 的调用明显违背了 Demeter 定律,因为 ClassThatNeedsInsuranceClaim 将直接调用其 Repository 字段的属性的方法。现在,请将您注意力转向
图 4,该图显示了同一代码的另一个示例,但这次它遵循了 Demeter 定律。
图 4 较好的解耦
我们实现了什么?Repository2 比 Repository 更易于使用,因为您有一个直接的方法来调用 InsuranceClaim 信息。在我们违反 Demeter 定律时,Repository 的使用者与 Repository 的实现紧密耦合。在修订过的代码中,我可以更好地更改 Repository 实现以添加更多高速缓存或以完全不同的对象换出基础 DataService。
Demeter 定律是一个功能强大的工具,可帮助您发现潜在的耦合问题,但不可盲目地遵循 Demeter 定律。违背 Demeter 定律的确会使您的系统实现更紧密的耦合,但有时候您可能会认为耦合到代码的某个稳定元素的潜在成本要比编写大量委托代码来避免违背 Demeter 定律的成本低。
只是告知,不要询问
“只是告知,不要询问”设计原则主张您告知对象将要执行什么任务。您不想做的事情是询问某对象其内部状态、对该状态做出决策,然后告知该对象将要执行什么任务。遵守“只是告知,不要询问”的对象交互风格是确保正确安置职责的有效途径。
图 5 说明了违背“只是告知,不要询问”原则的情况。该代码的任务是购买某种商品、确认 $10,000 以上的购买金额是否可能有折扣,最后检查帐户数据来判断是否有充足的资金。先前的 DumbPurchase 和 DumbAccount 类都无此功能。帐户和购买业务规则都是在 ClassThatUsesDumbEntities 中编码的。
图 5 过度询问
图 6 显示了同一代码,但这次遵循了“只是告知,不要询问”的模式。在此代码中,我将用于购买和帐户的业务规则移动到它们自己的 Purchase 和 Account 类中。当我们打算进行购买时,只需告诉 Account 类从自身扣除购买金额即可。Account 和 Purchase 了解它们自身及其内部规则。Account 的使用者只需要知道去调用 Account.Deduct(Purchase,PurchaseMessenger) 方法就可以了。
图 6 告知您的应用程序要执行什么任务
Account 和 Purchase 对象比较易于使用,因为您无需对这些类有太多了解即可执行我们的业务逻辑。我们也潜在地减少了系统中的重复。我们不费吹灰之力就可以在整个系统中重用 Accounts 和 Purchases 的业务规则,因为这些规则位于 Account 和 Purchase 类的内部,而不是隐藏在使用这些类的代码内。另外,更改 Purchases 和 Accounts 的业务规则将更加轻松,因为这些规则只能在系统中的一个位置处找到。
与“只是告知,不要询问”紧密关联的是“信息专家”模式。如果您对您的系统有新的职责,那么新职责应属于哪个类呢?“信息专家”模式会问道,谁了解履行该职责所必需的信息?换言之,任何新职责的第一候选项都是已具有受该职责影响的数据字段的类。在购买示例中,Purchase 类知道您用于确定可能的折扣率的某个购买项的信息,因此 Purchase 类自身就是计算折扣率的直接候选项。
只说一次
在系统中提高聚合的最有效方法之一就是只要发现重复项就将其消除。如果您认为自己并不完全了解系统今后将要如何变化,但您可以通过在类结构中保持良好的聚合和耦合来改进代码接受更改的能力,这可能就是最佳的方法。
多年前我曾参与过一个大型装运应用程序的辅助设计工作,该应用程序用于管理某工厂车间的货箱流。在其最初的成形阶段,该系统轮询一个消息队列以获取外来消息,然后通过应用一大组业务规则以确定货箱的下一站目的地来响应这些消息。
次年,该业务需要从桌面客户端启动货箱路线逻辑。令人遗憾的是,业务逻辑代码与用于读取和写入 MQ Series 队列的机制间的耦合过于紧密。根据判断,将原始业务逻辑代码从 MQ Series 基础结构中解脱出来风险极大,因此在新的桌面客户端的并行库中重复了整个业务规则主体。该决定使得新的桌面客户端变得切实可行,但也使今后的所有工作更加困难,因为对货箱路线逻辑的每个更改都需要对两个截然不同的库进行并行更改,而这些种类的业务规则经常发生更改。
我们从这个实际案例中得到了几个教训。代码中的重复对于构建系统的组织会产生实际成本,而该重复很大程度上是由于类结构中耦合和聚合质量不佳导致的。这种情况会直接影响公司的盈亏状况。
找到在代码中检查重复的一种途径,就是增加一个在以后改进设计的机会。如果您发现两个或多个类有某些功能重复,您可以判定重复的功能一定是完全不同的职责。改进代码库的聚合质量的最佳方法之一是,将重复项提取到可在整个代码库中共享的单独类中。
但我得到的痛苦经验是,即使看似无负面影响的重复项也会让你头疼不已。随着 Microsoft .NET Framework 2.0 中泛型的出现,许多人都开始创建如下所示的参数化的 Repository 类:
public interface IRepository<T> { void Save(T subject); void Delete(T subject); }
在此接口中,T 是 Invoice、Order 或 Shipment 之类的域实体。StructureMap 的用户希望能够调用此代码并获得完全成形的能处理特定域实体的存储库对象,如 Invoice 对象:
IRepository<Invoice> repository = ObjectFactory.GetInstance<IRepository<Invoice>>();
_PluginFamilies.Add(family.PluginType.FullName,family);
以及如下所示的代码:
MementoSource source = this.getMementoSourceForFamily(pluginType.FullName);
还有如下所示的代码:
private IInstanceFactory this[Type PluginType] { get { return this[PluginType.FullName]; } set { this[PluginType.FullName] = value; } }
您是否已发现了重复?不要过于沉浸在此示例中,我有个毫无疑问的规则表明与 System.Type 相关的对象是通过将 Type.FullName 属性用作 Hashtable 中的键进行存储的。这是个毫不起眼的逻辑,但我已在整个代码库中重复了多次。在实现泛型时,我判定如果按实际类型而不是 Type.FullName 在内部存储对象,这个逻辑会更有效。
这个在行为方面做出的看似微小的变动却花费了我数天的时间,而不是先前假定的数小时,因为我已将这少量数据重复了很多次。我从中得到的教训是,对于系统中的任何规则,无论表面看来多么微不足道,都应只表达一次。
总结
聚合和耦合应用于设计和体系结构的每个级别,但我多数时候是侧重于类和方法级别的细粒度细节。当然,您最好具有较大的体系结构决策权 – 技术选择、项目构造和物理部署都很重要,但这些决策通常都完全限制在多个选择中,而权衡得失利弊之后所做的选择通常能够得到广泛的理解。
我发现您在类和方法级别所做的成千上百个小的决策的累积效应对项目的成功具有着深远的影响,而您也在小的事情上也得到了更多的选择和替代方案。尽管通常在生活中不一定是这样,但在软件设计中请不要忽视这些小事情。