配套代码:GitHub 仓库与运行说明 · 24种模式源码目录。使用 JDK 25、Gradle Wrapper 9.6.1,包名前缀为 com.hanserwei.patterns

系列导航:Java 25 设计模式学习指南

上一篇:学习路线 · 下一篇:简单工厂

设计模式容易学成类图记忆题:看到接口就叫策略,看到包装就叫代理,看到静态方法就叫工厂。要避免这种情况,先建立一套检查对象设计的方法。本篇不新增一个模式,而是为后续24篇准备共同的语言和可运行工程。

对象先负责行为,再维护状态

一个对象的价值不只是把几个字段放在一起。Draft 管理自己的标签,Manuscript 管理合法生命周期,CommandHistory 管理撤销顺序。每个对象都应该知道自己需要维护的规则,并通过方法保证这些规则不被普通调用者绕过。

private 只挡住直接字段访问。如果 getter 返回内部 ArrayList,调用者仍可以任意清空它;如果提供 public setState,调用者仍可以把草稿直接改成任何状态。封装需要同时检查字段可见性、返回引用和可调用的方法。

final 也有精确边界:final List 禁止字段重新赋值,但并不禁止列表内容变化。本系列经常使用 List.copyOf 或 Map.copyOf 保护容器边界;这类复制是浅层复制,对元素对象的可变状态还要单独判断。

接口表达契约,不只是方法名称

以策略篇的 FeePolicy 为例,输入是非负字数,返回值单位是“分”,溢出必须显式失败。两个实现都叫 fee 并不足以证明可替换;若一个返回元、另一个返回分,或者一个允许负数、另一个把负数解释为退款,上下文就无法安全替换算法。

下面是配套仓库中该接口的完整内容,路径为 src/main/java/com/hanserwei/patterns/strategy/FeePolicy.java

package com.hanserwei.patterns.strategy;

/** 可替换的文章校对计费算法契约. */
public interface FeePolicy {
  /** 对非负字数计算以分为单位的非负费用;溢出时抛出异常. */
  long fee(int words);
}

接口契约至少需要回答:什么输入有效、返回值代表什么、哪些异常表示预期失败、是否产生副作用,以及调用者能否重复或并发调用。并非每个简单方法都要写成长篇说明,但关键语义不能靠读者猜测。

抽象类适合同时提供共享流程和受控扩展点,例如 Exporter 固定校验后调用工厂方法。接口适合描述跨实现的能力,不必为了复用两行代码建立父类。一个空接口如果没有实际契约意义,也不会自动改善设计。

组合与继承分别在哪里起作用

QuoteService 持有 FeePolicy,是“使用一个算法”的组合关系。MarkdownExporter 继承 ArticleExporter,是“遵守某个固定导出骨架”的扩展关系。选择时先看业务语义,不要只看哪种写法少几行代码。

以下是策略上下文的完整实现:

package com.hanserwei.patterns.strategy;

import java.util.Objects;

/** 通过注入策略完成报价的业务上下文. */
public final class QuoteService {
  /** 当前服务使用的计费策略. */
  private final FeePolicy policy;

  /** 在装配时选择算法. */
  public QuoteService(FeePolicy policy) {
    this.policy = Objects.requireNonNull(policy, "policy");
  }

  /** 委托策略计算费用,返回以分为单位的金额. */
  public long quote(int words) {
    return policy.fee(words);
  }
}

构造器注入让依赖在 new QuoteService(...) 的地方显式出现。服务内部既不查询全局注册表,也不检查策略具体类型。要测试另一个算法,只需装配另一个实现;这种依赖关系可以不借助任何注入框架完成。

继承意味着子类必须维持父类契约。父类允许的输入,子类不应无理由拒绝;父类承诺的结果,子类需要兑现。仅为了复用字段建立“看起来像父子”的关系,可能造成子类必须禁用父类方法的尴尬。

组合也有成本:多了一次委托、装配对象和生命周期管理。优先考虑组合,是在提醒我们分开独立变化的职责,不是禁止模板方法、状态层级等合理继承结构。

用 SOLID 检查变化,而不是背口号

原则 可操作的问题 本系列观察点
单一职责 SRP 这个类会因哪几类不同需求而修改? 适配器翻译接口,发送业务不复制旧 SDK 参数规则
开闭原则 OCP 新增这一维变化时,已有稳定代码能否保持不动? 策略增加算法,抽象工厂增加产品族
里氏替换 LSP 子类和实现是否遵守调用者依赖的契约? 所有计费策略拒绝负字数,迭代器耗尽抛规定异常
接口隔离 ISP 调用者是否被迫依赖用不到的方法? 组合的叶子不必实现无意义的 addChild
依赖倒置 DIP 高层流程依赖业务能力还是某个具体供应商? 通知通过 NotificationChannel,代理通过 ArticleSource

开闭原则始终相对于某个变化方向。抽象工厂容易加主题族,却不容易加全新的产品种类;访问者容易加操作,却要为新元素修改访问者接口。能指出受益方向和受损方向,比宣布“完全符合开闭原则”更有价值。

工程结构与运行入口

文章目录和代码仓库分开。以下树形结构相对于配套代码仓库根目录;strategy 只是24个独立包中的一个:

design-patterns-java25/
├── gradlew / gradlew.bat
├── gradle/wrapper/
├── build.gradle.kts
├── settings.gradle.kts
├── config/checkstyle/
├── src/main/java/com/hanserwei/patterns/strategy/
│   ├── FeePolicy.java
│   ├── FlatFee.java
│   ├── PerWordFee.java
│   ├── QuoteService.java
│   └── Demo.java
└── src/test/java/com/hanserwei/patterns/strategy/PatternTest.java

先安装完整 JDK 25,并让 JAVA_HOME 指向它。进入代码仓库后执行:

java -version
./gradlew --version
./gradlew -q javaToolchains
./gradlew clean check
./gradlew runStrategy

运行策略示例的业务输出为1000与1200,单位都是分。每篇给出自己的 run 任务名,全部任务可以通过 ./gradlew tasks --group examples 查看。任务名与包名直接对应,例如工厂方法是 runFactorymethod,没有隐藏的跨章节启动依赖。

Java Toolchain 指定25,options.release 也为25。前者选择编译和测试工具,后者约束编译目标及可用平台 API。所有示例入口都通过25号工具链运行;无需开启 preview,也不使用简化源文件或模块通配导入。

规范如何落实到每次修改

Java 源码遵循 Google 风格:UTF-8、两个空格缩进、正常包声明、每个顶级类型一个文件、显式导入与有意义的名称。详细规则以 Google Java Style Guide 为准,不通过单个“格式化成功”推断全部设计质量。

本仓库把三类检查同时接入 check:google-java-format 验证格式,Checkstyle 的固定版本 Google 配置检查命名及其他约束,额外 documentationCheck 检查所有可见性下的类、字段、构造器和方法注释。@Override、getter、测试方法和私有辅助方法都没有注释豁免。

./gradlew formatJava
./gradlew check
./gradlew javadoc

注释写意图和契约。例如“保存已校验参数的快照”比“构造一个对象”更有用;“返回不可变标签快照”比“获取标签”更能防止误用。简单方法可以使用单行 Javadoc,复杂输入、结果和异常可补充 @param、@return、@throws。现有中文 Javadoc 使用英文句点结尾,以匹配固定检查器的摘要句末规则。

工具版本都固定在构建脚本中,Wrapper 配置还固定了发行包校验和。首次下载需要联网,后续修改不依赖系统安装的任意版本 Gradle。不要手动删掉检查来消除报警,应先判断代码或注释是否需要调整。

测试应证明职责划分确实成立

测试不止是运行 Demo 后看到输出。装饰器要检查顺序,原型要检查修改隔离,迭代器要检查耗尽协议,状态要检查非法动作没有改变当前状态。每种模式至少有两项行为测试,覆盖正向行为与有意义的边界。

./gradlew test --tests 'com.hanserwei.patterns.prototype.PatternTest'
./gradlew test --tests 'com.hanserwei.patterns.state.PatternTest'

JUnit 测试通过显式断言判断结果,不依赖 JVM 的 assert 开关。演示类用于跟踪对象装配,测试类用于固定契约,两者承担不同任务。

学习时可以故意把原型复制改成共享列表、把缓存失效去掉,或让状态错误分支继续迁移,再观察测试失败。修复后运行 check,确保行为、格式和注释都回到要求之内。

进入模式章节之前的小练习

打开 QuoteService,指出它知道和不知道的信息;再打开 PerWordFee,解释为什么校验单价在构造器里、校验字数在方法里。然后把 Demo 装配成 FlatFee,确认 QuoteService 没有变化。

完成这几步后,再进入简单工厂,学习如何为具体对象的创建决策安排一个明确位置。