一个团队给"金额"包了 Money 值对象,也给"用户名"包了 UserName、给"页码"包了 PageNo——半年后代码里到处是 new PageNo(1),而"新人不看类定义就不知道"它想表达什么"。值对象被用成了"类型体操".
值对象的判据不是"这个概念重要不重要",而是"包一层能不能消灭一类 bug"。三类 bug 值得包:单位错、校验漏、可变共享——而"包了却没消灭 bug",就是"多写了一个类".
1. "该包"的三个判据
| 判据 | 例子 |
|---|---|
| 有"隐含单位/量纲" | 金额(元/分)、时长(毫秒/秒) |
| 有"不变式"(构造即校验) | 手机号、邮箱、区间 |
| 会被"多处共享且可变" | 时间范围、坐标 |
第一项最能"省事故":"金额用分还是元"这类错误,一次线上事故就够痛第一项的"收益"是"把运行时错误变成编译期错误":long fee 里"分/元"搞反,测试未必覆盖——**而 Money.minorOf(1) 与 Money.yuanOf(1) 是"两个不同的构造",写错就"读着别扭".
——**而 Money 让它"编译期就不可能搞错".
2. "不该包"的三种情形
| 情形 | 为什么 |
|---|---|
只是"重命名"(UserName vs String) |
没消灭任何 bug |
纯粹"计数/序号"(PageNo) |
校验由"调用方"更清楚 |
| 只在一处出现 | 抽象成本 > 收益 |
第一项的"识别"很直接:"这个类的方法,除了 getValue() 还有什么"——**什么都没有,说明它只是"重命名".
第一项是"类型体操"的典型:包了一层,但"能传 String 的地方还是传 String"——于是"多一层转换,少零个 bug".
"包一层"的最小实现只有二十行,而它把"三个特征"都占住:
// 一个"够用"的值对象(金额,单位=分)
public final class Money { // final:不可被继承改造
private final long minor; // 不可变
private Money(long minor) { this.minor = minor; }
public static Money minorOf(long m) { return new Money(m); }
public static Money yuanOf(long y) { return new Money(y * 100); }
public long minor() { return minor; }
@Override public boolean equals(Object o) { // 按值相等
return o instanceof Money m && m.minor == minor;
}
@Override public int hashCode() { return Long.hashCode(minor); }
}
// 判据:构造器私有 + 静态工厂 + 按值相等 → 三个特征齐
3. "值对象"的三个必备特征
| 特征 | 说明 |
|---|---|
不可变(final 字段 + 无 setter) |
可安全共享 |
| 相等性按"值"比 | 重写 equals/hashCode |
| 构造即校验 | 非法状态无法构造 |
第三项是"最值钱"的:"无法构造非法值"意味着"下游不必再校验"第三项的"量化"很直观:"手机号校验"原本在"注册/改绑/找回"三处各写一遍——**包成 Phone 之后,三处都"不必校验",因为"构造不出来非法值".
——于是"校验代码"从"每处一次"变成"一处".
"校验"放"构造器"里最省,但它要"可用的错误信息":
// 反例:校验在构造器,错误信息太粗
public Phone(String v) { if (!v.matches("1\d{10}")) throw new IllegalArgumentException("bad"); }
// 正解:错误信息"指出哪不对"(便于定位,且"能直接给用户看")
public Phone(String v) {
if (v == null || v.length() != 11) throw new IllegalArgumentException("手机号应为 11 位");
if (!v.startsWith("1")) throw new IllegalArgumentException("手机号应以 1 开头");
this.v = v;
}
// 判据:这条异常"能否直接变成用户提示"
4. "包一层"的三种收益
| 收益 | 例子 |
|---|---|
| 消灭"单位错" | Money.minor() vs Money.yuan() |
| 消灭"重复校验" | 构造器里 require(valid(phone)) |
| 让"方法签名自解释" | transfer(Money, Account) |
第三项是"可读性":"参数名 fee 是 long"要靠注释,"Money fee"自解释——而它"顺带"让"IDE 的类型检查"帮到忙.
"万能构造"是"校验被绕过"的入口,而它常被"为了方便"加回来:
// 三种"绕过"
1 提供 of(String raw) → 调用方"先字符串后转换",校验形同虚设
2 提供"反射/setter" → 不可变被破坏
3 序列化框架"直接写字段" → 绕过构造器(需 @JsonCreator 或"后校验")
// 判据:所有"进入"路径都必须"过校验"
5. "包了但没效果"的三种原因
| 原因 | 表现 |
|---|---|
| 只在"入口"包,内部仍用原始类型 | 中间层又退回 String |
提供了"万能构造"(of(String raw)) |
校验被绕过 |
| 可变(有 setter/暴露内部集合) | 共享时被改 |
第一项的"渗透"很隐蔽:"某一层为了"方便"改成 long,于是"整条链路"都退回原始类型——而"值对象"只在"入口/出口"出现,中间"裸奔".
第一项最常见:"Controller 收 Money,Service 里又拆成 long"**——**于是"单位错"仍会发生,只是"晚一层".
"全程用值对象"这条要"从边界贯穿到存储",否则"中间裸奔":
// 一条"贯穿"的链路
HTTP(JSON 字符串) → Controller(转 Money) → Service(只用 Money)
→ Repository(转 long 存库) → 读回(再转 Money)
// 反例:Service 里"money.minor() 存 long 变量,再拼 SQL" → 单位重新变成"裸数字"
6. 排错:五种"值对象问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
到处 new PageNo(1) |
包了但无收益 | 拆掉 |
| 单位仍搞错 | 内部退回原始类型 | 全程用值对象 |
| 校验被绕过 | 万能构造 | 只留"校验构造" |
| 共享后被改 | 可变 | 改不可变/返回副本 |
| 性能下降 | 每次 new 小对象 |
缓存常用值/基本类型兜底 |
第五项在"高频路径"上真实存在:"每行 new Money(...)"会产生大量小对象第五项的"判断"要看"分配速率":"每秒百万次 new"是问题,"每秒几千次"不是——而"值对象"的分配"很小、很快死",对"年轻代 GC"几乎无感.
——解法是"缓存常用值"(如 Money.ZERO)或"只在边界用".
"DTO/序列化边界"要"扁平",而"转换只做一次"是关键:
// 三种"边界"处理
1 DTO 里用原始类型(JSON 友好),转成领域对象后"全程用值对象"(推荐)
2 DTO 里也用值对象(需自定义序列化,成本高)
3 全程原始类型(省事,但"单位错"风险回来)
// 判据:转换"只发生在边界",而不是"每次调用"
7. 边界:什么情况"原始类型"更好
| 场景 | 为什么 | 做法 |
|---|---|---|
| 局部变量、循环计数 | 无"跨层语义" | 用 int/long |
| DTO/序列化边界 | 需要"扁平结构" | 转换一次 |
| 性能敏感的热路径 | 分配成本 | 原始类型 + 断言 |
| 只有"一种单位"且"永不变" | 单位错无从发生 | 原始类型 |
最后一条与"演进"有关的边界:"包"可以是"渐进"的:先把"金额"从 long 改成 Money(收益最大、风险最小),观察一段时间再决定"要不要包 PageNo"——而"一次性包十个"的团队,通常半年后要"拆掉八个".
判断口径一句话:"包一层能不能"消灭一类 bug""**。能(单位、校验、共享可变),值得包;不能(只是换个名字),它就是"多写一个类"——**而"值对象"的价值从来不在"看起来更面向对象",而在"让错误写不出来".
8. 自测
- "该包"的三个判据里,哪一个"最能省事故"?为什么?
- "不该包"的三种情形里,哪一种是"类型体操"的典型?
- 值对象的三个必备特征里,哪一项"最值钱"?它把校验从几处变成几处?
- "包了但没效果"的三种原因里,哪一种最常见?
- "高频路径"上值对象会带来什么成本?两种解法是什么?
- 判断"该不该包"的那个问题是什么?