原创

从数字前缀到合法金额:用 ParsePosition 建立完整输入边界

金额输入框收到 12.50kg,调用 NumberFormat.parse 却返回了数字,控制器也顺利往下走。这个接口能够解析数字前缀,并不承诺整个字符串都符合金额协议。把“返回非空”当作输入合法,尾部内容就会被悄悄丢掉。

再加一句“检查已经读到末尾”,还没解决所有问题。美国格式里的 1,2 可能被当成 12,解析位置也确实到达末尾。完整消费和分组是否合法,是两个不同的判断。下面不修饰一个通用数字工具,而是完整实现一个有明确输入合同的金额入口。

先把输入合同写窄

这里支持两种显示约定:US 用逗号分组、点作小数符号,DE 则相反。调用者必须显式选择一种,程序不根据字符串猜地区。没有分组也可以;一旦写了分组,第一组一至三位,后面每组恰好三位。小数部分至多两位,若有小数符号,后面至少一位数字。

此外,接口只接收 ASCII 数字,不接收货币符号、正负号、指数、前后空白和多余前导零。范围是零至一百万,最终返回保留两位的 BigDecimal。这些限制属于本例的业务协议,不是对所有地区金额表达方式的概括。要接收更多文字数字或会计负数,应另开明确规格,不能悄悄放宽这个入口。

64 个字符的上限先于格式解析检查,用来限定这条窄协议的工作量。金额范围本身会限制有效数字,但不能因此让任意长的无效文本先进入后面的处理。输入约束来自字段含义,没必要先允许超长字符串,再依赖解析器在深处拒绝。

完整程序

保存为 CompleteAmount.java,使用 Java 17 执行 java CompleteAmount.java。程序先展示两个宽松解析结果,再逐项调用金额入口。没有第三方依赖,也不需要修改进程默认 Locale。

import java.math.*;
import java.text.*;
import java.util.*;
import java.util.regex.Pattern;

public class CompleteAmount {
    enum Style {
        US(Locale.US,
            "(?:0|[1-9][0-9]*|[1-9][0-9]{0,2}(?:,[0-9]{3})+)(?:\\.[0-9]{1,2})?"),
        DE(Locale.GERMANY,
            "(?:0|[1-9][0-9]*|[1-9][0-9]{0,2}(?:\\.[0-9]{3})+)(?:,[0-9]{1,2})?");
        final Locale locale;
        final Pattern grammar;
        Style(Locale locale, String expression) {
            this.locale = locale;
            this.grammar = Pattern.compile(expression);
        }
    }
    record InputError(String code) {}
    static final class Rejected extends IllegalArgumentException {
        final InputError error;
        Rejected(String code) { super(code); error = new InputError(code); }
    }
    static BigDecimal amount(String text, Style style) {
        Objects.requireNonNull(style, "style");
        if (text == null) throw new Rejected("MISSING");
        if (text.isEmpty() || text.length() > 64) throw new Rejected("LENGTH");
        if (!style.grammar.matcher(text).matches()) throw new Rejected("SYNTAX");
        NumberFormat format = NumberFormat.getNumberInstance(style.locale);
        if (!(format instanceof DecimalFormat decimal)) {
            throw new IllegalStateException("DecimalFormat required for supported locale");
        }
        decimal.setParseBigDecimal(true);
        ParsePosition position = new ParsePosition(0);
        Number parsed = decimal.parse(text, position);
        if (!(parsed instanceof BigDecimal value)
                || position.getErrorIndex() >= 0
                || position.getIndex() != text.length()) {
            throw new Rejected("PARSE");
        }
        if (value.signum() < 0
                || value.compareTo(new BigDecimal("1000000.00")) > 0) {
            throw new Rejected("RANGE");
        }
        return value.setScale(2, RoundingMode.UNNECESSARY);
    }
    static void show(String text, Style style) {
        try { System.out.println(style + " [" + text + "] => " + amount(text, style)); }
        catch (Rejected e) { System.out.println(style + " [" + text + "] => " + e.error.code()); }
    }
    public static void main(String[] args) {
        NumberFormat loose = NumberFormat.getNumberInstance(Locale.US);
        for (String text : List.of("12.50kg", "1,2")) {
            ParsePosition p = new ParsePosition(0);
            System.out.println("loose [" + text + "] => " + loose.parse(text, p)
                    + ", consumed=" + p.getIndex() + "/" + text.length());
        }
        show("1,234.50", Style.US);
        show("1.234,50", Style.DE);
        show("12.5", Style.US);
        show("0", Style.US);
        show("12.50kg", Style.US);
        show("1,2", Style.US);
        show("1,2", Style.DE);
        show("01.20", Style.US);
        show(" 12.50", Style.US);
        show("1.234", Style.US);
        show("1000000.01", Style.US);
        show(null, Style.US);
        if (amount("1,234.50", Style.US).compareTo(amount("1.234,50", Style.DE)) != 0)
            throw new AssertionError("localized values must agree");
    }
}

输出如下:

loose [12.50kg] => 12.5, consumed=5/7
loose [1,2] => 12, consumed=3/3
US [1,234.50] => 1234.50
DE [1.234,50] => 1234.50
US [12.5] => 12.50
US [0] => 0.00
US [12.50kg] => SYNTAX
US [1,2] => SYNTAX
DE [1,2] => 1.20
US [01.20] => SYNTAX
US [ 12.50] => SYNTAX
US [1.234] => SYNTAX
US [1000000.01] => RANGE
US [null] => MISSING

开头两行把故障拆得很清楚。12.50kg 只读了数字前缀,解析位置小于字符串长度;1,2 则全部被消费,但没有满足本例的三位分组规则。后面的入口将这两类输入都归为语法错误,合法的 1,234.50 与 1.234,50 则映射到相同金额。

这里故意让 1,2 在 DE 模式下成功。它在那里是一个带一位小数的数字,返回 1.20;不能因为 US 不接受这个串,就把它描述成全球通用的非法金额。接口拿不到地区约定时,应要求调用方补齐,尤其不能把订单币种与数字书写地区混成一个概念。

为什么语法检查之后仍然保留 ParsePosition

正则负责限定我们愿意接收的文字形状,DecimalFormat 负责按指定 Locale 解释数值。两层各自有职责。检查 ParsePosition 可以防止未来维护人员改宽语法后,不经意重新接受数字前缀;检查实际返回类型,则明确后续计算只接收 BigDecimal。

setParseBigDecimal(true) 避免先得到二进制浮点数,再把近似结果包成十进制金额。解析结果即便数值正确,也不一定天然具有两位小数,所以代码最后使用 setScale(2, UNNECESSARY)。一位小数补零不丢信息;需要舍去第三位时则不允许自动四舍五入。当前语法已拒绝三位小数,这一行仍保留了金额对象的尺度约束。

上限用 BigDecimal 的 compareTo 比较数值,不能把 equals 当作通用数值比较,因为 equals 同时考虑尺度。这里的上限固定且很小,构造常量对象容易阅读;实际服务可以把它作为不可变常量或版本化业务配置,但配置修改也应有明确生效范围。

NumberFormat 工厂返回抽象类型。本例针对两个已支持地区检查其是否为 DecimalFormat;若运行环境不满足要求,属于部署实现不匹配,抛出的不是用户输入错误。不要捕获所有异常再返回“金额非法”,否则一项环境问题会被伪装成大量用户输错。

错误要落到原始输入合同上

MISSING、LENGTH、SYNTAX 和 RANGE 是这份入口自己的错误类别。前端可以把它们翻译为用户可理解的提示,接口日志也可以统计是哪一层失败。代码没有把原始金额拼进异常;若业务确实要保留输入用于申诉,应放在受控的数据记录中,而不是让底层异常随处传播。

不自动 trim 是一个刻意的选择。若产品决定接受前后空格,应在合同中明确允许哪类空白,并在进入解析前进行一次受控规范化。简单删除所有空格,可能把原本分成两段的错误输入拼成合法数字;删除所有点逗号则更糟,会直接改变金额。修复解析不能依靠“尽量猜出用户想说什么”。

同一个金额在页面上允许本地化展示,并不意味着内部接口也必须接收本地化文本。系统之间的金额交换通常可以选择固定十进制字符串或最小货币单位整数,再由展示层处理分组。本教程适合确实承担本地化输入责任的边界;把它放到每一层反复解析,反而增加格式漂移机会。

另一个容易混淆的设置是 setParseIntegerOnly(true)。它表示只解析整数部分,遇到小数符号就停止,并不是在验证输入必须是整数。若直接取返回值,带小数的输入又会变成前缀成功。需要整数数量时,应规定整数语法和上下限,而不是把解析模式当作校验承诺。

显示精度与输入精度也不能共用一套直觉。把格式器的最大小数位设为两位,主要控制格式化输出,不代表所有解析入口都会据此拒绝第三位。本文直接在文字合同中限定小数位,数值转换以后再固定尺度,两处约束都能在测试中单独观察。

接到 HTTP 接口时,应先把地区代码映射为有限的 Style 枚举,再调用 amount;不受支持的地区属于协议错误。不要把任意用户字符串直接当作 Locale 交给格式工厂,否则接口的接受范围可能随着运行环境变化。返回内部数值时使用明确的十进制表示,展示需要分组时再按用户地区格式化;不要把带千位符的显示文本重新送回计算链路。

修改规格时应新增哪些样本

如果以后开放负数,应先决定它表示退款还是普通数值,再补充零、负零、下限和符号位置的测试;不能只在正则前加一个可选负号。若开放更多地区,则需要分别覆盖该地区的小数符号、分组规则和特殊空白,不能把两个分隔符交换就当作完整国际化。

已有断言证明两个合法输入映射到同一数值,但它不取代失败样本。至少保留尾部垃圾、错误分组、三位小数、前导零、边界外一分和缺失值。这些例子分别保护不同的合同,减少维护时“一处放宽,多处意外接受”的风险。

格式器在每次调用中局部创建,因此没有共享可变解析状态。若为了减少分配而缓存对象,需要为并发访问提供隔离;不要把一个 ParsePosition 在请求之间复用。它记录的是本次消费位置,上一条输入留下的状态不属于下一条输入。让这些对象的生命周期与一次解析一致,通常比在入口外维护隐含状态更容易验证。

正文到此结束
评论插件初始化中...
Loading...
本文目录