Java常用类库-21-数值布尔与枚举的失败语义
坏库存不能自动变成零
库存导入字段 quantity 收到文本 bad。Integer.parseInt 抛异常,NumberUtils.toInt 返回默认的零。系统因而可以继续执行,但“库存为零”和“库存无法解析”变成了同一个结果。若后续按零库存下架商品,一次格式错误就会改变商品状态。
默认值不是独立于业务的容错功能。它把原本属于错误通道的信息折叠进正常值域。商品数量、金额、开关和运行模式都可能受影响,只是折叠的值分别表现为零、false 或某个枚举常量。选择工具函数之前,应先决定哪些失败允许退回默认配置,哪些必须阻止当前记录生效。
本篇固定 Commons Lang 3.20.0,Java 8 API,运行环境为 Zulu 8.0.472 与 Corretto 21.0.11。完整实验包含非法文本、溢出、指数、布尔三态、未知枚举和 Validate。运行说明沿用系列工程;字符串缺失与空白定义可参照 第 20 篇。
toInt 将解析失败映射到默认值
本实验把 null、空串、bad、带前导空格的 " 2"、2147483648、1e3 分别传给 NumberUtils.toInt(input, -1),全部得到 -1。严格 Integer.parseInt 对这些输入均抛 NumberFormatException。输入 “0” 与 bad 在无第二参数的 toInt 中都会得到零,证明结果本身不足以恢复解析是否成功。
3.20.0 NumberUtils 固定源码直接调用 Integer.parseInt,在 RuntimeException 分支返回 defaultValue。这里没有独立的十进制扫描器,也不会自动 trim。溢出与字符非法沿同一默认路径处理,错误类别没有保留在返回值中。
用 -1 作为哨兵比无条件返回零保留了更多区分,但仍要求业务值域排除 -1。若负库存用于表示欠货,哨兵又与合法值发生碰撞。更清楚的入口可以严格解析后产生“成功数量”或带字段名、行号、原因的失败记录,默认值只用于明确允许缺省的配置字段。
这里的错误报告不需要曝光整个原始记录。商品导入可以报告“第 17 行 quantity 不是合法整数”并保留受限长度的字段摘要;令牌、个人资料和其他列没有必要一同写入。错误的位置和类别比单纯把异常转换为默认值更有助于修复输入。
可迁移模式是先画出成功值域,再决定失败如何表示。如果所有 int 都可能合法,就不能再从 int 中选择一个绝无冲突的错误值;如果只有非负值合法,负数哨兵仍需在接口契约中保留并校验。该判断同样适用于文件大小、分页参数与配置优先级。
“是数字”取决于解析语言
NumberUtils.isCreatable 检查的语言比十进制整数更宽。实验中 1e3 可创建 Number,数值为 1000;0x10 可创建十六进制数值 16;09 则不符合该入口对前导零数值的规则。另一方面,toInt(“09”) 按十进制解析得到 9。不能用 isCreatable 预检后就认定 Integer.parseInt 必然成功。
NumberUtils API说明 createNumber 接受的格式及可能返回的 Number 子类型。实现会根据前缀、指数、小数点和类型限定符走不同分支。它适合需要这种复合数字语法的场景,但商品数量若只允许十进制整数字符串,应直接定义较窄语法。
BigDecimal 接受 1e3,其表示在实验中与 new BigDecimal(“1E+3”) 相等;它不接受 0x10。金额业务即使使用 BigDecimal,仍需限制允许的输入形式、精度与范围。使用任意 Number 再调用 intValue 或 doubleValue,也不代表转换没有截断或精度损失。本实验的 doubleValue 仅验证 1e3 对应的数值,不是通用金额处理方式。
十进制外观与存储类型还应分开。格式 “0010” 是否保留前导零取决于它是编码还是数量:商品编号应保留字符串身份,不能因为只含数字就解析成整数;库存数量则可接受统一表示。一个工具类无法根据字段内容判断其业务含义。
从源码跟踪时,值得关注的是“选哪种语法”和“产生哪个结果类型”,而不是把 NumberUtils 的所有方法列出来。对一个输入验证器而言,预检方法和正式解析方法必须接受同一种语言;否则预检成功后的异常仍会进入业务主路径。若要使用预检,测试应同时断言“被接受的每种样本都能由目标解析器处理”。
三态布尔与二态默认
BooleanUtils.toBooleanObject(“yes”) 得到 TRUE,“off” 得到 FALSE,“maybe” 得到 null。返回 Boolean 的意义在于保留一个未知状态。继续调用 toBoolean 或 toBooleanDefaultIfNull(…, false),未知就与明确 false 合并。是否允许这项合并应由开关契约决定。
JDK Boolean.parseBoolean(“yes”) 返回 false,因为 JDK 的真值语法与 Commons 不同。BooleanUtils 固定实现按字符串长度及字符比较识别可接受文本,无法识别则返回 null。JDK 与 Commons 都有可用场景,迁移时要检查外部配置文件实际使用哪些真值和假值。
对于安全开关或写入许可,unknown 自动退回 false 可能符合关闭优先策略;对于“是否继承上层设置”,unknown 可能需要表示未配置,不能提前变成 false。业务若明确只接受 yes/no,可以使用指定真值和假值的重载,未匹配文本抛 IllegalArgumentException。测试对 maybe 验证了这条拒绝路径。
Boolean 三态也不等于完整诊断。null 仍可能同时代表输入缺失和输入非法。若外部协议需要区分两者,必须在解析前检查缺失,或让解析结果携带原因。三态只是比二态保留更多信息,不能自动满足所有错误分类需求。
可迁移模式是尽量在决策边界才折叠未知状态。读取配置时保留原状态,合并层级配置时区分未指定,最终执行动作时才按明确策略决定开或关。数据库 nullable 字段、可选查询条件和继承式配置都适用这一思路。
未知枚举既可能是坏数据,也可能是新版本
枚举 Mode 只有 SAFE、FAST。EnumUtils.getEnum 对小写 safe 返回 null,getEnumIgnoreCase 返回 SAFE,带默认值的 getEnum 对 unknown 返回 SAFE;Mode.valueOf 对 unknown 抛 IllegalArgumentException。大小写宽容和未知值回退是两个不同功能,不应通过一个“宽松解析”标签混在一起。
EnumUtils 的 getEnum 实现检查缺失后调用 Enum.valueOf,捕获 IllegalArgumentException 返回默认值。这使方法便于读取可缺省配置,但也把拼写错误与新版本枚举值放进相同返回路径。
服务之间传输枚举时,旧消费者遇到新生产者新增的值,不一定应该当成默认模式执行。例如新值可能代表一种不同的价格计算策略,回退 SAFE 虽然避免异常,却可能产生错误金额。可以明确拒绝并报告版本不支持,或保留原始字符串进入 UNKNOWN 分支;选择应写进协议兼容约定。
应用内部的有限状态机则通常应严格限制状态。记录从 PENDING 转为 PAID 是否允许,属于状态迁移规则,EnumUtils 只处理名称到常量的映射。解析成功无法证明这个状态在当前上下文有效,同样不能代替权限、时序和业务约束校验。
Validate 检查的是已知条件
实验使用 Validate.notNull 检查缺失 quantity,使用 Validate.isTrue 检查非负数量。前者抛 NullPointerException,后者抛 IllegalArgumentException,并保留指定的错误消息。调用方不能假定所有 Validate 失败都属于一种异常类型;异常契约应与统一错误响应层对齐。
Validate API把参数条件检查集中为方法调用,减少重复 if/throw。它不会解析文本,也不会决定默认值是否合理。把 NumberUtils.toInt(“bad”) 得到的零传给“数量大于等于零”的校验,会成功通过,因为解析失败的信息已经丢失。
正确顺序应由业务条件推出:先识别缺失,再按目标语法解析,再验证范围与跨字段约束,最后构造业务对象。失败可以在每一步转成带位置和原因的统一错误,但不能在每一步都先替换成一个正常值,再期待末尾的范围校验识别原始问题。
错误消息也属于接口。直接把某个第三方异常全文回传会耦合库版本和内部格式;仅返回“失败”又不足以定位。稳定的业务错误码加字段位置可以隔离库异常细节,原始异常仅在适当的诊断位置保留。这里没有实现统一服务错误框架,测试只验证本章公开工具的返回和抛出行为。
解析错误与业务错误分别保留
导入系统可以在边界保存三类结果:没有提供字段、提供了无法解析的字段、解析成功但不满足范围。它们对应不同修复动作。缺失可能补默认值,拼写错误应修正原文本,超出范围则需要核实业务数量。全部转成零再交给下游,会把三类问题都表现为同一种库存变化。
异常类型也不能直接替代业务错误码。整数解析溢出和字符非法都可能产生 NumberFormatException,但产品界面可能需要分别提示;Validate 的参数异常则发生在另一阶段。统一响应层应根据明确的处理阶段生成错误,而不是只根据最终捕获到的类名决定提示文本。
批量处理还需要确定拒绝单位。一行数量非法,可以选择拒绝该行并保留其他行,也可以让整批导入失败;无论选择哪一种,都应先收集解析结果,再按约定提交。工具类返回默认值不会自动建立批次事务,异常被捕获也不意味着已处理的写入被撤销。本实验只覆盖解析函数,不对数据库提交行为作保证。
对于配置默认值,应记录默认触发的原因和来源层级。字段完全不存在时采用系统默认,与用户明确填写无法识别文本后退回默认,对排错有不同含义。可以允许第一种情况,同时拒绝第二种。这样既保持配置简洁,又不会让一个拼写错误长期以普通运行状态存在。
迁移时比较的输出也应包含失败分类。只统计新旧实现处理后得到的整数相同,会把两边共同错误回退为零误判成成功。验收需要同时对照接受或拒绝、解析后的数值、默认是否触发,以及错误定位是否保留。合法数据的一致性与坏数据的处置一致性缺一不可。
实测结果与练习
Chapter21Test 在双 JDK 各通过四个测试方法,失败、错误、跳过均为 0;原始输出和 XML 在 evidence/21。数值实验包含溢出与指数,布尔实验包含未知值,枚举实验包含大小写和默认分支。没有对全部 NumberUtils 格式组合执行穷举,也没有测试本地化数字格式。
反例题:先用 NumberUtils.toInt(input) 再 Validate.isTrue(value >= 0),能否拒绝非法库存文本?不能。bad 已经变成零,范围条件成立。把默认改成 -1 可以修复这个受限非负值域的例子,但错误类别仍会合并,不能代替需要精确报告的解析接口。
改动练习:为库存字段实现“缺失使用上次库存,非法拒绝,合法负数也拒绝”。测试 null、空串、bad、0、-1、整数溢出和正常正整数,断言只有缺失能够进入继承分支。默认值只能出现在已经确认缺失之后,不能覆盖所有解析异常。
| 需求信号 | 判断模式 | 验收重点 |
|---|---|---|
| 解析失败返回零 | 检查成功值域与失败值碰撞 | 非法文本与合法零对照 |
| 是数字即可 | 预检与解析使用同一语言 | 指数、前导零、溢出 |
| 开关未配置 | 延迟折叠未知状态 | null、false、非法文本 |
| 枚举扩展 | 显式协议兼容策略 | 未知值不静默执行 |
| 参数校验 | 保留解析错误再校验范围 | 失败顺序与错误分类 |
