作为一名专业的软件开发者,我们中的大多数人迟早会面对处理货币数值时的繁琐过程。其实你们很可能已经用过 Java 1.4 引入的 java.util.Currency 类。这个类基于 ISO-4217 来表示货币。另外,java.text.DecimalFormat 类支持将数字转换为货币金额格式。转换后的值用于基本的数学计算后也能正确的以货币形式呈现。但是如果仔细研究便会发现很多需求并没有被满足,只是部分实现而已。
你会惊讶地发现 java.util.Currency 类缺少了几种很重要的 ISO 货币代码。比如缺少了瑞士的两种瑞士法郎代码 CHE 和 CHW。(与之相反,各种美元代码如 USD、USS、USN 则全部都有。)
Java 7 发布之后,可以添加自定义的货币,但需要开发者编写并注册一个所谓的“Java 扩展”(一种 Java 机制,安装在 JRE lib 文件夹下),这对很多公司来说并不可行。而且当前的 Currency 类不能处理在 Java EE 环境或者多租户环境的情况下动态变化的需求。对其它使用场景例如虚拟货币则几乎完全不支持。
尽管 java.text.DecimalFormat 类提供了很多基本货币格式转换的功能,但仍然缺乏健壮的格式化能力。它的构造函数能接受的表达式形式只有如下两种:
- 一个正值,包括零。如##0.00
- 或一个正值和一个附加的负值表达式,二者用分号隔开。如##0.00;(##0.00)
然而,实际应用中需要这种类的行为更加灵活,例如根据金额大小来格式化货币数字,或者引入一种专门的模式来表示 0. 除此之外,并非所有的国际货币都使用标准的每隔 3 位数字就用逗号分隔的表示方法,我们无法用当前的工具灵活定义其它的分隔方式,比如印度卢比除了最后一组是 3 位数字之外,其它都是 2 位数字一组(例如 INR 12,23,123.34)。
另外,这个格式化类不是线程安全的,这个问题众所周知,开发者必须将其单独作为一个案例来解决。
最后,它完全没有对金额数字的抽象。你可能想用 BigDecimal,但首先你必须保证把金额与其代表的货币一起传过去,下文会提到这有多复杂。(而且千万不要试图把双精度类型数字用在任何严谨的财务计算场合,否则你很快就会面对四舍五入导致的错误,因为浮点型计算并不适用于财务应用)。由于这些限制的存在,也进一步导致了诸如货币转换和货币舍入等无法解决的问题。
JSR 354 专门处理 Java 中货币和金额数字的标准化问题。这个 JSR 的目标是往 Java 生态系统中加入一类在处理金额时更为简便安全,同时又灵活可扩展的 API。它为所有数值处理需求提供支持,定义函数扩展点,同时也提供用于货币转换与格式化的函数。
JSR 354 于 2012 年春季启动,一年之后,公布了经过评审之后的初步草案。我们起初打算将上述那些新 API 作为平台的一部分集成到 Java 9 里。然而很快我们就意识到这一目标过于激进,因此这个 JSR 就作为一个独立的技术规格启动了。伴随着 Java EE 7 和 Java 8,JSR 354 逐步完善,我们期待在后续几周内能尘埃落定。
货币建模
前文已提到,现有的货币类是基于 ISO-4217 的。我们在 JSR 会议上集中讨论了为支持新兴的社会货币、历史货币及虚拟货币浪潮而需要增加的特性。
除这些高阶需求之外,ISO 4217 还存在一些比较基础却重要的缺陷。首先,ISO 标准滞后于新生货币的标准化进程。另外,该标准中还有歧义,例如代码 CFA 就有可能表示两种不同的货币,即中部非洲的货币 CFA-Franc BEAC 和西部非洲货币 CFA-Franc BCEAO ,因为有 14 个原 CFA- 法郎区的国家都在使用它。另一个问题是这个 ISO 标准还有很多情况没有覆盖到,例如不同的舍入模式、法定货币、和历史货币等。更糟糕的是,已废弃的代码经过一段时间之后可能被重复使用。然而,从这里可以看出要为一种货币建模仅需 4 个方法,它们组成了我们新的 CurrencyUnit 接口(这些方法中的大部分也存在于现有的 Currency 类中)。
public interface CurrencyUnit { String getCurrencyCode(); int getNumericCode(); int getDefaultFractionDigits(); CurrencyContext getCurrencyContext(); // new }
当用新 API 的货币代码表示非 ISO 货币时,代码可由开发者自由选择。这样你就可以定义自己的货币代码体系或者集成到你已有的代码中去。为自己的货币代码命名是很灵活的。只要这个代码不是 ISO 货币代码,你就可以使用它,只要保证其唯一性即可。因此以下所有的代码都是可用的:
CHF // ISO CS:23345 // Proprietary currency code 45-1 // Proprietary currency code
CurrencyUnit 实例可以通过 MonetaryCurrencies 访问,MonetaryCurrencies 是一个单例模式的类,类似于 java.util.Currency.
与只能返回一种特定货币的 Currency 相比,JSR 354 API 支持更为复杂的场景,通过一种 fluent 语法使用 Java 中新的日期与时间 API,CurrencyQuery 能接受任意属性的参数。例如,要查询“所有在 1970 年时有效的欧洲货币”可以写成如下形式:
Set<currencyunit> currencies = MonetaryCurrencies .getCurrencies (CurrencyQueryBuilder.of() .set ("continent", "Europe") .set (Year.of(1970)).build());</currencyunit>
上述代码的背后有一个相关的服务提供接口(SPI)实现了 API 提供的查询功能,此外还有其它 SPI 为别的货币服务。有必要的话你还可以实现自己的服务提供类(更多内容参见下文 SPI 章节),查询选项只与服务提供类注册时的定义有关。关于更多细节,请参见 JSR 354 文档和实现参考文档。
货币金额建模
按照自然习惯,我们可能会直接把一个代表金额的数值和货币单位写在一起,比如作为一个BigDecimal 类型,然后赋值给另一个不可变的数值类型。很不幸,事实证明这种模式行不通,因为对不同范围金额数值的需求实在太多。比如在贸易场景中,我们可能需要具备快速计算能力,并且内存占用很低的类型,此时在数值属性上可以做一些妥协(比如decimal 的精度)。与之相反,我们在产品计价等情况下又往往需要超高精度的计算,为此可以牺牲一些计算时间。最后,危险估算和统计等情形则可能产生超出该数值类型所能表示的最大范围的巨大数字。
总之,想要在一个单一的类里满足所有这些需求是不可能的,因此我们决定支持多重实现模式。一个金额值由MonetaryAmount 接口表示,为了实现互操作性,同时还定义了防止出现舍入错误的规则。还有一个MonetaryContext 类用来提供额外的元数据(meta-data)支持,以描述底层的实现类型,比如其数值属性(精度和范围)等:
public interface MonetaryAmount extends CurrencySupplier, NumberSupplier, Comparable<monetaryamount> { CurrencyUnit getCurrency(); NumberValue getNumber(); MonetaryContext getMonetaryContext(); <r> R query (MonetaryQuery<r> query); MonetaryAmount with(MonetaryOperator operator); MonetaryAmountFactory extends MonetaryAmount> getFactory (); // ... }</r></r></monetaryamount>
一个金额数目的数值返回类型为 javax.money.NumberValue, 它扩展了 java.lang.Number 类,增加了正确返回数值,并且不会产生精度丢失的函数。其 with 和 query 方法定义了可供其它函数调用的扩展点,比如金额舍入、货币转换或断言等。MonetaryAmount 还提供了与 BigDecimal 类似的可用于比较金额大小和金额运算的操作。最后,每个金额都提供 MonetaryAmountFactory 工厂,通过实现它可以创建任意种类的金额类。
创建货币金额类
用 MonetaryAmountFactory 工厂类来生成金额类型。这些工厂类可以从单例模式的 MonetaryAmounts 生成。在最简单的情况下,你可以使用(可配置的)默认工厂创建一个 MonetaryAmount 实例:
MonetaryAmount amt = MonetaryAmounts.getDefaultAmountFactory() .setCurrency("EUR") .setNumber(200.5) .create();
同时,你还可以通过把需要实现的类型作为参数传给 MonetaryAmounts 来显式地获取一个 MonetaryAmountFactory。与货币种类类似,金额类工厂可以用 MonetaryAmountFactoryQuery 实现查询。
MonetaryAmountFactory> factory = MonetaryAmounts .getAmountFactory( MonetaryAmountFactoryQueryBuilder.of () .setPrecision (200) .setMaxScale(10) .build ());
货币金额舍入
我们已经看到,一个 MonetaryOperator 类的实例可以(通过调用 with 方法)传给 MonetaryAmount 类,从而运行返回其它金额类型的任意外部函数。
@FunctionalInterface public interface MonetaryOperator extends UnaryOperator <monetaryamount> {}</monetaryamount>
这个机制也被用于对金额做舍入。一个金额舍入类可由 MonetaryRounding 接口定义,该接口额外还提供 RoundingContext。一般来说,会考虑下列几种金额舍入情况:
- 在实现金额模型时就隐式地实现内部舍入。例如假设实现了一个声明支持小数点后最多 5 位的金额类型,现在我们计算 CHF 10/7,会得到一个无限循环小数。此时允许对计算结果按用户定义而隐式舍入成小数点后最多 5 位,结果为 CHF 1.42857。
- 外部舍入,可能发生在当一个金额数值被传入一个数值精度更低的表达式的情况下。例如假设由于某种原因,我们要用一个字节来表示一个金额数值(显然这种做法是不推荐的,但此处只是假设举例),那么 255.15 可以被舍入成 255.
- 舍入格式化可能会把一个金额变得面目全非。比如 CHF 2’030’043 会被显示成 CHF> 1 million。
基本上,内部(即隐式)舍入只有在发生上述类似情况时才允许使用。其它的舍入种类可由开发人员显式地实现。这是因为何时需要舍入以及如何舍入在很大程度上依赖于其使用场景。因此开发者需要获得最大程度的控制。
有些舍入类型可以从单例类 MonetaryRoundings 中直接得到:你可以直接获取一个货币舍入运算符,以及符合某个 MathContext 规则的普通算术舍入符。同时你也可以给复数传一个 RoundingQuery 类。JSR 本身不会进一步定义更多的 API,所以用户可以实现自己需要的舍入模式。举例来说,假设要为瑞士法郎 (CHF) 的现金支付做舍入。在瑞士,现金的最小单位是 5 分,因此舍入规则也应当相应地基于 5 分的单元来做,从而我们需要一个机制来访问刚刚定义的那个舍入规则。在默认情况下,如果直接做 CHF 货币舍入而没有传入其他属性,我们会得到一个基于默认最小货币单元的舍入规则,而对 CHF 来说这个默认值是 1/100。所以我们要额外传入一个标记把我们真正需要的舍入规则告诉提供者类。那么我们可以创建一个枚举类型来定义我们需要的舍入类型:
public enum RoundingType{ CASH, DEFAULT }
假设我们已经为此注册了一个"RoundingProvider",下面我们可以用以下方式实现 CHF 现金舍入:
MonetaryRounding cashRounding = MonetaryRoundings.getRounding( RoundingQueryBuilder.of () .setCurrency(MonetaryCurrencies.getCurrency("CHF")) .set(RoundingType.CASH) .build ());
从而这个新的舍入规则可以很方便地作用于任何金额:
MonetaryAmount amount = Money.of("CHF", 1.1221); MonetaryAmount roundedCHFCashAmount = amount.with(rounding); // result: CHF 1.10
货币转换
货币转换的核心是 ExchangeRate。包括发生转换的源货币和目的货币,以及转换因子和其它元数据。它还支持多级转换(如三方汇率)。汇率总是单向的,由所谓 ExchangeRateProvider 的实例表示。与金额、货币和舍入类似,它也可以传入一个 ConversionQuery 来定义转换的细节。CurrencyConversion 操作对 MonetaryOperator 做了扩展,增加了对一个汇率提供者类和目的货币类的引用。
CurrencyConversion conversion = MonetaryConversions .getConversion("USD"); ExchangeRateProvider prov = MonetaryConversions .getExchangeRateProvider();
通过定义一个供应者链,可以给以上函数调用传入多个汇率提供者类。与访问一个默认的金额工厂类类似,你也可以配置一个 default 链。那么货币转换就像货币舍入一样简单:
MonetaryAmount amountCHF = ...; MonetaryAmount convertedAmountUSD = amount.with(conversion);
目前的参考实现带有两个预先配置的提供者类,它们提供的货币转换因子基于欧洲央行和国际货币基金组织公布的数据,某些货币的相关数据可追溯到 1990 年。
格式化
货币金额格式化 API 的设计原则是保证其简单且灵活,同时解决 Java 中现有格式化 API 的缺陷,尤其是缺少线程安全的问题:
public interface MonetaryAmountFormat extends MonetaryQuery<string>{ AmountFormatContext getAmountFormatContext(); String format (MonetaryAmount amount); void print (Appendable appendable, MonetaryAmount amount) throws IOException; MonetaryAmount parse(CharSequence text) throws MonetaryParseException; }</string>
这个接口的实例可以从单例模式类 MonetaryFormats 获取:
MonetaryAmountFormat fmt = MonetaryFormats.getAmountFormat(Locale.US);
此处还可以传递一个包含任意参数的 AmountFormatQuery 以配置所需的格式:
DecimalFormatSymbols symbols = ...; MonetaryAmountFormat fmt = MonetaryFormats.getAmountFormat ( AmountFormatQueryBuilder.of(Locale.US) .set(symbols) .build ());
SPI
除了核心 API 之外,JSR 354 也提供了一套完整的服务提供接口,使用户可以按自身需求改造其中所有的函数。因此很容易就能实现添加货币种类、货币转换、金额舍入、金额格式化、或者总金额计算的实现等。最后,通过采用辅助逻辑程序,它们还能实现动态的、上下文关联的行为。SPI 可以通过 CDI 实现并管理。
总结
JSR 354 定义了一套简单又强大的 API,极大地简化了货币和金额的处理流程,同时还解决了金额舍入和货币转换等高阶问题。最主要的是它为货币金额贡献了一种简洁灵活的格式化 API。其函数扩展点能让开发者很容易地添加自己需要的额外功能,这是自 Java 8 引入函数式编程概念之后对其一次极为出色的应用。就这一点而言, Java Money OSS 项目也值得一看,该项目中实现了一些财务公式,同时实验性地集成了 CDI。
JSR 354 计划于 2015 年第一季度完成。一旦完成,我们还会为仍在使用 Java 7 的用户发布一个向前兼容的移植版本。
关于作者
Anatole Tresch是 JSR 354(Java 货币与金钱) 细则团队组长,他同时还参与 Java EE 及配置的工作。从苏黎世大学毕业后,Anatole 做了几年咨询顾问和任事股东。他目前在瑞信银行担任技术协调员和架构师。
查看英文原文: Go for the Money! JSR 354 Adds First Class Money and Currency Support to Java
感谢邵思华对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流。
评论