商品导入程序需要按商户分组,保留输入顺序,并拒绝同一商户下重复的商品编号。实现可以使用普通的 Map<String, List<Product>>,也可以引入 Guava 的多值集合。两个程序即使打印出相同的分组结果,也未必可以替换:返回集合是否允许修改、源集合修改是否传播、null 怎样处理,都属于调用方能观察到的行为。

类库减少代码后,这些责任仍然存在。本系列从一个很小的商品目录开始,把每次引入依赖的理由写进业务约束和实验。Guava、Apache Commons 是主要研究对象;缓存、JSON、日志和 HTTP 等类库在相应需求出现后加入。第一篇交付能运行的基线,后续章节在这份工程里累计反例和比较实现。

商品目录先确定哪些行为

假设输入是已经解析好的商品对象,每个对象包含商户编号、商品编号和价格。此次只研究内存中的校验与分组,文件解析、数据库、网络请求尚未加入。这样的边界使失败原因容易定位:重复编号的异常来自分组规则,价格精度错误来自金额构造,不会混入连接超时或 CSV 方言问题。

业务身份由商户编号和商品编号共同决定。shop-a/p-1 与 shop-b/p-1 可以同时出现;同一商户的第二条 p-1 必须失败,即使价格与第一条相同。选择拒绝重复而非覆盖,是因为覆盖会丢失一次输入冲突。将来要接受覆盖时,需要明确先到优先、后到优先或版本号优先,不能借用 Map.put 的默认行为替业务作决定。

顺序也纳入契约。商户按第一次出现的顺序排列,同一商户内的商品按输入顺序排列。输入不按编号排序,因此排序不能用于“整理结果”。空目录是合法输入,null 目录、null 商品对象则属于非法输入。金额非负,允许零价;所有返回金额采用两位小数,转换不得改变数值。

标识符暂时只拒绝 null 和空串。" shop-a " 与 "shop-a" 是不同的值,空格字符串也不会自动改写。这是尚未加入标识符语法校验的基线边界,不意味着最终导入接口应该接受所有空白。下一篇将给 SKU 单独建立 ASCII 语法,并检验空白工具能否表达这个语法。提前调用 trim() 会在规则确定前合并两个输入,后续测试便无法观察原始差异。

输入或操作 本批基线的结果 原因
不同商户使用同一商品编号 接受 商品编号只在商户内唯一
同一商户重复编号 抛出异常 不静默覆盖冲突
0、1.000 转为 0.00、1.00 数值不变,统一表示
1.001、负价 拒绝 不允许舍入或负数
清空原输入列表 已返回分组保持原内容 结果容器与输入列表隔离

用 JDK 写出比较对象

Product 的构造函数把规则集中在值进入对象的入口。字段是 final,类不可继承,字段类型 String 和 BigDecimal 都不提供修改其值的操作;当前对象没有嵌套的可变集合。调用方拿到价格引用以后,调用 add 或 setScale 会得到另一个金额对象,不能改掉这件商品的价格。

1
2
3
4
5
6
7
8
9
10
public Product(String merchantId, String productId, BigDecimal price) {
this.merchantId = requireIdentifier(merchantId, "merchantId");
this.productId = requireIdentifier(productId, "productId");
BigDecimal normalized = Objects.requireNonNull(price, "price")
.setScale(2, RoundingMode.UNNECESSARY);
if (normalized.signum() < 0) {
throw new IllegalArgumentException("price must be non-negative");
}
this.price = normalized;
}

UNNECESSARY 的含义是要求操作不需要舍入。1.000 去掉末尾零不会改变数值,因此成功;1.001 压到两位小数需要丢弃有效数字,因此抛出 ArithmeticException。如果使用 HALF_UP,后一个值会变成 1.00,程序虽可继续运行,却违反此次输入契约。金额从十进制字符串构造,避免先经过二进制浮点数再转换。这些行为由 Java 8 BigDecimal 文档界定。

分组采用两层 LinkedHashMap。外层维护商户首次出现顺序,内层维护商品首次出现顺序,同时用商品编号检测冲突。完成校验后,再把每个分组复制为列表,并提供不可修改的返回入口。下面是完整分组方法;导入声明和 Product 完整类保存在附件工程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static Map<String, List<Product>> groupByMerchant(List<Product> products) {
Objects.requireNonNull(products, "products");
Map<String, Map<String, Product>> index = new LinkedHashMap<>();
for (Product product : products) {
Objects.requireNonNull(product, "product");
Map<String, Product> group = index.get(product.getMerchantId());
if (group == null) {
group = new LinkedHashMap<>();
index.put(product.getMerchantId(), group);
}
if (group.containsKey(product.getProductId())) {
throw new IllegalArgumentException("duplicate product in merchant: "
+ product.getMerchantId() + "/" + product.getProductId());
}
group.put(product.getProductId(), product);
}
Map<String, List<Product>> result = new LinkedHashMap<>();
for (Map.Entry<String, Map<String, Product>> entry : index.entrySet()) {
result.put(entry.getKey(), Collections.unmodifiableList(
new ArrayList<>(entry.getValue().values())));
}
return Collections.unmodifiableMap(result);
}

列表复制只复制商品引用。此次商品值不可变,所以返回容器不能改、商品值也不能改;换成包含可变标签列表的对象后,这个推论便不成立。第 02 篇会专门把可变元素放进去,让“不可修改列表仍能观察到元素变化”的情况出现。不能把当前对象形状下的结论推广到所有对象图。

输入三件商品 shop-b/p-1/12.00、shop-a/p-1/7.50、shop-b/p-2/0,手算外层键顺序应为 shop-b、shop-a,分组大小分别为 2、1。处理到第二件商品时,商户不同,因此商品编号相同也不冲突。第三件商品只进入第一个分组,不会使商户重新排位。这个预期在选择类库之前已经确定,后续 Multimap 方案必须遵守同一张表。

固定什么版本,才能重复比较

本批固定 Java 8 源码与字节码基线,在 Zulu JDK 8u472 和 Corretto JDK 21.0.11 上分别编译、执行。Java 8 对应兼容基线,Java 21 用来观察现代 API 与字符分类差异。两次运行不等于验证了所有 JDK 发行商和平台;JDK 11、17、25 以及其他操作系统均未运行。

组件 固定版本 当前用途
Guava 33.5.0-jre 空白定义、不可变集合对照
Apache Commons Lang 3.20.0 null、empty、blank 对照
JUnit Jupiter 5.13.4 参数表与失败路径断言
Maven / Wrapper 3.9.14 / 3.3.4 固定构建入口,按需下载 Maven
Compiler / Surefire 3.14.1 / 3.5.4 Java 8 编译与 JUnit 执行

Guava 使用 jre 变体。冻结的 Guava README说明 Java 8 兼容要求;Commons Lang 的 3.20.0 源码构建描述保留 Java 8 基线。这里引用固定提交,项目首页即使以后更新,仍可回到本文研究的版本。JUnit 使用 5.13.4 文档核验运行要求,不能用当前默认文档替代这个版本的证据。

Maven 3.9.14 是本次选择的固定版本,没有宣称它是最新版。机器原有的 Maven 3.9.13 曾将测试库误带进发行包,官方在 3.9.14 发行说明中解释了修复;下载并固定 Wrapper 分发版,使读者不必依赖机器上的旧 Maven。构建还固定 Compiler、Surefire、Resources 和 Jar 插件版本,避免关键默认插件随 Maven 改变。

项目级 .mvn/settings.xml 只指向 Maven Central。运行命令显式加载该文件,防止个人 settings.xml 的内部镜像使外部读者无法下载。它控制依赖仓库,Wrapper 的 distributionUrl 另行固定 Maven 下载位置;两处都需要检查。首批不安装 Commons IO、Jackson 或 Caffeine,也不为未来章节创建空模块。

直接依赖版本写进 pom.xml,解析后的传递依赖保存为依赖树。依赖树证明这次解析选择了哪些坐标,编译证明代码能引用所用 API,运行断言证明给定输入下的行为。这三类证据各自回答不同的问题。某个包成功下载不能证明它符合业务规则,测试绿色也不能说明未覆盖的方法都兼容。

从公开契约追到运行记录

每个重要结论先写出调用方能观察到的预期,再查固定版本文档,必要时沿源码定位实现分支,最后运行独立断言。证据之间的关系可以画成以下路径:

1
2
3
4
5
6
7
8
9
10
业务输入与规则
│ 决定什么算正确
▼
手算预期表 ──► 公开 API 契约
│ │ 定位实现
│ ▼
│ 固定提交的源码分支
│ │ 解释原因
▼ ▼
独立测试断言 ──► 原始输出、运行环境、依赖树

以 Collections.unmodifiableList 为例,公开契约是限制通过包装入口进行修改,源码把读取委托给底层列表。于是测试必须从底层列表执行一次修改,再观察包装列表。只测试 view.add() 抛异常,无法回答底层变化是否可见。第 02 篇因此安排源列表的 add、set、remove,以及元素字段修改四种操作,避免用一个成功用例替代完整语义。

本批另一个反例来自 U+180E。它在不同 Unicode 版本中的字符分类发生过变化,JDK 的分类表也随版本变化。固定 Guava 与 Commons Lang 坐标后,换 JDK 仍可能改变 StringUtils.isBlank 的结果。第 01 篇会同时列出库版本与 JDK 版本,再记录这个输入;直接写“Unicode 空白都可被判断”既没有定义字符集合,也没有说明数据表版本。

实现细节只用来解释固定版本。Guava 对某些已有不可变集合可能复用对象,但引用相同不是调用方应依赖的保证。升级时应保留顺序、元素、null 与修改传播等公开行为断言,把引用复用单独作为观察记录。否则测试会把内部优化锁成业务要求,阻碍原本合法的升级。

源码仓库提交也不能证明本机 JDK 就由同一提交构建。本文记录实际 java -version 与 Maven 版本,Java 8 上游源码用于解释标准库机制,运行结果来自实际安装的发行版。其余类库的二进制从固定坐标下载,依赖树与校验和保留在工程中;不存在“读过源码,因此实验已经通过”的推断。

运行首批实验

下载00至02篇实验工程,解压后进入 `java-libraries`。需要安装 JDK 8 或 JDK 21;首次执行需访问 Maven Central 下载固定 Maven 和依赖。Windows 使用 `mvnw.cmd`,macOS、Linux 使用下面的入口:
1
2
./mvnw -B -ntp -s .mvn/settings.xml clean verify
./mvnw -B -ntp -s .mvn/settings.xml -Dtest=Chapter00Test test

首条命令运行三篇的全部实验,第二条只运行商品目录基线。切换 JAVA_HOME 后再运行 clean verify,避免把上一次生成的字节码当成新一次编译。现代 JDK 的 String.isBlank 和 List.copyOf 对照通过反射隔离;Java 8 会记录 API 不适用,不伪装成已经验证这些方法。

本批在两个 JDK 上分别执行了 29 项测试,均为 0 失败、0 错误、0 跳过。第 00 篇的三个测试覆盖顺序与商户内唯一性、重复输入和空目录、非法金额与标识符保留;第 01 篇有 20 项,第 02 篇有 6 项。Java 8 的现代 API 分支记录为不适用,这两个测试方法仍正常返回,因此不能把“0 跳过”理解成所有 API 分支均被执行。JDK 21 对照实际执行了这两个现代 API 分支。

原始日志、逐章报告与依赖树保存在工程 evidence/00/。编译后检查 Product.class 的 major version 为 52,符合 Java 8 字节码目标。JDK 21 构建保留了 Java 8 源/目标选项过时的警告,没有屏蔽;它不影响此次测试成功,也不是其他 JDK 兼容性的证明。性能基准、本机之外的平台和后续类库没有运行。

只看总数量仍会遗漏入口。比如 Product 尚未定义业务上的 equals/hashCode,本批断言通过保留原商品引用检查顺序,不宣称两个独立构造的商品按业务身份相等。第 03 篇会把对象相等、商户内去重和排序等价分别展开。新增方法以后,必须增加能使错误实现失败的输入,不能简单复制另一组正常商品来增加测试数量。

可以在附件副本中做两个小改动,观察断言是否失败。把分组的外层 LinkedHashMap 换成按键排序的 TreeMap,顺序应从 shop-b/shop-a 变成 shop-a/shop-b;把金额舍入模式改为 HALF_UP,1.001 将不再抛异常。前者违反遇见顺序,后者接受了超出精度的数据。练习改动未作为本批基准提交,也未声称已执行;原工程保持既定契约。

后续类库怎样进入工程

下一篇从缺失字段、null、空串和空白串开始。随后研究视图、复制、不可变与相等关系,再进入 Guava 的集合结构。商品导入需要字符校验时才引入相应工具,需要标签频次时才比较 Multiset,需要多值查询时才比较 Multimap。整个工程维持一个可运行入口,每一篇增加可重复的观察,而非增加一个脱离业务的小演示。

对于候选类库,先比较能否保持已有契约,再讨论代码量、对象分配与维护成本。若 JDK 的几行实现已经清楚且正确,保留它就是有效决策。若类库提供的结构直接表达需求,就把少掉的手工维护、增加的依赖和迁移边界写清楚。性能结论等待正确性等价的基准,当前测试运行时间不能拿来排列类库速度。

本批研究记录、完整源码和构建入口随附件保存。版本清单区分直接依赖、传递依赖与未来候选,续写状态区分正文完成、实验已运行和未覆盖项。阅读入口:第01篇:null、empty与blank;第02篇:视图、浅复制与不可变。

参考资料