引言
学习设计模式要回答两个问题:
- 什么是好代码? —— 见 §02。
- 怎样写出好代码? —— 面向对象、设计原则、设计模式、编程规范、代码重构这五件套各管一段,见 §03。
KISS 原则人人会背,难的是判断"多简单算简单"。后面所有内容都在给这类判断提供尺子。
02 代码质量:把 30 个形容词压成 2 个问题
书里列了 30 多个评价代码的英文词。它们都可以归到三层:
| 层面 | 关心的问题 | 代表属性 |
|---|---|---|
| 编码层 | 当下能不能读懂 | readability、simplicity、clean |
| 模块层 | 改动能不能控制住 | maintainability、testability、reusability |
| 系统层 | 长期能不能演进 | extensibility、flexibility、scalability |
剥到最里面,只剩两个问题:
- 改一行,要扫几个文件? —— 决定可维护性、可测试性。
- 加一个新需求,是插一段,还是重写? —— 决定可扩展性、灵活性。
其余术语的定位:
- flexibility 是结果不是属性:易扩展 / 易复用 / 易用三者满足任一即可称"灵活"。
- elegant / good / clean 偏主观,不构成评价维度,适合表态不适合度量。
- robustness / reliability / scalability 描述运行时行为,属于架构而非代码质量。
- DRY 是手段不是目标:复用是结果,过早抽象反而降低可维护性。
边界:可读性和可维护性常被混用。可读性是"读"的成本,可维护性是"改"的成本。一段代码可以读得懂但改不动(逻辑分散在 10 个文件),反之少见。
03 五件套:面向对象 / 设计原则 / 设计模式 / 编程规范 / 代码重构
| 工具 | 解决什么 | 抽象层次 | 关键内容 |
|---|---|---|---|
| 面向对象 | 复杂程序的组织方式 | 最高 | 封装、抽象、继承、多态(§04~§10) |
| 设计原则 | 写代码时的判断依据 | 高 | SOLID、DRY、KISS、YAGNI、LOD |
| 设计模式 | 反复出现的设计问题 | 中 | 23 种,分创建型 / 结构型 / 行为型 |
| 编程规范 | 可读性 | 低 | 命名、注释、函数长度、模块划分 |
| 代码重构 | 代码质量不下降的兜底 | 低 | 单元测试为前提,分大重构 / 小重构两种规模 |
依赖关系:
- 面向对象是基石,提供封装、抽象、继承、多态四种语法手段。
- 设计原则是经验,指导何时用哪种手段;如开闭原则指导策略 / 模板模式。
- 设计模式是套路,把"原则 + 手段"凝固成 23 种可复用方案,主攻可扩展性。
- 编程规范偏代码细节,是持续小重构的理论基础。
- 重构利用前四者,把可维护性兜住。
23 种模式速查
| 分组 | 常用 | 不常用 |
|---|---|---|
| 创建型 | 单例、工厂(方法 / 抽象)、建造者 | 原型 |
| 结构型 | 代理、桥接、装饰者、适配器 | 门面、组合、享元 |
| 行为型 | 观察者、模板、策略、职责链、迭代器、状态 | 访问者、备忘录、命令、解释器、中介 |
一条纪律:先别过度设计
开发初期不预先套复杂模式,等代码痛了再针对性重构 —— 这是避免过度设计最简单的纪律。

04 面向对象的定义
以类或对象作为代码的基本组织单元,封装、抽象、继承、多态四个特性是其基石。
05 面向对象四大特性
| 特性 | 一句话定义 | 解决的问题 | 边界 / 易混 |
|---|---|---|---|
| 封装 | 通过暴露有限接口,限制外部对内部数据的访问 | 数据被随意改、易用性差 | 不是"加 private 就叫封装",关键是接口收敛 |
| 抽象 | 隐藏实现,只暴露"做什么" | 实现变更冲击调用方 | 任何函数都是抽象,无需面向对象语法 |
| 继承 | 用 is-a 关系复用父类成员 | 重复代码 | 层次 ≤ 2 层、关系稳定才用;否则上组合 |
| 多态 | 子类替换父类,运行时分派到具体实现 | 调用方与实现解耦 | 不与继承绑定,duck-typing / 接口也是多态 |
封装
接口收敛比加 private 更重要。下面两版都有 getter,封装强度差别巨大:
// 反例:贫血对象,外部可以随意改内部状态
class Cart {
public array $items = [];
}
$cart->items[] = $item; // 任何地方都能这么干
// 正例:行为收敛到类内部
class Cart {
private array $items = [];
public function add(Item $item): void { /* 校验、计价、触发事件 */ }
public function items(): array { return $this->items; }
}
抽象
判断点:方法名是否暴露实现细节?
// 反例
public function getAliyunPictureUrl(string $key): string;
// 正例
public function getPictureUrl(string $key): string;
OSS 从阿里云换七牛云时,前者要改全部调用方,后者不动。
继承
只保留 is-a 关系。“鸟会飞 / 不会飞 / 会叫 / 不会叫” 用继承组合会爆炸,用接口 + 组合更线性:
interface Flyable {
public function fly(): void;
}
class FlyWithWings implements Flyable {
public function fly(): void { /* ... */ }
}
class Sparrow implements Flyable {
public function __construct(private FlyWithWings $ability) {}
public function fly(): void { $this->ability->fly(); }
}
多态
PHP 实现多态的三种方式,本质都是"调用方只认接口,不认实现":
| 方式 | 例子 |
|---|---|
| 继承 + 重写 | Animal::speak() 被 Dog::speak() 覆盖 |
| 接口实现 | PaymentGateway 由 Alipay / Wechat 实现 |
| duck-typing | PHP 弱类型场景下方法签名一致即可调用 |
06 面向对象 vs 面向过程
| 维度 | 面向过程 | 面向对象 |
|---|---|---|
| 组织单元 | 过程(函数) | 类 / 对象 |
| 数据与方法 | 分离 | 绑定 |
| 思维方向 | 自顶向下,按执行流程拆任务 | 自底向上,先建模块再编排流程 |
| 适用场景 | 微小程序、数据处理脚本、算法题 | 处理流程网状的大规模复杂业务程序 |
不是谁取代谁,是规模和复杂度在决定 —— Laravel artisan 命令、一次性数据迁移脚本,写成过程式更省事;业务核心服务越往复杂处长,越需要 OO 把复杂度装进类的边界。
07 容易写出面向过程代码的三种坑
坑 1:getter 返回内部集合
返回内部 array 或可变 Collection 时,调用方能直接改,等于把 private 当摆设。
// 反例:对象集合按引用共享,外部 mutation 会污染内部状态
class Cart {
private array $items = [];
public function items(): array { return $this->items; }
}
// 正例 1:返回不可变副本
public function items(): Collection {
return collect($this->items); // 新实例,改不到原状态
}
// 正例 2:暴露行为而非数据
public function add(Item $item): void { /* 校验 + 计价 + 事件 */ }
public function remove(string $id): void { /* ... */ }
坑 2:Utils / Constants 类堆静态方法
只装静态方法、不持有状态的 Utils 类,本质是一组全局函数,破坏封装。
判据:能否找到一个名词,让这堆方法围绕它聚合?能则升级成对象(StringHelper::slug() → Slugger->make());不能则按职责拆细,避免大杂烩。
坑 3:贫血模型(Anemic Domain Model)
Laravel 默认 MVC 即贫血结构:
Controller ← 暴露 HTTP
└─ Service ← 业务逻辑
└─ Model ← 只有字段和关系,没有行为
Order 只有属性,“下单 / 取消 / 退款"全压在 OrderService,是面向过程写法。
充血模型则把"取消订单"这类行为放回 Order::cancel(),模型自带不变量校验,Service 只负责事务与外部协作。
贫血不等于"错”,它是大部分 Web 项目实际采用的模式,原因见 §11。
08 抽象类 vs 接口
| 维度 | 抽象类 | 接口 |
|---|---|---|
| 实例化 | 不能 | 不能 |
| 持有属性 | 可以 | 不可以 |
| 方法实现 | 可有可无(抽象方法 + 普通方法) | 只能声明(PHP 8+ 默认方法除外) |
| 表达关系 | is-a | has-a / behaves-as |
| 解决问题 | 代码复用 | 解耦、行为抽象 |
| 设计方向 | 自下而上(从重复中提父类) | 自上而下(先定契约再写实现) |
PHP 实践:能用接口就别用抽象类,Laravel Contracts/* 走的就是这个路线。需要复用一段实现时,优先考虑 trait + 接口的组合,而非加深继承层级。
09 基于接口而非实现编程
Program to an interface, not an implementation.
越脱离具体实现的设计,越能应对未来变化。落地三步:
- 命名不暴露实现:
getPictureUrl()而非getAliyunPictureUrl()。 - 封装实现细节:调用方不需要知道你用的是 Guzzle 还是 cURL。
- 依赖接口而非实现类:构造函数注入
PaymentGateway,不注入AlipayGateway。
需要练成三种意识:抽象、封装、接口。
10 组合 vs 继承
继承层次过深会拖垮可维护性。鸟类的"会飞 / 不会飞 / 会叫 / 不会叫 / 会下蛋 / 不会下蛋"用继承组合爆炸成 $2^3 = 8$ 个子类。改写成 接口 + 组合 + 委托 后,正交能力可任意拼装(PHP 示例见 §05「继承」)。
代价是类和接口变多,复杂度从纵向(深继承)转到横向(多组合)。
何时选哪个
| 场景 | 用继承 | 用组合 |
|---|---|---|
| 类关系稳定、层次浅(≤ 2 层) | ✓ | |
| is-a 关系明确(Cat is Animal) | ✓ | |
| 需要复用"能力"而非"身份" | ✓ | |
| 行为可能在运行时切换 | ✓ | |
| 子类只复用父类一部分方法 | ✓ | |
| 框架强制(模板方法、Laravel 基类) | ✓ |
特定模式固定一种选择:
- 继承:模板模式(template pattern)。
- 组合:装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)。
11 贫血模型 vs DDD(充血模型)
领域驱动设计(DDD):用领域对象而非过程化的 Service 组织业务,把规则放回对象内部。
两种模式对照
| 维度 | 贫血模型(传统 Web) | 充血模型(DDD) |
|---|---|---|
| 数据载体 | BO / Entity 只有字段 | Domain 类带行为 + 不变量 |
| Service | 重,业务逻辑全在 Service | 轻,只做编排、跨模型聚合、外部协作 |
| 设计时机 | 边写边加方法到 Service | 上手前先建模,确定领域对象暴露哪些操作 |
| 适用场景 | CRUD 主导的简单业务 | 业务规则复杂、长期演进的核心系统 |
贫血流行的三条原因
- 业务简单 —— SQL CRUD 已经够用。
- 充血先要建模,门槛更高。
- 团队思维固化,转型成本不低。
何时该用 DDD
业务规则复杂、生命周期长、需求持续叠加的系统,如金融(利息计算、还款计划、风控)。CRUD 系统强上 DDD 是负收益。
12 案例:虚拟钱包
业务:充值、提现、支付、转账、查询余额 / 流水;进阶有冻结、透支。
按"账户归属"切两个子系统:
- 虚拟钱包:应用内账户的余额变动。
- 三方支付:与银行 / 三方对账。
两者间只承诺最终一致。

同一段逻辑:贫血 vs 充血
// 贫血:Service 直接操作字段,规则散在每个方法里
public function debit(int $walletId, string $amount): void
{
$entity = $this->walletRepo->find($walletId);
if (bccomp($entity->balance, $amount, 2) < 0) {
throw new InsufficientBalanceException();
}
$this->walletRepo->updateBalance($walletId, bcsub($entity->balance, $amount, 2));
}
// 充血:规则封装在领域对象内
public function debit(int $walletId, string $amount): void
{
$entity = $this->walletRepo->find($walletId);
$wallet = VirtualWallet::fromEntity($entity);
$wallet->debit($amount); // 规则、不变量在这里
$this->walletRepo->updateBalance($walletId, $wallet->balance());
}
领域类长出业务能力
支持冻结、透支后,VirtualWallet 自带的能力天然内聚:
final class VirtualWallet
{
public function __construct(
private int $id,
private string $balance = '0',
private bool $allowedOverdraft = true,
private string $overdraftAmount = '0',
private string $frozenAmount = '0',
) {}
public function availableBalance(): string { /* balance - frozen (+ overdraft) */ }
public function debit(string $amount): void { /* 校验 + 扣减 */ }
public function credit(string $amount): void { /* 入账 */ }
public function freeze(string $amount): void { /* ... */ }
public function unfreeze(string $amount): void { /* ... */ }
public function openOverdraft(): void { /* ... */ }
public function closeOverdraft(): void { /* ... */ }
}
Service 为什么不会被完全替代
| Service 职责 | 例子 |
|---|---|
| 与 Repository 打交道,做 DTO ↔ 领域转换 | VirtualWallet::fromEntity() + 调 walletRepo->update() |
| 跨领域模型的聚合 | 转账涉及两个钱包,单个 VirtualWallet 不该背这个流程 |
| 非功能性 / 外部协作 | 幂等、事务、邮件、消息、RPC |
保持领域模型纯净(不依赖 Eloquent / Spring / 框架),让它可被复用、可被独立测试。
哪些层照旧贫血
| 层 | 是否充血 | 原因 |
|---|---|---|
| Controller | 否 | VO 只是 DTO,业务逻辑薄 |
| Service | 轻 | 编排 + 跨聚合,业务下沉到 Domain |
| Domain | 充血核心 | 不变量、状态机、领域规则在此 |
| Repository | 否 | Entity 生命周期短,纯读写 |
遗留问题:Entity ↔ Domain 的转换放在哪?工厂方法 / Assembler 类 / Repository 内部三选一,没有标准答案,团队约定即可。
13 案例:接口鉴权的 OOA 迭代
OOA → OOD → OOP 是面向对象开发的三段:分析需求 → 设计类与协作 → 编码。本节只走 OOA,让需求从模糊到可落地。
需求迭代三版
| 版本 | 方案 | 暴露的问题 |
|---|---|---|
| v1 | URL + AppID + 密码 拼接后加密 → token | 重放攻击:token 可被原样重发 |
| v2 | 引入时间戳作为随机变量参与 token 生成 | 解决重放,但需服务端校验时间戳 |
| v3 | 服务端校验时间戳是否落在窗口(如 1 分钟) | 工程上可用 |
v2 示意:

v3 示意:

OOA 的产出不是代码,是一份"哪些做、哪些不做、哪些以后再做"的清单。
14 案例:把 7 个步骤归到 3 个类
鉴权流程共 7 步:
- 拼接 URL + AppID + 密码 + 时间戳 → 字符串
- 字符串加密生成 token
- 把 token + AppID + 时间戳拼回 URL
- 解析 URL,取出 token / AppID / 时间戳
- 从存储里取出 AppID 对应的密码
- 校验时间戳是否过期
- 校验两个 token 是否一致
按职责聚合到 3 个类:
| 类 | 负责的步骤 | 关心什么 |
|---|---|---|
AuthToken | 1, 2, 6, 7 | token 的生成与验证 |
Url | 3, 4 | URL 的拼接与解析 |
CredentialStorage | 5 | AppID / 密码的存取 |
一次"以名词收敛行为"的演练 —— 跟 §07 坑 2 的判据一致:能找到名词的就升级成对象。
– EOF –