重读设计模式:从理论到实践的反思(二)

重读设计模式:从理论到实践的反思(二)

引言

《Head First 设计模式》(Head First Design Patterns)的第四至六章,内容涵盖了工厂模式(Factory Pattern)、单例模式(Singleton Pattern)以及命令模式(Command Pattern)。

在阅读过程中,我感觉设计模式在本质上与中间件(Middleware)是很相似的。无论是设计模式还是中间件,其底层逻辑都是在“变动不居”的两端之间,强行插入一个契约层。这个中介层的存在,是为了实现时空与逻辑上的解耦。我们可以有一个有趣的类比:接口是微观的中间件,中间件是宏观的接口。 根据交互两端的变化频率与性质不同,演化出了各具特色的设计模式:

  • 策略模式(Strategy Pattern): 它是业务流程与频繁变动的具体算法之间的中介。流程保持稳定,算法可以自由切换。
  • 命令模式(Command Pattern): 它在调用者(Invoker)与执行者(Receiver)之间构建了一道屏障。通过将“动作”抽象为对象,它不仅解耦了逻辑,更实现了时空上的灵活性——调用者无需知道谁在执行、何时执行以及如何执行。
  • 工厂系列(Factory Patterns): 随着“创建逻辑”复杂度的提升,中介层也在不断增厚:
    • 简单工厂(Simple Factory): 解决的是调用者与“具体类名”之间的解耦(由工厂负责选型)。
    • 工厂方法(Factory Method): 解决的是调用者与“生产工艺”之间的解耦(定义标准接口,由子类决定具体实现)。
    • 抽象工厂(Abstract Factory): 解决的是系统与“产品族(生态)”之间的解耦(确保整套组件的兼容性与一致性)。

模式与中间件本身都不是目的,应对变化才是。在软件架构中,引入中介层(无论是增加一个接口、一个工厂类,还是引入 Redis/MQ 这样的中间件)都会带来额外的抽象成本。因此,决策的关键在于对“变化”的预判:

  1. 何时不该加?

    如果交互的两端是静态的、确定性的,增加接口或中间件就是典型的过度设计(Over-engineering)。这不仅会增加代码的认知负担,还会带来不必要的性能损耗。

  2. 何时必须加?

    当你预测(或已经观察到)实现类会频繁更迭、算法需要动态切换、系统流量存在激增风险,或者需要支持第三方插件扩展时,中介层的价值便凸显出来。它将“变化”隔离在一个可控的范围内,保护了系统核心逻辑的稳定性。

工厂模式:在确定性与灵活性之间寻找平衡

工厂模式人为地划分为三种形态:简单工厂(Simple Factory)工厂方法(Factory Method)抽象工厂(Abstract Factory)。它们分别在不同的维度上处理“对象创建”这一多变的需求。

为什么需要工厂?—— DI 无法覆盖的盲区

在现代软件开发(如 WPF 或 Spring 框架)中,依赖注入(DI)是管理对象生命周期的主流方式。我们倾向于在构造函数中要求接口,并在程序入口处进行统一注入。

如果所有的依赖都能在程序启动时硬编码确定,那我们面对的就是一个“静态世界”。现实开发(如工业仿真或复杂电商系统)中,工厂模式的存在是为了解决 DI 无法提前预知信息的两种场景:

A. 运行时参数依赖 (Runtime Parameters)

这是最常见的原因。某些对象不仅依赖基础服务,还需要瞬时产生的数据才能初始化。 * 例子: 一个发票生成器 InvoiceGenerator 需要 OrderId 才能创建。 * 矛盾: OrderId 只有在用户点击请求时才知道。你无法在程序入口处注入一个带有动态 ID 的实例。 * 方案: 注入一个工厂。在业务流中调用 factory.Create(orderId),由工厂将静态的服务依赖与动态的运行时参数“揉”在一起。

B. 动态决定实现类 (Dynamic Selection)

虽然你面向接口编程,但直到运行的那一刻,程序才知道该实例化哪一个具体类。 * 例子: 在工业仿真软件中,用户通过 UI 配置了不同的求解算法(如直接求解或 Krylov 子空间迭代)。 * 矛盾: 入口函数无法预知用户的配置。 * 方案: 注入工厂,根据配置文件或用户输入,动态产出对应的算法实例。

DI 与 Factory 的维度对比:

维度 直接注入接口 (DI) 使用工厂模式 (Factory)
创建时机 程序启动或容器初始化时 业务逻辑运行到特定时刻时
所需信息 全局配置、静态单例服务 运行时用户输入、数据库动态数据
实现类 启动时已固定 根据逻辑动态切换多个实现
生命周期 通常较长(Singleton/Scoped) 通常较短,随用随取,用完即弃

简单工厂:封装创建过程

简单工厂的核心是将“根据类型选实现”的逻辑从业务代码中剥离出来。

1
2
3
4
5
6
7
8
9
10
11
12
public class CoffeeFactory {
public Coffee createCoffee(String type) {
if (type.equals("Americano")) {
return new AmericanoCoffee();
} else if (type.equals("Latte")) {
return new LatteCoffee();
} else if (type.equals("Cappuccino")) {
return new CappuccinoCoffee();
}
throw new IllegalArgumentException("未知咖啡类型");
}
}

硬编码的困境:

上述代码虽然解耦了调用者,但工厂类本身却陷入了 if/else 的泥潭。每增加一种咖啡,都要修改工厂类并重新编译。这显然违反了开闭原则(Open-Closed Principle)。为了消除这种硬编码,我们可以引入以下两种进阶方案:

方案一:反射机制 (Reflection) —— 极简的灵活性

在 Java 或 C# 中,反射允许程序在运行时根据字符串类名动态寻找并实例化对象。

Java 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CoffeeFactory {
public static Coffee createCoffee(String className) {
try {
// 获取类蓝图并调用无参构造函数
return (Coffee) Class.forName(className)
.getDeclaredConstructor()
.newInstance();
} catch (Exception e) {
System.err.println("工厂无法创建该产品:" + className);
return null;
}
}
}

C# 实现:

1
2
3
4
5
6
7
8
9
10
11
public class CoffeeFactory {
public static ICoffee CreateCoffee(string className) {
try {
// 获取类型并动态创建实例
Type t = Type.GetType(className);
return (ICoffee)Activator.CreateInstance(t);
} catch {
return null;
}
}
}

这种方式下,工厂不再关心具体的类,它只是一台“根据图纸造机器”的通用设备。

方案二:Map 注册机制 —— 配置化管理

这是 Spring 等大型框架常用的做法,将具体类与标识符(Key)的对应关系存储在容器中。

1
2
3
4
5
6
7
8
9
10
11
12
public class CoffeeFactory {
// 注册表:Key 是标识,Value 是创建实例的逻辑或原型对象
private static final Map<String, Coffee> registry = new HashMap<>();

public static void register(String name, Coffee coffee) {
registry.put(name, coffee);
}

public static Coffee createCoffee(String name) {
return registry.get(name);
}
}
  • 如何扩展? 新增产品时,只需编写新类,并在程序启动时调用 register
  • 优势: 彻底去除了 switch-case,工厂变成了一个通用的容器。
  • 注意: 注册逻辑应集中在配置层或初始化阶段,避免注册行为分散导致系统难以追踪。

工厂方法与抽象工厂:从“封装创建”到“定义标准”

为什么在许多经典文献中,并不将“简单工厂”归类为 GoF 23 种设计模式之一,而仅将其视为一种“编程习惯”?

其核心差异在于抽象层次。正如前文所述,模式的本质是在变动的两端引入中介(抽象)。简单工厂只是通过一个静态方法或类重新组织了代码结构,它只是封装了实例化的逻辑,但并没有在“工厂”这一行为本身上建立抽象。当我们的变化从“对象的名称”升级到“对象的生产工艺”时,我们就需要真正的设计模式了。

工厂方法模式 (Factory Method):将实例化推迟到子类

想象一下,你正在为一套工业软件开发搜索算法模块。目前你提供了一个 SearchAlgorithm 接口,并基于 OpenBLAS 实现了一套 BinarySearch

随着硬件适配的需求增加,情况变得复杂了:针对 Intel 芯片,你需要一套基于 Intel MKL 库优化的版本;针对移动端或高性能场景,你可能还需要一套基于 GPU (CUDA) 的版本。对于用户(调用者)来说,他们只想要一个“二分查找”,不希望去记忆 IntelMKLBinarySearchGPUBinarySearch 这种冗长的类名。

此时,工厂方法模式通过引入“抽象工厂类”,让具体的子类决定该生产哪一种具体的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 1. 抽象产品
interface SearchAlgorithm {
void search(int[] data, int target);
}

// 2. 抽象工厂:定义“生产”这一动作的标准
abstract class SearchFactory {
// 这是一个“工厂方法”
public abstract SearchAlgorithm createSearcher();

// 可以在基类中包含一些通用逻辑
public void executeSearch(int[] data, int target) {
SearchAlgorithm algorithm = createSearcher();
algorithm.search(data, target);
}
}

// 3. 具体工厂实现:决定具体的“工艺版本”
class IntelMKLFactory extends SearchFactory {
@Override
public SearchAlgorithm createSearcher() {
// 返回针对 Intel MKL 优化的具体版本
return new MKLSpeedSearch();
}
}

class NvidiaGPUFactory extends SearchFactory {
@Override
public SearchAlgorithm createSearcher() {
// 返回针对 GPU 优化的具体版本
return new CUDASpeedSearch();
}
}

简单工厂是“我给你一个字符串,你给我一个实例”。而工厂方法是“我定义一个生产接口,由不同的工厂分支(子类)来决定生产出什么样的实例”。这通过多态解决了工厂本身的扩展性问题,和简单工厂相比解决的其实是不同层级上的多态问题 。

这是一个非常深刻且符合工程实战的观察。在复杂的软件系统中,设计模式很少孤立存在,它们往往是嵌套和组合的。

将抽象工厂内部实现直接委派给特定的工厂方法(即具体的工厂类),不仅可行,而且是组合优于继承(Composition over Inheritance)原则的典型应用。这种做法将“生态系统的管理”与“具体产品的生产工艺”进一步解耦。

以下是优化后的抽象工厂部分,体现了这种“工厂的工厂”的层级结构:

抽象工厂模式 (Abstract Factory):构建产品家族的“配套准则”

如果说工厂方法关注的是单一产品的多种实现,那么抽象工厂关注的则是产品家族(Product Family)

在实际工程中,抽象工厂往往扮演着“指挥官”的角色。它不一定非要亲自书写 new 的逻辑,而是通过组合(Composition)多个具体的工厂方法类,来构建一个完整的技术生态。例如,一个 IntelEcosystem 可以直接调用我们之前定义的 IntelMKLFactory 来生产搜索器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 1. 定义生态系统接口:它是多个产品领域工厂的集合
interface AlgorithmEcosystem {
SearchAlgorithm createSearcher(String type);
SortAlgorithm createSorter(String type);
}

// 2. Intel 生态系统实现:它通过“委派”具体的工厂类来完成工作
class IntelEcosystem implements AlgorithmEcosystem {
// 内部持有一个具体的工厂方法实现
private final SearchFactory searchFactory = new IntelMKLFactory();
private final SortFactory sortFactory = new IntelMKLSortFactory();

@Override
public SearchAlgorithm createSearcher(String type) {
// 抽象工厂不需要写具体的 if/else,直接交给专业的工厂方法类
return searchFactory.createSearcher(type);
}

@Override
public SortAlgorithm createSorter(String type) {
return sortFactory.createSorter(type);
}
}

// 3. NVIDIA 生态系统实现
class NvidiaEcosystem implements AlgorithmEcosystem {
private final SearchFactory searchFactory = new NvidiaGPUFactory();

@Override
public SearchAlgorithm createSearcher(String type) {
// 委派给 GPU 专门的生产线
return searchFactory.createSearcher(type);
}

@Override
public SortAlgorithm createSorter(String type) {
// ... 同理
return null;
}
}

关键洞察:

  • 层级化解耦: 在这个结构中,SearchFactory 处理的是“如何造一个特定的搜索器”(生产工艺),而 AlgorithmEcosystem 处理的是“这些搜索器应该属于哪个阵营”(架构一致性)。
  • 组合的威力: 抽象工厂通过内部直接调用(委派)工厂方法的派生类,实现了一种多态的叠加。当你选定了 IntelEcosystem 时,你不仅选定了 Intel 的搜索工厂,也选定了 Intel 的排序工厂。
  • 约束即安全: 这种模式强行约束了调用者:你一旦进入某个生态,你拿到的全套工具链就天然具备了兼容性。它从系统维度规避了“组件冲突”(如 MKL 搜索器配 CUDA 排序器)的风险。

总结:工厂的三重境界

我们将这三种“工厂”放在一起看,会发现它们应对的变化维度在逐层递增,且可以互相支撑:

  1. 简单工厂: 处理“对象名”的变化。它是一个“导购”,解决了初级的选型问题。
  2. 工厂方法: 处理“生产工艺”的变化。它是一个“独立车间”,通过多态让不同的版本自主实现生产。
  3. 抽象工厂: 处理“架构一致性”的变化。它是一个“工业园区”,通过组合多个独立车间(工厂方法),确保产出的全套组件能够严丝合缝地配合。

当你的系统中,谁来做(具体类)、怎么做(生产逻辑)以及和谁配套(生态兼容)都处于变动中时,这种层级化的中介体系便展现出了它应对复杂性的强大威力。

单例模式:全局唯一性的权衡与保障

单例模式(Singleton Pattern)可能是设计模式中结构最简单、使用最频繁,但也最容易被滥用和误解的一个。它的核心定义非常明确:确保一个类只有一个实例,并提供一个全局访问点。

从静态类到单例:为什么我们需要一个实例?

在初学者看来,如果一个类(如工厂类或工具类)没有成员变量,只有方法,那么直接将其定义为静态类(Static Class)似乎更为直接。既然目的都是为了全局访问,为什么还要费力将其设计为“单例实例”呢?

这涉及到面向对象设计中的几个深层次权衡:

  • 对多态的支持: 静态类无法实现接口(在大多数主流语言中)。而单例是一个真正的对象,它可以实现接口并继承基类。这使得单例可以被视为一种协议的实现。例如,在生产环境中你使用 FileLogger,而在测试环境中你可以通过 DI 容器将其替换为实现了同一接口的 MockLogger
  • 依赖注入(DI)的亲和力: 现代架构体系(如 Spring 或 .NET Core)倾向于通过构造函数注入依赖,而不是让代码中充满“从天而降”的静态调用。DI 容器可以轻松管理单例对象的生命周期。对于调用者而言,它只知道自己拿到了一个接口实例,而不需要关心这个实例在全局是否唯一,这种透明性极大地实现了逻辑解耦。
  • 控制初始化时机(Lazy Loading): 静态类通常在类加载时便完成了初始化。如果该类资源消耗巨大(如建立数据库连接池),这会拖慢整个程序的启动速度。单例模式允许我们将初始化延迟到第一次调用 getInstance() 时,实现更精细的资源控制。
  • 状态封装与安全性: 相比于散落在全局的静态变量,单例模式提供了一个受控的边界。它可以隐藏内部字段,仅暴露必要的方法。单例不仅仅是一个存储数据的容器,更是一个具有完整行为逻辑的对象。

线程安全性:从初稿到双重检查锁定

在单线程环境下,一个简单的单例实现如下:

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static Singleton instance;
private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

然而,在多线程高并发的场景下,上述代码会触发竞态条件(Race Condition)

想象这样一种情况:线程 A 执行完 if (instance == null) 且结果为真,但在执行 new 操作之前,CPU 将执行权切换给了线程 B。此时线程 B 看到 instance 仍为 null,于是也创建了一个实例。当线程 A 重新获得执行权时,它会继续执行 new,最终导致内存中产生了两个不同的实例,违背了单例的初衷。

为了解决这一问题且不牺牲性能,通常采用双重检查锁定(Double-Checked Locking, DCL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
// 必须使用 volatile 关键字
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) { // 第一次检查:若已初始化则直接返回,避免加锁损耗性能
synchronized (Singleton.class) { // 加锁,确保只有一个线程进入创建逻辑
if (instance == null) { // 第二次检查:确认在等待锁期间没有被其他线程创建
instance = new Singleton();
}
}
}
return instance;
}
}

在双重检查锁定中,volatile 关键字不仅是为了保证可见性,更是为了禁止指令重排序(Instruction Reordering)

执行 instance = new Singleton(); 这行逻辑,在 CPU 指令层面实际上分为三步: 1. 分配内存空间(Memory Allocation)。 2. 执行构造函数(Initialization)。 3. 将引用指向内存地址(Assignment)。

由于编译器和 CPU 的优化,第 2 步和 第 3 步的顺序可能会被颠倒。如果发生重排序: - 线程 A 先执行了第 1 步和第 3 步(此时 instance 已非空,但房子里还是空的,尚未初始化)。 - 线程 B 此时恰好进入 getInstance(),在第一层检查时发现 instance != null,于是直接拿走并开始使用。 - 结果: 线程 B 拿到的是一个尚未完成初始化的“半成品”对象,极易导致空指针异常或未定义的行为。

加上 volatile 后,它在底层建立了一个内存屏障(Memory Barrier),强制要求“初始化”必须在“赋值”之前完成,从而彻底杜绝了这种隐患。

命令模式:将“动作”对象化

命令模式(Command Pattern)的核心思想非常纯粹:将“请求”封装成对象。

在传统的代码逻辑中,调用者(Invoker)往往需要直接持有执行者(Receiver)的引用,并明确知道要调用哪个方法。这种硬编码导致了两者之间的紧耦合。而命令模式引入了一个抽象的 Command 接口,将具体的动作封装在独立的类中。

想象一个通用的远程控制系统。我们不希望遥控器(Invoker)知道电灯、空调或音响(Receivers)的具体 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 命令接口
interface Command {
void execute();
}

// 2. 具体命令:封装了“打开电灯”这一动作
class LightOnCommand implements Command {
private Light light; // 具体的执行者
public LightOnCommand(Light light) { this.light = light; }

@Override
public void execute() {
light.switchOn(); // 委派给真正的执行者
}
}

// 3. 调用者:遥控器,它只认识 Command 接口
class RemoteControl {
private Command slot;
public void setCommand(Command command) { this.slot = command; }
public void pressButton() { slot.execute(); }
}

通过这种方式,遥控器不再关心它是打开了一盏灯还是启动了一个复杂的工业流程。它只负责在按下按钮时,触发 execute() 这一契约动作。

从设计模式到中间件:命令模式的宏观演变

正如我们在文章开头所讨论的,“中间件是宏观的接口”。如果我们跳出代码细节,从系统架构的角度审视,会发现任务队列(如 Redis Queue)本质上就是命令模式的宏观实现。

在单机程序里,命令模式实现了逻辑解耦;在分布式系统中,它进一步实现了时空解耦

要素 经典命令模式 (代码级) Redis 队列 (架构级/中间件)
Command (命令) 封装成对象的请求(execute() 方法) 写入 Redis 的 JSON/消息数据(包含动作 ID 与参数)
Invoker (发起者) 调用命令对象的对象 Web 服务器/前端接口(产生任务的一端)
Receiver (接收者) 真正执行业务逻辑的对象 Worker 进程/消费者(后台处理任务的一端)
Client (客户端) 创建命令并装配接收者 业务逻辑层(决定什么任务在何时入队)

在命令模式下,请求不再是“立即执行”的,而是“可存储”的。这意味着我们可以将命令对象放入一个队列中,由后台线程(或另一台服务器上的 Worker)慢慢处理。 * 削峰填谷: 当大量请求(命令)涌入时,Invoker 只管将命令丢进 Redis,Receiver 可以根据自身的处理能力,平稳地从队列中消耗。 * 失败重试: 如果命令对象执行失败,我们可以轻松地将其重新放回队列,或记录其状态。

命令模式的另一个强大之处在于可记录性。 * 日志记录: 既然每一个动作都是一个对象,我们可以将这些对象序列化并存入磁盘。 * 系统恢复: 在数据库事务或复杂的分布式操作中,如果系统崩溃,我们可以通过重新读取“命令日志(Command Log)”并按序执行 execute(),将系统恢复到崩溃前的状态。这正是许多数据库(如 Redis 的 AOF 机制或数据库的 Redo Log)底层遵循的哲学。

命令模式通过将“动作”转化为“数据(对象)”,彻底打破了请求发送者与接收者之间的时间和空间依赖。它让我们意识到:一旦动作变成了可以被存储、传递和排队的东西,系统就获得了极大的灵活性。 无论是在内存中管理撤销(Undo)操作,还是在架构层面构建高并发的任务处理集群,命令模式都是那一层至关重要的“中介”。