Java常用类库-07-Joiner与Splitter的分隔契约
商品标签中的空项是什么
商品导入记录用逗号连接标签,输入为 ,新品,,促销,。预期结果可能是五个位置固定的字段,也可能是两个有效标签。这两种需求使用相同字符串,却不能使用相同的丢弃规则。空项一旦被删除,原来位于哪个位置也随之丢失;下游无法根据剩余值判断是缺字段、空字段还是多余分隔符。
Guava 33.5.0-jre 的 Splitter 默认保留空项,Joiner 默认拒绝 null。它们没有自动推断业务格式的职责。标签导入若决定忽略空项,可以明确配置;价格、数量等按列定位的记录若套用这项配置,则可能把下一列的值读到前一列。类库调用之前应先决定空项是否携带位置意义。
这里的验证使用 Java 8 API、Guava 33.5.0-jre,分别运行在 Zulu 8.0.472 和 Corretto 21.0.11。完整测试是 Chapter07Test.java,运行方式见 RUN.md。基础工程下载入口位于 第 00 篇。
保留规则决定输出形状
将 ,A,, 按逗号切分,手算共有三个分隔符、四个字段。默认 Splitter 的结果为 ["", "A", "", ""];Java 8 的 String.split(",") 返回 ["", "A"],因为默认等价于 limit 为零,尾部空字符串被丢弃。显式使用 split(",", -1) 才保留这些尾项。这是 String.split 的公开契约,不是不同虚拟机的偶然表现。
| 输入或选项 | Guava 结果 | 业务含义 |
|---|---|---|
Splitter.on(',') 处理 ,A,, |
四个字段,保留三个空项 | 仍能定位每列 |
再加 omitEmptyStrings() |
只有 A | 空项和位置丢失 |
| 默认切分空字符串 | 含一个空字符串的列表 | 一项空字段 |
| 忽略空项后切分空字符串 | 空列表 | 没有有效字段 |
“空列表”和“含一个空字符串的列表”打印时容易混淆,测试应检查 size 和元素值,不能仅比较控制台的方括号外观。示例测试分别断言单元素列表和空列表,也覆盖了首尾分隔符。固定列数协议还应对解析后的字段数量做校验:保留空项解决的是分隔语义,不负责证明必填项存在。
另一个差异在分隔符解释方式。Guava 的 on(".") 接受字面字符串,因此 A.B 分成 A、B。JDK 的 split(".") 把点解释为正则表达式中的任意字符,本例最终得到空数组。JDK 可以用 Pattern.quote 表达字面分隔符;Guava 也可以通过 onPattern 选择正则。迁移代码时只替换类名而保留分隔符参数,可能改变整个解析语言。
Splitter 的配置实例不可变,调用 trimResults() 会返回新实例,忽略返回值不会改变原实例。一个预先配置好的实例可作为静态常量复用;被解析的输入若是可变 CharSequence,则输入自身的并发修改仍需要单独约束。splitToList 已经物化输出,split 返回的 Iterable 可以惰性求值。这些保证都来自 冻结版本 Splitter 文档,不意味着任意输入对象都被防御性复制。
trim、omit 与 limit 的执行关系
标签配置常写成 trim 加 omit:先去掉每项两端空白,再丢弃清理后为空的项。因此 " , A , , B , " 得到 A、B。配置方法的书写先后不改变这个执行关系,omitEmptyStrings().trimResults() 仍然先 trim。默认空白集合采用 Guava CharMatcher.whitespace()。String.trim() 则按小于等于 U+0020 判断,两者接受集合不同;具体差异见 第 01 篇。
limit 限制的是输出项数量。对 " A , , B ,, C,,D " 使用 trim、omit、limit(3),输出为 ["A", "B", "C,,D"]。第三项取得余下字符串,其中的两个逗号不再继续切分。已经忽略的空项不消耗这三个输出名额。把 limit 理解成“最多读取三个分隔符”会计算出不同答案。
固定 SHA 的 SplittingIterator可以直接解释这个结果:先找到当前分隔符边界,再推进 start/end 以移除首尾匹配字符;如果要求 omit 且 start 等于 end,则继续寻找下一项,尚未减少 limit。只有接受一个非空结果时才消费名额。limit 剩一时把 end 延伸到输入末端,并再次裁掉末端空白。
这个顺序还有资源方面的边界:limit 不等于输入长度上限。最后一项可以很长,忽略空项之前也可能扫描大量分隔符。导入接口仍需在接收层限制原始字符串长度,在业务层检查字段数量和单项长度。四个字段的小型实验没有测量超长输入的运行时间,不能据此给出吞吐或拒绝服务防护结论。
可迁移的判断是先定义信息保留范围,再决定清理顺序。日志标签、搜索关键词、枚举过滤列表常允许忽略空项;固定列协议、可选参数占位和带位置含义的路径段通常需要保留。清理动作应有可写出的前后关系,例如“只删除标签两侧空白,标签内部空白保留”,而不是统称为字符串清洗。
Joiner 的 null 策略不能恢复原值
默认 Joiner 遇到 null 抛 NullPointerException。skipNulls() 将 null 对应的元素整项删除,useForNull("?") 把它编码成问号。对于 ["A", null, "B"],两种结果分别为 A,B 和 A,?,B。但如果原始业务值本来就允许问号,解析后无法判断问号来自真实数据还是替代值。删除 null 则连原来的元素个数都无法恢复。
JDK 8 String.join 对 null 元素使用字符串 "null"。这个差异可以影响日志文本和签名输入,测试对单个 null 元素做了断言。项目若以往接受 null 并写入文字 null,换成默认 Joiner 会提前抛异常;若改成 skipNulls,又进一步改变了位置信息。应先决定缺失值是否允许进入这个字段,再选择库调用。Joiner 的 null 处理契约与 JDK String.join分别定义了这些行为。
即使所有元素非 null,join 与 split 也不必互逆。["A,B", "C"] 和 ["A", "B,C"] 都会被 Joiner 编码成 A,B,C。两个不同输入映射到同一输出,解码器没有足够信息恢复边界。换一个罕见分隔符只会缩小碰撞机会;除非输入契约明确禁止该字符,否则不能证明无损。
无损序列化需要额外结构,例如字段长度、转义规则,或具有明确方言的 CSV/JSON。普通 Splitter 不识别引号,"A,B",C 会被切成三项,双引号仍是普通字符。对商品名称允许逗号或换行的导入文件,应使用对应格式解析器;自行追加一个去引号步骤也无法处理被引用的换行和双引号转义。
这种判断可以迁移到复合缓存键、消息路由键和签名原文:先尝试构造两组不同输入得到相同编码。找到一组碰撞,就足以否定“连接后可以无损还原”;没有找到碰撞则尚不足以证明编码可逆。需要可逆时,编码协议本身应包含解除歧义的信息。
MapSplitter 接受的是受限键值语法
商品属性可以用 color=red;size=L 表达。外层 Splitter 按分号划分条目,内层按等号划分键和值。默认的 withKeyValueSeparator('=') 要求每个条目恰有两部分;color= 保留空值,token=A=B 则因为多出一部分而抛异常。缺少等号同样不满足结构要求。
如果业务允许值中出现等号,可以显式用内层 Splitter.on('=').limit(2),把第一次等号以后的内容作为整个值。这个规则没有解决分号出现在值中的情形,也没有引入引号或通用转义。协议扩展必须逐种说明允许的字符,不能把 limit(2) 当成通用配置解析器。
重复键会抛 IllegalArgumentException,不会自动选择首值或末值。源代码的 MapSplitter 在插入前调用 containsKey 检查已存在的键,然后检查内层迭代器是否多出部分。这样,同一输入 color=red;color=blue 不会因为无意的 Map.put 覆盖而悄悄改变含义。重复声明究竟是错误、覆盖还是多值,应由配置格式定义;需要多值时可采用下一类数据结构,而不是先解析成普通 Map 再猜测被覆盖的内容。
分隔表达式也有输入契约
正则分隔器和字面分隔器不应混在同一配置入口中。若接口接收用户指定的分隔字符串,传给 on(String) 表示按字面匹配;传给 onPattern 则相当于允许用户提交一段表达式。输入中出现点、竖线、方括号时,解释结果会变化。需要动态表达式的场景应显式命名为正则配置,并另行限制表达式与输入规模。本实验没有测量任意正则的耗时。
固定实现对能匹配空字符串的正则会在构造时拒绝;某些只在特定位置产生零宽匹配的模式,则在迭代中通过推进扫描偏移避免停留原地。这解释了源码中 separatorPosition 之后仍有 offset 检查。应用不宜利用这段处理构造隐含的复杂词法规则,原因是输出项的语义将依赖正则、空项处理和 limit 三套条件。字段协议能用单个明确分隔符表达时,选择字面入口更容易审查。
错误的发现时间也应纳入调用约定。创建 Splitter 验证的是配置,例如 limit 必须为正数;处理输入的异常则发生在实际求值时。split 返回 Iterable,调用方将求值移到别的线程或延后遍历,就会同时移动解析工作与异常位置。入口处需要完整校验时,splitToList 物化结果后再返回更直接;需要惰性消费时,应把异常处理放在消费阶段,且保证原始 CharSequence 不被并发修改。
这与一次成功的字符串切分不同:配置可复用不代表解析结果已算好,方法返回也不代表整个输入已通过验证。导入流程若要在写数据库之前发现坏行,必须在事务写入前完成所需的解析与业务校验。普通 Splitter 只提供分段,金额格式、字段数量和重复编号仍由后续步骤验证。
对带行号的导入记录,解析失败还应保留原始行号及字段位置。过早删除空项会使报错位置与原文件错位,即使最后成功抛出异常,也可能无法定位需要修复的字段。
实验结果与改动题
附件测试包含四组场景:空项及 JDK 默认差异、trim/omit/limit 和配置不可变、null 及分隔符碰撞、MapSplitter 的重复键与额外分隔符。双 JDK 各运行 4 个测试方法,失败 0、错误 0、跳过 0;原始 Surefire XML 和命令输出位于工程的 evidence/07/。这些断言验证语义,没有执行性能基准,也没有验证 CSV 方言。
反例题:允许标签中出现逗号,同时允许 null,能否通过 useForNull("") 加默认 Splitter 无损恢复?不能。null 与原始空字符串产生相同编码,含逗号的标签又与多个标签产生相同编码。两个独立歧义中修复一个,另一个仍然存在。
改动练习:把属性条目改为“重复键保留全部值”。验收应增加同名属性的两个值、空值、值含等号三种输入,并明确键的迭代顺序。不能只把异常删除后继续 Map.put,因为那只实现了覆盖语义。
| 需求信号 | 可迁移判断 | 具体落点 |
|---|---|---|
| 固定列、位置有意义 | 明确信息保留范围 | 保留空项并校验列数 |
| 标签清理、空项无意义 | 写出清理顺序 | trim 后 omit,另限输入长度 |
| 复合键、要求往返 | 寻找编码碰撞 | 使用转义、长度或标准格式 |
| 重复属性 | 先定义重复策略 | 拒绝、覆盖、多值分别实现 |
继续阅读:第 08 篇:字符处理的单位。
