BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

轻松“撰”钱 ——JSR 354为Java引入金钱与货币的完美支持

| 作者 Anatole Tresch 关注 0 他的粉丝 ,译者 段珊珊 关注 0 他的粉丝 发布于 2015年5月6日. 估计阅读时间: 23 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

作为一名专业的软件开发者,我们中的大多数人迟早会面对处理货币数值时的繁琐过程。其实你们很可能已经用过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 currencies = MonetaryCurrencies 
.getCurrencies (CurrencyQueryBuilder.of()
.set ("continent", "Europe")
.set (Year.of(1970)).build());

上述代码的背后有一个相关的服务提供接口(SPI)实现了API提供的查询功能,此外还有其它SPI为别的货币服务。有必要的话你还可以实现自己的服务提供类(更多内容参见下文SPI章节),查询选项只与服务提供类注册时的定义有关。关于更多细节,请参见JSR 354文档实现参考文档

货币金额建模

按照自然习惯,我们可能会直接把一个代表金额的数值和货币单位写在一起,比如作为一个BigDecimal类型,然后赋值给另一个不可变的数值类型。很不幸,事实证明这种模式行不通,因为对不同范围金额数值的需求实在太多。比如在贸易场景中,我们可能需要具备快速计算能力,并且内存占用很低的类型,此时在数值属性上可以做一些妥协(比如decimal的精度)。与之相反,我们在产品计价等情况下又往往需要超高精度的计算,为此可以牺牲一些计算时间。最后,危险估算和统计等情形则可能产生超出该数值类型所能表示的最大范围的巨大数字。

总之,想要在一个单一的类里满足所有这些需求是不可能的,因此我们决定支持多重实现模式。一个金额值由MonetaryAmount接口表示,为了实现互操作性,同时还定义了防止出现舍入错误的规则。还有一个MonetaryContext类用来提供额外的元数据(meta-data)支持,以描述底层的实现类型,比如其数值属性(精度和范围)等:

public interface MonetaryAmount 
extends CurrencySupplier, NumberSupplier,  Comparable {
  CurrencyUnit getCurrency();
  NumberValue getNumber();
  MonetaryContext getMonetaryContext();
   R query (MonetaryQuery query);
  MonetaryAmount with(MonetaryOperator operator);
  MonetaryAmountFactory  getFactory ();
  // ...
}

一个金额数目的数值返回类型为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  {}

这个机制也被用于对金额做舍入。一个金额舍入类可由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{
  AmountFormatContext getAmountFormatContext();
  String format (MonetaryAmount amount);
  void print (Appendable appendable, MonetaryAmount amount)
    throws IOException;
  MonetaryAmount parse(CharSequence text)
    throws MonetaryParseException;
}

这个接口的实例可以从单例模式类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)关注我们,并与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT