Ejemplo práctico de ServiceLoader con ServiceProvider de Java Money

Escrito por el .
blog-stack java planeta-codigo programacion
Comentarios

Una aplicación que trabaje con importes y diferentes divisas necesitará ratios de conversión, estos ratios de conversión deberemos obtenerlos de algún servicio. Con la API de Java Money que aun en Java 8 no está incorporada en el JDK aunque si como una librería podremos trabajar de forma cómoda con importes, divisas y ratios. En este artículo explicaré un ejemplo de uso práctico de la clase ServiceLoader y como obtener ratios del servicio Open Exchange Rates.

Java

La semana pasada comentaba la clase ServiceLoader disponibles en el JDK y como nos puede servir para que nuestra aplicación o API sea extensible en futuras versiones o para alguien que quiera adaptarla a sus necesidades. La clase ServiceLoader es el método que se emplea en la API de Java para tratamiento de divisas, importes y conversiones que quizá en un futuro se ofrezca en Java con la especificación JSR-354. Por el momento se puede usar la librería con la implementación de referencia. En este ejemplo mostraré cómo proporcionar un nuevo proveedor de ratios que obtenga ratios de conversión del servicio Open Exchange Rates.

Para disponer del proveedor de ratios usable para Java Money deberemos implementar una clase que extienda de AbstractRateProvider junto con la implementación para el método getExchangeRate que devolverá los ratios, también el método newDataLoaded que procesará los datos de ratios obtenidos del servicio. El código del método getExchangeRate es una copia del proveedor del Banco Central Europeo (ECB), el método newDataLoaded obtiene los ratios a partir de un InputStream con los ratios devueltos por la URL del servicio, en el caso de Open Exchange Rates en el endpoint _/api/latest.json?app_id=[apikey].

Internamente la implementación de referencia de Java Money usa la clase ServiceLoader. En el archivo META-INF/services/javax.money.convert.ExchangeRateProvider incluimos el nombre cualificado completo de la clase de la implementación de AbstractRateProvider, en este caso io.github.picodotdev.javamoney.OpenExchangeRatesRateProvider.

1
io.github.picodotdev.javamoney.OpenExchangeRatesRateProvider
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
package io.github.picodotdev.javamoney;

import java.io.InputStream;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;

import javax.money.Monetary;
import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.ConversionQuery;
import javax.money.convert.CurrencyConversionException;
import javax.money.convert.ExchangeRate;
import javax.money.convert.ProviderContext;
import javax.money.convert.ProviderContextBuilder;
import javax.money.convert.RateType;
import javax.money.spi.Bootstrap;

import org.apache.commons.io.IOUtils;
import org.javamoney.moneta.ExchangeRateBuilder;
import org.javamoney.moneta.spi.AbstractRateProvider;
import org.javamoney.moneta.spi.DefaultNumberValue;
import org.javamoney.moneta.spi.LoaderService;
import org.javamoney.moneta.spi.LoaderService.LoaderListener;
import org.json.JSONObject;

public class OpenExchangeRatesRateProvider extends AbstractRateProvider implements LoaderListener {

    private static final String DATA_ID = OpenExchangeRatesRateProvider.class.getSimpleName();

    private static final ProviderContext CONTEXT = ProviderContextBuilder.of("OER", RateType.DEFERRED).set("providerDescription", "Open Exchange Rates").set("days", 1)
            .build();

    private String BASE_CURRENCY_CODE = "USD";

    protected Future<Boolean> load;
    
    protected final Map<LocalDate, Map<String, ExchangeRate>> rates = new ConcurrentHashMap<>();

    public OpenExchangeRatesRateProvider() {
        super(CONTEXT);
        LoaderService loader = Bootstrap.getService(LoaderService.class);
        loader.addLoaderListener(this, getDataId());
        load = loader.loadDataAsync(getDataId());
    }

    public String getDataId() {
        return DATA_ID;
    }

    @Override
    public void newDataLoaded(String resourceId, InputStream is) {
        try {
            String data = IOUtils.toString(is, "UTF-8");

            LocalDate date = LocalDate.now();
            ExchangeRateBuilder builder = new ExchangeRateBuilder(ConversionContextBuilder.create(CONTEXT, RateType.DEFERRED).set(date).build());
            
            rates.putIfAbsent(date, new HashMap<String, ExchangeRate>());

            JSONObject json = new JSONObject(data);
            JSONObject r = json.getJSONObject("rates");
            builder.setBase(Monetary.getCurrency(BASE_CURRENCY_CODE));            
            for (String term : r.keySet()) {
                if (!Monetary.isCurrencyAvailable(term)) {
                    continue;
                }

                Double factor = r.getDouble(term);
            
                builder.setTerm(Monetary.getCurrency(term));
                builder.setFactor(DefaultNumberValue.of(factor));
                
                ExchangeRate exchangeRate = builder.build();                
                rates.get(date).put(term, exchangeRate);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
        try {
            load.get();
        } catch (Exception e) {
            return null;
        }
        
        Objects.requireNonNull(conversionQuery);
        if (rates.isEmpty()) {
            return null;
        }
        LocalDate[] dates = getQueryDates(conversionQuery);
        LocalDate selectedDate = null;
        Map<String, ExchangeRate> targets = null;
        for (LocalDate date : dates) {
            targets = this.rates.get(date);
            if (targets != null) {
                selectedDate = date;
                break;
            }
        }
        if (Objects.isNull(targets)) {
            return null;
        }
        ExchangeRateBuilder builder = getBuilder(conversionQuery, selectedDate);
        ExchangeRate sourceRate = targets.get(conversionQuery.getBaseCurrency().getCurrencyCode());
        ExchangeRate target = targets.get(conversionQuery.getCurrency().getCurrencyCode());
        return createExchangeRate(conversionQuery, builder, sourceRate, target);
    }

    private LocalDate[] getQueryDates(ConversionQuery query) {
        LocalDate date = query.get(LocalDate.class);
        if (date == null) {
            LocalDateTime dateTime = query.get(LocalDateTime.class);
            if (dateTime != null) {
                date = dateTime.toLocalDate();
            } else {
                date = LocalDate.now();
            }
        }
        return new LocalDate[] { date, date.minus(Period.ofDays(1)), date.minus(Period.ofDays(2)), date.minus(Period.ofDays(3)) };
    }

    private ExchangeRateBuilder getBuilder(ConversionQuery query, LocalDate localDate) {
        ExchangeRateBuilder builder = new ExchangeRateBuilder(ConversionContextBuilder.create(getContext(), RateType.HISTORIC).set(localDate).build());
        builder.setBase(query.getBaseCurrency());
        builder.setTerm(query.getCurrency());
        return builder;
    }

    private ExchangeRate createExchangeRate(ConversionQuery query, ExchangeRateBuilder builder, ExchangeRate sourceRate, ExchangeRate target) {
        if (areBothBaseCurrencies(query)) {
            builder.setFactor(DefaultNumberValue.ONE);
            return builder.build();
        } else if (BASE_CURRENCY_CODE.equals(query.getCurrency().getCurrencyCode())) {
            if (Objects.isNull(sourceRate)) {
                return null;
            }
            return reverse(sourceRate);
        } else if (BASE_CURRENCY_CODE.equals(query.getBaseCurrency().getCurrencyCode())) {
            return target;
        } else {
            // Get Conversion base as derived rate: base -> EUR -> term
            ExchangeRate rate1 = getExchangeRate(query.toBuilder().setTermCurrency(Monetary.getCurrency(BASE_CURRENCY_CODE)).build());
            ExchangeRate rate2 = getExchangeRate(query.toBuilder().setBaseCurrency(Monetary.getCurrency(BASE_CURRENCY_CODE)).setTermCurrency(query.getCurrency()).build());
            if (Objects.nonNull(rate1) && Objects.nonNull(rate2)) {
                builder.setFactor(multiply(rate1.getFactor(), rate2.getFactor()));
                builder.setRateChain(rate1, rate2);
                return builder.build();
            }
            throw new CurrencyConversionException(query.getBaseCurrency(), query.getCurrency(), sourceRate.getContext());
        }
    }

    private boolean areBothBaseCurrencies(ConversionQuery query) {
        return BASE_CURRENCY_CODE.equals(query.getBaseCurrency().getCurrencyCode()) && BASE_CURRENCY_CODE.equals(query.getCurrency().getCurrencyCode());
    }

    private ExchangeRate reverse(ExchangeRate rate) {
        if (Objects.isNull(rate)) {
            throw new IllegalArgumentException("Rate null is not reversible.");
        }
        return new ExchangeRateBuilder(rate).setRate(rate).setBase(rate.getCurrency()).setTerm(rate.getBaseCurrency())
                .setFactor(divide(DefaultNumberValue.ONE, rate.getFactor(), MathContext.DECIMAL64)).build();
    }
}

También deberemos sobreescribir la propiedad conversion.default-chain en el archivo javamoney.properties, junto con algunas otras propiedades necesarias para que cargue los datos a partir de una URL del servicio que proporciona los ratios. La URL será la del servicio Open Exchange Rates que devolverá un resultado en formato JSON, lo procesaremos y construiremos los ExchangeRates a partir de los datos que nos son proporcionados en la clase AbstractRateProvider con el método newDataLoaded en forma de InputStream. En el archivo javamoney.properties el número entre llaves ({}) indica la prioridad de la propiedad cuando haya varios archivos javamoney.properties en diferentes archivos jar, deberemos indicar 0 o más ya que la prioridad por defecto es -1.

1
2
3
4
5
6
7
8
# OER Rates
{0}load.OpenExchangeRatesRateProvider.type=SCHEDULED
{0}load.OpenExchangeRatesRateProvider.period=03:00
{0}load.OpenExchangeRatesRateProvider.resource=/java-money/defaults/OER/open-exchange-rates.json
{0}load.OpenExchangeRatesRateProvider.urls=https://openexchangerates.org/api/latest.json?app_id=9275a617a9d248129a32d996914c9344

#Currency Conversion
{0}conversion.default-chain=IDENT,ECB,IMF,ECB-HIST,ECB-HIST90,OER

Implementado el servicio de ratios personalizado de Open Exhcnage Rates y configurado podemos usarlo con las siguientes líneas de código tal y como hacíamos con el servicio del Banco Central Europeo (ECB) proporcionado en la implementación de referencia de la librería de Java Money.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// exchange rates
ExchangeRateProvider exchangeRateProviderECB = MonetaryConversions.getExchangeRateProvider("ECB");
ExchangeRateProvider exchangeRateProviderOER = MonetaryConversions.getExchangeRateProvider("OER");
ExchangeRate exchangeRateECB = exchangeRateProviderECB.getExchangeRate("USD", "EUR");
ExchangeRate exchangeRateOER = exchangeRateProviderOER.getExchangeRate("USD", "EUR");

System.out.println();
System.out.println("exchange rates");
System.out.printf("Ratio de conversión de USD a EUR (ECB, European Central Bank): %f\n", exchangeRateECB.getFactor().doubleValue());
System.out.printf("Ratio de conversión de USD a EUR (OER, Open Exchange Rates): %f\n", exchangeRateOER.getFactor().doubleValue());

// conversion
CurrencyConversion toEuroECB = MonetaryConversions.getConversion("EUR", "ECB");
CurrencyConversion toEuroOER = MonetaryConversions.getConversion("EUR", "OER");
MonetaryAmount tenDollarToEuroECB = tenDollar.with(toEuroECB);
MonetaryAmount tenDollarToEuroOER = tenDollar.with(toEuroOER);

System.out.println();
System.out.println("conversion");
System.out.printf("10 USD son %s EUR (ECB)\n", tenDollarToEuroECB);
System.out.printf("10 USD son %s EUR (OER)\n", tenDollarToEuroOER);

// exchange rates
// Ratio de conversión de USD a EUR (ECB, European Central Bank): 0,887469
// Ratio de conversión de USD a EUR (OER, Open Exchange Rates): 0,882016

// conversion
// 10 USD son EUR 8.874689385871494 EUR (ECB)
// 10 USD son EUR 8.82016 EUR (OER)

La API de Java Money ofrece más posibilidades como obtener datos históricos de los ratios de conversión, Open Exchange Rates también ofrece datos históricos en un endpoint tal que /api/historical/2011-10-18.json. Sin embargo, cómo hacer esto será tarea para el lector o tema para otro futuro artículo.

No es muy complicado lo que hay que hacer para integrar un proveedor de ratios sabiendo lo que hay que hacer y adaptando otro proveedor similar, sin embargo, no hay documentación que lo explique y he tenido que indagar en el código fuente del proyecto para saber cómo hacerlo, sin el código fuente me hubiese sido imposible escribir este artículo, esta es una de las ventajas del código abierto y software libre.

Puedes obtener el código fuente completo del ejemplo del repositorio de ejemplos de este blog en GitHub.