读完这篇,你能做什么
能设计带验证的 record 与稳定枚举映射,并识别可变组件的影响。
学习理由、准备要求与知识点
为什么学它
有限状态和值对象可以减少含义不明的数字,也能减少数据被随意修改的机会,让数据模型更清楚。
先读这些内容
知识点清单
- 枚举独立类型
- 稳定状态编码
- ordinal 持久化风险
- 状态转换合法性
- record 组件访问器
- 紧凑构造器验证
- record 相等与哈希
- 浅不可变与框架适配
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
订单状态只有 NEW、PAID、SHIPPED、CANCELLED 这些选项时,用枚举比随意填写整数或字符串更清楚。在正常的类型安全代码中,拼错的状态不能直接传进去。枚举也可以带字段和方法,但它只限制有哪些可选值,不会自动判断“未付款能否直接发货”;状态之间允许怎样变化,还要另写规则。
需要把几个相关数据放在一起时,可以考虑 record。例如 record Point(int x,int y) 会提供读取 x、y 的方法、构造器,以及按组件生成的 equals、hashCode 和 toString。Java17 已正式支持 record,不需要预览参数。记录类不能被继承,但能实现接口,也能声明静态成员和实例方法,适合表达以数据值为主的对象。
记录类的组件字段不可重新赋值,却不保证组件指向的对象也不可变。record Order(List<String> items) 若直接保存外部可变列表,列表内容仍可能变化。可以在紧凑构造器中校验并创建 List.copyOf 快照,但这仍是浅复制,元素如果可变还需另行设计。选 record 的依据是值建模,而不是看到任何 DTO 都机械替换。
文中这几个词,先认一认
- 枚举常量
- 枚举中提前列好的具名对象,例如订单状态 PAID,调用方从这些选项里选择。
- 规范构造器
- 参数与 record 的全部组件一一对应,用来创建记录对象的构造器。
- 紧凑构造器
- record 的一种简写构造器,不重复写参数列表,适合在创建时统一校验和整理输入。
- 状态迁移
- 收到某个事件后,状态能否从当前值变成下一值,例如付款后由 NEW 变成 PAID。
02看看它怎么工作
枚举有 name 与 ordinal 两种常见信息。name 是声明的常量名字,ordinal 是声明位置,从零开始;插入或重排常量会改变序号,因此不要把 ordinal 当作长期数据库或接口编码。对外协议需要稳定编码时显式定义 code 字段,并为新增、未知和历史值制定兼容策略。
record 自动生成的方法遵循组件语义。两个组件内容相等的记录可以作为值键,但数组组件仍按数组自身的相等规则,集合组件也可能因为外部修改影响哈希。紧凑构造器适合在对象公开前完成校验,如拒绝负金额和空标识;不能把后续业务状态变化藏在看起来不可变的数据载体里。
状态机把枚举值与合法迁移连接起来。输入事件先与当前状态组合,只有存在合法转移时才更新状态;没有转移就停止并报告失败位置。图中的教学订单只允许未付款取消,付款后不再直接取消,退款流程属于另一套业务规则。把这一边界写出来,才能避免读者把示例当作所有订单系统的通用流程。
正在加载流程图…
查看流程图源码(Mermaid)
stateDiagram-v2
state "未付款 NEW" as NEW
state "已付款 PAID" as PAID
state "已发货 SHIPPED" as SHIPPED
state "已取消 CANCELLED" as CANCELLED
[*] --> NEW
NEW --> PAID: PAY
NEW --> CANCELLED: CANCEL
PAID --> SHIPPED: SHIP
SHIPPED --> [*]
CANCELLED --> [*]按顺序看,每一步做了什么
- 列出有限状态和外部稳定编码。
- 定义每个事件允许的起止状态。
- 用 record 表达不可变结果快照。
- 在构造边界验证组件并决定复制深度。
- 测试未知值、终态和重复事件。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Enum.java · java.lang.Enum#valueOf(Class,String)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
public final class SourceModel {
private SourceModel() {}
public static <E extends Enum<E>> E lookup(Class<E> type, String name) {
if (type == null || name == null) throw new NullPointerException();
// 教学模型线性扫描常量,真实 Enum.valueOf 使用名字目录。
for (E value : type.getEnumConstants())
if (value.name().equals(name)) return value;
throw new IllegalArgumentException("unknown enum name");
}
}真实 Enum.valueOf 从枚举类的常量名字目录中查找精确名称,找不到时区分 null 名字与未知名字并抛对应异常。模型用 getEnumConstants 线性扫描,保留按 name 精确匹配的核心契约,省略缓存和内部目录接口,不把教学扫描的复杂度冒称真实实现。
toString 可以被枚举重写为友好显示文本,但 valueOf 使用的仍是声明名字。因此把中文显示标签直接传给 valueOf 不会自动匹配。大小写与空白也不自动规范化,输入层若希望接受其它写法,应显式映射并说明冲突策略,而不是依赖显示文本不变。
ordinal 是枚举常量的位置,name 是声明名字,valueOf 按名字查找,并不先转成位置。若把位置存进数据库,之后调整代码顺序就可能让历史数字对应错状态。可以另外定义稳定 code,把外部协议与内部名字分开,同时检查 code 不重复,并明确未知 code 怎么处理。
泛型边界 E extends Enum<E> 使返回值保持具体枚举类型,调用者不必接收普通 Object 后再猜类型。这里没有构造新的枚举实例,而是返回该枚举已定义的常量。枚举实例本身可以声明可变字段,因此也不应向全局共享常量随意写请求专属状态。
代码先调用谁,再调用谁
Enum.valueOf接收枚举 Class 与名称- 读取枚举常量名字目录
- 按精确 name 返回已有常量
- 空或未知名称走明确异常路径
04动手写一个例子
审计订单事件序列并返回不可变快照
从 NEW 开始处理 PAY、SHIP、CANCEL。只允许 NEW→PAID、PAID→SHIPPED、NEW→CANCELLED;首个非法事件停止。返回最终合法状态、已接受事件数及错误位置,错误位置为零表示无错误。
- 输入 1
[PAY,SHIP]- 输出 1
SHIPPED,accepted=2,failedAt=0
- 输入 2
[SHIP]- 输出 2
NEW,accepted=0,failedAt=1
- 输入 3
[]- 输出 3
NEW,accepted=0,failedAt=0
用 enum 表示状态与事件,使非法拼写在进入算法前就被解析层拒绝。迁移函数只负责一个当前状态与一个事件的结果,不依赖全局字段。返回 null 表示没有合法转移,这是私有辅助方法的明确约定;公开返回则使用 record,把多个结果字段作为一个快照交付。
主循环保持当前状态和已接受数量。先计算候选下一状态,确认合法后才更新当前状态与计数;失败时返回最后合法状态,而不是把错误事件产生的猜测状态写进去。错误位置使用一开始的序号,方便与输入记录对应;内部下标仍从零开始,两者转换必须一致。
重复 PAY 在本题属于非法迁移,不自动视为幂等成功。这个选择便于观察严格状态机,但生产消息系统可能需要按事件标识去重,不能直接把教学规则用于消息重试。测试同时覆盖正常终态、未付款取消、跳步和重复事件,验证状态更新前置检查的顺序。
为什么这样写是对的每轮开始时 state 是已接受前缀执行后的合法状态,accepted 恰好等于该前缀长度。
每个事件只做固定状态与事件分支,遇到首个错误立即停止,快照字段数量固定。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.List;
public class Main {
enum State { NEW, PAID, SHIPPED, CANCELLED }
enum Event { PAY, SHIP, CANCEL }
record Snapshot(State state, int accepted, int failedAt) {}
static State next(State state, Event event) {
return switch (state) {
case NEW -> switch (event) {
case PAY -> State.PAID;
case CANCEL -> State.CANCELLED;
default -> null;
};
case PAID -> event == Event.SHIP ? State.SHIPPED : null;
default -> null;
};
}
static Snapshot audit(List<Event> events) {
if (events == null) throw new IllegalArgumentException("events");
State state = State.NEW;
int accepted = 0;
for (Event event : events) {
if (event == null) throw new IllegalArgumentException("event");
State candidate = next(state,event);
// 先验证转移,失败快照保留最后合法状态。
if (candidate == null) return new Snapshot(state,accepted,accepted+1);
state = candidate;
accepted++;
}
return new Snapshot(state,accepted,0); // 零错误位置明确表示全部接受。
}
static void check(Snapshot wanted, Event... events) {
if (!wanted.equals(audit(List.of(events)))) throw new AssertionError(wanted);
}
public static void main(String[] args) {
// 正常、空、取消、跳步和重复事件覆盖不同转移边界。
check(new Snapshot(State.SHIPPED,2,0),Event.PAY,Event.SHIP);
check(new Snapshot(State.NEW,0,0));
check(new Snapshot(State.CANCELLED,1,0),Event.CANCEL);
check(new Snapshot(State.NEW,0,1),Event.SHIP);
check(new Snapshot(State.PAID,1,2),Event.PAY,Event.PAY);
check(new Snapshot(State.CANCELLED,1,2),Event.CANCEL,Event.PAY);
System.out.println("PASS 6");
}
}这些特殊情况也要试一试
- 空事件序列保留初始状态。
- 终态后任何新事件都非法。
- 重复事件不会自动去重。
- 失败快照不包含非法转移。
进阶选读 · 可选深入:事件幂等与版本化状态机
场景相同事件可能重投,历史订单又使用旧版本流程。
做法保存事件标识与处理结果,以状态版本或条件更新防止并发覆盖;把迁移规则版本和历史回放策略一起设计。
复杂度单次迁移计算可为常数,持久化去重与历史存储随事件规模增长。
好处与代价严格拒绝重复与幂等重放语义不同,迁移升级也需要兼容历史状态。
05项目里怎么用
对外传输枚举应避免只依赖 Java 常量名字。新增状态后旧客户端可能无法反序列化,重命名也可能改变协议;可以使用稳定代码和明确未知值处理。未知不是必然等于默认状态,盲目降级成 NEW 可能触发错误业务动作,接口应保留未识别信息或明确拒绝。
record 适合值、结果和配置快照,但某些框架实体需要代理、无参构造器或可变生命周期,具体支持要按框架版本核对。不要把所有持久化对象一键改成 record。即使记录类可用于序列化,也要审查自动 toString 是否包含敏感组件,避免日志顺手暴露凭证。
状态变化涉及数据库和外部动作时,不能只在内存里先判断再赋值。两个请求可能同时看到同一旧状态,都去付款或发货,需要数据库条件更新、事务和重复请求处理来控制。纯状态变化规则可以单独测试,实际执行仍要保证读取、写入和相关动作相互对应。
容易踩的坑
- 把 ordinal 当作长期持久化编码。
- 把
toString显示标签交给valueOf。 - 认为 record 自动深度不可变。
- 把严格状态机当成已解决消息幂等与并发事务。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 枚举状态 | 范围有限且编译器可辅助检查 | 新增状态需要兼容策略 | 稳定有限业务状态 |
| record 值快照 | 值语义清晰且样板少 | 浅不可变且不适合所有框架生命周期 | 结果、配置与不可变键 |
| 普通类 | 可控制继承和可变行为 | 需要维护更多样板与不变量 | 有身份和复杂生命周期的对象 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么不能用 ordinal 保存状态?
展开参考答案与推导
它取决于声明顺序,插入或重排常量会改变历史数值含义。应使用明确稳定代码,并检查唯一性和未知值策略,避免代码整理意外变成数据迁移。
02record 的列表组件为什么还会变化?
展开参考答案与推导
组件字段保存的是引用且不可重新赋值,但原列表对象可能仍可变。构造时可做容器快照,元素可变性再另行处理,不能仅凭 record 关键字推断整个对象图被冻结。
03枚举是否自动提供合法迁移?
展开参考答案与推导
没有。它只限定可用状态值,NEW 和 SHIPPED 都合法不代表可以直接从前者到后者。迁移需要按当前状态与事件组合定义,并由执行层保证原子更新。
换个情况,还会处理吗
01显示标签改为中文后 valueOf 失败,为什么?
展开参考答案与推导
valueOf 查找声明 name,不读取重写的 toString 显示文本。为外部编码与显示标签分别建立映射,避免界面翻译或文案修改影响协议解析。
02重复付款事件被审计算法拒绝,生产能直接报错吗?
展开参考答案与推导
要先看消息重投契约。真实系统可能需要按事件 ID 返回已处理结果,严格状态机只是规则核心;幂等识别应在执行边界与持久化结果共同实现。
03record 自动 toString 很方便,能直接记录所有请求吗?
展开参考答案与推导
先检查组件是否包含密码、令牌或个人敏感数据。自动生成方法不会替业务隐藏这些字段,应使用经过筛选的日志表示,并按用途控制数据范围。
写代码试一试
01给快照增加失败事件信息
- 输入 / 条件
[PAY,CANCEL]- 输出 / 预期
PAID 与 CANCEL
提示 使用可空或 Optional 的明确约定。
展开参考答案与推导
扩展 Snapshot,失败时保存当前非法事件,成功时使用约定的缺失表示。不要把上一个成功事件写进去;测试错误位置与事件必须对应同一输入项。
02增加紧凑构造器校验
- 输入 / 条件
accepted=-1- 输出 / 预期
拒绝
提示 快照字段也有不变量。
展开参考答案与推导
在 Snapshot 紧凑构造器中拒绝负计数和负错误位置,并要求 state 非空。进一步约束错误位置与 accepted 的关系时,要覆盖成功与失败两种合法形式。
03验证终态后的首个错误
- 输入 / 条件
[PAY,SHIP,CANCEL,PAY]- 输出 / 预期
SHIPPED,accepted=2,failedAt=3
提示 首错即返回。
展开参考答案与推导
加入对应快照测试,确认第四项不再执行。若需要收集全部错误,应另建恢复规则,不能在状态未知的情况下继续猜测后续迁移。
读完之后,独立完成
课后作业:自己做一遍
本篇共 9 道作业,先完成 7 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能设计带验证的 record 与稳定枚举映射,并识别可变组件的影响。
本篇重点加练
重点加练稳定状态编码与构造校验,保证值模型在持久化和版本变化时仍有明确边界。
- 稳定状态编码
- 紧凑构造器验证
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
订单状态准备持久化为外部编码,给出 CREATED、PAID、CANCELLED 三个枚举常量;设计稳定编码与未知编码政策,并评审按声明位置保存的演进风险。
- 提交内容
- 编码合同和新增/重排状态验收案例。
- 怎样算完成
- 外部格式独立于展示顺序
- 未知值处理明确
练到的知识点枚举独立类型 · 稳定状态编码 · ordinal 持久化风险
- 作业 02编程实践
实现订单状态转换检查器,限定支付、取消和重复请求的规则;先画状态图,再用输入事件序列验证,不让调用方任意赋值状态。
- 提交内容
- Java 程序、自写合法/非法/重复事件测试及运行证据。
- 怎样算完成
- 状态转换政策完整
- 失败后状态有明确合同
练到的知识点状态转换合法性
综合应用
- 作业 03代码推演
定义 record
Lesson(String title,int minutes),要求标题非空白且时长在 1 到 180。测试紧凑构造器中的校验与参数规范化,并核对访问器返回的实际组件。- 提交内容
- record 代码、自写构造边界测试和运行记录。
- 怎样算完成
- 构造前置条件与最终组件对应
- 不绕过验证暴露非法状态
练到的知识点record 组件访问器 · 紧凑构造器验证
- 作业 04排错实验
分别创建含
String、数组和List组件的 record 样例,检查相等、哈希及外部修改的影响;每组至少有同值不同实例和共享组件两种输入。- 提交内容
- 最小 Java 实验、自写比较测试与观察表。
- 怎样算完成
- 组件语义逐类核验
- 浅不可变边界有证据
练到的知识点record 相等与哈希 · 浅不可变与框架适配
- 作业 05面试场景
旧客户端收到新增状态,序列化框架需要无参构造器,团队希望所有 DTO 全换为 record。评审状态兼容性与框架适配各自需通过的测试。
- 提交内容
- 兼容性矩阵与迁移条件。
- 怎样算完成
- 语言特性和框架能力分开
- 旧数据旧客户端有验收
练到的知识点稳定状态编码 · 状态转换合法性 · 浅不可变与框架适配
- 作业 06编程实践
实现 record Enrollment,包含稳定课程状态编码与课程编号;紧凑构造器按合同验证输入,编码解析不接受未知值但需能定位错误。
- 提交内容
- Java 实现、自写未知编码/空编号/边界测试与运行证据。
- 怎样算完成
- 外部编码政策显式
- 构造完成后的状态均符合约束
练到的知识点稳定状态编码 · 紧凑构造器验证
- 作业 07设计评审
历史数据包含已停用状态与缺失字段,新模型要求更严格。规定导入前怎样校验、满足什么条件才能创建正式对象,并说明拒绝记录如何保存诊断证据。
- 提交内容
- 历史输入矩阵和对象创建流程。
- 怎样算完成
- 旧数据与新不变量关系明确
- 非法记录不会被含糊修补
练到的知识点稳定状态编码 · 紧凑构造器验证
进阶挑战 · 掌握前置后选做
- 作业 08设计评审
完成泛型后:设计类型化任务结果模型,表达成功、业务拒绝和执行失败;评审枚举附带字段与独立值类型两种表示的非法组合风险。
- 提交内容
- 类型设计、构造合同和调用方使用示例。
- 怎样算完成
- 无效状态组合得到处理
- 相等规则符合领域语义
练到的知识点枚举独立类型 · 状态转换合法性 · 紧凑构造器验证 · record 相等与哈希
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 09思维拓展 · 条件变化
状态编码由服务独享变成供外部长期保存的协议,未来还会增加状态。比较严格拒绝和保留未知编码两种策略,对构造不变量及调用方能力分别评估。
- 提交内容
- 版本演进样本与取舍表。
- 怎样算完成
- 兼容目标明确
- 方案包含未知状态验收
练到的知识点稳定状态编码 · 紧凑构造器验证
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读规范英文
Java 17 语言规范:类、方法与记录类 (新标签页)↗
按需阅读构造器、方法重载、static
/final、枚举和 record 的定义与约束。 - 实践参考文档英文
Java 17 API:Object (新标签页)↗
逐条阅读 equals 与
hashCode的一致性约定,设计稳定业务键。 - 深入参考文档英文
Java 17 API:Collections (新标签页)↗
阅读不可修改视图、排序、二分查找与同步包装的前提条件。
- 深入参考文档英文
OpenJDK 17u java.lang.Enum (新标签页)↗
定位
Enum.valueOf的名字目录查找、null与未知名称异常路径。 - 深入参考文档英文
Java17 记录类指南 (新标签页)↗
阅读规范与紧凑构造器、浅不可变和自动方法。
- 深入参考文档英文
Java17 Enum API (新标签页)↗
比较 name、ordinal、
toString与valueOf的职责。