Java常用类库-20-StringUtils的空值与边界契约
缺失名称不一定等于空名称
商品更新接口收到缺失的 name,可能表示保留原名称;收到空字符串则可能表示主动清空。若入口统一调用 StringUtils.defaultString,两者都变成空字符串。下游没有再抛 NullPointerException,但原先可以区分的两个业务动作已经合并。
StringUtils 所谓 null 安全,通常指方法对 null 定义了返回行为,不意味着所有方法返回相同默认值,更不意味着结果符合业务约束。trim(null) 返回 null,split(null) 返回 null 数组,defaultString(null) 返回空字符串。读取这些返回值的下一步仍可能失败,例如直接访问 split 返回数组的 length。
本篇固定 Commons Lang 3.20.0、Guava 33.5.0-jre,代码以 Java 8 为基线,运行环境为 Zulu 8.0.472 与 Corretto 21.0.11。完整 测试源与 运行说明对照空白、切分、截取及默认值;前置语义见 第 01 篇。
trim 与 strip 使用不同判定
trim 沿用 String.trim 的规则,裁掉两端不大于 U+0020 的代码单元。strip 在未指定 stripChars 时,按 Character.isWhitespace 判断两端字符。这两个集合并不相同。将方法名由 trim 改成 strip,可能让原先删除的字符留下,也可能开始删除此前保留的字符。
实验分别把三个字符放在 A 两侧:U+0000、U+2003 EM SPACE、U+00A0 NBSP。trim 删除 U+0000,strip 保留;strip 删除 U+2003,trim 保留;两者都保留 U+00A0。Guava CharMatcher.whitespace().trimFrom 则删除 U+00A0。这些结果与 StringUtils 的 strip/trim 契约及 JDK Character.isWhitespace一致。
| 两端字符 | trim | 默认 strip | Guava whitespace trim |
|---|---|---|---|
| U+0000 | 删除 | 保留 | 不以“所有控制字符”作统一规则 |
| U+2003 | 保留 | 删除 | 属于其空白集合 |
| U+00A0 | 保留 | 保留 | 删除 |
| U+200B | 不因不可见而删除 | 不因不可见而删除 | 保留 |
表的前三列以本章断言及对应公开契约为依据。第四列不是“更完整”的空白定义,只是另一组明确字符集合。NBSP 具有不换行语义,是否删除取决于字段格式;把不可见字符全部当成无意义噪声,会误删格式信息。
3.20.0 固定源码中,stripStart 在 stripChars 为 null 时循环调用 Character.isWhitespace,在自定义字符串分支则按 indexOf 判断是否属于字符集合。因此 stripChars 不是正则,也不是要完整匹配的前缀文本。stripStart(“yxabc”, “xyz”) 可以删除 y 和 x,而不是只尝试删除字符串 xyz。
Character 的分类表来自运行时 JDK,具体 Unicode 版本会影响部分字符。第 01 篇已用 U+180E 记录 Java 8 与 21 的差异。本章沿用空串、ASCII 空格、制表符、NBSP、EM SPACE、零宽空格和 BOM 的对照样本,分别检查 isBlank 与 Character 判定一致。不能据此宣称所有 Unicode 字符在两个 JDK 上分类相同。
可迁移判断是把清理规则写成输入集合。配置中写“去空白”还不够,需要说明是哪一种字符判定、仅首尾还是全文、是否允许改变展示内容。商品标识、搜索词和日志消息可以选择不同集合,不应为了复用工具函数强制合并。
split 的默认行为会删除空项
StringUtils.split(“,A,”, ‘,’) 返回只含 A 的数组,splitPreserveAllTokens 才返回四个字段。Guava 默认 Splitter 则保留这四个字段。字符串从一种工具切换到另一种,即使方法名都含 split,列数仍可能改变。
空输入也有差异。StringUtils.split(“”, ‘,’) 返回长度为零的数组;Guava 默认 Splitter 对空字符串返回含一个空字符串的列表。导入固定列记录时,这个区别影响“没有记录”和“一条空字段记录”的判断。测试直接断言数组内容与长度,避免打印为方括号时无法看清内部是否存在空字符串。
多个分隔字符又是一种契约。StringUtils.split(“A:B;C”, “:;”) 把冒号或分号都当分隔符,得到 A、B、C;splitByWholeSeparator 使用整个字符串 “:;”,本例没有连续出现这个序列,因此只返回原字符串。不能把接收 String 参数就理解为按整个字符串分隔。这里与 第 07 篇的字面与正则分隔一样,需要先固定分隔语言。
splitWorker 的 char 分支跟踪 start、match、lastMatch 以及 preserveAllTokens。遇到分隔符时,只有已经积累字符或启用了保留选项,才把当前区间放进结果;结尾是否补空项也依赖标志。这解释了重复分隔符为什么通常被合并。它没有引号状态,因此普通 split 仍不能解析含引号的 CSV。
若行号和字段位置用于报错,先丢掉空项再校验数量会破坏原始列位置。更稳妥的顺序是按格式保留字段结构,验证列数,再分别验证每列内容。标签列表可以按业务约定忽略空项,金额列则通常不能。方法返回非 null 只是流程中一个条件,不能替代完整记录校验。
容错 substring 仍按 UTF-16 截取
StringUtils.substring 支持负索引,并将部分越界位置调整到有效范围。substring(“ABC”, -2) 得到 BC,substring(“ABC”, 9) 得到空字符串,substring(“ABC”, -9, 9) 得到 ABC。JDK String.substring(9) 对同一字符串会抛 StringIndexOutOfBoundsException。
负索引先从末尾折算,然后处理小于零或超过长度的边界。固定 substring 实现最终仍调用 String.substring,所以索引单位仍为 UTF-16 代码单元。测试对一个表情取区间 [0,1),结果只含高代理项 D83D。这种容错解决的是位置越界,不负责保持 Unicode 码点或字素完整。
业务若用负索引显示编号最后四位,容错返回完整短编号可能符合展示需求;若用同样方法截取定长协议字段,短输入没有抛异常反而会隐藏坏记录。两种代码都“没有越界异常”,质量却取决于此前是否验证了长度。协议解析通常应先校验结构,再按精确位置截取。
代码点处理边界见 第 08 篇。改成 offsetByCodePoints 可以建立码点索引,但仍不能直接处理组合字符与界面宽度。方法名包含 substring,也不意味着它能满足所有形式的“截前几个字”。
可迁移模式是区分展示容错与协议验证。前者可以对缺少内容给出短结果,后者需要区分格式不足和合法空值。切分、截取、默认值工具都可能把错误转换为普通值,调用位置决定这种转换是预期行为还是信息丢失。
默认值需要定义触发条件
defaultIfEmpty 只把 null 和零长度序列转换为默认值;defaultIfBlank 还会检查 Character.isWhitespace 意义上的全空白。一个普通空格在前者中保留,在后者中触发 fallback;NBSP 在两者中均保留。测试专门比较这三个输入,防止把 blank 理解为“显示起来没有内容”。
调用方还需处理默认值本身为 null 的情形。方法允许返回所提供的默认值,不保证结果非 null。若后续逻辑要求非空商品名,就应该在替换后验证最终值,或直接拒绝缺失输入。字符串工具承担转换,业务校验承担拒绝策略,两者不能因为都返回 String 就合成一个隐含约定。
另一个容易遗漏的成本是默认值求值时机。Java 在调用方法前计算参数表达式,即使原字符串最终被保留,普通 defaultIfBlank 的第二个参数表达式也已经求值。这来自 Java 8 方法调用表达式规则。如果默认值需要远程查询或昂贵计算,应选择显式条件或具有惰性供应契约的 API;本章未执行远程调用实验。
默认值还会改变可观测性。原始 name=null 与 name=“” 在 defaultString 后都变成空串,错误报表再按结果分组便无法分别统计。若运营需要区分缺字段、空字段与空白字段,应在转换前记录分类,错误信息只保留定位所需内容,避免把整个请求原文写入日志。
将字符测试转换为导入规则
测试矩阵可以按三个互不替代的轴组织:缺失状态、字符内容、结构位置。缺失状态包括 null 与空串;字符内容包括普通空格、不可见字符和非空文本;结构位置包括首尾、字段中间和分隔符之间。只测试普通名称两侧有空格,无法发现空字段丢失,也无法发现默认值合并更新指令。
例如名称为一个普通空格时,先执行 trim 再检查 isEmpty,可以拒绝它;名称是 NBSP 时,相同流程保留该字符。这个结果本身没有证明实现错误,错误在于需求如果声称拒绝所有视觉空白,却没有给出可执行的字符定义。验收需要引用确定的规则,并用业务接受与拒绝样本约束规则,而不是根据屏幕显示判断。
字段结构应比内容清理更早固定。对记录整体进行 trim,可能删除末列具有意义的空格;对字段先过滤空串,可能改变列位置。更容易定位错误的处理链是先识别记录边界,再解析分隔结构,然后对每个字段按自身协议清理。若字段允许引号或转义,就应使用具备相应状态处理的解析器,不能继续叠加 split 参数尝试覆盖格式。
长度验证还需要说明验证前还是验证后。例如编号要求恰好八个代码单元,先补默认名称再验证长度与先验证原输入,是不同协议;先截取到八位再验证只会保证结果长度,无法证明输入原本合法。输入值、规范化值与展示值可以分别命名,减少一个变量在多步修改后失去原始语义的风险。
升级验证也应保留这些轴。将同一组固定输入同时交给旧实现和候选实现,逐项比较值、数组长度、异常类别与代理项完整性。若新规则确实要删除更多字符,应把差异列为格式变更,再决定历史数据如何迁移。测试不应为了消除差异而把两个结果再次统一清理,否则会遮蔽真正需要审查的行为。
官方在线文档可能跟随新版本更新,因此查方法时还要核对固定源码中的注释与实现。本文的结果绑定到给定版本和样本,不把在线页面新增的重载或弃用说明反向套到旧版本。运行环境的字符分类变化与库本身的实现变化,应作为两种独立变量排查。
实验结果与边界练习
Chapter20Test 在 JDK 8、21 各运行四个方法,失败、错误、跳过均为 0。测试覆盖空白判定、切分空项和分隔符集合、负索引及代理项截断、默认值合并。原始命令、环境与 Surefire XML 位于工程 evidence/20。没有运行文本吞吐基准,也没有遍历全部 Unicode 码点。
反例题:导入协议要求三列,输入 A,C 能否先 StringUtils.split 再检查第二列为空?不能。默认 split 已删除空字段,结果只剩两项。若业务忽略列数继续按位置读,C 会被误认为第二列。应使用保留空项的解析方式,再按第三列契约检查 C。
改动练习:为名称更新增加“缺失表示不更新、空串表示清空、全空白拒绝”的规则。至少用 null、空串、普通空格、NBSP、正常名称五种输入验收,并明确 NBSP 是否属于业务拒绝集合。不要在判断缺失之前调用 defaultString,否则第一条规则无法实现。
| 需求信号 | 判断模式 | 验收位置 |
|---|---|---|
| 去空白 | 写出字符集合 | trim/strip/Guava 对照输入 |
| 固定列记录 | 保留结构再验证 | 保留空项与列数断言 |
| 展示截断 | 区分容错与格式验证 | 短输入及 Unicode 边界 |
| 缺失字段补默认 | 保留业务状态差异 | null、empty、blank 分别测试 |

