【iSAQB软件架构】良好的设计技术
除了已经介绍的架构原则之外,还有一些软件架构师应该知道的实现良好设计的特定技术。软件架构设计中的一个重要挑战是有效管理各个软件构建模块的相互依赖关系。有时依赖关系无法避免,有时它们甚至是有利的——例如,如果必须向另一个类发送消息,或者必须调用来自不同子系统的特定方法。
重要的是,您始终要讨论设计,并保持您的替代方案和选择开放。模型不是现实,并且应该始终与最终用户和客户进行协调。
退化设计
对于长期频繁修改的软件,随着时间的推移,软件的结构可能会退化。这是一个普遍的问题。一开始,架构师和设计师创建一个干净、灵活的软件结构,这在软件的第一个版本中仍然可以看到。然而,在初始实现之后,需求的变化通常是不可避免的。这意味着软件必须被修改、扩展和维护。如果在此过程中不考虑初始设计,原始结构可能变得难以辨认,并且只能艰难地理解。
有三个基本症状表明存在退化设计:
• 脆弱性
一个地方的更改可能导致其他地方不可预见的错误。
• 僵化
即使是简单的修改也很困难,会影响大量依赖的组件。
• 低可复用性
由于组件之间有很多依赖关系,它们无法单独被复用。
退化设计如下图所示:
松耦合
正如已经解释的那样,构建块和组件之间的关系能够实现有效的协作,因此构成了整个系统的基础的一部分。然而,关系会导致组件之间的依赖,这反过来又可能导致问题。例如,对一个接口的更改意味着使用这个接口的所有构建块可能也必须更改。
构建块之间的这种关系,连同其强度和由此产生的依赖,被称为耦合。
衡量一个组件与其他组件耦合程度的一个简单方法是计算它们之间的关系数量。除了量化它之外,耦合的性质也很重要。耦合类型的一些例子有:
• 调用
当一个类通过调用另一个类的方法直接使用该类时,存在一种耦合。
• 生成
当一个构建块生成另一个构建块时,存在一种不同类型的耦合。
• 数据
当类通过全局数据结构或仅通过方法参数进行通信时,存在一种较松的耦合。
• 执行位置
当构建块必须在相同的运行时环境或相同的虚拟机上运行时,存在一种基于硬件的耦合。
• 时间
当对构建块的调用的时间顺序影响最终结果时,存在一种时间耦合。
• 继承
在面向对象的代码中,由于属性的继承,子类已经与其父类耦合。耦合的程度取决于继承的属性的数量。
松耦合的目的是降低结构的复杂性。多个构建块之间的耦合越松,在不检查大量其他构建块的情况下理解单个构建块就越容易。另一个方面是修改的容易程度。耦合越松,在不影响其他构建块的情况下对单个构建块进行局部更改就越容易。
松耦合的一个例子是观察者模式。
observer模式示例
主体对其观察者唯一了解的是它们实现了观察者接口。观察者和主体之间没有固定的链接,观察者可以随时注册或移除。主体或观察者的变化对另一方没有影响,并且两者都可以相互独立地复用。
观察者模式(Observer Pattern)是一种经典的松耦合设计模式,常用于实现对象之间的一对多依赖关系。具体来说,观察者模式的核心思想是当一个对象的状态发生变化时,它的所有依赖者(观察者)都会被自动通知并更新状态,而观察者和被观察者之间并不直接依赖,从而实现了松耦合。
解释
-
被观察者(Subject):
- 这是一个状态可能发生变化的对象。它维护一个观察者的集合,并在状态变化时通知所有的观察者。被观察者并不知道具体有哪些观察者,所有的观察者只是注册在它的观察者列表中。
-
观察者(Observer):
- 观察者是那些感兴趣于被观察者状态变化的对象。当被观察者的状态发生变化时,观察者会得到通知,并根据需要更新自身的状态。
如何松耦合?
在观察者模式中,被观察者和观察者之间通过接口进行交互,并不直接依赖于彼此的具体实现。这使得它们之间保持了松耦合。具体表现为:
- 无直接依赖:被观察者只知道如何通知观察者,而不知道具体有哪些观察者。观察者也只关心状态变化,而不需要了解被观察者的内部实现。
- 灵活扩展:你可以随时增加或删除观察者,而不影响被观察者的功能。
- 动态交互:观察者可以在运行时自由注册和注销,而不需要重新定义或修改其他对象。
例子:天气预报系统
假设你正在设计一个天气预报系统,在这个系统中有多个不同的界面或应用程序需要显示天气信息(如温度、湿度等)。当天气数据发生变化时,所有依赖这些数据的界面都需要更新显示内容。
- 被观察者:天气站(WeatherStation),它是数据源,负责更新天气信息(如温度、湿度)。
- 观察者:不同的显示界面(比如温度显示器、湿度显示器等)。
工作流程:
- 天气站(被观察者)维护一个观察者列表,并定义一个方法(
attach()
)来注册观察者。 - 当天气站的天气信息发生变化时,它会调用所有已注册的观察者的
update()
方法,通知它们数据已更新。 - 各个观察者接收到通知后,自动更新自己的显示内容。
通过这种方式,天气站和显示界面之间并不直接关联,它们之间的交互是松耦合的,只通过接口进行通信。添加新的显示界面时,只需要实现观察者接口并注册到天气站即可,而不需要修改天气站的代码。
代码示例:
# 观察者接口
class Observer:def update(self, temperature):pass# 被观察者(天气站)
class WeatherStation:def __init__(self):self._observers = []self._temperature = Nonedef attach(self, observer: Observer):self._observers.append(observer)def detach(self, observer: Observer):self._observers.remove(observer)def set_temperature(self, temperature):self._temperature = temperatureself._notify()def _notify(self):for observer in self._observers:observer.update(self._temperature)# 具体观察者(温度显示器)
class TemperatureDisplay(Observer):def update(self, temperature):print(f"Temperature updated to {temperature}°C")# 使用示例
weather_station = WeatherStation()
temp_display = TemperatureDisplay()weather_station.attach(temp_display)
weather_station.set_temperature(25) # Temperature updated to 25°C
weather_station.set_temperature(30) # Temperature updated to 30°C
在这个例子中,WeatherStation
(天气站)并不知道具体有哪些Observer
(观察者),它只通过update()
方法通知所有已注册的观察者。而每个观察者(如TemperatureDisplay
)都只关心温度的变化,更新自己的显示内容,而不会对天气站的内部实现做出任何改变。
这种设计方式有效地实现了松耦合,使得系统具有更高的灵活性和可扩展性。
高内聚
“内聚”一词来自拉丁语“cohaerere”,意思是“相关”。
松耦合原则通常会导致高内聚原则,因为松耦合往往会导致构建块的设计更具内聚性。
一个内聚的类解决一个单一的问题,并具有一定数量的高度内聚的函数。内聚性越高,一个类在应用中的职责就越内聚。
这里同样涉及到系统构建块在本地修改和理解的难易程度。如果一个系统构建块结合了理解和更改它所需的所有属性,您可以更轻松地对其进行更改,而无需涉及其他系统构建块。
您不应该将所有相同类型的类分组到包中(例如所有过滤器或所有实体),而应该按系统和子系统进行分组。内聚的包容纳内聚功能复合体的类。
开放/封闭原则
开放/封闭原则由伯特兰·迈耶(Bertrand Meyer)于 1988 年定义,指出软件模块应该对扩展开放,但对修改封闭。
在这种情况下,“封闭”意味着模块可以无风险地使用,因为其接口不再改变。“开放”意味着模块可以毫无问题地扩展。
简而言之:
一个模块应该对扩展开放
模块的原始功能可以通过扩展模块来适应,其中扩展模块只处理期望功能和原始功能之间的偏差。
一个模块应该对修改封闭
要扩展模块,不需要对原始模块进行更改。因此,它应该提供定义的扩展点,扩展模块可以连接到这些扩展点。
这种明显矛盾的解决方案在于抽象。借助抽象基类,可以创建具有定义的、不可更改的实现的软件模块,但其行为可以通过多态性和继承自由改变。
以下是一个如何避免这样做的示例:
viod draw(Form f) {
if(f.type == circle) drawCircle (f);
else if (f.type == square) drawSquare (f);
…
此示例不适用于扩展。如果要绘制其他形状,则必须修改绘图方法的源代码。更好的方法是将形状的绘图移动到实际的形状类中。
开放/封闭原则(Open/Closed Principle,OCP)是面向对象设计中的一条重要原则,属于SOLID原则之一。它的核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。
具体来说,开放/封闭原则强调以下两点:
-
对扩展开放:当你需要给现有系统添加新功能时,不需要修改现有的代码。可以通过扩展已有的类或模块,增加新的功能。这样能够保证系统的灵活性和可维护性。
-
对修改封闭:对于现有的代码,如果要修改它们,应该避免直接更改已经完成和测试好的代码。通过封装和抽象,让代码的行为可以根据需求进行扩展,而不破坏原有的功能。
例子
假设你有一个图形绘制应用程序,其中有一个Shape
基类,和多个继承它的子类(如Circle
、Rectangle
等)。按照开放/封闭原则,如果你想要添加一个新的形状,比如Triangle
,你应该:
- 扩展:创建一个
Triangle
类,继承自Shape
,并实现它的绘制功能。 - 不修改:你不应该修改
Circle
或Rectangle
等现有类的代码,只是通过添加新的类来扩展程序。
这种方法能确保系统在添加新功能时不影响现有代码,减少潜在的风险。
总结:开放/封闭原则的目的是提高代码的可维护性和可扩展性。通过设计时考虑到扩展性和减少对现有代码的修改,能使软件在面对不断变化的需求时更加稳健。
依赖倒置
依赖倒置原则指出,不应允许任何直接依赖,而应只依赖抽象。这最终使得替换构建块更容易。应该使用诸如工厂方法之类的方法来解耦类之间的直接依赖。使用依赖倒置的一个核心原因(显然不是唯一的)是一种架构风格,借助它可以非常轻松地编写模拟单元测试,从而使 TDD 方法更可行。
让我们看一个例子。假设您要开发一个 Windows 应用程序,它从互联网读取天气预报并以图形方式显示。基于上述原则,您将处理 Windows API 的功能重新定位到一个单独的库中。
windows应用程序示例
用于显示天气数据的模块现在依赖于 Windows API,但 Windows API 并不依赖于天气数据的显示。Windows API 也可以在其他应用程序中使用。然而,您的天气显示应用程序目前只能在 Windows 下运行。以其当前的形式,它无法在 Mac 或 Linux 环境中运行。
借助一个抽象的操作系统模块可以解决这个问题。这个模块指定具体的实现必须提供哪些功能。在这种情况下,操作系统的抽象不依赖于具体的实现。您可以毫无问题地添加进一步的实现(例如,针对 Solaris)。
依赖倒置如下图:
依赖倒置原则(Dependency Inversion Principle, DIP)是面向对象设计的五大SOLID原则之一(由Robert C. Martin提出),其核心目标是解耦高层模块与低层模块,提升系统的灵活性、可维护性和可测试性。
核心思想
- 高层模块不应依赖低层模块,二者都应依赖于抽象。
- 抽象不应依赖细节,细节应依赖抽象。
→ 通过抽象(接口或抽象类)定义交互规范,打破模块间的直接依赖。
为什么需要依赖倒置?
- 传统依赖问题:
高层模块直接调用低层模块的细节(如具体类),导致:- 低层模块改动时,高层模块被迫修改(紧耦合)。
- 系统扩展困难(如替换数据库、支付方式需重写代码)。
- 依赖倒置的解决方案:
引入抽象层,使高层模块依赖接口而非具体实现,低层模块通过实现接口来提供服务。
实现方式
1. 定义抽象接口
// 抽象层(高层模块依赖此接口)
public interface StorageService {void saveData(String data);
}
2. 高层模块依赖抽象
public class DataProcessor {// 依赖抽象接口,而非具体实现private final StorageService storage;// 通过构造函数注入依赖(依赖注入)public DataProcessor(StorageService storage) {this.storage = storage;}public void process(String data) {storage.saveData(data); // 调用抽象方法}
}
3. 低层模块实现抽象
// 低层模块1:数据库存储
public class DatabaseStorage implements StorageService {@Overridepublic void saveData(String data) {System.out.println("Saving to database: " + data);}
}// 低层模块2:文件存储
public class FileStorage implements StorageService {@Overridepublic void saveData(String data) {System.out.println("Saving to file: " + data);}
}
4. 运行时动态绑定
public class Main {public static void main(String[] args) {// 灵活切换实现,不影响高层模块StorageService dbStorage = new DatabaseStorage();DataProcessor processor1 = new DataProcessor(dbStorage);processor1.process("Data1"); // 输出:Saving to databaseStorageService fileStorage = new FileStorage();DataProcessor processor2 = new DataProcessor(fileStorage);processor2.process("Data2"); // 输出:Saving to file}
}
关键优势
优势 | 说明 |
---|---|
解耦 | 高层模块与低层模块通过接口隔离,修改低层实现不影响高层逻辑。 |
可扩展性 | 新增功能只需实现接口(如添加CloudStorage ),无需修改现有代码(开闭原则)。 |
可测试性 | 可通过Mock接口(如Mockito)模拟依赖,轻松单元测试高层模块。 |
并行开发 | 团队可基于接口契约并行开发高层和低层模块。 |
依赖倒置 vs 依赖注入 vs 控制反转
- 依赖倒置(DIP):设计原则,定义模块间依赖关系的方向(依赖抽象)。
- 依赖注入(DI):实现DIP的技术,将依赖从外部注入(如构造函数注入、Setter注入)。
- 控制反转(IoC):框架级模式,将程序控制权交给容器(如Spring管理对象生命周期)。
✅ 关系:依赖注入是实现依赖倒置的常用手段,控制反转容器(如Spring)自动化了依赖注入过程。
典型应用场景
- 插件化架构(如IDE插件、支付网关)。
- 多环境适配(如本地存储/云存储切换)。
- 单元测试(Mock依赖项)。
- 微服务通信(通过接口定义服务契约)。
类比理解
🔌 电灯开关与灯泡
- 传统依赖:开关直接控制白炽灯(开关依赖白炽灯)。
- 依赖倒置:开关控制
插座接口
,灯泡实现插头标准
。
→ 开关可控制任意符合标准的设备(LED灯、风扇等)。
依赖倒置通过抽象接口反向约束依赖关系,将系统从“高层调用低层”转变为“低层适配高层定义的抽象”。它是构建灵活、可持续演进的软件架构的基石,尤其在复杂系统中能显著降低维护成本。
接口分离
在一个广泛的接口被多次使用的情况下,基于以下方面将该接口分离为几个更具体的接口可能是有用的:
• 语义上下文
• 职责范围
这种类型的分离减少了依赖用户的数量,从而也减少了可能的后续更改数量。此外,许多较小、更集中的接口更易于实现和维护。
解决循环依赖
循环依赖会使系统的维护和修改变得更加困难,并阻碍单独的复用。
如图循环依赖
不幸的是,循环依赖并非总是能够避免。然而,在上述示例中,您可以执行以下操作:
- 以抽象CA的形式分离出C所使用的A的部分。
- 通过从A到抽象CA的继承关系来消除循环依赖。
上述提出的方案是通过抽象化和继承关系来解决循环依赖问题。具体来说,方法是将循环依赖中的一个类(比如C)所使用的另一个类(A)的部分抽象成接口或抽象类(CA
),然后通过继承或依赖这种抽象类来消除直接的循环依赖。让我们详细分析一下这个方法。
方案步骤解析:
1. 以抽象CA的形式分离出C所使用的A的部分
-
你首先需要识别出类
C
对类A
的依赖。然后,通过定义一个抽象类CA
或接口,提取出类A
中 C 需要的功能部分。例如,如果C
需要A
的某些方法或属性,可以将这些方法提取到CA
中,而不再直接依赖A
。 -
这样,类
C
将不再依赖于A
的具体实现,而是依赖于抽象类CA
,打破了直接的循环依赖关系。
2. 通过从A到抽象CA的继承关系来消除循环依赖
-
接下来,类
A
可以实现CA
,以提供C
所需要的功能。由于C
依赖的是CA
,而不是A
,所以A
的实现细节不再直接影响C
。 -
这种方法确保了类
A
依然可以提供C
需要的服务,而类C
则通过继承或实现CA
来获取依赖。A
通过继承CA
来提供具体的实现,从而避免了A
和C
之间的直接循环依赖。
示例:
假设有类 A
和 C
,它们之间存在循环依赖:
class A:def __init__(self):self.c = C(self)class C:def __init__(self, a):self.a = a
这里,类 A
和类 C
互相依赖,形成了循环依赖。
步骤 1:提取出 A
被 C
使用的部分,创建抽象类 CA
。
from abc import ABC, abstractmethodclass CA(ABC): # 抽象类CA@abstractmethoddef some_method(self):passclass A(CA): # 类A继承CAdef __init__(self):self.c = C(self)def some_method(self):print("A's implementation of some_method.")
步骤 2:类 C
依赖于 CA
,而不是 A
。
class C:def __init__(self, ca: CA): # C依赖CA,而不是Aself.ca = cadef use_method(self):self.ca.some_method()
最终的结构:
a = A()
c = a.c
c.use_method() # 调用A的some_method方法
解释:
C
不再直接依赖A
,而是依赖于CA
,因此我们避免了直接的循环依赖。A
实现了CA
接口,提供了C
所需要的方法。C
通过依赖抽象接口CA
来使用A
的方法,避免了直接循环依赖的情况。
优点:
- 解耦:通过引入抽象类或接口,
C
和A
之间不再有直接的依赖关系,降低了类之间的耦合度。 - 扩展性:如果以后需要更换或扩展
A
的实现,只需修改实现CA
的类,而不需要改变C
的代码。 - 可维护性:系统更加灵活,修改和扩展变得更加容易,同时也减少了循环依赖带来的问题。
总结:通过抽象类或接口的方式,将 C
和 A
的依赖关系从具体实现中解耦出来,可以有效避免循环依赖。这种方法符合面向对象设计中的依赖倒转原则,并且提高了代码的可扩展性和可维护性。
里氏替换原理
里氏替换原则以芭芭拉·利斯科夫(Barbara Liskov)的名字命名,最初的定义如下:
设 q(x) 是类型为 T 的对象 x 的可证明属性。那么对于类型为 S 的对象 y(其中 S 是 T 的子类型),q(y) 也应该是可证明的。
该原则指出,基类应该总是能够被其派生类(子类)替换。在这种情况下,子类的行为应该与父类完全相同。
如果一个类不符合这个原则,很可能在泛化/特化方面错误地使用了继承。
许多编程语言重写方法的能力可能存在潜在问题。如果方法的签名被更改——例如,将可见性从 public 更改为 private——或者方法突然不再抛出异常,可能会导致不想要的行为,从而违反替换原则。
违反这一原则的一个例子,乍一看不太明显,就是将正方形建模为矩形的子类——换句话说,正方形继承了矩形的所有属性和方法。
如图所示,正方形作为一个矩形的一个子类。
首先我们注意到,一个正方形只需要一个属性,即它的边长。然而,一个正方形也可以用两条边长来定义,这就需要您检查正方形的属性(即所有边长度相等)是否得到满足。为此,必须修改 setHeight 和 setWidth 方法,使它们将正方形的高度和宽度设置为相同的值。
起初,这似乎不是一个问题。一个关键的问题首先出现在用正方形代替矩形的情况中,因为矩形并不总是可以被正方形替代。例如:一幅图片要被给予一个矩形的框架。客户端将图片的高度和宽度、其左上角的坐标以及一个正方形(不是矩形)传递给 drawFrame 方法。现在,drawFrame 方法调用正方形的 setHeight 和 setWidth 操作,结果是一个边长等于图片宽度的正方形。这是因为 setWidth 方法将正方形的宽度和高度设置为相同的值。
里氏替换原理(Liskov Substitution Principle,LSP) 是面向对象设计中的一个重要原则,它的核心思想是:子类对象应该可以替换掉任何父类对象,并且程序的行为应该保持不变。
在实际设计中,这意味着子类不仅要继承父类的属性和方法,还应当确保替代父类对象时,程序的逻辑和行为能够正常运行,不会引入错误或不一致的行为。
问题背景:正方形作为矩形的子类
这个问题在很多面向对象编程设计中被讨论过,主要是因为在现实生活中,“正方形”确实是“矩形”的一种特殊情况。但在面向对象的设计中,将正方形作为矩形的子类会导致违反里氏替换原则。具体分析如下:
1. 矩形(Rectangle)类的定义:
矩形类通常具有长和宽的属性,你可以分别设置矩形的长度和宽度,且这两个值是独立的。例如:
class Rectangle:def __init__(self, width, height):self.width = widthself.height = heightdef set_width(self, width):self.width = widthdef set_height(self, height):self.height = heightdef get_area(self):return self.width * self.height
2. 正方形(Square)类的定义:
正方形是一种特殊的矩形,具有相同的长和宽。在数学上,它是矩形的一个子集。所以可以在矩形的基础上创建正方形类:
class Square(Rectangle):def __init__(self, size):super().__init__(size, size)def set_width(self, size):self.width = sizeself.height = sizedef set_height(self, size):self.height = sizeself.width = size
3. 违反里氏替换原则的原因:
按照里氏替换原则,Square
作为 Rectangle
的子类,应该能够替换 Rectangle
类对象,而不引入程序逻辑上的错误。但是,正方形的特殊性导致它在替代矩形时会破坏矩形的行为。
- 矩形的行为:矩形的宽和高可以独立设置,因此可以分别设置不同的宽度和高度,表示一个不规则的矩形。
- 正方形的行为:正方形的宽度和高度是相同的,因此每次设置宽度时,高度也必须改变,反之亦然。
这就导致了一个问题,当你期望替换一个矩形对象时,如果用正方形替代,可能无法按预期独立设置矩形的宽度和高度。例如:
def print_area(rectangle: Rectangle):rectangle.set_width(5)rectangle.set_height(10)print("Area:", rectangle.get_area())# 使用矩形
r = Rectangle(5, 10)
print_area(r) # 输出 Area: 50# 使用正方形
s = Square(5)
print_area(s) # 输出 Area: 25,而不是50
在这个例子中,正方形替代矩形后,set_width
和 set_height
的调用不再是独立的,导致计算的面积与矩形行为不一致。
4. 如何改进设计:
为了遵循里氏替换原则,我们可以通过改变设计,避免将正方形作为矩形的子类。一个更好的方式是将正方形和矩形作为平行的类进行设计,避免继承关系。
例如,采用组合或者接口/抽象类来处理不同的形状,而不是强行将正方形作为矩形的子类:
class Shape(ABC):@abstractmethoddef get_area(self):passclass Rectangle(Shape):def __init__(self, width, height):self.width = widthself.height = heightdef get_area(self):return self.width * self.heightclass Square(Shape):def __init__(self, size):self.size = sizedef get_area(self):return self.size * self.size
通过这种方式,Square
和 Rectangle
都实现了 Shape
类,并且各自独立处理自己的逻辑。这避免了继承导致的行为异常。
总结:
- 里氏替换原则要求子类对象能够替换父类对象,而不会引入错误或不一致的行为。
- 将正方形作为矩形的子类违反了这个原则,因为正方形在行为上与矩形不一致,无法独立设置宽度和高度。
- 更好的设计是通过抽象类或接口来定义共同的行为,而不是强行使用继承来解决。
八大原则进一步分析
我们来详细分析一下 iSAQB(International Software Architecture Qualification Board)软件架构中列出的这些核心良好设计技术。这些原则是构建可维护、可扩展、健壮和灵活软件系统的基石。
-
退化设计
- 核心思想: 系统在部分功能失败、资源受限或遇到异常情况时,能够有策略地、可控地降低功能或服务质量,而不是完全崩溃或产生灾难性后果。目标是保持核心功能的可用性或提供基本服务,实现“优雅降级”。
- 为什么重要:
- 提升可用性: 即使部分组件故障,用户仍能使用核心服务。
- 增强健壮性: 系统能更好地应对意外情况(如网络中断、依赖服务不可用、超负荷)。
- 改善用户体验: 提供降级体验(如展示缓存数据、简化界面)比显示错误页面或完全无响应要好得多。
- 隔离故障: 防止局部故障扩散导致整个系统雪崩。
- 实现方式:
- 超时与重试机制: 对依赖调用设置合理的超时,避免阻塞;实施带退避策略的有限重试。
- 熔断器模式: 当依赖服务连续失败达到阈值时,自动“熔断”,快速失败并直接返回降级结果(如默认值、缓存数据、错误提示),避免持续冲击故障服务。定期尝试恢复。
- 舱壁模式: 将系统资源(线程池、连接池)按功能或用户隔离,防止一个区域的故障耗尽所有资源。
- 缓存: 使用缓存提供降级数据源。
- 降级开关/功能标志: 允许在运行时动态关闭非核心或高消耗功能。
- 队列与异步处理: 将非实时关键操作异步化,避免阻塞主流程。
- 架构考量: 需要在架构设计早期就考虑关键路径的降级策略,识别核心功能与非核心功能,设计降级数据源和流程。
-
松耦合
- 核心思想: 系统中的模块、组件或服务之间的相互依赖程度最小化。一个模块的变化应尽可能少地影响其他模块。依赖应通过定义良好的、稳定的接口进行,而不是直接依赖于具体实现细节。
- 为什么重要:
- 提高可维护性: 修改一个模块时,影响范围小,降低修改成本和风险。
- 增强可测试性: 模块可以更容易地被独立测试(通过Mock/Stub其依赖)。
- 促进可复用性: 低依赖的模块更容易被复用在其他上下文中。
- 支持独立部署: 对于微服务架构尤其关键,服务可以独立更新和发布。
- 提高灵活性: 更容易替换实现、技术栈或集成新组件。
- 实现方式:
- 面向接口编程: 依赖抽象接口,而非具体类。
- 依赖注入: 将依赖项从外部“注入”到组件中,而不是在组件内部硬编码创建。
- 消息传递/事件驱动: 使用消息队列或事件总线进行通信,生产者与消费者解耦,无需知道对方的存在。
- 发布-订阅模式: 进一步解耦消息生产者和消费者。
- 定义清晰的API/契约: 组件间通过稳定、版本化的API交互。
- 遵循单一职责原则: 职责单一的模块自然依赖更少。
- 架构考量: 在模块划分、服务边界定义、通信协议选择上都要考虑如何最小化耦合。识别并管理跨领域或核心的共享依赖。
-
高内聚
- 核心思想: 一个模块、类或组件内部的元素(数据、方法)彼此紧密相关,共同完成一个明确定义、单一的功能或职责。模块内部的联系强于模块之间的联系。
- 为什么重要:
- 提高可理解性: 模块的职责清晰明确,易于理解其功能。
- 增强可维护性: 修改与特定功能相关的代码集中在同一个高内聚的模块内,无需四处查找。
- 提升可复用性: 完成特定、独立功能的模块更容易被复用。
- 降低耦合: 模块内部处理好自己的事,对外暴露必要的接口,自然减少了不必要的对外依赖(与松耦合相辅相成)。
- 简化测试: 测试目标明确,测试用例更聚焦。
- 实现方式:
- 单一职责原则: 一个类/模块只应有一个引起它变化的原因。这是实现高内聚的关键指导原则。
- 信息隐藏/封装: 将数据和操作该数据的方法绑定在一起,对外只暴露必要的接口,隐藏实现细节。内部高度相关的数据和操作自然聚集。
- 功能分组: 将与同一功能域相关的代码组织在一起(如将所有“订单处理”相关的类放在一个包/模块下)。
- 避免“上帝类”: 将庞大臃肿、负责太多事情的类拆分成多个高内聚的小类。
- 架构考量: 在定义模块、组件、服务的边界时,应以业务能力或功能域为依据,确保边界内的元素高度相关。领域驱动设计中的限界上下文是实践高内聚的典范。
-
开放/封闭原则
- 核心思想: 软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着在不修改现有代码的情况下,应该能够通过添加新代码来扩展系统的行为。
- 为什么重要:
- 提高稳定性: 核心、稳定的代码不需要频繁修改,降低了引入新错误的风险。
- 增强可扩展性: 新功能可以通过添加新类或模块来实现,系统易于演进。
- 提升可维护性: 修改被限制在新添加的代码中,影响范围可控。
- 实现方式:
- 抽象与多态: 定义抽象(接口或抽象类),让具体实现依赖于抽象。新增行为时,实现新的具体类即可。
- 策略模式: 将算法或行为封装成可互换的策略。
- 模板方法模式: 在基类中定义算法骨架,允许子类重写特定步骤。
- 观察者模式: 通过订阅机制添加新的观察者,无需修改被观察者。
- 依赖注入: 通过配置注入不同的实现来改变行为。
- 架构考量: 设计稳定的核心抽象层和扩展点。框架设计尤其需要遵循OCP,允许用户通过插件或扩展机制添加功能而不修改框架本身。识别系统中哪些部分是稳定的(封闭修改),哪些是可能变化的(开放扩展)。
-
依赖倒置原则
- 核心思想:
- 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
- 抽象不应该依赖于细节。细节应该依赖于抽象。
- 为什么重要:
- 解耦核心逻辑与实现细节: 使高层策略(业务逻辑)独立于底层技术细节(如数据库访问、外部服务调用)。
- 提高可测试性: 高层模块可以通过Mock抽象接口进行测试,无需依赖真实的底层实现。
- 增强灵活性与可替换性: 更容易替换底层实现(如更换数据库、改用不同的云服务提供商),只需提供符合抽象的新实现。
- 促进并行开发: 只要抽象接口定义好,高层和低层可以并行开发。
- 实现方式:
- 定义接口: 为低层模块提供的服务定义接口。
- 高层依赖接口: 高层模块只引用和调用这些接口。
- 低层实现接口: 低层模块具体类实现这些接口。
- 依赖注入: 将实现了接口的低层具体对象注入到高层模块中(通常由IoC容器完成)。
- 架构考量: 定义清晰的抽象层(如Repository接口抽象数据访问,Service接口抽象业务逻辑)。依赖注入框架是实现DIP的常用基础设施。在分层架构中,DIP是打破层间严格向下依赖的关键(如领域层定义接口,基础设施层实现)。
- 核心思想:
-
接口分离原则
- 核心思想: 不应该强迫客户端(使用接口的类)依赖它们不使用的接口方法。应该将庞大臃肿的接口拆分成更小、更具体的接口,使得客户端只需要知道和依赖它们实际使用的方法。
- 为什么重要:
- 减少耦合: 客户端只与它真正需要的最小接口耦合,不受无关方法变更的影响。
- 提高内聚性: 拆分后的接口本身更内聚,专注于一组紧密相关的方法。
- 增强可理解性: 小接口职责更明确,更容易理解和使用。
- 避免“接口污染”: 防止客户端被迫实现它们根本不需要的方法(尤其是在使用抽象类或需要实现整个接口时)。
- 实现方式:
- 识别角色: 分析一个庞大接口服务于哪些不同的角色或客户端。
- 按角色拆分: 为每个角色或功能子集定义一个专门的接口。
- 组合接口: 如果一个类需要实现多个角色,它可以实现多个小接口(组合),而不是一个大接口。
- 避免“胖接口”: 警惕包含太多不相关方法的接口。
- 架构考量: 在设计服务API、组件接口、领域模型中的接口/抽象类时应用ISP。特别是在微服务架构中,定义细粒度的API契约非常重要。确保提供给不同消费者的接口(如管理API vs 用户API)是分离的。
-
解决循环依赖
- 核心思想: 消除或打破模块、类或组件之间形成的环形依赖关系(A依赖B,B依赖C,C又依赖A)。循环依赖会导致编译/链接问题、初始化顺序难题、测试困难、代码紧耦合,并阻碍独立部署。
- 为什么重要:
- 避免构建/部署问题: 循环依赖使模块无法独立编译、链接或部署。
- 打破紧耦合: 循环依赖是紧耦合的极端表现,使系统难以理解和修改。
- 解决初始化死锁: 可能导致运行时初始化失败。
- 支持模块化与微服务: 循环依赖是模块化开发和微服务架构的大敌。
- 解决方法:
- 依赖倒置: 引入抽象层(接口)。让A和B都依赖于一个抽象接口I,C实现I并依赖于A。这样A->I<-C->A 的循环被打破为 A->I, C->I, C->A。
- 提取公共依赖: 将导致循环的公共功能或数据提取到一个新的模块D中,让A、B、C都依赖于D。
- 合并模块: 如果循环依赖的模块职责高度相关且拆分不合理,考虑将它们合并为一个内聚的模块。
- 事件/消息机制: 使用事件总线或消息队列。A产生事件,C监听事件并处理,移除A对C的直接依赖,打破A<->C循环。
- 回调/观察者: 将依赖关系从直接调用改为注册回调或观察者模式。
- 接口分离: 如果循环是因为一个大接口,尝试将其拆分成更小的接口,使依赖更精确。
- 延迟注入/Setter注入: 在某些框架中,通过Setter方法或延迟解析注入依赖,可以绕过编译期循环检查(但这只是绕开问题,并非根本解决设计问题)。
- 架构考量: 在架构设计时就要关注模块依赖图,使用工具分析识别循环依赖。明确分层和依赖规则(如严格的分层架构禁止向上或循环依赖)。提倡单向依赖(树状或星状结构)。
-
利斯科夫替换原理
- 核心思想: 子类型必须能够替换掉它们的基类型,而不改变程序的正确性。也就是说,程序中任何使用基类对象的地方,都应该可以透明地替换为其子类对象,程序的行为(从客户端的角度看)应该保持一致(不产生错误、异常或违反预期的行为)。
- 为什么重要:
- 确保多态的正确性: 是多态和继承机制能够正确、可预测工作的基石。如果违反LSP,使用多态会导致难以预料的行为和错误。
- 增强可维护性: 客户端代码可以安全地依赖基类契约,无需关心具体的子类实现细节。
- 提高代码复用性: 通过继承复用基类行为时,不会引入破坏基类约定的风险。
- 支持契约式设计: 强调子类必须遵守基类的契约(前置条件、后置条件、不变量)。
- 关键要求:
- 方法签名兼容: 子类方法参数类型应比基类更宽松(逆变,实践中很少见且需谨慎)或相同;返回值类型应比基类更严格(协变)或相同。现代语言通常要求完全相同。
- 行为兼容:
- 子类不能加强前置条件(对输入的要求不能比基类更严格)。
- 子类不能减弱后置条件(对输出/状态改变的承诺不能比基类更弱)。
- 子类必须维护基类的不变量(对象在任何合法状态下都必须满足的条件)。
- 子类不能抛出基类方法未声明的新的检查型异常(或更广泛的异常)。
- 违反LSP的典型例子:
- 正方形继承自矩形:
Rectangle.SetWidth(w)
和SetHeight(h)
独立设置。Square.SetWidth(w)
需要同时设置高度为w。如果客户端代码期望SetWidth
只改变宽度,传入Square
对象就会破坏矩形的行为(高度也被改了)。这表明Square
不是行为上的Rectangle
子类型。
- 正方形继承自矩形:
- 实现方式:
- 谨慎设计继承层次: 优先考虑“is-a”关系是否在行为上也成立。
- 优先组合优于继承: 如果行为兼容性难以保证,考虑使用组合和委托。
- 契约式设计: 明确基类契约,在子类实现中严格遵守。
- 测试驱动: 编写针对基类接口的测试,确保所有子类实现都能通过这些测试。
- 架构考量: 在设计核心领域模型、框架扩展点(基类/接口)时,必须严格遵循LSP。确保框架定义的基类/接口的所有实现者(插件、扩展)都能安全地替换基类/接口。违反LSP会严重破坏基于多态的架构的稳定性和可预测性。
总结:
这八项设计技术构成了现代软件架构设计的核心支柱:
- 退化设计、解决循环依赖 关注系统的健壮性、可用性和可构建/部署性。
- 松耦合、高内聚 是基础性、普适性的设计目标,直接影响可维护性、可测试性、可复用性和灵活性。
- 开放/封闭原则、依赖倒置原则、接口分离原则、利斯科夫替换原理 构成了著名的 SOLID 原则(其中DIP, ISP, LSP是其中的三个),它们提供了具体的、面向对象的设计指导,共同服务于构建可扩展、可维护、灵活且稳定的系统。SOLID原则是达到松耦合和高内聚的重要手段。
熟练掌握并综合运用这些原则和技术,是成为一名合格的软件架构师(尤其是在iSAQB认证框架下)的关键能力。它们共同的目标是构建能够有效应对变化、易于理解和维护、稳定可靠的高质量软件系统。在实际项目中,需要根据具体场景权衡和灵活应用这些原则。