Teil 3: SPIs für Konversion, Formatierung und Lifecycle

Java in the Enterprise: Einführung in das Money and Currency API

Anatole Tresch

@shutterstock/Mark Carrel

In den vorangehenden Artikeln haben wir das API von JSR 354 und einen ersten Teil des SPI vorgestellt. In diesem Artikel zeige ich nun, wie Konversionen und Formate erweitert werden können und wie der Bootstrap-Mechanismus eine transparente Integration mit Java EE erlaubt.

Das API für Währungskonversion befindet sich im Paket javax.money.conversion. Die Schlüsselklasse, die eine einzelne Konversion modelliert, ist die ExchangeRate (Listing 1). Eine Instanz dieser Schnittstelle definiert den Multiplikator (factor), der angewendet auf einen Betrag in der Quellwährung (baseCurrency) den entsprechenden Betrag in der Zielwährung (currency) ergibt. Da es in Abhängigkeit des aktuellen Anwendungsfalls viele solcher Multiplikatoren geben kann, kommt noch ein Kontext (ConversionContext) hinzu, der genauer beschreibt, um welche Art der Konversion es sich handelt.

Artikelserie

Teil 1: Grundlagen: API und Usage
Teil 2: SPI: Basics, Rundungen und Währungen
Teil 3: SPI: Konversion, Formatierung und Java EE

public interface ExchangeRate extends CurrencySupplier {
  NumberValue getFactor();
    CurrencyUnit getBaseCurrency();
    CurrencyUnit getCurrency();
    ConversionContext getContext();
    List<ExchangeRate> getExchangeRateChain();
    default boolean isDerived(){...}
}

Nun soll eine zusätzliche Währungskonversion auf Basis des RSS Feeds der US Federal Reserve Bank bereitgestellt werden, d. h. wir müssen einen ExchangeRateProvider (Listing 2) implementieren. Das Federal Reserve System (FED) liefert dabei immer Konversionsdaten auf Basis des US-Dollar-Kurses der Vorwoche. Folglich unterstützt unsere Implementierung nur Raten vom Typ RateType.HISTORIC und BaseCurrency=USD. Mit diesen Informationen können wir bereits den ProviderContext definieren, der unseren Provider genauer beschreibt und beim Aufruf von getContext() zurückgeben werden muss (Listing 3).

public interface ExchangeRateProvider{

    ProviderContext getContext();
    ExchangeRate getExchangeRate(ConversionQuery conversionQuery);
    CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery);

    default boolean isAvailable(ConversionQuery conversionQuery){...}
    default ExchangeRate getExchangeRate(CurrencyUnit base, CurrencyUnit term){...}
    default CurrencyConversion getCurrencyConversion(CurrencyUnit term){...}
    default boolean isAvailable(CurrencyUnit base, CurrencyUnit term){...}
    default boolean isAvailable(String baseCode, String termCode){...}
    default ExchangeRate getExchangeRate(String baseCode, String termCode){...}
    default ExchangeRate getReversed(ExchangeRate rate){...}
    default CurrencyConversion getCurrencyConversion(String termCode){...}
}
private static final ProviderContext CONTEXT = ProviderContextBuilder.of("FRB", RateType.HISTORIC)
  .set("providerDescription", "Federal Reserve Bank of the United States")
  .build();

@Override
public ProviderContext getContext(){
  return CONTEXT;
}

Die wichtigste Methode eines ExchangeRateProvider ist natürlich die Methode getExchangeRate. Dieser Methode wird als Parameterobjekt eine so genannte ConversionQuery übergeben (Listing 4), welche die Parameter für die Währungsanfrage enthält. Unsere Implementierung realisiert folgende Prüfungen:

  1. Bei den gewünschten rateTypes müssen auch historische Konversionen erwünscht sein, d. h. ConversionQuery.getRateTypes().contains(RateType.HISTORIC) muss zutreffen.
  2. Die baseCurrency muss USD sein.
  3. Optional sollen auch Zieldatumswerte übergeben werden können. Wird kein Zieldatum definiert, soll unser Provider den letzten geladenen Wert zurückgeben. Zugriffe am Sonntag, liefern z. B. also die letzten Werte vom Freitag zurück.

Wenn eine Anfrage nicht den Anforderungen entspricht oder nicht beantwortet werden kann, kann eine CurrencyConversionException oder MonetaryException geworfen werden. In den meisten Fällen genügt es aber, einfach null zurückzugegeben. Hier sind sich die Implementierung und die Spezifikation nicht ganz einig. Nutzt man die Moneta-Referenzimplementierung, vereinfacht die Basisklasse AbstractRateProvider die Implementierung eines ExchangeRateProviders erheblich. AbstractRateProvider implementiert zusätzlich die Ableitung einer CurrencyConversion und die Evaluation von LocalDate, LocalDateTime oder LocalDate[] als Stichtagsparameter (Listing 5). Somit können wir den ExchangeRateProvider wie in Listing 6 gezeigt implementieren. Ich habe nur die Details der konkreten Anbindung an das FED entfernt, um das Beispiel übersichtlich zu halten. Die vollständige Implementierung ist hier nachzulesen.

In der Praxis kommt ein weiterer Aspekt hinzu: Es ist nicht zwingend dauernd eine Internetverbindung gegeben. Oder man möchte konfigurieren können, wann die verschiedenen Datenprovider ihre Daten lesen oder aktualisieren. Und für den Fall, dass keine Internetverbindung besteht, sollen default-Werte aus dem Klassenpfad gezogen werden können oder Daten nicht von außen direkt, sondern von einer internen Ressource im Unternehmensnetz bezogen werden. Genau diese Funktionalität bietet die Referenzimplementierung mit dem LoaderService an. Um diesen zu nutzen, muss ein Provider die Schnittstelle LoaderListener implementieren und sich beim LoaderService registrieren (Listing 7). Nachdem wir unseren Provider mit dem Java ServiceLoader registriert haben, können wir schließlich FED-Daten wie in Listing 8 gezeigt abrufen.

public final class ConversionQuery extends AbstractQuery
implements CurrencySupplier {
  public Set<RateType> getRateTypes();
  public CurrencyUnit getBaseCurrency();
  public CurrencyUnit getCurrency();
  public ConversionQueryBuilder toBuilder();
}
public abstract class AbstractRateProvider implements ExchangeRateProvider {
  private final ProviderContext context;

  public AbstractRateProvider(ProviderContext providerContext) {...}

  @Override
  public ProviderContext getContext(){return context;}

  @Override
  public abstract ExchangeRate getExchangeRate(ConversionQuery conversionQuery);

  @Override
  public CurrencyConversion getCurrencyConversion(
    ConversionQuery conversionQuery) {
     if (getContext().getRateTypes().size() == 1) {
       return new LazyBoundCurrencyConversion(conversionQuery, this,
          ConversionContext.of(
            getContext().getProviderName(),
            getContext().getRateTypes().iterator().next()));
      }
      return new LazyBoundCurrencyConversion(conversionQuery, this,
        ConversionContext.of(getContext().getProviderName(), RateType.ANY));
  }

  protected LocalDate[] getQueryDates(ConversionQuery query) {
    if (Objects.nonNull(query.get(LocalDate.class)) ||
        Objects.nonNull(query.get(LocalDateTime.class))) {
        LocalDate localDate = Optional.ofNullable(
          query.get(LocalDate.class))
          .orElseGet(() -> query.get(LocalDateTime.class).toLocalDate());
        return new LocalDate[]{localDate};
    } else if(Objects.nonNull(query.get(LocalDate[].class))) {
        return query.get(LocalDate[].class);
    }
    return null;
  }
public class USFederalReserveRateProvider extends AbstractRateProvider{

  private static final ProviderContext CONTEXT =
    ProviderContextBuilder.of("FRB", RateType.HISTORIC)
      .set("providerDescription", "Federal Reserve Bank of the United States")
      .build();

  private final Map<LocalDate, Map<String, ExchangeRate>> rates = new ConcurrentHashMap<>();
  public USFederalReserveRateProvider(){
    super(CONTEXT);
    initialize();
  }

  private void initialize(){
    // load data from FED into rates Map...
    // ...
  }

  @Override
  public ExchangeRate getExchangeRate(ConversionQuery query) {
    Objects.requireNonNull(query);
    if (rates.isEmpty()) {
      return null;
    }
    if (!query.getRateTypes()contains(RateType.HISTORIC)) {
      return null;
    }
    if(!"USD".equals(conversionQuery.getBaseCurrency().getCurrencyCode())){
      return null;
    }
    LocalDate[] dates = getQueryDates(query);
    if(dates == null) {
      Comparator<LocalDate> comparator = Comparator.naturalOrder();
      LocalDate date =
        this.rates
          .keySet()
          .stream()
          .sorted(comparator.reversed())
          .findFirst()
          .orElseThrow(
            () -> new MonetaryException("No more recent rate.”));
      return this.rates.get(date).get(query.getCurrency().getCurrencyCode());
    } else {
      for(LocalDate localDate : dates) {
        Map<String, ExchangeRate> targets = this.rates.get(localDate);
        if(Objects.nonNull(targets)) {
          return targets.get(query.getCurrency().getCurrencyCode());
        }
      }
    }
    return null;
  }
}
public class USFederalReserveRateProvider extends AbstractRateProvider
implements LoaderListener{

  [...]

  private void initialize(){
    LoaderService loader = Bootstrap.getService(LoaderService.class);
    loader.addLoaderListener(this, getDataId());
    try {
      loader.loadDataAsync(getDataId());
    } catch(Exception e) {
        throw new RuntimeException(e);
    }
  }

  private String getDataId() {
    return USFederalReserveRateProvider.class.getSimpleName();
  }

  @Override
  public void newDataLoaded(String resourceId, InputStream is) {

    final int oldSize = this.rates.size();
    try {
      Map<LocalDate, Map<String, ExchangeRate>> newRates = new HashMap<>();
      // load data from FED into rates Map...
      // ...
      LOG.info("Loaded " + resourceId + " exchange rates);
    } catch (Exception e) {
      LOG.log(Level.WARNING, "Error during data load.", e);
    }
  }

  [...]
}
CurrencyConversion currencyConversion = MonetaryConversion.getCurrencyConversion("CAD", "FRB");
MonetaryAmount money = Money.of(BigDecimal.TEN, "USD"); // US Dollar
MonetaryAmount result = money.with(currencyConversion); // Canada Dollar

Flexible Formatierung implementieren

Im nächsten Schritt möchten wir eine flexible Ausgabeformatierung implementieren, die folgende Funktionen bietet:

  • Die Währungscodes sind immer nachgestellt.
  • Beträge größer/gleich 1 Mio. werden auf 100 000 genau gerundet und als x.y Mio. formatiert (Gruppierung 3, eine Nachkommastelle).
  • Beträge kleiner als 1 Mio. werden auf 1 000 genau gerundet (Gruppierung 3, keine Nachkommastellen).
  • 0 wird als „-“ angezeigt.
  • Negativbeträge werden als „- Betrag“ formatiert.

Wer die Java-DecimalFormat-Klasse kennt, sieht sofort, dass sie mit diesen Anforderungen nicht umgehen kann. Aber kein Problem mit dem JSR 354: Dazu implementieren wir einfach die Schnittstelle MonetaryAmountFormatProviderSpi (Listing 9) und registrieren unsere Klasse mit dem Java-ServiceLoader. Da wir das Format mit dem Namen REPORT-SHORT registrieren möchten, geben wir diesen Namen im Resultat von getAvailableFormatNames zurück. Um ein Format für eine bestimmte Locale-Variante zu registrieren, müssten wir genauso vorgehen, aber die Locale im Resultat von getAvailableLocales zurückgeben. Somit bleibt noch die Methode getAmountFormats. Diese soll genau ein Format zurückliefern, aber nur dann, wenn das Format explizit mit seinem Namen angefragt worden ist (Listing 10).

public interface MonetaryAmountFormatProviderSpi {

  default String getProviderName(){...}

  Collection<MonetaryAmountFormat> getAmountFormats(
    AmountFormatQuery formatQuery);

  Set<Locale> getAvailableLocales();
  Set<String> getAvailableFormatNames();
}
public class MyFormatProvider implements MonetaryAmountFormatProviderSpi {
  private static Set<String> NAMES = ...;
  private static Collection<MonetaryAmountFormat> FORMATS = ...;
  private static Set<String> initNames(){...}

  public Collection<MonetaryAmountFormat> getAmountFormats(
    AmountFormatQuery formatQuery){
      if("REPORT-SHORT".equals(query.getFormatName())){
        return FORMATS;
      }
      return Collections.emptySet();
  }

  public Set<Locale> getAvailableLocales(){
    return Collections.emptySet();
  }
  public Set<String> getAvailableFormatNames(){
    return NAMES;
  }
}

Natürlich müssen wir nun noch die Schnittstelle MonetaryAmountFormat implementieren. Dazu benötigen wir als Erstes einen AmountFormatContext, der Details über die vorliegende Formatierung enthält. Als Providernamen nehmen wir der Einfachheit halber unseren Formatnamen (Nebenbemerkung: ein Provider kann mehrere unterschiedliche Formate anbieten). Da MonetaryAmountFormat auch MonetaryQuery<string> implementiert, fügen wir unsere Formatierungslogik der Methode String queryFrom(MonetaryAmount) zu. Dort müssen wir – entsprechend unseren Anforderungen – eine Fallunterscheidung machen und die Beträge entsprechend runden und formatieren. Dabei kann es durchaus sinnvoll sein, für die Nummernformatierung intern wieder auf DecimalFormat zurückzugreifen. Da JSR 354 aber auch weitere Währungen unterstützt, muss das Anhängen des Währungscodes implementiert werden. Auch zu beachten ist, dass DecimalFormat pro Thread isoliert oder aber synchronisiert zugegriffen wird, da die Klasse nicht threadsicher ist. Unsere fertige Formatklasse zeigt Listing 11.
Bei symmetrischen Formatalgorithmen müssten wir nun auch die Methode parse implementieren. In unserem Fall macht das Parsen eines Ausdrucks jedoch wenig Sinn, sodass wir einfach eine MonetaryParseException werfen.

class MyFormat implements MonetaryAmountFormat {
  static final String STYLE = "REPORT-SHORT";
  private static final AmountFormatContext CONTEXT = ountFormatContextBuilder.of(STYLE).build();
  private final ThreadLocal<DecimalFormat> millionsFormat = new ThreadLocal<>(){
    protected T initialValue() {
      // Create, initialize and return a new instance...
    }
  };
  private final ThreadLocal<DecimalFormat> thousandsFormat = new ThreadLocal<>(){
    protected T initialValue() {
      // Create, initialize and return a new instance...
    }
  };

  @Override
  public AmountFormatContext getContext() {
    return CONTEXT;
  }

  @Override
  public void print(Appendable appendable, MonetaryAmount amount)
  throws IOException {
    requireNonNull(appendable).append(queryFrom(amount));
  }

  @Override
  public MonetaryAmount parse(CharSequence text) throws MonetaryParseException {
    throw new MonetaryParseException(“Parsing not supported for “ + STYLE);
  }

  @Override
  public String queryFrom(MonetaryAmount amount) {
    return Optional.ofNullable(amount)
      .map(m -> {
        if(mAbs .isZero()){
          return "-";
        }
        MonetaryAmount mAbs = m.absolute();
        DecimalFormat df;
        if(m.isLessThan(1000000L)){
          df = thousandsFormat.get();
        }else{
          df = millionsFormat.get();
        }
        df.setCurrency(Currency.getInstance(
          amount.getCurrency().getCurrencyCode()));
          return df.format(
            m.getNumber().numberValue(BigDecimal.class));
        }
    ).orElse("null");
  }

Eine Finanzformel implementieren

Finanzielle Berechnungen lassen sich mit den Erweiterungspunkten des API implementieren und zur Verfügung stellen. Dazu möchten wir einen FutureValue FV implementieren, der den Wert eines Cashflows C0 unter Annahme eines kalkulatorischen Zinses r in n Zinsperioden definiert. Der Wert wird mit FV = C0 x (1+r)n berechnet. Mit anderen Worten: Ein FutureValue ist eine Funktion fFV(C0, r , n) → FV. Ein MonetaryOperator modelliert dabei eine Funktion f(M1) → M2, die einen Geldbetrag in einen anderen abbildet. Halten wir also r und n konstant, können wir unsere Formel als MonetaryOperator implementieren und z. B. in einem CommonFinance Singleton anbieten (Listing 12).

public final class CommonFinance {
  [...]
    public static MonetaryaOperator futureValue(double rate, int periods){...}
  [...]
}

Eine vollständige Implementierung der Formel findet sich hier. Die Berechnung eines FutureValue ist dann nichts anderes als das Anwenden eben dieses Operators auf den Betrag des ursprünglichen CashFlows zum Zeitpunkt 0. In Listing 13 wird ein FutureValue in fünf Jahren unter Annahme von 7 Prozent Jahreszins berechnet.

import static  CommonFinance.futureValue;

MonetaryAmount cashFlow = Money.of(1000, "EUR");
// Fut.value in 5 Jahren bei 7 Prozent kalk. Jahreszins
MonetaryAmount futureValue = cashFlow.with(futureValue(0.07, 5));

JavaMoney mit CDI/Java EE kombinieren

Die Klasse javax.money.spi.Bootstrap stellt den zentralen Zugriffspunkt des JSRs für alle Services bereit. Standardmäßig stützt sich der Bootstrap-Mechanismus auf die Funktionalität der Klasse java.util.ServiceLoader. Der Bootstrap-Mechanismus erlaubt es jedoch, eine eigene Implementierung eines ServiceProvider bereitzustellen. Das nutzen wir, um nebst dem Java-ServiceLoader auch CDI als SPI-Quelle heranziehen zu können (Listing 14: der vollständige Code).

public class CDISEServiceProvider implements ServiceProvider {

  /** Default provider, using ServiceLoader. */
  private ServiceProvider defaultServiceProvider =
    new PriorityAwareServiceProvider();
  @Override
  public <T> List<T> getServices(Class<T> serviceType) {
    List<T> instances = new ArrayList<T>();
    Set<String> types = new HashSet<>();
    for (T t : CDIAccessor
      .getInstances(serviceType)) {
        instances.add(t);
        types.add(t.getClass().getName());
    }
    for (T t : defaultServiceProvider.getServices(serviceType)) {
      if (!types.contains(t.getClass().getName())) {
        instances.add(t);
      }
    }
    instances.sort(PriorityAwareServiceProvider::compareServices);
    return instances;
  }

  public <T> List<T> getServices(Class<T> serviceType, List<T> defaultList) {
    List<T> services = getServices(serviceType);
    if (services.isEmpty()) {
      return defaultList;
    }
    return services;
  }

  @Override
  public String toString() {
    return "CDISEServiceProvider{" +
      "defaultServiceProvider=" + defaultServiceProvider +
      '}';
  }

  @Override
  public int getPriority() {
    return PRIO;  // 100
  }
}

Der neue CDISEServiceProvider wird selbst mit dem Standard-Java-ServiceLoader registriert und übersteuert den Standardprovider aufgrund der höheren Priorität (Methode getPriority). Dies ermöglicht es uns, nun SPIs direkt als CDI-Komponenten zu implementieren und zu registrieren (Listing 15).

@Singleton
public class CDITestCurrencyProvider implements CurrencyProviderSpi {
  [...]
}

Der umgekehrte Weg, d .h. die Artefakte und Abstraktionen von JSR 354 direkt injizierbar zu machen, ist im Prinzip nichts neues (Listing 16). Allerdings sind bis jetzt noch keine entsprechenden Annotationen definiert worden. Geneigte Leserinnen und Leser sind als Mitwirkende im JavaMoney-Projekt gerne willkommen.

Fazit

Mit diesem Artikel endet die Serie zum JSR 354. Obschon das API relativ einfach daher kommt, zeigt sich bei genauem Hinsehen ein flexibles und mächtiges SPI, das auch für komplexe Anwendungs- und Laufzeitszenarien Unterstützung bietet. Aktuell arbeitet das Projektteam daran, ein Maintenance-Release der Referenzimplementierung bereitzustellen. Die Unterstützung von CDI als Plattform und damit zusammenhängend ein entsprechendes Annotation-API wäre jedoch mit Sicherheit eine willkommene Ergänzung.

Ob und in welcher Form JSR 354 in die zukünftige Java-9-Plattform einfließen wird, ist zum jetzigen Zeitpunkt noch offen. Einzig eine vollständige Integration scheint aus heutiger Sicht kaum realistisch zu sein. Das muss aber mit der Verfügbarkeit von Jigsaw nicht zwingend ein Nachteil sein. Es bleibt also spannend.

Aufmacherbild: Digital money and online payment concept with old-style von Shutterstock / Urheberrecht: Mark Carrel

Geschrieben von
Anatole Tresch
Anatole Tresch
A​natole Tresch studierte Wirtschaftsinformatik und war anschließend mehrere Jahre lang als Managing Partner und Berater aktiv. ​Die letzten Jahre war ​Anatole Tresch als technischer Architekt und Koordinator bei der Credit Suisse​ tätig. Aktuell arbeitet Anatole ​als Principal Consultant für die Trivadis AG, ist Specification Lead des JSR 354 (Java Money & Currency) und PPMC Member von Apache Tamaya. Twitter: @atsticks
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: