3.5良好的设计技术
除了已经介绍的架构原则之外,还有一些软件架构师应该知道的实现良好设计的特定技术。软件架构设计中的一个重要挑战是有效管理各个软件构建模块的相互依赖关系。有时依赖关系无法避免,有时它们甚至是有利的——例如,如果必须向另一个类发送消息,或者必须调用来自不同子系统的特定方法。 重要的是,您始终要讨论设计,并保持您的替代方案和选择开放。模型不是现实,并且应该始终与最终用户和客户进行协调。
3.5.1 退化设计
对于长期频繁修改的软件,随着时间的推移,软件的结构可能会退化。这是一个普遍的问题。一开始,架构师和设计师创建一个干净、灵活的软件结构,这在软件的第一个版本中仍然可以看到。然而,在初始实现之后,需求的变化通常是不可避免的。这意味着软件必须被修改、扩展和维护。如果在此过程中不考虑初始设计,原始结构可能变得难以辨认,并且只能艰难地理解。 有三个基本症状表明存在退化设计:
• 脆弱性 一个地方的更改可能导致其他地方不可预见的错误。
• 僵化 即使是简单的修改也很困难,会影响大量依赖的组件。
• 低可复用性 由于组件之间有很多依赖关系,它们无法单独被复用。

图3-7设计退化的症状
3.5.2 松耦合
正如已经解释的那样,构建块和组件之间的关系能够实现有效的协作,因此构成了整个系统的基础的一部分。然而,关系会导致组件之间的依赖,这反过来又可能导致问题。例如,对一个接口的更改意味着使用这个接口的所有构建块可能也必须更改。
构建块之间的这种关系,连同其强度和由此产生的依赖,被称为耦合。
衡量一个组件与其他组件耦合程度的一个简单方法是计算它们之间的关系数量。除了量化它之外,耦合的性质也很重要。耦合类型的一些例子有:
• 调用
当一个类通过调用另一个类的方法直接使用该类时,存在一种耦合。
• 生成
当一个构建块生成另一个构建块时,存在一种不同类型的耦合。
• 数据
当类通过全局数据结构或仅通过方法参数进行通信时,存在一种较松的耦合。
• 执行位置
当构建块必须在相同的运行时环境或相同的虚拟机上运行时,存在一种基于硬件的耦合。
• 时间
当对构建块的调用的时间顺序影响最终结果时,存在一种时间耦合。
• 继承
在面向对象的代码中,由于属性的继承,子类已经与其父类耦合。耦合的程度取决于继承的属性的数量。
松耦合的目的是降低结构的复杂性。多个构建块之间的耦合越松,在不检查大量其他构建块的情况下理解单个构建块就越容易。另一个方面是修改的容易程度。耦合越松,在不影响其他构建块的情况下对单个构建块进行局部更改就越容易。 松耦合的一个例子是观察者模式。

图3- 8observer模式示例
主体对其观察者唯一了解的是它们实现了观察者接口。观察者和主体之间没有固定的链接,观察者可以随时注册或移除。主体或观察者的变化对另一方没有影响,并且两者都可以相互独立地复用。
3.5.3 高内聚
“内聚”一词来自拉丁语“cohaerere”,意思是“相关”。
松耦合原则通常会导致高内聚原则,因为松耦合往往会导致构建块的设计更具内聚性。
一个内聚的类解决一个单一的问题,并具有一定数量的高度内聚的函数。内聚性越高,一个类在应用中的职责就越内聚。
这里同样涉及到系统构建块在本地修改和理解的难易程度。如果一个系统构建块结合了理解和更改它所需的所有属性,您可以更轻松地对其进行更改,而无需涉及其他系统构建块。
您不应该将所有相同类型的类分组到包中(例如所有过滤器或所有实体),而应该按系统和子系统进行分组。内聚的包容纳内聚功能复合体的类。
3.5.4开放/封闭原则
开放/封闭原则由伯特兰·迈耶(Bertrand Meyer)于 1988 年定义,指出软件模块应该对扩展开放,但对修改封闭。
在这种情况下,“封闭”意味着模块可以无风险地使用,因为其接口不再改变。“开放”意味着模块可以毫无问题地扩展。
简而言之:
一个模块应该对扩展开放
模块的原始功能可以通过扩展模块来适应,其中扩展模块只处理期望功能和原始功能之间的偏差。
一个模块应该对修改封闭
要扩展模块,不需要对原始模块进行更改。因此,它应该提供定义的扩展点,扩展模块可以连接到这些扩展点。
这种明显矛盾的解决方案在于抽象。借助抽象基类,可以创建具有定义的、不可更改的实现的软件模块,但其行为可以通过多态性和继承自由改变。
以下是一个如何避免这样做的示例:
viod draw(Form f) { if(f.type == circle) drawCircle (f); else if (f.type == square) drawSquare (f); … 此示例不适用于扩展。如果要绘制其他形状,则必须修改绘图方法的源代码。更好的方法是将形状的绘图移动到实际的形状类中。
3.5.5 依赖倒置
依赖倒置原则指出,不应允许任何直接依赖,而应只依赖抽象。这最终使得替换构建块更容易。应该使用诸如工厂方法之类的方法来解耦类之间的直接依赖。使用依赖倒置的一个核心原因(显然不是唯一的)是一种架构风格,借助它可以非常轻松地编写模拟单元测试,从而使 TDD 方法更可行。
让我们看一个例子。假设您要开发一个 Windows 应用程序,它从互联网读取天气预报并以图形方式显示。基于上述原则,您将处理 Windows API 的功能重新定位到一个单独的库中。

图3- 9windows应用程序示例
用于显示天气数据的模块现在依赖于 Windows API,但 Windows API 并不依赖于天气数据的显示。Windows API 也可以在其他应用程序中使用。然而,您的天气显示应用程序目前只能在 Windows 下运行。
以其当前的形式,它无法在 Mac 或 Linux 环境中运行。 借助一个抽象的操作系统模块可以解决这个问题。这个模块指定具体的实现必须提供哪些功能。在这种情况下,操作系统的抽象不依赖于具体的实现。您可以毫无问题地添加进一步的实现(例如,针对 Solaris)。

图3-10依赖倒置
3.5.6接口分离
在一个广泛的接口被多次使用的情况下,基于以下方面将该接口分离为几个更具体的接口可能是有用的:
• 语义上下文,或
• 职责范围
这种类型的分离减少了依赖用户的数量,从而也减少了可能的后续更改数量。此外,许多较小、更集中的接口更易于实现和维护。
3.5.7解决循环依赖
循环依赖会使系统的维护和修改变得更加困难,并阻碍单独的复用。

图3-11循环依赖
不幸的是,循环依赖并非总是能够避免。然而,在上述示例中,您可以执行以下操作:
以抽象CA的形式分离出C所使用的A的部分。
通过从A到抽象CA的继承关系来消除循环依赖。
3.5.8里氏替换原理
里氏替换原则以芭芭拉·利斯科夫(Barbara Liskov)的名字命名,最初的定义如下:
设 q(x) 是类型为 T 的对象 x 的可证明属性。那么对于类型为 S 的对象 y(其中 S 是 T 的子类型),q(y) 也应该是可证明的。
该原则指出,基类应该总是能够被其派生类(子类)替换。在这种情况下,子类的行为应该与父类完全相同。
如果一个类不符合这个原则,很可能在泛化/特化方面错误地使用了继承。
许多编程语言重写方法的能力可能存在潜在问题。如果方法的签名被更改——例如,将可见性从 public 更改为 private——或者方法突然不再抛出异常,可能会导致不想要的行为,从而违反替换原则。
违反这一原则的一个例子,乍一看不太明显,就是将正方形建模为矩形的子类——换句话说,正方形继承了矩形的所有属性和方法。

图3-12A正方形作为一个矩形的一个子类
首先我们注意到,一个正方形只需要一个属性,即它的边长。然而,一个正方形也可以用两条边长来定义,这就需要您检查正方形的属性(即所有边长度相等)是否得到满足。为此,必须修改 setHeight 和 setWidth 方法,使它们将正方形的高度和宽度设置为相同的值。
起初,这似乎不是一个问题。一个关键的问题首先出现在用正方形代替矩形的情况中,因为矩形并不总是可以被正方形替代。例如:一幅图片要被给予一个矩形的框架。客户端将图片的高度和宽度、其左上角的坐标以及一个正方形(不是矩形)传递给 drawFrame 方法。现在,drawFrame 方法调用正方形的 setHeight 和 setWidth 操作,结果是一个边长等于图片宽度的正方形。这是因为 setWidth 方法将正方形的宽度和高度设置为相同的值。
Last updated