前端之家收集整理的这篇文章主要介绍了
实用模式--聚合和耦合,
前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。
实用模式
聚合和耦合
很多软件设计一直都存在一个问题:这段
代码应放置在哪里?我一直在寻找编排
代码的最佳
方法,以便能够更轻松地编写、理解
代码,并在以后更方便地进行更改。如果我的
代码构造很漂亮,我将可名扬四海,无限荣光。如果构造得很糟糕,那些追随我的开发人员会一直对我埋怨不停。
- 让代码中需要一起更改的部分尽可能靠近在一起。
- 允许代码中不相关的部分独立更改(也称为“正交性”)。
- 最大程度减少代码中的重复部分。
要实现这三个目标,我需要一些工具来协助我了解应将新
代码放置到什么位置,并借助其他一些工具帮助我识别出是否将
代码放置到了
错误位置。
大体上说,这些目标都与聚合和耦合这两个典型
代码质量紧密相关。我通过达到更高程度的聚合和更松散的耦合实现这些目标。当然,我们首先需要了解这些质量的含义以及为何聚合和耦合是有用的概念。然后我想讨论一些我将称之为“设计矢量”的
内容,它们可以帮助我们实现更佳的结构,并帮助我们认识到何时需要丢弃那些已经不知不觉在我们
代码中形成的
错误结构。
补充一点,我是名为 StructureMap 的开源控制反转 (IOC) 工具的主要开发人员。我将使用源自 StructureMap 的一些现实示例来说明这些矢量所针对的设计问题。换言之,千万不要再犯我已犯过的
错误。
降低耦合
在软件设计的几乎所有讨论场合,你都会随时听到“松散耦合”或“紧密耦合”这样的术语。类或子系统之间的耦合是这些类或子系统之间互联程度的量度标准。紧密耦合表示相关的类必须了解彼此的内部细节、更改将波及整个系统,并且系统可能更难以了解。
图 1 显示了一个精心设计的业务处理模块示例,该模块与除业务逻辑之外的其他各种关注问题紧密耦合。
public class BusinessLogicClass {
public void DoSomething() {
// Go get some configuration
int threshold =
int.Parse(ConfigurationManager.AppSettings["threshold"]);
string connectionString =
ConfigurationManager.AppSettings["connectionString"];
string sql =
@"select * from things
size > ";
sql += threshold;
using (sqlConnection connection =
new sqlConnection(connectionString)) {
connection.Open();
sqlCommand command = new sqlCommand(sql,connection);
using (sqlDataReader reader = command.ExecuteReader()) {
while (reader.Read()) {
string name = reader["Name"].ToString();
string destination = reader["destination"].ToString();
// do some business logic in here
doSomeBusinessLogic(name,destination,connection);
}
}
}
}
}
假设我们真正关心的是实际的业务处理,但我们的业务逻辑
代码是与数据访问方面的焦点以及配置设置结合在一起的。那么,这种
代码有可能出现什么
错误呢?
第一个问题是该
代码会因侧重点失偏而有些难以理解。我将在有关聚合的下一节中深入讨论这一点。
第二个问题是数据访问策略、
数据库结构或配置策略中的任何更改同样也会波及整个业务逻辑
代码,因为它们全部都属于同一个
代码文件。这种业务逻辑对底层基础结构过于了解。
第三,我们不能独立于特定的
数据库结构或在没有 AppSettings 键的情况下重用该业务逻辑
代码。我们也不能重用在 BusinessLogicClass 中内嵌的数据访问
功能。数据访问与业务逻辑之间的耦合可能不是问题,但如果我们希望改变此业务逻辑的用途,以对照由分析人员直接输入到 Excel 电子表格中的数据来使用它会怎样呢?如果我们要单独测试或调试该业务逻辑又会怎样呢?我们无法实现上述的任何操作,因为该业务逻辑与数据访问
代码是紧密耦合的。如果我们能将业务逻辑从其他关注问题中隔离出来,则对它进行更改就会变得轻松得多。
总之,在类与模块之间实现松散耦合的实际目标是为了:
- 使代码更易于阅读。
- 将类的麻烦的内部运行隐藏在设计完善的 API 之后,从而使其他开发人员可以更轻松地使用这些类。
- 隔离对较小区域代码的可能更改。
- 在全新的上下文中重用类。
提高聚合
聚合的理论定义是类的所有职责、数据和
方法彼此关联紧密程度的度量标准。我更倾向于将聚合视为是判断某个类在系统内是否有明确定义的角色的度量标准。我们通常认为高度聚合是件好事,并且将“高度聚合”视为魔咒一样反复叨念。但其中的原因是什么?
让我们将编码看作是与计算机进行的一次对话。更准确地讲,我们在与计算机同时进行多个对话。我们的对话
内容是有关如何实现安全性、基础结构方面的问题应如何表现以及业务规则是什么。
如果您处于一个正同时进行多个不同对话的喧闹聚会中,则很难将注意力集中在某一个您正设法进行的对话。在一个只进行一个对话的安静环境中进行对话就会容易得多。
对聚合的一个简单测试就是观察某个类并判断该类的所有
内容是否与类的
名称直接相关并由该
名称描述,含糊的类
名称(例如 InvoiceManager)不算在内。如果该类的职责不与其
名称相关,则这些职责有可能属于其他类。如果您发现一些
方法和字段的子集可轻松地在另一个类
名称下单独分组,则您可能应将这些
方法和字段
提取到一个新类中。
举例说明,如果您在 TrainStation、Train 和 Conductor 类中发现一些
方法和数据看起来与 TrainSchedule 类的
主题最精确匹配,可将这些
方法和数据移至 TrainSchedule 中。我最喜爱的有关设计的一句熟语在此处比较适用:将
代码放置在您认为应该能找到它的位置。对我而言,将涉及列车时刻表的
功能放置在 TrainSchedule 类中是最符合逻辑的。
延续我之前的对话比喻,由聚合类和子系统组成的系统就像是一个设计合理的在线讨论组。在线组中的每个区域都仅仅集中在一个特定
主题,这样比较容易跟踪讨论
内容,如果您正在寻找有关某特定
主题的对话,则只能访问一个房间。
消除不适当的亲密
不适当的亲密指的是某个类中某个
方法对另一个类过于熟知。不适当的亲密是两个类之间存在有负面影响的紧密耦合的迹象。假设我们有一个业务逻辑类,该类
调用类 DataServer1 的实例以
获取其进行业务逻辑处理所需的数据。
图 2 显示了一个示例。在这种情况下,Process
方法需要了解 DataServer1 的大量内部工作,并对
sqlDataReader 类略知一二。
图 2 不适当的亲密
public void Process() {
string connectionString = getConnectionString();
sqlConnection connection = new sqlConnection(connectionString);
DataServer1 server = new DataServer1(connection);
int daysOld = 5;
using (sqlDataReader reader = server.GetWorkItemData(daysOld)) {
while (reader.Read()) {
string name = reader.GetString(0);
string location = reader.GetString(1);
processItem(name,location);
}
}
}
现在让我们重新编写
图 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 所示。
public interface DataService {
InsuranceClaim[] FindClaims(Customer customer);
}
public class Repository {
public DataService InnerService { get; set; }
}
public class ClassThatNeedsInsuranceClaim {
private Repository _repository;
public ClassThatNeedsInsuranceClaim(Repository repository) {
_repository = repository;
}
public void TallyAllTheOutstandingClaims(Customer customer) {
// This line of code violates the Law of Demeter
InsuranceClaim[] claims =
_repository.InnerService.FindClaims(customer);
}
}
ClassThatNeedsInsuranceClaim 类需要
获取 InsuranceClaim 数据。它有一个对 Repository 类的引用,Repository 类本身包含一个 DataService 对象。ClassThatNeedsInsuranceClaim 到达 Repository 内部以
获取内部 DataService 对象,然后
调用 Repository.FindClaims 获得其数据。请注意,对 _repository.InnerService.FindClaims(customer) 的
调用明显违背了 Demeter 定律,因为 ClassThatNeedsInsuranceClaim 将直接
调用其 Repository 字段的
属性的
方法。现在,请将您注意力转向
图 4,该图
显示了同一
代码的另一个示例,但这次它遵循了 Demeter 定律。
我们实现了什么?Repository2 比 Repository 更易于使用,因为您有一个直接的
方法来
调用 InsuranceClaim 信息。在我们违反 Demeter 定律时,Repository 的使用者与 Repository 的实现紧密耦合。在修订过的
代码中,我可以更好地更改 Repository 实现以
添加更多高速缓存或以完全不同的对象换出基础 DataService。
Demeter 定律是一个
功能强大的工具,可帮助您发现潜在的耦合问题,但不可盲目地遵循 Demeter 定律。违背 Demeter 定律的确会使您的系统实现更紧密的耦合,但有时候您可能会认为耦合到
代码的某个稳定元素的潜在成本要比编写大量委托
代码来避免违背 Demeter 定律的成本低。
“只是告知,不要询问”设计原则主张您告知对象将要执行什么任务。您不想做的事情是询问某对象其内部状态、对该状态做出决策,然后告知该对象将要执行什么任务。遵守“只是告知,不要询问”的对象交互风格是确保正确安置职责的有效途径。
图 5 说明了违背“只是告知,不要询问”原则的情况。该
代码的任务是购买某种商品、确认 $10,000 以上的购买金额是否可能有折扣,最后检查帐户数据来判断是否有充足的资金。先前的 DumbPurchase 和 DumbAccount 类都无此
功能。帐户和购买业务规则都是在 ClassThatUsesDumbEntities 中编码的。
这种类型的
代码在几个方面可能存在问题。在与此类似的系统中,可能会出现重复,因为某个实体的业务规则分散在这些实体之外的程序
代码中。您可能会不知不觉地重复逻辑,因为先前编写的业务逻辑所在位置并不明显。
图 6 显示了同一
代码,但这次遵循了“只是告知,不要询问”的模式。在此
代码中,我将用于购买和帐户的业务规则移动到它们自己的 Purchase 和 Account 类中。当我们打算进行购买时,只需告诉 Account 类从自身扣除购买金额即可。Account 和 Purchase 了解它们自身及其内部规则。Account 的使用者只需要知道去
调用 Account.Deduct(Purchase,PurchaseMessenger)
方法就可以了。
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>>();
这听起来是一个不错的
功能,因此我着手为这些种类的参数化类型
添加支持。对 StructureMap 进行这种更改后来证明是非常困难的,就是因为如下所示的
代码:
_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 在内部存储对象,这个逻辑会更有效。
这个在行为方面做出的看似微小的变动却花费了我数天的时间,而不是先前假定的数小时,因为我已将这少量数据重复了很多次。我从中得到的教训是,对于系统中的任何规则,无论表面看来多么微不足道,都应只表达一次。
聚合和耦合应用于设计和体系结构的每个级别,但我多数时候是侧重于类和
方法级别的细粒度细节。当然,您最好具有较大的体系结构决策权 – 技术选择、项目构造和物理部署都很重要,但这些决策通常都完全限制在多个选择中,而权衡得失利弊之后所做的选择通常能够得到广泛的理解。
我发现您在类和
方法级别所做的成千上百个小的决策的累积效应对项目的成功具有着深远的影响,而您也在小的事情上也得到了更多的选择和替代方案。尽管通常在生活中不一定是这样,但在软件设计中请不要忽视这些小事情。
开发人员们共有的看法是,担心所有这些聚合和耦合问题不过是影响工作进度的象牙塔理论。我的感受是,如果您的
代码的聚合和耦合质量良好,会随着时间的推移一直保持
代码的工作效率。我强烈建议您将对聚合和耦合质量的认识内在化到无需有意识地思考这些质量的程度。此外,我能够推荐的用于改进您的设计技巧的最佳练习之一就是重新回顾以前的编码成果,尝试找到本来可以改进这些旧
代码的
方法,然后设法回忆过去的设计中使
代码易于更改或难以调整的元素。