JavaMoney規範(JSR 354)與對應實現解讀

vivo網際網路技術發表於2021-12-14

一、概述

1.1 當前現狀

當前JDK中用來表達貨幣的類為java.util.Currency,這個類僅僅能夠表示按照[ISO-4217]描述的貨幣型別。它沒有與之關聯的數值,也不能描述規範外的一些貨幣。對於貨幣的計算、貨幣兌換、貨幣的格式化沒有提供相關的支援,甚至連能夠代表貨幣金額的標準型別也沒有提供相關說明。JSR-354定義了一套標準的API用來解決相關的這些問題。

1.2 規範目的

JSR-354主要的目標為:

  • 為貨幣擴充套件提供可能,支撐豐富的業務場景對貨幣型別以及貨幣金額的訴求;

  • 提供貨幣金額計算的API;

  • 提供對貨幣兌換匯率的支援以及擴充套件;

  • 為貨幣和貨幣金額的解析和格式化提供支援以及擴充套件。

1.3 使用場景

線上商店

商城中商品的單價,將商品加入購物車後,隨著物品數量而需要計算的總價。在商城將支付方式切換後隨著結算貨幣型別的變更而涉及到的貨幣兌換等。當使用者下單後涉及到的支付金額計算,稅費計算等。

金融交易網站

在一個金融交易網站上,客戶可以任意建立虛擬投資組合。根據建立的投資組合,結合歷史資料顯示計算出來的歷史的、當前的以及預期的收益。

虛擬世界和遊戲網站

線上遊戲會定義它們自己的遊戲幣。使用者可以通過銀行卡中的金額去購買遊戲幣,這其中就涉及到貨幣兌換。而且因為遊戲種類繁多,需要的貨幣型別支援也必須能夠支撐動態擴充套件。

銀行和金融應用

銀行等金融機構必須建立在匯率、利率、股票報價、當前和歷史的貨幣等方面的貨幣模型資訊。通常這樣的公司內部系統也存在財務資料表示的附加資訊,例如歷史貨幣、匯率以及風險分析等。所以貨幣和匯率必須是具有歷史意義的、區域性的,並定義它們的有效期範圍。

二、JavaMoney解析

2.1 包和工程結構

2.1.1 包概覽

JSR-354 定義了4個相關包:

(圖2-1 包結構圖)

javax.money包含主要元件如:

  • CurrencyUnit;

  • MonetaryAmount;

  • MonetaryContext;

  • MonetaryOperator;

  • MonetaryQuery;

  • MonetaryRounding ;

  • 相關的單例訪問者Monetary。

javax.money.convert 包含貨幣兌換相關元件如:

  • ExchangeRate;

  • ExchangeRateProvider;

  • CurrencyConversion ;

  • 相關的單例訪問者MonetaryConversions 。

javax.money.format包含格式化相關元件如:

  • MonetaryAmountFormat;

  • AmountFormatContext;

  • 相關的單例訪問者MonetaryFormats 。

javax.money.spi:包含由JSR-354提供的SPI介面和引導邏輯,以支援不同的執行時環境和元件載入機制。

2.2.2 模組概覽

JSR-354原始碼倉庫包含如下模組:

  • jsr354-api:包含本規範中描述的基於Java 8的JSR 354 API;

  • jsr354-ri:包含基於Java 8語言特性的Moneta參考實現;

  • jsr354-tck:包含技術相容套件(TCK)。TCK是使用Java 8構建的;

  • javamoney-parent:是org.javamoney下所有模組的根“POM”專案。這包括RI/TCK專案,但不包括jsr354-api(它是獨立的)。

2.2 核心API

2.2.1 CurrencyUnit

2.2.1.1 CurrencyUnit資料模型

CurrencyUnit包含貨幣最小單位的屬性,如下所示:


public interface CurrencyUnit extends Comparable<CurrencyUnit>{
    String getCurrencyCode();
    int getNumericCode();
    int getDefaultFractionDigits();
    CurrencyContext getContext();
}

方法getCurrencyCode()返回不同的貨幣編碼。基於ISO Currency規範的貨幣編碼預設為三位,其他型別的貨幣編碼沒有這個約束。

方法getNumericCode()返回值是可選的。預設可以返回-1。ISO貨幣的程式碼必須匹配對應的ISO程式碼的值。

defaultFractionDigits定義了預設情況下小數點後的位數。CurrencyContext包含貨幣單位的附加後設資料資訊。

2.2.1.2 獲取CurrencyUnit的方式

根據貨幣編碼獲取

CurrencyUnit currencyUnit = Monetary.getCurrency("USD");

根據地區獲取

CurrencyUnit currencyUnitChina = Monetary.getCurrency(Locale.CHINA);

按查詢條件獲取

CurrencyQuery cnyQuery =             CurrencyQueryBuilder.of().setCurrencyCodes("CNY").setCountries(Locale.CHINA).setNumericCodes(-1).build();
Collection<CurrencyUnit> cnyCurrencies = Monetary.getCurrencies(cnyQuery);

獲取所有的CurrencyUnit;

Collection<CurrencyUnit> allCurrencies = Monetary.getCurrencies();

2.2.1.3 CurrencyUnit資料提供者

我們進入Monetary.getCurrency系列方法,可以看到這些方法都是通過獲取MonetaryCurrenciesSingletonSpi.class實現類對應的例項,然後呼叫例項對應getCurrency方法。

public static CurrencyUnit getCurrency(String currencyCode, String... providers) {
    return Optional.ofNullable(MONETARY_CURRENCIES_SINGLETON_SPI()).orElseThrow(
        () -> new MonetaryException("No MonetaryCurrenciesSingletonSpi loaded, check your system setup."))
        .getCurrency(currencyCode, providers);
}

private static MonetaryCurrenciesSingletonSpi MONETARY_CURRENCIES_SINGLETON_SPI() {
        try {
            return Optional.ofNullable(Bootstrap
                    .getService(MonetaryCurrenciesSingletonSpi.class)).orElseGet(
                    DefaultMonetaryCurrenciesSingletonSpi::new);
        } catch (Exception e) {
            ......
            return new DefaultMonetaryCurrenciesSingletonSpi();
        }
    }

介面MonetaryCurrenciesSingletonSpi預設只有一個實現DefaultMonetaryCurrenciesSingletonSpi。它獲取貨幣集合的實現方式是:所有CurrencyProviderSpi實現類獲取CurrencyUnit集合取並集。

public Set<CurrencyUnit> getCurrencies(CurrencyQuery query) {
    Set<CurrencyUnit> result = new HashSet<>();
    for (CurrencyProviderSpi spi : Bootstrap.getServices(CurrencyProviderSpi.class)) {
        try {
            result.addAll(spi.getCurrencies(query));
        } catch (Exception e) {
            ......
        }
    }
    return result;
}

因此,CurrencyUnit的資料提供者為實現CurrencyProviderSpi的相關實現類。Moneta提供的預設實現存在兩個提供者,如圖所示;

(圖2-2 CurrencyProviderSpi預設實現類圖)

JDKCurrencyProvider為JDK中[ISO-4217]描述的貨幣型別提供了相關的對映;

ConfigurableCurrencyUnitProvider為動態變更CurrencyUnit提供了支援。方法為:registerCurrencyUnit、removeCurrencyUnit等。

因此,如果需要對CurrencyUnit進行相應的擴充套件,建議按擴充套件點CurrencyProviderSpi的介面定義進行自定義的構造擴充套件。

2.2.2 MonetaryAmount

2.2.2.1 MonetaryAmount資料模型

public interface MonetaryAmount extends CurrencySupplier, NumberSupplier, Comparable<MonetaryAmount>{

    //獲取上下文資料
    MonetaryContext getContext();

    //按條件查詢
    default <R> R query(MonetaryQuery<R> query){
        return query.queryFrom(this);
    }

    //應用操作去建立貨幣金額例項
    default MonetaryAmount with(MonetaryOperator operator){
        return operator.apply(this);
    }
    
    //獲取建立貨幣金額新例項的工廠
    MonetaryAmountFactory<? extends MonetaryAmount> getFactory();

    //比較方法
    boolean isGreaterThan(MonetaryAmount amount);
    ......
    int signum();

    //演算法函式和計算
    MonetaryAmount add(MonetaryAmount amount);
    ......
    MonetaryAmount stripTrailingZeros();
}

對應MonetaryAmount提供了三種實現為:FastMoney、Money、RoundedMoney。

(圖2-3 MonetaryAmount預設實現類圖)

FastMoney是為效能而優化的數字表示,它表示的貨幣數量是一個整數型別的數字。Money內部基於java.math.BigDecimal來執行算術操作,該實現能夠支援任意的precision和scale。RoundedMoney的實現支援在每個操作之後隱式地進行舍入。我們需要根據我們的使用場景進行合理的選擇。如果FastMoney的數字功能足以滿足你的用例,建議使用這種型別。

2.2.2.2 建立MonetaryAmount

根據API的定義,可以通過訪問MonetaryAmountFactory來建立,也可以直接通過對應型別的工廠方法來建立。如下;

FastMoney fm1 = Monetary.getAmountFactory(FastMoney.class).setCurrency("CNY").setNumber(144).create();
FastMoney fm2 = FastMoney.of(144, "CNY");

Money m1 = Monetary.getAmountFactory(Money.class).setCurrency("CNY").setNumber(144).create();
Money m2 = Money.of(144, "CNY");

由於Money內部基於java.math.BigDecimal,因此它也具有BigDecimal的算術精度和舍入能力。預設情況下,Money的內部例項使用MathContext.DECIMAL64初始化。並且支援指定的方式;

Money money1 = Monetary.getAmountFactory(Money.class)
                              .setCurrency("CNY").setNumber(144)
                              .setContext(MonetaryContextBuilder.of().set(MathContext.DECIMAL128).build())
                              .create();
Money money2 = Money.of(144, "CNY", MonetaryContextBuilder.of().set(MathContext.DECIMAL128).build());

Money與FastMoney也可以通過from方法進行相互的轉換,方法如下;

org.javamoney.moneta.Money.defaults.mathContext=DECIMAL128

同時可以指定精度和舍入模式;

org.javamoney.moneta.Money.defaults.precision=256
org.javamoney.moneta.Money.defaults.roundingMode=HALF_EVEN

Money與FastMoney也可以通過from方法進行相互的轉換,方法如下;

FastMoney fastMoney = FastMoney.of(144, "CNY");

Money money = Money.from(fastMoney);
fastMoney = FastMoney.from(money);

2.2.2.3 MonetaryAmount的擴充套件

雖然Moneta提供的關於MonetaryAmount的三種實現:FastMoney、Money、RoundedMoney已經能夠滿足絕大多數場景的需求。JSR-354為MonetaryAmount預留的擴充套件點提供了更多實現的可能。

我們跟進一下通過靜態方法Monetary.getAmountFactory(ClassamountType)獲取MonetaryAmountFactory來建立MonetaryAmount例項的方式;

public static <T extends MonetaryAmount> MonetaryAmountFactory<T> getAmountFactory(Class<T> amountType) {
    MonetaryAmountsSingletonSpi spi = Optional.ofNullable(monetaryAmountsSingletonSpi())
        .orElseThrow(() -> new MonetaryException("No MonetaryAmountsSingletonSpi loaded."));
    MonetaryAmountFactory<T> factory = spi.getAmountFactory(amountType);
    return Optional.ofNullable(factory).orElseThrow(
        () -> new MonetaryException("No AmountFactory available for type: " + amountType.getName()));
}

private static MonetaryAmountsSingletonSpi monetaryAmountsSingletonSpi() {
    try {
        return Bootstrap.getService(MonetaryAmountsSingletonSpi.class);
    } catch (Exception e) {
        ......
        return null;
    }
}

如上程式碼所示,需要通過MonetaryAmountsSingletonSpi擴充套件點的實現類通過方法getAmountFactory來獲得MonetaryAmountFactory。

Moneta的實現方式中MonetaryAmountsSingletonSpi的唯一實現類為DefaultMonetaryAmountsSingletonSpi,對應的獲取MonetaryAmountFactory的方法為;

public class DefaultMonetaryAmountsSingletonSpi implements MonetaryAmountsSingletonSpi {

    private final Map<Class<? extends MonetaryAmount>, MonetaryAmountFactoryProviderSpi<?>> factories =
            new ConcurrentHashMap<>();

    public DefaultMonetaryAmountsSingletonSpi() {
        for (MonetaryAmountFactoryProviderSpi<?> f : Bootstrap.getServices(MonetaryAmountFactoryProviderSpi.class)) {
            factories.putIfAbsent(f.getAmountType(), f);
        }
    }

    @Override
    public <T extends MonetaryAmount> MonetaryAmountFactory<T> getAmountFactory(Class<T> amountType) {
        MonetaryAmountFactoryProviderSpi<T> f = MonetaryAmountFactoryProviderSpi.class.cast(factories.get(amountType));
        if (Objects.nonNull(f)) {
            return f.createMonetaryAmountFactory();
        }
        throw new MonetaryException("No matching MonetaryAmountFactory found, type=" + amountType.getName());
    }
    
    ......
}

最後可以發現MonetaryAmountFactory的獲取是通過擴充套件點MonetaryAmountFactoryProviderSpi通過呼叫createMonetaryAmountFactory生成的。

所以要想擴充套件實現新型別的MonetaryAmount,至少需要提供擴充套件點MonetaryAmountFactoryProviderSpi的實現,對應型別的AbstractAmountFactory的實現以及相互關係的維護。

預設MonetaryAmountFactoryProviderSpi的實現和對應的AbstractAmountFactory的實現如下圖所示;

(圖2-4 MonetaryAmountFactoryProviderSpi預設實現類圖)

(圖2-5 AbstractAmountFactory預設實現類圖)

2.2.3 貨幣金額計算相關

從MonetaryAmount的介面定義中可以看到它提供了常用的算術運算(加、減、乘、除、求模等運算)計算方法。同時定義了with方法用於支援基於MonetaryOperator運算的擴充套件。MonetaryOperators類中定義了一些常用的MonetaryOperator的實現:

  • 1)ReciprocalOperator用於操作取倒數;

  • 2)PermilOperator用於獲取千分比例值;

  • 3)PercentOperator用於獲取百分比例值;

  • 4)ExtractorMinorPartOperator用於獲取小數部分;

  • 5)ExtractorMajorPartOperator用於獲取整數部分;

  • 6)RoundingMonetaryAmountOperator用於進行舍入運算;

同時繼承MonetaryOperator的介面有CurrencyConversion和MonetaryRounding。其中CurrencyConversion主要與貨幣兌換相關,下一節作具體介紹。MonetaryRounding是關於舍入操作的,具體使用方式如下;

MonetaryRounding rounding = Monetary.getRounding(
    RoundingQueryBuilder.of().setScale(4).set(RoundingMode.HALF_UP).build());
Money money = Money.of(144.44445555,"CNY");
Money roundedAmount = money.with(rounding);  
# roundedAmount.getNumber()的值為:144.4445

還可以使用預設的舍入方式以及指定CurrencyUnit 的方式,其結果對應的scale為currencyUnit.getDefaultFractionDigits()的值,比如;

MonetaryRounding rounding = Monetary.getDefaultRounding();
Money money = Money.of(144.44445555,"CNY");
MonetaryAmount roundedAmount = money.with(rounding);
#roundedAmount.getNumber()對應的scale為money.getCurrency().getDefaultFractionDigits()

CurrencyUnit currency = Monetary.getCurrency("CNY");
MonetaryRounding rounding = Monetary.getRounding(currency);
Money money = Money.of(144.44445555,"CNY");
MonetaryAmount roundedAmount = money.with(rounding);
#roundedAmount.getNumber()對應的scale為currency.getDefaultFractionDigits()

一般情況下進行舍入操作是按位進1,針對某些型別的貨幣最小單位不為1,比如瑞士法郎最小單位為5。針對這種情況,可以通過屬性cashRounding為true,並進行相應的操作;

CurrencyUnit currency = Monetary.getCurrency("CHF");
MonetaryRounding rounding = Monetary.getRounding(
    RoundingQueryBuilder.of().setCurrency(currency).set("cashRounding", true).build());
Money money = Money.of(144.42555555,"CHF");
Money roundedAmount = money.with(rounding);
# roundedAmount.getNumber()的值為:144.45

通過MonetaryRounding的獲取方式,我們可以瞭解到都是通過MonetaryRoundingsSingletonSpi的擴充套件實現類通過呼叫對應的getRounding方法來完成。如下所示按條件查詢的方式;

public static MonetaryRounding getRounding(RoundingQuery roundingQuery) {
    return Optional.ofNullable(monetaryRoundingsSingletonSpi()).orElseThrow(
        () -> new MonetaryException("No MonetaryRoundingsSpi loaded, query functionality is not available."))
        .getRounding(roundingQuery);
}

private static MonetaryRoundingsSingletonSpi monetaryRoundingsSingletonSpi() {
    try {
        return Optional.ofNullable(Bootstrap
                                   .getService(MonetaryRoundingsSingletonSpi.class))
            .orElseGet(DefaultMonetaryRoundingsSingletonSpi::new);
    } catch (Exception e) {
        ......
        return new DefaultMonetaryRoundingsSingletonSpi();
    }
}

預設實現中MonetaryRoundingsSingletonSpi的唯一實現類為DefaultMonetaryRoundingsSingletonSpi,它獲取MonetaryRounding的方式如下;

@Override
public Collection<MonetaryRounding> getRoundings(RoundingQuery query) {
   ......
    for (String providerName : providerNames) {
        Bootstrap.getServices(RoundingProviderSpi.class).stream()
            .filter(prov -> providerName.equals(prov.getProviderName())).forEach(prov -> {
            try {
                MonetaryRounding r = prov.getRounding(query);
                if (r != null) {
                    result.add(r);
                }
            } catch (Exception e) {
                ......
            }
        });
    }
    return result;
}

根據上述程式碼可以得知MonetaryRounding主要來源於RoundingProviderSpi擴充套件點實現類的getRounding方法來獲取。JSR-354預設實現Moneta中DefaultRoundingProvider提供了相關實現。如果需要實現自定義的Rounding策略,按照RoundingProviderSpi定義的擴充套件點進行即可。

2.3 貨幣兌換

2.3.1 貨幣兌換使用說明

上一節中有提到MonetaryOperator還存在一類貨幣兌換相關的操作。如下例項所示為常用的使用貨幣兌換的方式;

Number moneyNumber = 144;
CurrencyUnit currencyUnit = Monetary.getCurrency("CNY");
Money money = Money.of(moneyNumber,currencyUnit);
CurrencyConversion vfCurrencyConversion = MonetaryConversions.getConversion("ECB");
Money conversMoney = money.with(vfCurrencyConversion);

也可用通過先獲取ExchangeRateProvider,然後再獲取CurrencyConversion進行相應的貨幣兌換;

Number moneyNumber = 144;
CurrencyUnit currencyUnit = Monetary.getCurrency("CNY");
Money money = Money.of(moneyNumber,currencyUnit);
ExchangeRateProvider exchangeRateProvider = MonetaryConversions.getExchangeRateProvider("default");
CurrencyConversion vfCurrencyConversion = exchangeRateProvider.getCurrencyConversion("ECB");
Money conversMoney = money.with(vfCurrencyConversion);

2.3.2 貨幣兌換擴充套件

CurrencyConversion通過靜態方法MonetaryConversions.getConversion來獲取。方法中根據MonetaryConversionsSingletonSpi的實現呼叫getConversion來獲得。

而方法getConversion是通過獲取對應的ExchangeRateProvider並呼叫getCurrencyConversion實現的;

public static CurrencyConversion getConversion(CurrencyUnit termCurrency, String... providers){
    ......
    if(providers.length == 0){
        return getMonetaryConversionsSpi().getConversion(
            ConversionQueryBuilder.of().setTermCurrency(termCurrency).setProviderNames(getDefaultConversionProviderChain())
            .build());
    }
    return getMonetaryConversionsSpi().getConversion(
        ConversionQueryBuilder.of().setTermCurrency(termCurrency).setProviderNames(providers).build());
}

default CurrencyConversion getConversion(ConversionQuery conversionQuery) {
    return getExchangeRateProvider(conversionQuery).getCurrencyConversion(
        Objects.requireNonNull(conversionQuery.getCurrency(), "Terminating Currency is required.")
    );
}

private static MonetaryConversionsSingletonSpi getMonetaryConversionsSpi() {
    return Optional.ofNullable(Bootstrap.getService(MonetaryConversionsSingletonSpi.class))
        .orElseThrow(() -> new MonetaryException("No MonetaryConversionsSingletonSpi " +
                                                 "loaded, " +
                                                 "query functionality is not " +
                                                 "available."));
}

Moneta的實現中MonetaryConversionsSingletonSpi只有唯一的實現類DefaultMonetaryConversionsSingletonSpi。

ExchangeRateProvider的獲取如下所示依賴於ExchangeRateProvider的擴充套件實現;

public DefaultMonetaryConversionsSingletonSpi() {
    this.reload();
}

public void reload() {
    Map<String, ExchangeRateProvider> newProviders = new ConcurrentHashMap();
    Iterator var2 = Bootstrap.getServices(ExchangeRateProvider.class).iterator();

    while(var2.hasNext()) {
        ExchangeRateProvider prov = (ExchangeRateProvider)var2.next();
        newProviders.put(prov.getContext().getProviderName(), prov);
    }

    this.conversionProviders = newProviders;
}

public ExchangeRateProvider getExchangeRateProvider(ConversionQuery conversionQuery) {
    ......
    List<ExchangeRateProvider> provInstances = new ArrayList();
    ......

    while(......) {
       ......
        ExchangeRateProvider prov = (ExchangeRateProvider)Optional.ofNullable((ExchangeRateProvider)this.conversionProviders.get(provName)).orElseThrow(() -> {
            return new MonetaryException("Unsupported conversion/rate provider: " + provName);
        });
        provInstances.add(prov);
    }

    ......
        return (ExchangeRateProvider)(provInstances.size() == 1 ? (ExchangeRateProvider)provInstances.get(0) : new CompoundRateProvider(provInstances));
    }
}

ExchangeRateProvider預設提供的實現有:

  • CompoundRateProvider

  • IdentityRateProvider

(圖2-6 ExchangeRateProvider預設實現類圖)

因此,建議的擴充套件貨幣兌換能力的方式為實現ExchangeRateProvider,並通過SPI的機制載入。

2.4 格式化

2.4.1 格式化使用說明

格式化主要包含兩部分的內容:物件例項轉換為符合格式的字串;指定格式的字串轉換為物件例項。通過MonetaryAmountFormat例項對應的format和parse來分別執行相應的轉換。如下程式碼所示;

MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(Locale.CHINESE);
MonetaryAmount monetaryAmount = Money.of(144144.44,"VZU");
String formattedString = format.format(monetaryAmount);

MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(Locale.CHINESE);
String formattedString = "VZU 144,144.44";
MonetaryAmount monetaryAmount = format.parse(formattedString);

2.4.2 格式化擴充套件

格式化的使用關鍵點在於MonetaryAmountFormat的構造。MonetaryAmountFormat主要建立獲取方式為MonetaryFormats.getAmountFormat。看一下相關的原始碼;

public static MonetaryAmountFormat getAmountFormat(AmountFormatQuery formatQuery) {
    return Optional.ofNullable(getMonetaryFormatsSpi()).orElseThrow(() -> new MonetaryException(
        "No MonetaryFormatsSingletonSpi " + "loaded, query functionality is not available."))
        .getAmountFormat(formatQuery);
}

private static MonetaryFormatsSingletonSpi getMonetaryFormatsSpi() {
    return loadMonetaryFormatsSingletonSpi();
}

private static MonetaryFormatsSingletonSpi loadMonetaryFormatsSingletonSpi() {
    try {
        return Optional.ofNullable(Bootstrap.getService(MonetaryFormatsSingletonSpi.class))
            .orElseGet(DefaultMonetaryFormatsSingletonSpi::new);
    } catch (Exception e) {
        ......
        return new DefaultMonetaryFormatsSingletonSpi();
    }
}

相關程式碼說明MonetaryAmountFormat的獲取依賴於MonetaryFormatsSingletonSpi的實現對應呼叫getAmountFormat方法。

MonetaryFormatsSingletonSpi的預設實現為DefaultMonetaryFormatsSingletonSpi,對應的獲取方法如下;

public Collection<MonetaryAmountFormat> getAmountFormats(AmountFormatQuery formatQuery) {
    Collection<MonetaryAmountFormat> result = new ArrayList<>();
    for (MonetaryAmountFormatProviderSpi spi : Bootstrap.getServices(MonetaryAmountFormatProviderSpi.class)) {
        Collection<MonetaryAmountFormat> formats = spi.getAmountFormats(formatQuery);
        if (Objects.nonNull(formats)) {
            result.addAll(formats);
        }
    }
    return result;
}

可以看出來最終還是依賴於MonetaryAmountFormatProviderSpi的相關實現,並作為一個擴充套件點提供出來。預設的擴充套件實現方式為DefaultAmountFormatProviderSpi。

如果我們需要擴充套件註冊自己的格式化處理方式,建議採用擴充套件MonetaryAmountFormatProviderSpi的方式。

2.5 SPI

JSR-354提供的服務擴充套件點有;

(圖2-7 服務擴充套件點類圖)

1)處理貨幣型別相關的CurrencyProviderSpi、MonetaryCurrenciesSingletonSpi;

2)處理貨幣兌換相關的MonetaryConversionsSingletonSpi;

3)處理貨幣金額相關的MonetaryAmountFactoryProviderSpi、MonetaryAmountsSingletonSpi;

4)處理舍入相關的RoundingProviderSpi、MonetaryRoundingsSingletonSpi;

5)處理格式化相關的MonetaryAmountFormatProviderSpi、MonetaryFormatsSingletonSpi;

6)服務發現相關的ServiceProvider;

除了ServiceProvider,其他擴充套件點上文都有相關說明。JSR-354規範提供了預設實現DefaultServiceProvider。利用JDK自帶的ServiceLoader,實現面向服務的註冊與發現,完成服務提供與使用的解耦。載入服務的順序為按類名進行排序的順序;

private <T> List<T> loadServices(final Class<T> serviceType) {
    List<T> services = new ArrayList<>();
    try {
        for (T t : ServiceLoader.load(serviceType)) {
            services.add(t);
        }
        services.sort(Comparator.comparing(o -> o.getClass().getSimpleName()));
        @SuppressWarnings("unchecked")
        final List<T> previousServices = (List<T>) servicesLoaded.putIfAbsent(serviceType, (List<Object>) services);
        return Collections.unmodifiableList(previousServices != null ? previousServices : services);
    } catch (Exception e) {
        ......
        return services;
    }
}

Moneta的實現中也提供了一種實現PriorityAwareServiceProvider,它可以根據註解@Priority指定服務介面實現的優先順序。

private <T> List<T> loadServices(final Class<T> serviceType) {
    List<T> services = new ArrayList<>();
    try {
        for (T t : ServiceLoader.load(serviceType, Monetary.class.getClassLoader())) {
            services.add(t);
        }
        services.sort(PriorityAwareServiceProvider::compareServices);
        @SuppressWarnings("unchecked")
        final List<T> previousServices = (List<T>) servicesLoaded.putIfAbsent(serviceType, (List<Object>) services);
        return Collections.unmodifiableList(previousServices != null ? previousServices : services);
    } catch (Exception e) {
        ......
        services.sort(PriorityAwareServiceProvider::compareServices);
        return services;
    }
}

public static int compareServices(Object o1, Object o2) {
    int prio1 = 0;
    int prio2 = 0;
    Priority prio1Annot = o1.getClass().getAnnotation(Priority.class);
    if (prio1Annot != null) {
        prio1 = prio1Annot.value();
    }
    Priority prio2Annot = o2.getClass().getAnnotation(Priority.class);
    if (prio2Annot != null) {
        prio2 = prio2Annot.value();
    }
    if (prio1 < prio2) {
        return 1;
    }
    if (prio2 < prio1) {
        return -1;
    }
    return o2.getClass().getSimpleName().compareTo(o1.getClass().getSimpleName());
}

2.6 資料載入機制

針對一些動態的資料,比如貨幣型別的動態擴充套件以及貨幣兌換匯率的變更等。Moneta提供了一套資料載入機制來支撐對應的功能。預設提供了四種載入更新策略:從fallback URL獲取,不獲取遠端的資料;啟動的時候從遠端獲取並且只載入一次;首次使用的時候從遠端載入;定時獲取更新。針對不同的策略使用不同的載入資料的方式。分別對應如下程式碼中NEVER、ONSTARTUP、LAZY、SCHEDULED對應的處理方式;

public void registerData(LoadDataInformation loadDataInformation) {
    ......

    if(loadDataInformation.isStartRemote()) {
        defaultLoaderServiceFacade.loadDataRemote(loadDataInformation.getResourceId(), resources);
    }
    switch (loadDataInformation.getUpdatePolicy()) {
        case NEVER:
            loadDataLocal(loadDataInformation.getResourceId());
            break;
        case ONSTARTUP:
            loadDataAsync(loadDataInformation.getResourceId());
            break;
        case SCHEDULED:
            defaultLoaderServiceFacade.scheduledData(resource);
            break;
        case LAZY:
        default:
            break;
    }
}

loadDataLocal方法通過觸發監聽器來完成資料的載入。而監聽器實際上呼叫的是newDataLoaded方法。

public boolean loadDataLocal(String resourceId){
    return loadDataLocalLoaderService.execute(resourceId);
}

public boolean execute(String resourceId) {
    LoadableResource load = this.resources.get(resourceId);
    if (Objects.nonNull(load)) {
        try {
            if (load.loadFallback()) {
                listener.trigger(resourceId, load);
                return true;
            }
        } catch (Exception e) {
            ......
        }
    } else {
        throw new IllegalArgumentException("No such resource: " + resourceId);
    }
    return false;
}

public void trigger(String dataId, DataStreamFactory dataStreamFactory) {
    List<LoaderListener> listeners = getListeners("");
    synchronized (listeners) {
        for (LoaderListener ll : listeners) {
            ......
            ll.newDataLoaded(dataId, dataStreamFactory.getDataStream());
            ......
        }
    }
    if (!(Objects.isNull(dataId) || dataId.isEmpty())) {
        listeners = getListeners(dataId);
        synchronized (listeners) {
            for (LoaderListener ll : listeners) {
                ......
                ll.newDataLoaded(dataId, dataStreamFactory.getDataStream());
                ......
            }
        }
    }
}

loadDataAsync和loadDataLocal類似,只是放在另外的執行緒去非同步執行:

public Future<Boolean> loadDataAsync(final String resourceId) {
    return executors.submit(() -> defaultLoaderServiceFacade.loadData(resourceId, resources));
}

loadDataRemote通過呼叫LoadableResource的loadRemote來載入資料。

public boolean loadDataRemote(String resourceId, Map<String, LoadableResource> resources){
   return loadRemoteDataLoaderService.execute(resourceId, resources);
}

public boolean execute(String resourceId,Map<String, LoadableResource> resources) {

    LoadableResource load = resources.get(resourceId);
    if (Objects.nonNull(load)) {
        try {
            load.readCache();
            listener.trigger(resourceId, load);
            load.loadRemote();
            listener.trigger(resourceId, load);
            ......
            return true;
        } catch (Exception e) {
            ......
        }
    } else {
        throw new IllegalArgumentException("No such resource: " + resourceId);
    }
    return false;
}

LoadableResource載入資料的方式為;

protected boolean load(URI itemToLoad, boolean fallbackLoad) {
    InputStream is = null;
    ByteArrayOutputStream stream = new ByteArrayOutputStream();
    try{
        URLConnection conn;
        String proxyPort = this.properties.get("proxy.port");
        String proxyHost = this.properties.get("proxy.host");
        String proxyType = this.properties.get("proxy.type");
        if(proxyType!=null){
            Proxy proxy = new Proxy(Proxy.Type.valueOf(proxyType.toUpperCase()),
                                    InetSocketAddress.createUnresolved(proxyHost, Integer.parseInt(proxyPort)));
            conn = itemToLoad.toURL().openConnection(proxy);
        }else{
            conn = itemToLoad.toURL().openConnection();
        }
        ......
            
        byte[] data = new byte[4096];
        is = conn.getInputStream();
        int read = is.read(data);
        while (read > 0) {
            stream.write(data, 0, read);
            read = is.read(data);
        }
        setData(stream.toByteArray());
        ......
        return true;
    } catch (Exception e) {
        ......
    } finally {
        ......
    }
    return false;
}

定時執行的方案與上述類似,採用了JDK自帶的Timer做定時器,如下所示;

public void execute(final LoadableResource load) {
    Objects.requireNonNull(load);
    Map<String, String> props = load.getProperties();
    if (Objects.nonNull(props)) {
        String value = props.get("period");
        long periodMS = parseDuration(value);
        value = props.get("delay");
        long delayMS = parseDuration(value);
        if (periodMS > 0) {
            timer.scheduleAtFixedRate(createTimerTask(load), delayMS, periodMS);
        } else {
            value = props.get("at");
            if (Objects.nonNull(value)) {
                List<GregorianCalendar> dates = parseDates(value);
                dates.forEach(date -> timer.schedule(createTimerTask(load), date.getTime(), 3_600_000 * 24 /* daily */));
            }
        }
    }
}

三、案例

3.1 貨幣型別擴充套件

當前業務場景下需要支援v鑽、鼓勵金、v豆等多種貨幣型別,而且隨著業務的發展貨幣型別的種類還會增長。我們需要擴充套件貨幣型別而且還需要貨幣型別資料的動態載入機制。按照如下步驟進行擴充套件:

1)javamoney.properties中新增如下配置;

{-1}load.VFCurrencyProvider.type=NEVER
{-1}load.VFCurrencyProvider.period=23:00
{-1}load.VFCurrencyProvider.resource=/java-money/defaults/VFC/currency.json
{-1}load.VFCurrencyProvider.urls=http://localhost:8080/feeds/data/currency
{-1}load.VFCurrencyProvider.startRemote=false

2)META-INF.services路徑下新增檔案javax.money.spi.CurrencyProviderSpi,並且在檔案中新增如下內容;

com.vivo.finance.javamoney.spi.VFCurrencyProvider

3)java-money.defaults.VFC路徑下新增檔案currency.json,檔案內容如下;

[{
  "currencyCode": "VZU",
  "defaultFractionDigits": 2,
  "numericCode": 1001
},{
  "currencyCode": "GLJ",
  "defaultFractionDigits": 2,
  "numericCode": 1002
},{
  "currencyCode": "VBE",
  "defaultFractionDigits": 2,
  "numericCode": 1003
},{
  "currencyCode": "VDO",
  "defaultFractionDigits": 2,
  "numericCode": 1004
},{
  "currencyCode": "VJP",
  "defaultFractionDigits": 2,
  "numericCode": 1005
}
]

4)新增類VFCurrencyProvider實現

CurrencyProviderSpi和LoaderService.LoaderListener,用於擴充套件貨幣型別和實現擴充套件的貨幣型別的資料載入。其中包含的資料解析類VFCurrencyReadingHandler,資料模型類VFCurrency等程式碼省略。對應的實現關聯類圖為;

(圖2-8 貨幣型別擴充套件主要關聯實現類圖)

關鍵實現為資料的載入,程式碼如下;

@Override
public void newDataLoaded(String resourceId, InputStream is) {
    final int oldSize = CURRENCY_UNITS.size();
    try {
        Map<String, CurrencyUnit> newCurrencyUnits = new HashMap<>(16);
        Map<Integer, CurrencyUnit> newCurrencyUnitsByNumricCode = new ConcurrentHashMap<>();
        final VFCurrencyReadingHandler parser = new VFCurrencyReadingHandler(newCurrencyUnits,newCurrencyUnitsByNumricCode);
        parser.parse(is);

        CURRENCY_UNITS.clear();
        CURRENCY_UNITS_BY_NUMERIC_CODE.clear();
        CURRENCY_UNITS.putAll(newCurrencyUnits);
        CURRENCY_UNITS_BY_NUMERIC_CODE.putAll(newCurrencyUnitsByNumricCode);

        int newSize = CURRENCY_UNITS.size();
        loadState = "Loaded " + resourceId + " currency:" + (newSize - oldSize);
        LOG.info(loadState);
    } catch (Exception e) {
        loadState = "Last Error during data load: " + e.getMessage();
        LOG.log(Level.FINEST, "Error during data load.", e);
    } finally{
        loadLock.countDown();
    }
}

3.2 貨幣兌換擴充套件

隨著貨幣型別的增加,在充值等場景下對應的貨幣兌換場景也會隨之增加。我們需要擴充套件貨幣兌換並需要貨幣兌換匯率相關資料的動態載入機制。如貨幣的擴充套件方式類似,按照如下步驟進行擴充套件:

javamoney.properties中新增如下配置;

{-1}load.VFCExchangeRateProvider.type=NEVER
{-1}load.VFCExchangeRateProvider.period=23:00
{-1}load.VFCExchangeRateProvider.resource=/java-money/defaults/VFC/currencyExchangeRate.json
{-1}load.VFCExchangeRateProvider.urls=http://localhost:8080/feeds/data/currencyExchangeRate
{-1}load.VFCExchangeRateProvider.startRemote=false

META-INF.services路徑下新增檔案javax.money.convert.ExchangeRateProvider,並且在檔案中新增如下內容;

com.vivo.finance.javamoney.spi.VFCExchangeRateProvider

java-money.defaults.VFC路徑下新增檔案currencyExchangeRate.json,檔案內容如下;

[{
  "date": "2021-05-13",
  "currency": "VZU",
  "factor": "1.0000"
},{
  "date": "2021-05-13",
  "currency": "GLJ",
  "factor": "1.0000"
},{
  "date": "2021-05-13",
  "currency": "VBE",
  "factor": "1E+2"
},{
  "date": "2021-05-13",
  "currency": "VDO",
  "factor": "0.1666"
},{
  "date": "2021-05-13",
  "currency": "VJP",
  "factor": "23.4400"
}
]

新增類VFCExchangeRateProvider

繼承AbstractRateProvider並實現LoaderService.LoaderListener。對應的實現關聯類圖為;

(圖2-9 貨幣金額擴充套件主要關聯實現類圖)

3.3 使用場景案例

假設1人民幣可以兌換100v豆,1人民幣可以兌換1v鑽,當前場景下使用者充值100v豆對應支付了1v鑽,需要校驗支付金額和充值金額是否合法。可以使用如下方式校驗;

Number rechargeNumber = 100;
CurrencyUnit currencyUnit = Monetary.getCurrency("VBE");
Money rechargeMoney = Money.of(rechargeNumber,currencyUnit);

Number payNumber = 1;
CurrencyUnit payCurrencyUnit = Monetary.getCurrency("VZU");
Money payMoney = Money.of(payNumber,payCurrencyUnit);

CurrencyConversion vfCurrencyConversion = MonetaryConversions.getConversion("VBE");
Money conversMoney = payMoney.with(vfCurrencyConversion);
Assert.assertEquals(conversMoney,rechargeMoney);

四、總結

JavaMoney為金融場景下使用貨幣提供了極大的便利。能夠支撐豐富的業務場景對貨幣型別以及貨幣金額的訴求。特別是Monetary、MonetaryConversions、MonetaryFormats作為貨幣基礎能力、貨幣兌換、貨幣格式化等能力的入口,為相關的操作提供了便利。同時也提供了很好的擴充套件機制方便進行相關的改造來滿足自己的業務場景。

文中從使用場景出發引出JSR 354需要解決的主要問題。通過解析相關工程的包和模組結構說明針對這些問題JSR 354及其實現是如果去劃分來解決這些問題的。然後從相關API來說明針對相應的貨幣擴充套件,金額計算,貨幣兌換、格式化等能力它是如何來支撐以及使用的。以及介紹了相關的擴充套件方式意見建議。接著總結了相關的SPI以及對應的資料載入機制。最後通過一個案例來說明針對特定場景如何擴充套件以及應用對應實現。

作者:vivo網際網路伺服器團隊-Hou Xiaobi

相關文章