2095 字
约 6 分钟
18
S18-A26 · 值对象:什么时候该"包一层",什么时候是"多写一个类"

一个团队给"金额"包了 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. 自测

  1. "该包"的三个判据里,哪一个"最能省事故"?为什么?
  2. "不该包"的三种情形里,哪一种是"类型体操"的典型?
  3. 值对象的三个必备特征里,哪一项"最值钱"?它把校验从几处变成几处?
  4. "包了但没效果"的三种原因里,哪一种最常见?
  5. "高频路径"上值对象会带来什么成本?两种解法是什么?
  6. 判断"该不该包"的那个问题是什么?
S18-A26 · 值对象:什么时候该"包一层",什么时候是"多写一个类"
https://www.clxhxhhr.top/posts/6695/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。