Java 21 的 record、密封类与模式匹配:把业务状态写进类型系统
订单操作可能是支付、退款或取消。常见设计是一个 type 字符串加上金额、原支付单号、取消原因等可空字段,再靠注释约定“退款时必须有原支付单号”。这种模型允许大量无效状态,每增加一种操作,都要在各处搜索字符串分支。Java 21 可以用 record 表达值,用 sealed 限定合法类型,再让编译器检查 switch 是否处理所有情况。
一、record 不是少写 getter 的工具
记录类适合承载有明确值语义的数据。下面的声明自动获得组件访问器、构造器,以及基于所有组件的 equals、hashCode 和 toString:
recordMoney(Stringcurrency,longcents){Money{if(currency==null||currency.isBlank()||cents<0){thrownewIllegalArgumentException("invalid money");}}}访问金额使用money.cents(),而不是getCents()。两个new Money("CNY", 100)值相等,应使用 equals 比较。紧凑构造器先做验证,再由编译器完成字段赋值,验证失败就不会产生对象。金额以分为单位只是为了避免示例中的浮点误差;生产系统仍须定义币种的小数位、舍入与汇率规则。
记录类是浅不可变。如果组件是列表,其引用不会被重新赋值,但外部原列表可能继续变化。可在构造时复制:
recordReceipt(Stringid,java.util.List<String>itemIds){Receipt{itemIds=java.util.List.copyOf(itemIds);}}List.copyOf返回不可修改列表,也拒绝 null 元素;如果列表元素本身可变,仍需进一步处理。record 不适合作为所有 JPA 实体的机械替代:实体具有身份和变化的生命周期,值对象与实体的边界应由业务语义决定。
二、sealed 描述封闭的业务集合
假设指令目前只有三类:
sealedinterfaceOrderCommandpermitsPay,Refund,Cancel{}recordPay(StringorderId,Moneyamount)implementsOrderCommand{}recordRefund(StringorderId,StringpaymentId,Moneyamount)implementsOrderCommand{}recordCancel(StringorderId,Stringreason)implementsOrderCommand{}三个 record 都隐式为 final,因此符合密封接口对子类型的要求。普通类若直接继承密封类型,必须明确声明 final、sealed 或 non-sealed;最后一种会重新开放该分支。示例中声明都在同一源文件;拆成多个文件时还要遵守同包或同模块的限制。
并非所有接口都该密封。第三方支付适配器需要外部扩展,应该保留开放实现能力;由服务端掌控、每新增一种都必须重新检查处理逻辑的指令集合,适合封闭。选择标准是“新增一种业务情况是否要求调用方重新审视”,而不是“语法是否新”。
三、switch 模式匹配让遗漏变成编译错误
staticStringdescribe(OrderCommandcommand){returnswitch(command){casePaypay->"支付 "+pay.amount().cents();caseRefundrefund->"退款 "+refund.paymentId();caseCancelcancel->"取消 "+cancel.reason();};}编译器知道 OrderCommand 的允许子类型,上面的 switch 表达式已穷尽,所以不需要 default。如果新增 Chargeback,编译时就会提醒处理新分支。随手添加default -> "未知"会掩盖这种提醒;对于必须处理的业务事件,应慎用兜底分支。
模式顺序也有语义。宽泛的模式若放在具体模式前面,后者可能无法到达。条件分支可用 when:
staticStringlevel(Objectvalue){returnswitch(value){caseIntegern when n<0->"负数";caseIntegern->"非负整数";casenull->"空值";default->"其他";};}这里明确处理 null;不要想当然认为 null 会自然进入 default。对于跨服务数据,除了编译期穷尽检查,还要考虑新旧版本同时运行时的协议兼容与未知事件处置。Java 类型的封闭集合不能代替部署策略。
四、记录模式简化结构化拆解
当分支还需要读取嵌套值,可以直接拆解:
staticStringformat(Objectevent){returnswitch(event){casePay(Stringid,Money(Stringcurrency,longcents))->id+": "+currency+" "+cents;caseRefundr->"refund "+r.paymentId();caseCancelc->"cancel "+c.orderId();casenull->"missing";default->"unsupported";};}记录模式表达“值具有何种结构”,避免多次访问同一个嵌套组件。但一个分支若要查库、修改状态和发消息,不应把几十行副作用塞入 switch;switch 只负责路由,复杂动作委托给有清晰名称的方法。构造器负责保证已构造对象的不变量,反序列化与数据库旧数据则仍需在边界层校验,不能把模式匹配当成输入验证器。
五、运行一个完整示例
保存为OrderDemo.java,执行javac OrderDemo.java和java OrderDemo:
publicclassOrderDemo{recordMoney(Stringcurrency,longcents){Money{if(currency==null||currency.isBlank()||cents<0){thrownewIllegalArgumentException("invalid money");}}}sealedinterfaceCommandpermitsPay,Refund,Cancel{}recordPay(Stringid,Moneyamount)implementsCommand{}recordRefund(Stringid,StringpaymentId)implementsCommand{}recordCancel(Stringid,Stringreason)implementsCommand{}staticStringroute(Commandcommand){returnswitch(command){casePayp->"pay "+p.id()+" "+p.amount().cents();caseRefundr->"refund "+r.paymentId();caseCancelc->"cancel "+c.reason();};}publicstaticvoidmain(String[]args){Commandcmd=newPay("O-100",newMoney("CNY",1299));System.out.println(route(cmd));}}试着增加一个新指令并更新 permits,再重新编译;route 会提示你补全处理逻辑。这种提醒很有价值,但编译器不会判断退款金额是否超过原支付金额,后者仍需要领域校验、事务和测试。
六、怎样迁移现有项目
不必一次修改数据库、接口和内部模型。先在应用边界把旧 DTO 转换为受约束的指令,明确定义非法输入返回的错误;内部服务使用密封接口。对外 JSON 可以保持稳定的 type 字段和版本号,Java 类名不应直接充当长期协议。发布新指令时先部署能识别新类型的消费者,再开放生产者,避免滚动升级期间出现无法解析的消息。
测试至少覆盖三层:构造器拒绝空值与非法金额;各指令分支的成功、失败与幂等;旧数据和新代码之间的序列化兼容。对于金额、账号和身份等敏感信息,记录类自动生成的 toString 可能把字段写进日志,应考虑脱敏或不要直接打印对象。浅不可变、穷尽分支和自动生成方法都是真实的语义,不仅是语法糖。
七、从可空字段模型迁移的具体步骤
假设旧对象有type、amount、paymentId、reason四个字段。第一步不是立即删除旧对象,而是在边界层写一个转换函数:支付必须有正金额,退款必须有原支付单号和金额,取消必须有原因。对无关字段也要决定策略:是拒绝多余字段,还是暂时忽略以兼容旧客户端。两种行为都应通过接口文档和测试固定下来。
第二步让业务服务只接收OrderCommand,不再读取type字符串。业务层拿到指令后,就不必每次担心“取消类型却携带退款金额”这种组合。第三步清理旧的分支和转换代码,在服务日志中统计未知类型与非法组合,确认线上旧客户端已经迁移。这样可在不改变数据库与公网协议的前提下逐步收敛内部模型。
构造器验证不必把全部领域规则写进去。例如订单是否已支付,需要查询实时数据,不适合由纯值对象的构造器完成;构造器适合验证本地、稳定、无 I/O 的约束。更复杂的指令执行要通过服务和事务完成,并给出明确的错误类别,避免把所有业务失败都包装成IllegalArgumentException。
八、值语义的细节和陷阱
record 的 equals 比较全部组件。若Receipt中加入一个“生成时间”组件,即使两个收据的业务编号一样,也不再相等。因此不要把数据库实体的“ID 相同就是同一实体”语义直接套用到 record。另一个常见误区是把密码、访问令牌放进记录类后直接日志打印:自动生成的 toString 会显示组件值。可避免日志打印整个对象,或在类型中明确覆写 toString 做脱敏,但不要忘记序列化日志与异常链的其他出口。
如果组件是数组,默认生成的相等性依照数组的引用语义,不会自动进行深层内容比较。值对象若需要按内容比较字节数据,可使用不可变包装、显式复制与自己定义的相等策略。即使是列表,外层做了List.copyOf,列表中的可变对象仍可能被外界改动。所谓不可变并非一个修饰符就能保证,而是从输入、存储到输出的整体约束。
密封类也不负责“所有子类型都要长得相似”。如果三类指令的字段和行为完全不同,统一接口可能只是一种路由入口,不必强行给接口塞满无意义方法。对于真正共享的概念,可以在接口上定义少量稳定行为,例如orderId();子类型通过相同名称的组件访问器实现这个契约。要避免为了方便一次性处理而把互不相关的事件纳入同一个庞大继承树。
九、保持分支可读
模式匹配能省掉强转,但不应为了展示语法而把复杂判断堆进一个when。当条件涉及数据库状态或价格策略,先命名业务谓词,再通过短分支转给处理方法。对null、未知反序列化类型以及未来版本的事件,要明确入口层应拒绝、暂存还是转交;不要在每一个内部 switch 中重复隐藏式兜底。
审查新分支时,把编译报错当作待办清单,而不是机械地加入空实现。新增退款撤销指令可能影响权限、审计、幂等键、报表和财务对账,编译器只能找到直接以这个密封类型做 switch 的位置。跨系统影响仍需业务评审和回归测试。类型系统减小了遗漏的范围,但不会替你决定正确的业务动作。
模式匹配还有一个团队协作上的收益:代码评审可以直接核对每个指令与处理路径,而不必从多个可空字段推测隐藏状态。对于必须有失败语义的分支,返回一个有类型的处理结果通常比返回空字符串更稳。比如把成功、可重试失败和永久失败也建模成封闭类型,再要求调用方显式处理;但不要为每个简单返回值都造一层类型,只有跨模块流转且容易漏判的结果才值得这样做。
最后检查命名:Pay是“支付请求”还是“支付已完成事件”?指令与事件代表不同时间点,混在同一继承树会让权限和幂等规则变得混乱。清楚的名称、明确的输入校验、可编译检查的分支三者共同工作,类型设计才真正改善维护性。
总之,record 适合值语义,sealed 适合可枚举的业务变体,模式匹配适合清楚地处理分支。让编译器负责它能检查的结构约束,让业务校验和测试负责它无法判断的领域规则,才是这组特性在生产代码中的正确位置。
参考资料:Oracle Java 21 记录类、密封类、switch 模式匹配。