同一个商户编号为什么得到不同结果

导入文件中的商户编号需要去除两端空白,再统一为大写。String.trim()、Commons Lang 的 StringUtils.strip()、Guava 的 CharMatcher.whitespace().trimFrom() 都能把普通的 " acme " 变成 "acme"。只用这一条输入验收,三个实现看起来可以自由替换。

把两端字符换成不换行空格 U+00A0,结果就不同了:Guava 删除它,另外两个实现保留它。若规范化结果作为目录索引的商户键,替换工具方法可能合并原来分开的键,也可能让同一来源在不同服务里查不到。依赖选型首先要确定这次转换允许丢掉哪些信息,再比较代码、包体和维护成本。

本文固定 Guava 33.5.0-jre、Commons Lang 3.20.0、Java 8 API,运行时对照 Zulu 8u472 与 Corretto 21.0.11。第 01 篇已区分空值与空白,第 03 篇明确身份与排序关系;这里把这些差异放进一个具体的依赖决策。

先写出规范化契约

示例的最小业务规则是:输入不可为 null;允许删除两端 ASCII 空格、制表符和换行;转换大小写时不依赖机器的默认地区;返回 JDK String。这个规则还没有授权删除全部 Unicode 空白,也没有授权把缺失商户转成空串。因此,可以先对受约束的 ASCII 数据做等价比较,再用扩展字符观察边界,不能把三个库的所有输入都宣布为等价。

trim 删除两端编码值不大于 U+0020 的字符,范围比上述几个常见空白更广。如果导入协议只允许那几个字符,应另外写精确的字符集合或在解析后拒绝控制字符。直接选 trim 是接受它的完整字符域,而不仅是复用三个好看的测试结果。String.trim 契约

本章测试把 null 校验放在所有实现前面,保证这项业务要求相同。否则 StringUtils.strip(null) 原本返回 null,而 CharMatcher.trimFrom(null) 会失败;带着这种差异比较“代码行数”,会把移走的业务校验误算为节省。已有工具函数可以参与实现,但业务规则不能由工具默认值反向决定。

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
import com.google.common.base.CharMatcher;
import java.util.Locale;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;

public final class MerchantNormalization {
static String jdk(String input) {
return Objects.requireNonNull(input, "merchant").trim().toUpperCase(Locale.ROOT);
}
static String commons(String input) {
return StringUtils.strip(Objects.requireNonNull(input, "merchant"))
.toUpperCase(Locale.ROOT);
}
static String guava(String input) {
return CharMatcher.whitespace().trimFrom(Objects.requireNonNull(input, "merchant"))
.toUpperCase(Locale.ROOT);
}
public static void main(String[] args) {
String input = "\u00a0acme\u00a0";
if (!"\u00a0ACME\u00a0".equals(jdk(input))) { throw new AssertionError(); }
if (!jdk(input).equals(commons(input))) { throw new AssertionError(); }
if (!"ACME".equals(guava(input))) { throw new AssertionError(); }
System.out.println("NBSP: JDK/Commons preserve, Guava removes");
}
}

源码中的策略差异

Commons Lang 3.20.0 的 StringUtils.strip(String) 委托到带 stripChars 参数的重载。当字符集合为 null 时,stripStart 与 stripEnd 调用 Character.isWhitespace 检查边界字符。因此它继承运行时的字符分类,而不等价于 String.trim 的固定编码阈值。源代码将两种分支明确分开:显式字符集合走集合查找,默认路径走字符分类。固定版本 StringUtils

Guava 的 CharMatcher.whitespace() 返回固定实现,其匹配依据随 Guava 版本发布;trimFrom 从字符串两端寻找第一个不匹配字符,只截掉边缘,不删除内部字符。U+00A0 被该匹配器接受,而 U+001C 不被接受。这里不能把 API 名字里的 whitespace 直接翻译成“所有看不见的东西”。固定版本 CharMatcher

测试使用 U+001C 作为反向输入:JDK trim 与 Commons strip 都删除它,Guava 保留它。两组反例共同说明不存在“Guava 永远删得更多”的简单包含关系。业务若需要保持既有键空间,就应把输入语料和预期结果一并纳入迁移回归,而不能只看方法名、空白测试或官方的一句话概述。

大小写转换是第二项独立策略。Locale.ROOT 把 i 转为 I,土耳其语地区则产生 U+0130。实验显式传入两个 Locale,没有修改 JVM 全局默认 Locale,避免影响同进程其他测试。输入 ß 转大写后得到 SS,长度也发生变化。若商户编号协议只允许 ASCII,适当的做法是先验证字符域,再按协议规范化;Unicode 大写转换不能替代协议校验。String 大小写说明

已有依赖与新增依赖的成本不同

实验工程已为整套系列引入两种库,但一个真实的小模块可能都没有。若模块只需上述受约束的 ASCII 转换,JDK 已能表达规则,引入 Guava 不会自动增加正确性。若模块已经使用 Guava 的集合或缓存,继续使用 CharMatcher 不再增加同一个 JAR 的下载体积,不过仍要接受其字符策略和升级回归责任。

本次直接读取 Maven 缓存中的已解析文件,Guava 主 JAR 为 3,017,283 字节,Commons Lang 主 JAR 为 713,862 字节。它们是这两个固定版本的压缩文件大小,不是 JVM 堆占用、加载类大小、APK 增量或部署镜像最终增量。更不是吞吐测试。两者功能范围不同,仅凭这个数字无法判定哪个库“臃肿”。原始结果随实验记录保存。

dependency:tree -Dscope=compile 还显示,Guava 依赖路径包含 failureaccess:1.0.3、空占位包 listenablefuture:9999.0-empty-to-avoid-conflict-with-guava,以及注解相关的 jspecify:1.0.0、error_prone_annotations:2.41.0、j2objc-annotations:3.1。Commons Lang 在本工程的编译依赖树中没有自己的传递依赖。这里只陈述实际解析结果,不把注解依赖等同于执行每个 String 转换都必须加载所有类。

Maven 的依赖调解还会受到调用方 POM、dependencyManagement、排除项和其他依赖路径影响。同一个主库坐标在另一工程中可能解析出不同传递版本。因此记录一份官方 POM 不足以证明应用实际运行的依赖集;应保存应用级依赖树,并在升级前后比较。Maven 依赖机制

规范化后的键空间也要验收

规范化通常是多对一变换。普通空格包围的 acme 与 ACME 经过整理会得到相同结果。这种合并只有在商户身份规则允许时才成立;原始标识若区分大小写,统一大写就会错误合并。规则应在导入接口建立时约定,并与外部系统交换的标识保持一致,不能在某个内部模块为了方便查询而单方面大写化。

幂等性是值得加入的回归性质:对规范化结果再次执行,结果应保持不变。它适合防止多层入口重复处理后继续损坏数据,但幂等本身不代表语义正确。把所有输入都变成空串也完全幂等,却丢失了商户身份。所以验收还需要检查预期冲突与不应发生的冲突,保留足够的原始输入样本解释结果。

本章使用的大小写转换也不承担 Unicode 标准化责任。看起来相似或相同的字符序列是否需要合并,是另一项协议选择。将 trim、大小写、标准化和合法性校验全部塞进一个名为 clean 的函数,会让调用者无法知道它丢掉了什么信息。更清楚的命名应反映该字段的具体契约,例如商户编码规范化,而不是承诺清理任意字符串。

规范化放在读取边界还是每次查询前,也会影响迁移。若历史目录保留原始键,新查询统一转换却没有重建索引,可能产生查询缺失。若导入与查询双方各自使用不同的空白定义,则会出现同一文本在写入和读取时生成不同键。替换实现时,需要把两个入口放进同一份契约测试,不能仅验证新增工具函数。

工具库选择因此包含一个容易漏掉的成本:已有数据是否需要迁移。规范化规则改变之前,应枚举变换前后合并的键,确定冲突处置策略,再更新索引。对于本文的教学样本,只展示有无边缘字符的差异,没有真实商户历史数据可供迁移,也就没有声称证明线上键空间无冲突。

依赖维护还包括源码和文档的对应关系。在线 Commons 文档可能已经指向后续快照,因此本章用固定提交解释 strip 分支;Guava 文档使用版本化路径。运行时字符分类再由两个实际 JDK 的输出补足。这样在以后升级任一依赖时,可以确定应该重验哪些结论,不必把所有字符串行为都归因于某一个库版本。

公开类型决定依赖传播范围

三个规范化函数都接收和返回 String。调用者不需要导入 Guava 或 Commons 类型,替换内部实现时,源代码层面的适配面较小;运行结果是否兼容仍由输入契约判断。把公开返回值改成 Guava Optional<String> 后,调用方的编译依赖也会改变,这就是下一篇涉及的类型边界之一。

只把依赖藏在私有方法里,也不能据此宣布运行时可以删除它。字节码中的方法调用仍指向第三方类,触发相应路径时需要满足类加载与链接要求。真正移除依赖,应删除或替换这些引用,重新构建,再验证执行路径。这里没有通过制造缺失 JAR 来证明完整链接行为,相关隔离实验留给依赖升级专题;本章的验收结论限于规范化行为与解析后的依赖清单。

维护成本同样不能伪造成一个精确分数。可以记录需要维护的内容:版本固定、升级后重跑字符矩阵、公开类型是否传播、部署是否已有此 JAR、业务是否还有其他成熟 API 需要复用。这些是可检查的决策项目;“节省十小时”一类数字若没有实际测量,就不进入表格。

当前条件 选择 需要验收的代价
仅 ASCII 边界清理,模块无库依赖 JDK 实现并限制输入字符域 控制字符和地区规则由应用明确
已有 Commons,需求采用 Character 空白语义 StringUtils.strip JDK 升级可能影响字符分类
已有 Guava,业务接受其 whitespace 集合 CharMatcher.whitespace 库升级后复验固定字符集合
需要自定义删除字符集合 显式规则,再选合适实现 先固定输入输出,不能靠默认名字猜测

包体记录也应与构建配置一起保存。只列本地缓存目录中的全部 JAR 会混入 Maven 插件依赖或旧版本,不能代表应用依赖树。本章只统计明确坐标的主文件,并用独立的依赖树列出编译依赖;测试工具和构建插件不被算成业务库的传递依赖。

实验边界与改动练习

完整测试在 Java 8 与 Java 21 各完成 2 个测试,失败、错误、跳过均为 0。运行说明列出工程坐标、调用命令及原始依赖树的位置。测试在五组普通输入上确认三者相同,用 U+00A0、U+001C 显示差异,再检查 null 拒绝、ROOT/土耳其语大小写和长度扩张。

手算题:输入两端是 U+00A0,中间为 acme,规范要求保留所有非 ASCII 字符。哪种实现会违反这条规则?Guava 的默认 whitespace 清理会删掉边缘字符。改成 Commons 或 JDK 后,也仍需检查它们对允许保留的其他控制字符是否满足要求。

改动练习:把契约收紧为“只能删除普通空格 U+0020,拒绝 null,内部空格保留”。为三种实现分别构造满足该契约的版本,再加入制表符、换行和不换行空格,要求所有结果逐项相同。此时才具备比较代码复杂度或性能的前提;用不等价的规范化结果比较快慢没有业务意义。

可迁移做法 应用于本例
先固定等价输入域,再比较替代方案 普通 ASCII 输入与 Unicode 反例分别验收
区分已有依赖的复用成本与新增依赖成本 包体、传递依赖、公开类型、升级回归分别记录

下一篇:Preconditions 与 Verify 的失败契约。系列入口:可复现基线。