商品目录需要按商户分组、拒绝商户内重复商品、保留输入顺序,并让查询方无法修改发布后的容器。JDK的有序映射、列表复制与不可修改包装已经能够实现这些要求。加入Guava可以改变实现写法,却不会自动产生一个此前无法表达的业务能力。类库决策应先说明缺少什么,再说明额外依赖补上了什么。

本系列采用同一类商品数据,逐步检查空值、相等、集合视图、缓存、文本协议与资源生命周期。几种常见误用会跨越包名重复出现:默认值掩盖坏输入、惰性视图与快照混淆、完成结果被当成持久状态、解析成功被当成安全边界。类库提供的机制各不相同,调用方仍要定义输入预算、错误分类和可观察结果。

本篇形成一份适用于这份示例工程的决策记录。实验工程汇集多个候选方案以便对照,其POM不应直接成为生产服务的依赖清单。每个候选只有在承担明确业务责任、通过对应回归并满足运行基线时,才进入应用配置;没有使用理由的候选可以移除。

保留标准库的商品目录

第00篇的 Product 保存非空、非空串的商户和商品编号,以及不需要舍入的两位非负金额。它没有trim、没有SKU正则,也没有定义实体相等和哈希。Catalog 按商户建立有序索引,对商户内相同商品编号拒绝导入,完成后复制并包装返回的容器。跨商户相同SKU合法,键身份应包括商户维度。

这些边界不能被工具名称替代。StringUtils.isBlank 会加入空白分类,严格SKU语法会排除更多编号,反射相等Builder会纳入字段,大小写转换又可能合并原本不同的标识符。它们都需要单独的业务理由。商品目录只拒绝null和空串时,直接使用JDK检查有更小的语义范围;需要更严格格式时,应增加具名验证规则和输入回归。

不可修改容器与深度不可变也需要分别判断。目录返回的映射与列表不能通过正常修改API写入,输入列表随后清空不会影响目录。列表中的Product仍是相同对象;此处Product字段final、金额和字符串也不提供原地修改接口,所以浅复制符合当前发布要求。换成包含可变库存对象的实体后,原来的容器包装不再足以隔离元素状态。

以下完整运行类复用原始实现,检查商户作用域、重复拒绝与容器快照,不需要额外类库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package blog.libraries;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

public final class LibraryDecisionDemo {
public static void main(String[] args) {
List<Product> input = new ArrayList<>(Arrays.asList(
new Product("merchant-a", "sku", new BigDecimal("10.00")),
new Product("merchant-a", "sku-2", new BigDecimal("20.00")),
new Product("merchant-b", "sku", new BigDecimal("30.00"))));
Map<String, List<Product>> catalog = Catalog.groupByMerchant(input);
input.clear();
if (catalog.size() != 2 || catalog.get("merchant-a").size() != 2
|| catalog.get("merchant-b").size() != 1) {
throw new AssertionError("merchant scope or snapshot changed");
}
boolean rejected = false;
try {
Catalog.groupByMerchant(Arrays.asList(
new Product("merchant-a", "sku", BigDecimal.ONE),
new Product("merchant-a", "sku", BigDecimal.TEN)));
} catch (IllegalArgumentException expected) {
rejected = true;
}
if (!rejected) throw new AssertionError("duplicate product was accepted");
boolean immutable = false;
try {
catalog.get("merchant-a").clear();
} catch (UnsupportedOperationException expected) {
immutable = true;
}
if (!immutable) throw new AssertionError("published list was mutable");
System.out.println("catalog=3; merchants=2; sku-reuse=allowed; duplicate=rejected; snapshot=isolated");
}
}

该类实际在Zulu8u472与Corretto21.0.11执行,输出相同。它证明当前三条目录规则,没有证明SKU语法、线程发布协议或对象深复制。完整 决策基线源码、复跑说明、全系列实验工程 与 下载包校验值 可以与各篇反例一起核查。

每项额外依赖对应一项责任

对当前商品流程,可以按操作判断依赖,而不是按项目知名度排列候选。下面是示例的选择范围,版本均定位在系列冻结表;表中的保留条件属于本项目约束,不是通用排名。

操作与规则 当前选择 保留理由与退出条件
商户内唯一、遇见顺序与容器快照 JDK集合 规则已有短小实现;复杂多值操作才另外考虑专用结构
多值映射、频次与区间模型 Guava 多个调用需要同一结构协议时保留;单次局部分组可用JDK
CSV引号、分隔符与多行字段 Commons CSV 避免自行拼接协议;只处理真正无转义的受限文本时重新评估
明确类型与预算的JSON边界 Jackson 需要树、流或类型映射;Gson可在同一契约回归通过后作为替代
借用流复制与目录操作 JDK加有界辅助代码,按需Commons IO 简便API不能省去预算与所有权;没有实际API需求就移除工具依赖
ZIP/TAR输入解析 Commons Compress加私有解包边界 多格式支持由类库承担,路径与资源策略仍属应用;只生成固定ZIP可用JDK
可复用HTTP连接与请求生命周期 HttpClient,另对照JDK HttpClient 由运行版本、连接管理和异常要求选择,不同时装两个生产客户端
容量与刷新缓存 Java8示例Guava,现代对照Caffeine 迁移先确认缺失、刷新失效与取消;无需跨请求重用时删除缓存
日志协议 SLF4J加一个provider 应用确定唯一provider;复现多provider的测试配置不能照搬

这里的“退出”应有可观察条件。缓存只有一次查询时,加载器和失效策略增加了维护成本,没有可重用结果;若缓存连续访问收益抵不过旧值风险,可以删除。多值结构若只服务一个小循环,JDK局部集合足以表达规则;但若十处代码都重复维护计数与总数,就应重新评估Multiset这样的具名协议。

退出并不要求先包一个只有单实现的接口。业务边界已经清楚时,可以把库类型限制在导入、后端调用或索引实现内部,对外返回项目已有的结果类型。真正出现第二个实现或独立替换需求时,再引入适合的接口。过早把每个静态工具包装一层,会增加文件而没有减少实际迁移责任。

错误规则比方法对应表更稳定

类库替换的回归单位应是失败分类。商品不存在、编号非法、导入重复、后端暂时失败与预算耗尽分别有不同处理。把它们全部变成empty或false,会让简洁的调用代码持续发布错误数据。异常类型变化还可能绕过原有catch分支,固定cause的比较需要和调用方的处理策略一起检查。

缓存迁移已经给出两个独立状态:刷新future可以返回迟到的新值,而映射仍处于失效状态。对应回归应同时检查观察结果与缓存内容,不能只断言 join 返回了新值。异步取消也要分为共享future是否取消、工作线程是否中断、HTTP请求是否停止和副作用是否已经发生;某一个状态改变不能证明后面三个状态。

文本协议的错误则应落在输入边界。缺失JSON字段与显式null、CSV不一致列数与空字段、Base64严格模式与忽略非字母字符,都不能仅凭API命名推断。本系列的实际对照保留了默认差异;生产配置可以选择其中一种,但配置之后需要同一份样本验证接受集合和拒绝集合。

反例也要能持续执行。预期的 NoSuchMethodError 在独立fork中断言,故意放入的重复商品由正常测试断言拒绝;主回归仍应成功。真正的突变测试先产生独立红条,再恢复正确源码确认全绿。长期保留一份无法区分预期与偶发错误的失败脚本,会降低后续升级时证据的可信度。

预算与提交边界决定失败是否可控

导入路径至少需要限制输入字节、记录数、单字段或结构深度、后端响应字节和导出总量。一个按记录迭代的CSV解析器仍可能被非常大的单字段占用内存;readTree 即使限制了字节,也仍需考虑嵌套深度。预算要靠实际读取字节和已处理记录计数,不能只接受网络头或归档头中声明的大小。

解包进一步需要限定路径、文件类型和解压后大小。字符串规范化可以拒绝 ..,却不能消除文件系统中的符号链接及并发替换。示例只在可信父目录下创建私有暂存目录,不允许恶意并发写入;这个前提必须写进部署约束。完成后对暂存产物统一交付,失败则清理暂存区,不能把已产生一半的文件暴露给下游。

时间限制同样要注明检查位置。循环内读数检查能限制检查点之间持续工作的总量,不能保证一个已经阻塞的read或解析调用会在截止时刻返回。HTTP响应超时、连接池获取超时与连接建立超时又各自作用于不同阶段;不能把拒绝连接当成连接超时的证据。任务取消也不能撤销远端已经写入的记录,真正写入业务仍需幂等或补偿协议。

隔离解包 与37综合案例把这些预算放进真实执行路径。选择类库只解决解析和传输的部分责任;何时发布结果、清理失败如何保留原始异常、调用者是否拥有流,仍需要项目代码明确执行。

版本升级的三层回归

依赖升级首先检查解析和二进制链接:实际加载的是哪个jar、编译依赖和运行依赖是否一致、provider或处理器是否被错误引入。第38篇的新源码调用 buildKeepingLast 在旧Guava运行时产生 NoSuchMethodError,说明源码编译成功只证明编译时类路径存在该方法。运行jar的CodeSource与字节码方法签名能定位这个差异。

第二层检查行为契约。相同的输入、输出所有权、异常分类与边界时序应保持项目所需结果。源码兼容和二进制兼容不能保证新的默认值、字符分类或刷新规则完全相同。Java8与21的字符差异也提醒运行时本身是契约条件,不能只记录依赖坐标。

第三层才是资源与性能。JMH实验比较完整等价工作,并保存两个fork与原始各迭代数据。平均耗时、分配率与布局估计是不同指标;缓存未命中区间重叠也不形成稳定排名。即使小基准更快,仍要确认生产工作集、并发、后端等待和旧值预算,不能用一次单线程结果覆盖完整迁移回归。

安全修复另有明确的公告、受影响版本和触发面,不能从一个ImmutableMap链接实验推断所有IO路径安全。版本冻结方便复现;公告变化后需要重新核查采用版本和实际调用面。引入旧jar复现二进制问题时,旧jar应只进入测试fork,不成为正常运行依赖。

回归工程与未覆盖范围

全系列工程分为Java8兼容主实验、modern、processors 与 benchmarks 四个独立入口。主实验在Java8和21运行;现代缓存、Resilience4j、Mockito与HTTP对照采用更高目标且在21执行;处理器模块实际在两套JDK生成源码和检查字节码;JMH采用固定21环境。模块隔离使较高要求的依赖不会被误认为可在Java8运行。

原始证据按章放在 evidence/,记录官方文档、固定源码、实际实验及 NOT_RUN。最终整合记录与正文页面、附件检查分别保存:Maven成功证明对应测试运行,Hexo生成证明正文和标签可构建,本机HTTP字节核验确认交付文件可读取。这些检查没有覆盖所有浏览器的视觉效果,也没有执行生产服务部署。

本次没有在JDK11、17、25或Windows/Linux运行,没有模拟恶意并发目录写入,没有证明远端幂等,也没有测量大规模共享缓存的尾延迟。这些是适用范围,不是计划中尚未写完的章节;遇到对应上线条件时,应建立新的实验,而不是从已有绿色结果推断通过。

可迁移做法 项目中的交付形式
为依赖记录具体收益和退出条件 关联业务操作、回归样本、运行基线与删除条件,避免直接复制实验POM
分开验证链接、行为和资源成本 固定jar与源码,运行失败反例,再解释性能与预算证据

商品目录的最终规则仍然落在领域数据和输入输出边界上。项目可以更换具体的JSON、缓存或集合实现,只要商户作用域、钱值、所有权与失败发布规则保持可验证;这些规则应比某个库的方法清单更长久。

参考资料