构造器里写了校验,对象就一定合法吗
数量类型在构造器里检查了「1 到 99」,单元测试覆盖了 0、1、99、100 四个边界。上线后数据库里还是出现了数量为 0 的订单行。追查下来,这些对象没有一个是通过构造器创建的:有的来自 JSON 请求,有的来自缓存里反序列化的旧数据,有的是映射工具从数据库里读出来的。
把不变量放进构造器和业务方法,是领域建模的基本做法:值对象在创建时保证合法,实体的状态只能通过表达业务意图的方法改变。这个做法的前提是,所有的创建和修改都经过这些方法。实际系统里,对象还有很多别的来路:框架反序列化、反射赋值、拿到内部集合直接改、继承来的公共 setter。每一条来路都是一个入口,不变量要守住每一个。
本文把三条不变量放到七个入口前,看哪些入口绕过了它们。实验见文末。
一、创建入口:对象不一定是 new 出来的
不变量:数量在 1 到 99 之间。
public class Quantity {
private int value;
public Quantity() { } // 框架常要求的无参构造器
public Quantity(int value) {
if (value < 1 || value > 99) throw new IllegalArgumentException("数量必须在 1 到 99 之间:" + value);
this.value = value;
}
public int getValue() { return value; }
}new Quantity(0):被拒绝(IllegalArgumentException:数量必须在 1 到 99 之间:0)
JSON {"value":0} 反序列化为带无参构造器的类:Quantity(0)这个类没有 setter,看起来很安全。但 Jackson 发现有一个无参构造器、有一个 value 的 getter,就用无参构造器创建对象,再通过私有字段把 0 写了进去,完全没有经过校验。这是它的默认行为:有 getter 而没有 setter 时,推断出可以通过字段写入。
为了满足框架而加的无参构造器,就是一个绕过校验的入口。修法是让唯一的创建路径经过校验:
同样的 JSON 反序列化为 record(经过紧凑构造器):被拒绝(ValueInstantiationException:数量必须在 1 到 99 之间:0)
改成只有一个 @JsonCreator 构造器的类,同样的 JSON:被拒绝(ValueInstantiationException:数量必须在 1 到 99 之间:0)record 只能通过它的规范构造器创建,紧凑构造器里的校验对所有入口都有效;普通类去掉无参构造器、用 @JsonCreator 标注唯一的构造器,效果相同。
Java 序列化。 缓存、会话、远程调用里还有用 Java 原生序列化的地方:
Java 序列化的字节被改成 0 之后反序列化:Quantity(0)
加了 readObject 校验的类,被改过的序列化字节:被拒绝(IllegalArgumentException:数量必须在 1 到 99 之间:0)Java 反序列化不调用类自己的构造器,直接根据字节流还原字段。字节流来自缓存里的旧版本对象、被篡改的数据,或者只是一个有缺陷的旧版本程序写入的,都可以得到一个非法对象。需要用 Java 序列化的类,在 readObject 里重新校验。
反射写字段。
反射直接写私有字段(ORM 与映射工具常用的方式):Quantity(0)ORM 从数据库重建对象、映射工具在对象之间复制属性,很多都是这样做的。这条入口有它的合理性:从数据库重建一个已经存在的对象,不应该再走一遍「创建」的业务规则(比如「新订单必须有库存」)。但结构上的约束(数量在合法范围内)不应该被跳过,否则数据库里的一条脏数据会变成内存里的一个非法对象,继续参与计算。重建与创建的区别,见持久化与重建。
二、修改入口:交出去的引用也是入口
不变量:一张购物车最多 3 行,每行数量至少为 1。
购物车已有 3 行,再调用 add:被拒绝(IllegalStateException:最多 3 行)
从 lines() 拿到内部列表直接 add:现在有 4 行add 方法检查了行数,但 lines() 直接返回了内部的列表,调用方绕过 add 往里加了一行。聚合根是不变量的守护者,前提是对内部状态的所有修改都经过它;把内部集合交出去,就等于交出了修改能力。
改成返回 unmodifiableList 之后 add:被拒绝(UnsupportedOperationException:null)
但列表里的元素本身可变,setQty(0):购物车里现在是 [x0]只读视图挡住了增删,挡不住元素本身的修改。列表里的 LineItem 有公共的 setQty,拿到它就能把数量改成 0。要关上这个入口,返回的元素也要不可变(值对象、record),或者对外只返回只读的投影;需要修改某一行,通过聚合根的方法 changeQuantity(lineId, qty)。视图与快照的区别,见换一种写法,改了哪些契约。
继承来的公共 setter。 不变量:订单只能按「新建 → 已支付 → 已发货」转换。
新订单直接 ship():被拒绝(IllegalStateException:未支付不能发货)
调用父类的公共 setStatus("SHIPPED"):状态变成 SHIPPED,没有经过支付Order 的 pay 和 ship 都检查了前置状态,但它继承的 BaseEntity 有一个公共的 setStatus,本意是给框架和通用代码用的。任何人都能通过它跳过整个状态机。公共基类里放审计字段、乐观锁版本号这类技术字段是常见做法,但业务状态的写入方法不应该放在那里。用组合代替继承来复用技术字段,或者把基类的 setter 限制为包内可见。状态转换的系统化检查,见状态机与工作流。
三、身份比较也是一个入口
用 == 比较 Long 类型的 id:127 == 127 为 true,128 == 128 为 false;equals 都为 true实体按身份相等。Long 类型的 id 用 == 比较时,比的是对象引用;-128 到 127 之间的值有缓存,所以测试里用小 id 时一切正常,生产环境的 id 一大,两个相同的 id 就「不相等」了。聚合里按 id 找子对象、判断两个实体是否相同,都要用 equals,或者用基本类型 long,或者把 id 包装成值对象。
四、怎样关上这些入口
| 入口 | 修法 |
|---|---|
| 框架反序列化 | 只保留一条经过校验的创建路径:record、@JsonCreator、工厂方法;不为框架加无参构造器 |
| Java 序列化 | 在 readObject 里重新校验;能不用就不用 |
| 从数据库重建 | 重建走单独的路径(不执行创建规则),但仍检查结构约束;数据库本身也加约束 |
| 交出的集合 | 返回快照或只读投影;元素不可变;修改通过聚合根的方法 |
| 继承的公共 setter | 业务状态不放在通用基类里;用组合复用技术字段 |
| 身份比较 | 用 equals 或值对象,不用 == 比较包装类型 |
最后一道防线是数据库约束:CHECK (quantity BETWEEN 1 AND 99)。它不能代替模型里的校验(错误要尽早在业务语言里报出来),但能在所有代码路径都失守时,挡住非法数据落盘。
五、检查清单
- 列出每个领域对象的所有创建路径:构造器、工厂、反序列化(JSON、Java 序列化、消息)、从存储重建、复制与映射工具。
- 每条创建路径都经过结构校验;重建路径跳过的只是「创建」时的业务规则。
- 不对外交出可变的内部集合或可变元素。
- 业务状态只能通过表达意图的方法改变;检查继承来的公共方法。
- 包装类型的 id 不用
==比较。 - 关键约束在数据库里也有一份。
配套实验
- codesphere-labs/ddd/invariant-entry-points:JDK 25.0.4.1、Jackson 3.2.3:同一条不变量在构造器、JSON 反序列化、Java 序列化、反射写字段、暴露的内部集合、可变元素、父类公共 setter 前的结果,以及
Long的==(验证记录)
参考资料