引言

学习设计模式要回答两个问题:

  • 什么是好代码? —— 见 §02。
  • 怎样写出好代码? —— 面向对象、设计原则、设计模式、编程规范、代码重构这五件套各管一段,见 §03。

KISS 原则人人会背,难的是判断"多简单算简单"。后面所有内容都在给这类判断提供尺子。

02 代码质量:把 30 个形容词压成 2 个问题

书里列了 30 多个评价代码的英文词。它们都可以归到三层:

层面关心的问题代表属性
编码层当下能不能读懂readability、simplicity、clean
模块层改动能不能控制住maintainability、testability、reusability
系统层长期能不能演进extensibility、flexibility、scalability

剥到最里面,只剩两个问题:

  1. 改一行,要扫几个文件? —— 决定可维护性、可测试性。
  2. 加一个新需求,是插一段,还是重写? —— 决定可扩展性、灵活性。

其余术语的定位:

  • flexibility 是结果不是属性:易扩展 / 易复用 / 易用三者满足任一即可称"灵活"。
  • elegant / good / clean 偏主观,不构成评价维度,适合表态不适合度量。
  • robustness / reliability / scalability 描述运行时行为,属于架构而非代码质量。
  • DRY 是手段不是目标:复用是结果,过早抽象反而降低可维护性。

边界:可读性和可维护性常被混用。可读性是"读"的成本,可维护性是"改"的成本。一段代码可以读得懂但改不动(逻辑分散在 10 个文件),反之少见。

03 五件套:面向对象 / 设计原则 / 设计模式 / 编程规范 / 代码重构

工具解决什么抽象层次关键内容
面向对象复杂程序的组织方式最高封装、抽象、继承、多态(§04~§10)
设计原则写代码时的判断依据SOLID、DRY、KISS、YAGNI、LOD
设计模式反复出现的设计问题23 种,分创建型 / 结构型 / 行为型
编程规范可读性命名、注释、函数长度、模块划分
代码重构代码质量不下降的兜底单元测试为前提,分大重构 / 小重构两种规模

依赖关系:

  • 面向对象是基石,提供封装、抽象、继承、多态四种语法手段。
  • 设计原则是经验,指导何时用哪种手段;如开闭原则指导策略 / 模板模式。
  • 设计模式是套路,把"原则 + 手段"凝固成 23 种可复用方案,主攻可扩展性
  • 编程规范偏代码细节,是持续小重构的理论基础。
  • 重构利用前四者,把可维护性兜住。

23 种模式速查

分组常用不常用
创建型单例、工厂(方法 / 抽象)、建造者原型
结构型代理、桥接、装饰者、适配器门面、组合、享元
行为型观察者、模板、策略、职责链、迭代器、状态访问者、备忘录、命令、解释器、中介

一条纪律:先别过度设计

开发初期不预先套复杂模式,等代码痛了再针对性重构 —— 这是避免过度设计最简单的纪律。

image

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() 覆盖
接口实现PaymentGatewayAlipay / Wechat 实现
duck-typingPHP 弱类型场景下方法签名一致即可调用

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-ahas-a / behaves-as
解决问题代码复用解耦、行为抽象
设计方向自下而上(从重复中提父类)自上而下(先定契约再写实现)

PHP 实践:能用接口就别用抽象类,Laravel Contracts/* 走的就是这个路线。需要复用一段实现时,优先考虑 trait + 接口的组合,而非加深继承层级。

09 基于接口而非实现编程

Program to an interface, not an implementation.

越脱离具体实现的设计,越能应对未来变化。落地三步:

  1. 命名不暴露实现getPictureUrl() 而非 getAliyunPictureUrl()
  2. 封装实现细节:调用方不需要知道你用的是 Guzzle 还是 cURL。
  3. 依赖接口而非实现类:构造函数注入 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 主导的简单业务业务规则复杂、长期演进的核心系统

贫血流行的三条原因

  1. 业务简单 —— SQL CRUD 已经够用。
  2. 充血先要建模,门槛更高。
  3. 团队思维固化,转型成本不低。

何时该用 DDD

业务规则复杂、生命周期长、需求持续叠加的系统,如金融(利息计算、还款计划、风控)。CRUD 系统强上 DDD 是负收益。

12 案例:虚拟钱包

业务:充值、提现、支付、转账、查询余额 / 流水;进阶有冻结、透支。

按"账户归属"切两个子系统:

  • 虚拟钱包:应用内账户的余额变动。
  • 三方支付:与银行 / 三方对账。

两者间只承诺最终一致。

image

同一段逻辑:贫血 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 / 框架),让它可被复用、可被独立测试。

哪些层照旧贫血

是否充血原因
ControllerVO 只是 DTO,业务逻辑薄
Service编排 + 跨聚合,业务下沉到 Domain
Domain充血核心不变量、状态机、领域规则在此
RepositoryEntity 生命周期短,纯读写

遗留问题:Entity ↔ Domain 的转换放在哪?工厂方法 / Assembler 类 / Repository 内部三选一,没有标准答案,团队约定即可。

13 案例:接口鉴权的 OOA 迭代

OOA → OOD → OOP 是面向对象开发的三段:分析需求 → 设计类与协作 → 编码。本节只走 OOA,让需求从模糊到可落地。

需求迭代三版

版本方案暴露的问题
v1URL + AppID + 密码 拼接后加密 → token重放攻击:token 可被原样重发
v2引入时间戳作为随机变量参与 token 生成解决重放,但需服务端校验时间戳
v3服务端校验时间戳是否落在窗口(如 1 分钟)工程上可用

v2 示意:

image

v3 示意:

image

OOA 的产出不是代码,是一份"哪些做、哪些不做、哪些以后再做"的清单。

14 案例:把 7 个步骤归到 3 个类

鉴权流程共 7 步:

  1. 拼接 URL + AppID + 密码 + 时间戳 → 字符串
  2. 字符串加密生成 token
  3. 把 token + AppID + 时间戳拼回 URL
  4. 解析 URL,取出 token / AppID / 时间戳
  5. 从存储里取出 AppID 对应的密码
  6. 校验时间戳是否过期
  7. 校验两个 token 是否一致

按职责聚合到 3 个类:

负责的步骤关心什么
AuthToken1, 2, 6, 7token 的生成与验证
Url3, 4URL 的拼接与解析
CredentialStorage5AppID / 密码的存取

一次"以名词收敛行为"的演练 —— 跟 §07 坑 2 的判据一致:能找到名词的就升级成对象。

– EOF –