两个字符串还没有表达商品身份

商品目录用商户编号和商品编号共同定位一件商品。Pair<String, String> 可以容纳这两个值,但 left、right 没有表达哪个是商户,也没有声明 null 是否允许。类型相同还意味着调换顺序仍能编译,错误只能在调用约定或运行结果中发现。

Pair 适合局部算法里寿命较短的两值返回;跨模块业务身份通常更适合具名类型。具名类型能够在构造处拒绝缺失编号,规定相等关系,并把读取方法命名为 merchantId、productId。它未必需要框架,Java 8 的一个 final 类即可表达这些约束。

本篇使用 Commons Lang 3.20.0,示例以 Java 8 编译,在 Zulu 8.0.472 和 Corretto 21.0.11 运行。完整实验包含 Pair、Triple、MutableInt、反射 builders 与手写 ProductKey;运行说明沿用系列工程。第 09 篇讨论的浅层不可变,在这些便利类型中同样存在。

Pair 同时具有 Map.Entry 语义

Pair 的固定源码实现 Map.Entry。getKey 对应 left,getValue 对应 right;equals 可与其他 Map.Entry 比较,hashCode 使用键和值哈希的异或,遵循 Entry 的契约。它并不要求另一对象也是同一个 Pair 实现类。

测试把 MutablePair(“A”, 2) 与 AbstractMap.SimpleEntry(“A”, 2) 双向比较,结果均相等。这对通用条目操作合理,却不一定适合业务身份。假设订单键和商品键都恰好由两个相同字符串组成,使用 Pair 表达时缺少领域类型边界;具名 ProductKey 则只与同类型比较。

ImmutablePair 拒绝 setValue,MutablePair 接受并返回旧值。测试从 A→1 改为 A→2,setValue 返回 1,hashCode 随之变化。如果这样的可变对象已经作为 HashMap 的键,修改参与哈希的状态会破坏稳定查找前提。是否可用作键取决于对象状态稳定性,不取决于包装类型看起来有多轻便。

Triple 进一步增加 middle,但字段更多没有增加业务名称。测试只验证相同三元组相等;没有声称它与任意三个字段的对象自动可替换。返回坐标、临时比较结果时,位置可由短小上下文解释;金额、币种、税率这种容易调换的业务值,具名字段通常更清楚。

可迁移判断是先观察值会穿过多少边界。局部函数中的两值组合可以由上下文保持含义;进入公共接口、缓存键、持久化记录后,字段名称、缺失规则和相等范围就需要由类型承担。节省一份小类定义不应以隐藏这些契约为代价。

ImmutablePair 不冻结嵌套对象

将可变 List 放入 ImmutablePair 的右侧,随后向原列表添加 B,pair.getRight 也能读到 B。不可变 Pair 固定的是左右引用,未复制它们指向的对象。测试还验证 Pair.of(null, null) 可以产生空引用,说明不可变与非空也不等价。

这对目录快照的影响与集合相同。Pair<Merchant, List> 若用于表示某次查询结果,即使 Pair 本身不可替换两端,Merchant 和 List 仍可能变化。需要时间点快照时,应挑选接口真正承诺的状态进行复制,并让可达业务值满足相应的不可变要求。

可变包装 MutableInt 提供另一种别名关系。把同一个包装引用交给两个变量,调用 increment 后,两处都读到新数值。测试还验证 MutableInt(3) 不等于 Integer(3),因为包装类的 equals 没有把所有 Number 子类视为同一种值。方法参数写 Number 只统一读取数值的接口,不统一相等语义。

MutableInt 也没有 AtomicInteger 的并发更新保证。用它解决 lambda 捕获变量不能重新赋值的问题,只是把修改移进对象,没有建立线程同步。对并行任务的累计结果,应使用与并发协议匹配的原子类或归约操作。本章没有运行 MutableInt 的竞争实验,也不把单线程 increment 成功当成线程安全证据。

这里的可迁移模式是检查被固定的是引用还是对象图。final 字段、不可变 Pair、只读集合都可能只固定其中一层;共享可变包装则明确允许通过别名观察变化。接口若要提供稳定值,应同时说明元素状态,而不是依赖类名中的 Immutable。

反射 toString 会读取 private 字段

实验中的 Credentials 只有两个 private final 字段:user 和 token。token 使用合成文本 synthetic-secret,不涉及真实凭据。ToStringBuilder.reflectionToString 生成的字符串包含该值;显式构造的 safeText 只追加用户名和固定的 [REDACTED],不包含合成秘密。

private 约束普通 Java 访问路径,不等于字段不会进入反射输出。ReflectionToStringBuilder 固定源码获取字段、设置可访问性、按接受规则筛选,再读取值追加到结果。默认排除静态与 transient 等规则,不包含“字段名像 token 就自动脱敏”的语义。

该实现支持排除字段或 ToStringExclude 等机制,但隐式扫描的覆盖范围会随字段增加而变化。凭据类新增 refreshToken 后,如果排除列表没有同步更新,日志内容也会增加。显式白名单追加只输出已选择字段,新字段默认不进入文本,适合对输出范围有稳定要求的日志对象。

手写方法也不是天然脱敏。如果手写 toString 直接拼 token,泄露仍然存在;关键是输出字段白名单和替代策略,而不是是否使用 builder。测试断言不包含合成秘密,同时包含 [REDACTED],分别验证“没有原值”和“有明确占位”,避免把返回空字符串也误认为完整实现。

输出内容还受嵌套对象影响。显式追加一个 userProfile 对象,如果其 toString 会输出敏感字段,外层白名单并没有限制嵌套内容。日志 DTO 应只携带所需的标识和经过处理的值,避免把整个业务对象交给格式化层。

反射相等会随字段定义变化

EqualsBuilder.reflectionEquals 对两个相同用户名、相同 token 的 Credentials 返回 true,token 不同则返回 false。HashCodeBuilder.reflectionHashCode 对相等样本产生相同哈希。测试确认了两项可观察行为,但哈希相同本身并不能证明对象相等,哈希碰撞在契约中允许存在。

EqualsBuilder和 HashCodeBuilder的反射路径均枚举字段,并有 transient、静态、排除字段及对应注解的处理。两个方法必须依据一致的业务字段集合设计,不能分别套不同的排除策略后就认定满足 equals/hashCode 契约。

商品对象增加 lastViewedAt 后,是否应该影响“同一商品”的判断?如果反射相等默认包含它,对象身份可能随着一个统计字段改变。反射减少了列字段的工作,也把“新增字段是否影响身份”的决定隐含在实现中。对于稳定业务键,显式指定参与字段更容易审查。

实验中的 ProductKey 是 final 类,构造器使用 Objects.requireNonNull,equals 只接受 ProductKey 并比较 merchantId、productId,hashCode 对相同两字段计算。测试验证同值相等、哈希一致、与同值 Pair 不相等、缺失商户编号拒绝。它没有实现所有领域校验,例如空白编号仍需由业务入口另行约束,这个示例只展示类型和相等边界。

反射字段访问还有运行时限制。Java 9 模块系统及后续封装规则可能限制对其他模块私有字段的深反射;本实验读取的是本工程类,不能推导“对任意 JDK 对象都成功”。不应为省略一个明确定义的方法而默认增加广泛开放模块参数。必要时使用公开访问器或显式字段实现。

record 简化语法,也保留浅层边界

Java 16 正式提供 record,它可以用具名组件生成构造、访问器、equals、hashCode 和 toString,适合表达透明数据载体。JEP 395说明了其目标与语义。Java 8 基线不能直接编译 record,因此本章只作语言契约比较,没有把现代语法写进基础测试。

record 的组件引用固定,不会自动深复制可变 List;生成的 toString 也可能包含不适合记录的组件值。把 Credentials 改成 record 并不等于已脱敏,仍需明确输出策略。需要防御性复制时,应在构造边界完成;需要不同相等身份时,也要先确认 record 的按组件相等是否符合模型。

Pair、手写值类和 record 的选择可以由字段含义与运行时约束直接推出:Java 8 公共业务键采用小型具名类,现代数据载体在语义匹配时采用 record,短生命周期的局部组合可以使用 Pair。无须为两个不可变字符串再增加工厂体系,但也不应把所有两字段对象都压成 left/right。

具名类型的维护成本如何判断

具名类型并不要求建立复杂对象体系。实验的 ProductKey 只承担两个职责:构造时拒绝缺失编号,比较时限定相等字段与对象种类。它没有继承层次、注册表或反射框架。与 Pair 相比,新增代码换来的是调用处可以直接读懂商户编号和商品编号,代码审查也能检查两者是否传反。

构造器参数依旧可能交换,因此类型名称不是全部保障。如果两个字段都使用 String,还需要通过有区别的样本验证顺序。例如商户编号为 shop-A、商品编号为 item-B,交换后应得到不同的键,而不是在测试中让两个字段都写 A。测试数据能够表达角色差异,才有机会发现位置错误。

相等契约需要随着使用位置检查。缓存键要求参与比较的内容在驻留期间稳定;查询结果 DTO 可以包含更多展示字段;身份实体可能只按业务标识判断。把所有字段交给反射处理,会把这三种用途混成一个默认策略。选择显式实现的价值,在于能够直接审查哪些变化应影响身份。

日志字段则遵循另一套边界。一个字段参与相等,不代表它适合公开输出;一个字段允许输出,也不代表它应该参与哈希。凭据测试故意让 token 影响反射相等,同时证明它需要从日志排除,用来说明这些方法不能依赖同一份未经分析的字段列表。

增加字段时,可以把检查拆成三项:它是否改变业务身份,是否引入共享可变状态,是否可以进入文本输出。每项都能通过具体样本建立断言。这样的变更检查比要求开发者记住所有 builder 默认值更稳定,也适用于手写类和现代 record。

实验与改动题

Chapter22Test 双 JDK 各通过四个测试方法,失败、错误、跳过均为 0。覆盖可变与不可变 Pair、Entry 相等、嵌套 List、具名键、MutableInt 别名、反射敏感输出以及 equals/hashCode。原始证据位于 evidence/22。模块封装失败、record 编译和并发包装更新未运行,不计入本章实验通过范围。

反例题:Pair 的两个字段声明为 final,能否将 Pair<String, List> 作为完整目录快照?不能。List 仍可能被修改,返回结果随元素状态改变。要证明快照,应修改原列表并检查返回值,再检查列表内元素是否继续共享可变状态。

改动练习:给 Credentials 增加一个合成 refreshToken。保持 safeText 只包含已授权字段,增加两个秘密均不出现的断言;随后检查反射输出是否自动新增该字段。这个练习验证字段增长时日志策略是否仍然成立,不需要任何真实敏感数据。

需求信号 判断模式 具体选择
两值局部返回 根据跨越边界决定类型 短上下文 Pair
公共业务身份 类型表达字段与相等范围 具名不可变键
不可变包装 检查引用与对象图 嵌套可变对象反例
反射日志 输出白名单 明确字段与脱敏值
字段自动参与相等 身份规则显式化 对照具名手写实现