Pull to refresh

Пробуем Micronaut или Дорогая, я уменьшил фреймворк

Reading time25 min
Views10K

Пробуем Micronaut или Дорогая, я уменьшил фреймворк


Про фреймворк micronaut я мельком вычитал из дайджест рассылок. Заинтересовался, что за зверь такой. Фреймворк ставится в противовес напичканному всем нужным инструментарием Spring.


Micronaut


Предвосхищая грядущую конференцию для разработчиков, где как раз будут рассказывать и показывать, как использовать micronaut в ваших этих микросервисах, я решил хоть раз подготовиться и прийти хоть с каким-то контекстом в голове, с неким набором проблем и вопросов. Выполнить так сказать домашнее задание. Я решил налабать какой-нибудь небольшой pet-project за пару-тройку вечеров (как пойдёт). В конце статьи будет ссылка на репозиторий всех исходников проекта.


Micronaut — это фреймворк на JVM, поддерживает три языка для разработки: Java, Kotlin, Groovy. Он разработан компанией OCI, той же компанией, что подарила нам Grails. Имеет тулинг в виде cli-приложения и набор рекомендованных библиотек (разнообразные reactive- http и database клиенты)

Есть DI, реализует и повторяет идеи Spring, добавляя ряд своих фишек — асинхронщина, поддержка AWS Lambda, Client Side Load Balancing.

Идея сервиса: один мой друг в своё время с дуру накупил с полдюжины всяких разномастных криптовалют, вложив туда непропитые отпускные и заначку из зимней куртки. Все мы знаем, что волатильность всей это криптовалютной субстанции дикая, а сама тема вообще непредсказуемая, друг со временем решил поберечь свои нервы и просто подзабить на то, что происходит с его “активами”. Но иногда всё таки хочется посматривать, а что же там с этим всем, вдруг уже богат. Так и появилась идея простой панели (dashboard, наподобие Grafana или что попроще), некой веб-странички с сухой информацией, сколько всё это сейчас суммарно стоит в некой фиатной валюте (USD, RUR).


Disclaimers


  1. Целесообразность написания собственного решения оставим за бортом, нам всего-лишь нужно испытать новый фреймворк на чем-то похитрее HelloWorld.
  2. Алгоритм расчета, ожидаемые ошибки, погрешности и т.д. (по крайней мере для первой фазы продукта), обоснованность выбора криптобирж для вытягивания информации, “инвестиционный” криптопортфель друга также будет за скобками и не подлежит обсуждению или какой-то глубокой аналитике.

Итак, небольшой набор требований:


  1. Веб-сервис (доступ извне, по http)
  2. Отображение страницы в браузере со сводкой суммарной стоимости портфеля криптовалют
  3. Возможность конфигурировать портфель (выберем JSON формат загрузки и выгрузки структуры портфеля). Некий REST API для обновления портфеля и загрузки его, т.е. 2 API: на сохранение/обновление – POST, на выгрузку – GET. Структура портфеля – это по сути простая табличка вида
    BTC – купил 0.00005 ед.
    XEM – купил 4.5 ед.
    ...
  4. Данные берем из криптобирж и источников курса валют (для фиатных валют)
  5. Правила расчёта суммарной стоимости портфеля:
    Формулы расчета суммарной стоимости портфеля


Разумеется, всё то, что понаписано в пункте 5 — предмет отдельных споров и сомнений, но пусть будет, что бизнес захотел так.


Старт проекта


Итак, идем на официальный сайт фреймворка и смотрим, как нам можно начать разрабатывать. Официальный сайт предлагает установить инструмент sdkman. Штука, облегчающая разработку и менеджмент проектов на фреймворке micronaut (и прочих других в том числе, например – Grails).


Тот самый менеджер различных SDK

Небольшая ремарка: Если просто запустить инициализацию проекта без каких-то ключей, то по-умолчанию выбирается сборщик gradle. Удаляем папку, пробуем заново, на этот раз с ключом:
mn create-app com.room606.cryptonaut -b=maven

Интересный момент также, что sdkman как и Spring Tool Suite предлагает вам на стадии создания проекта задать, какие “кубики” вы желаете использовать уже на старте. С этим я особо не экспериментировал, также создал с дефолтным пресетом.


Наконец, открываем проект в Intellij Idea и любуемся тем комплектом исходников и ресурсов и болванок, что нас снабдил визард создания micronaut проекта.


Структура голого проекта

Глаз цепляется за файл Dockerfile
FROM openjdk:8u171-alpine3.7
RUN apk --no-cache add curl
COPY target/cryptonaut*.jar cryptonaut.jar
CMD java ${JAVA_OPTS} -jar cryptonaut.jar

Что же, это прикольно и похвально. Нас сразу же снабдили инструментом для быстрого вывода приложения на Prod/INT/QA/whatever окружение. За это мысленный плюсик проекту.


Достаточно лишь собрать проект Maven-ом, далее собрать Docker образ и опубликовать его в ваше Docker registry или же просто экспортировать бинарник образа как вариант в вашу CI-систему, тут уж как вам угодно.


В папке ресурсов также для нас подготовили болванку с конфигурационными параметрами приложения (аналог application.properties в Spring), а также конфиг файлом для библиотеки logback. Круто!


Идем во входную точку приложения и изучаем класс. Видим картину до боли знакомую нам по Spring Boot. Здесь разработчки фреймворка тоже не стали ничего мудрить и выдумывать.


public static void main(String[] args) throws IOException {
    Micronaut.run(Application.class);
}

Сравните со знакомым кодом по Spring-у.


public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
}

Т.е. мы также подымаем IoC-контейнер со всеми бинами, что включаются в работу по мере того, как они будут нужны. Немного пробежав по официальной документации, неспешно начинаем разработку.


Нам понадобятся:


  1. Модели предметной области
  2. Контроллеры для реализации REST API.
  3. Слой хранения данных (Database client либо ORM либо ещё что-то)
  4. Код потребителей данных из криптобирж, а также данных обмена фиатных валют. Т.е. нам нужно написать простейшие клиенты для 3rd party cервисов. В Spring на эту роль хорошо подходили известные нам RestTemplate.
  5. Минимальная конфигурация для гибкого управления и старта приложения (подумаем, что и как мы будем выносить в конфигурации)
  6. Тесты! Да, чтобы уверенно и без опаски рефачить код и внедрять новую функциональность нам нужно быть уверенными в стабильности старой
  7. Кеширование. Это не основное требование, но то, что неплохо бы иметь для хорошей производительности, и в нашем сценарии есть места, где кеширование точно является хорошим средством.
    Спойлер: здесь все пойдет очень плохо.

Модели предметной области


Для наших целей хватит следующих моделей: модели портфеля криптовалют, обменного курса пары фиатных валют, цены криптовалюты в фиатной валюте, суммарная стоимость портфеля.


Ниже приведен код лишь пары моделей, остальные можно будет посмотреть в репозитории. И да, я поленился в этом проекте вкручивать Lombok.


Portfolio.java

package com.room606.cryptonaut.domain;

import java.math.BigDecimal;
import java.util.Collections;
import java.util.Map;
import java.util.TreeMap;

public class Portfolio {
    private Map<String, BigDecimal> coins = Collections.emptyMap();
    public Map<String, BigDecimal> getCoins() {
        return new TreeMap<>(coins);
    }
    public void setCoins(Map<String, BigDecimal> coins) {
        this.coins = coins;
    }

FiatRate.java
package com.room606.cryptonaut.domain;

import java.math.BigDecimal;

public class FiatRate {
    private String base;
    private String counter;
    private BigDecimal value;
    public FiatRate(String base, String counter, BigDecimal value) {
        this.base = base;
        this.counter = counter;
        this.value = value;
    }
    public String getBase() {
        return base;
    }
    public void setBase(String base) {
        this.base = base;
    }
    public String getCounter() {
        return counter;
    }
    public void setCounter(String counter) {
        this.counter = counter;
    }
    public BigDecimal getValue() {
        return value;
    }
    public void setValue(BigDecimal value) {
        this.value = value;
    }
}

Price.java
...
Prices.java (агрегат)
...
Total.java
...

Контроллеры


Пробуем написать контроллер, реализующий простейший API, выдающий стоимость криптовалют по заданным буквенным кодам монет.
Т.е.


GET /cryptonaut/restapi/prices.json?coins=BTC&coins=ETH&fiatCurrency=RUR

Должен выдать что-то наподобие:


{"prices":[{"coin":"BTC","value":407924.043300000000},{"coin":"ETH","value":13040.638266000000}],"fiatCurrency":"RUR"}

Согласно документации, ничего сложного и напоминает те же подходы и соглашения Spring:


package com.room606.cryptonaut.rest;
import com.room606.cryptonaut.domain.Price;
import com.room606.cryptonaut.domain.Prices;
import com.room606.cryptonaut.markets.FiatExchangeRatesService;
import com.room606.cryptonaut.markets.CryptoMarketDataService;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Controller("/cryptonaut/restapi/")
public class MarketDataController {
    private final CryptoMarketDataService cryptoMarketDataService;
    private final FiatExchangeRatesService fiatExchangeRatesService;

    public MarketDataController(CryptoMarketDataService cryptoMarketDataService, FiatExchangeRatesService fiatExchangeRatesService) {
        this.cryptoMarketDataService = cryptoMarketDataService;
        this.fiatExchangeRatesService = fiatExchangeRatesService;
    }

    @Get("/prices.json")
    @Produces(MediaType.APPLICATION_JSON)
    public Prices pricesAsJson(@QueryValue("coins") String[] coins, @QueryValue("fiatCurrency") String fiatCurrency) {
        return getPrices(coins, fiatCurrency);
    }

    private Prices getPrices(String[] coins, String fiatCurrency) {
        List<Price> prices = Stream.of(coins)
                .map(coin -> new Price(coin, cryptoMarketDataService.getPrice(coin, fiatCurrency)))
                .collect(Collectors.toList());
        return new Prices(prices, fiatCurrency);
    }
}

Т.е. мы спокойно указываем возвращаемым типом наш POJO, и без конфигурирования каких-либо сериализаторов/десериализаторов, даже без навешивания дополнительных аннотаций Micronaut из коробки построит корректный http body с данными. Давайте сравним с Spring way:


@RequestMapping(value  = "/prices.json",
        method = RequestMethod.GET,
        produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public ResponseEntity<Prices> pricesAsJson(@RequestParam("userId") final String[] coins, @RequestParam("fiatCurrency") String fiatCurrency) {

В целом, с контроллерами у меня проблем не возникло, они просто работали так, как от них ожидалось, согласно документации. Их написание было интуитивно понятным и простым. Двигаемся дальше.


Слой хранения данных


Для первой версии приложения мы будем хранить лишь портфель юзера. Вообще, мы будем хранить лишь один портфель одного юзера. Проще говоря, у нас не будет пока что поддержки множества пользователей, только один главный пользователь со своим портфелем криптовалют. Вот это круто!


Для реализации персистентности данных документация предлагает варианты с подключением JPA, а также обрывочные примеры использования различных клиентов для чтения из БД (секция “12.1.5 Configuring Postgres”). JPA решительно было отброшено и было отдано предпочтение собственноручному написанию запросов и манипулированию ими. В application.yml была добавлена конфигурация БД, (в качестве РСУБД была выбрана Postgres), согласно указаниям документации:


postgres:
    reactive:
        client:
            port: 5432
            host: localhost
            database: cryptonaut
            user: crypto
            password: r1ch13r1ch
            maxSize: 5

В зависимости была добавлена библиотека postgres-reactive. Это клиент для работы с БД как в асинхронной манере, так и в синхронной.


<dependency>
    <groupId>io.micronaut.configuration</groupId>
    <artifactId>postgres-reactive</artifactId>
    <version>1.0.0.M4</version>
    <scope>compile</scope>
</dependency>

И, наконец, в папку /docker был добавлен файлик docker-compose.yml для развертывания будущего окружения нашего приложения, куда был добавлен компонет БД:


db:
  image: postgres:9.6
  restart: always
  environment:
    POSTGRES_USER: crypto
    POSTGRES_PASSWORD: r1ch13r1ch
    POSTGRES_DB: cryptonaut
  ports:
        - 5432:5432
  volumes:
    - ${PWD}/../db/init_tables.sql:/docker-entrypoint-initdb.d/1.0.0_init_tables.sql

Ниже представлен инициализационный скрипт базы с очень незатейливой структурой таблиц:


CREATE TABLE portfolio (
    id                 serial CONSTRAINT coin_amt_primary_key PRIMARY KEY,
    coin    varchar(16) NOT NULL UNIQUE,
   amount  NUMERIC     NOT NULL
);

Теперь попробуем накидать код, обновляющий портфель пользователя. Наш компонент для работы с портфелем будет выглядеть так:


package com.room606.cryptonaut;

import com.room606.cryptonaut.domain.Portfolio;
import java.math.BigDecimal;
import java.util.Optional;

public interface PortfolioService {

    Portfolio savePortfolio(Portfolio portfolio);
    Portfolio loadPortfolio();
    Optional<BigDecimal> calculateTotalValue(Portfolio portfolio, String fiatCurrency);

}

Поглядывая в набор методов клиента Postgres reactive client Накидываем вот такой класс:


package com.room606.cryptonaut;

import com.room606.cryptonaut.domain.Portfolio;
import com.room606.cryptonaut.markets.CryptoMarketDataService;
import io.micronaut.context.annotation.Requires;
import io.reactiverse.pgclient.Numeric;
import io.reactiverse.reactivex.pgclient.*;
import javax.inject.Inject;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

public class PortfolioServiceImpl implements PortfolioService {

    private final PgPool pgPool;
    ...

    private static final String UPDATE_COIN_AMT = "INSERT INTO portfolio (coin, amount) VALUES (?, ?) ON CONFLICT (coin) " +
            "DO UPDATE SET amount = ?";
    ...

    public Portfolio savePortfolio(Portfolio portfolio) {
        List<Tuple> records = portfolio.getCoins()
                .entrySet()
                .stream()
                .map(entry -> Tuple.of(entry.getKey(), Numeric.create(entry.getValue()), Numeric.create(entry.getValue())))
                .collect(Collectors.toList());
        pgPool.preparedBatch(UPDATE_COIN_AMT, records, pgRowSetAsyncResult -> {
           //Не делайте так
            pgRowSetAsyncResult.cause().printStackTrace();
        });
        return portfolio;
    }
    ...
}

Запускам окружение, пробуем обновить наш портфель через предусмотрительно реализованную заранее API:


package com.room606.cryptonaut.rest;

import com.room606.cryptonaut.PortfolioService;
import com.room606.cryptonaut.domain.Portfolio;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;
import javax.inject.Inject;

@Controller("/cryptonaut/restapi/")

public class ConfigController {

    @Inject
    private PortfolioService portfolioService;
    @Post("/portfolio")
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    public Portfolio savePortfolio(@Body Portfolio portfolio) {
        return portfolioService.savePortfolio(portfolio);
    }

Выполняем curl-запрос:


curl http://localhost:8080/cryptonaut/restapi/portfolio -X POST -H "Content-Type: application/json" --data '{"coins": {"XRP": "35.5", "LSK": "5.03", "XEM": "16.23"}}' -v

Ии… ловим в логах ошибку:


io.reactiverse.pgclient.PgException: syntax error at or near ","
    at io.reactiverse.pgclient.impl.PrepareStatementCommand.handleErrorResponse(PrepareStatementCommand.java:74)
    at io.reactiverse.pgclient.impl.codec.decoder.MessageDecoder.decodeError(MessageDecoder.java:250)
    at io.reactiverse.pgclient.impl.codec.decoder.MessageDecoder.decodeMessage(MessageDecoder.java:139)
...

Почесав репу, не находим никакого решения в официальной доке, пробуем гуглить доку по самой либе postgres-reactive, и это оказывается правильным решением, так как там подробно приведены примеры и правильный синтаксис запросов. Дело было в placeholder-ах параметров, оказывается, нужно применять нумерованные метки вида $x ($1, $2, etc.). Итак, фикс заключается в переписывании целевого запроса:


private static final String UPDATE_COIN_AMT = "INSERT INTO portfolio (coin, amount) VALUES ($1, $2) ON CONFLICT (coin) " +
        "DO UPDATE SET amount = $3";

Перезапускаем приложение, пробуем тот же REST запрос… ура. Данные складываются. Перейдем к чтению.


Перед нами стоит простейшая задача прочитать портфель криптовалют юзера из БД и смаппить их на POJO-объект. Для этих целей, применяем метод pgPool.query(SELECT_COINS_AMTS, pgRowSetAsyncResult):


public Portfolio loadPortfolio() {

Map<String, BigDecimal> coins = new HashMap<>();

pgPool.query(SELECT_COINS_AMTS, pgRowSetAsyncResult -> {
    if (pgRowSetAsyncResult.succeeded()) {
        PgRowSet rows = pgRowSetAsyncResult.result();
        PgIterator pgIterator = rows.iterator();
        while (pgIterator.hasNext()) {
            Row row = pgIterator.next();
            coins.put(row.getString("coin"), new BigDecimal(row.getFloat("amount")));
        }
    } else {
        System.out.println("Failure: " + pgRowSetAsyncResult.cause().getMessage());
    }
});
Portfolio portfolio = new Portfolio();
portfolio.setCoins(coins);
return portfolio;
}

Связываем все это вместе с контроллером ответственным за портфель криптовалют:


@Controller("/cryptonaut/restapi/")
public class ConfigController {
...
@Get("/portfolio")
@Produces(MediaType.APPLICATION_JSON)
public Portfolio loadPortfolio() {
    return portfolioService.loadPortfolio();
}
...

Перезапускаем сервис. Для тестирования сперва заполняем этот самый портфель хоть какими-то данными:


curl http://localhost:8080/cryptonaut/restapi/portfolio -X POST -H "Content-Type: application/json" --data '{"coins": {"XRP": "35.5", "LSK": "5.03", "XEM": "16.23"}}' -v

Теперь наконец протестируем наш код, читающий из БД:


curl http://localhost:8080/cryptonaut/restapi/portfolio -v

И… получаем… нечто странное:


{"coins":{}}

Довольно странно не так ли? Перепроверяем запрос десять раз, пробуем делать curl запрос ещё раз, даже перезапускаем наш сервис. Результат всё такой же дикий… Перечитав сигнатуру метода, а также вспомнив, что у нас Reactive Pg client, доходим до мысли, что ведь таки мы имеем дело с асинхронщиной. Вдумчивый дебаг это подтвердил! Стоило немного неспешно подебажить код, как вуа-ля, нам вернулись непустые данные!


Еще раз обратившись к доке библиотеки, закатав рукава переписывам код на истинно блокирующий, но зато целиком предсказуемый:


Map<String, BigDecimal> coins = new HashMap<>();
        PgIterator pgIterator = pgPool.rxPreparedQuery(SELECT_COINS_AMTS).blockingGet().iterator();
        while (pgIterator.hasNext()) {
            Row row = pgIterator.next();
            coins.put(row.getString("coin"), new BigDecimal(row.getValue("amount").toString()));
        }

Вот теперь получаем то, что мы ожидаем. Эту проблемку решили, двигаемся дальше.


Пишем клиент для получения данных по рынкам


Здесь конечно же хотелось бы решить проблему с наименьшим количеством велосипедов. В итоге получилось два решения:


  • готовые библиотеки клиентов для обращения к конкретным криптобиржам
  • небольшой собственноручно написанный код клиента для обращения за курсом валют. Здесь пригодилось то, что из коробки предлагает Micronaut.

С готовыми библиотеками все не так интересно. Отмечу лишь, что при беглом поиске был выбран проект https://github.com/knowm/XChange.


В принципе архитектура библиотеки проста как три копейки – есть набор интерфейсов для получения данных, основные интерфейсы и классы моделей типа Ticker (можно узнать bid, ask, всякие open price, close price etc.), CurrencyPair, Currency. Далее, уже сами реализации вы инициализируете в коде, предварительно для этого подключив завмисимость с реализацией, обращающейся к конкретной криптобирже. А основной класс, через который мы действуем – MarketDataService.java


Например, для своих экспериментов для начала нас устроит такая “конфигурация”:


<dependency>
  <groupId>org.knowm.xchange</groupId>
  <artifactId>xchange-core</artifactId>
  <version>4.3.10</version>
</dependency>
<dependency>
  <groupId>org.knowm.xchange</groupId>
  <artifactId>xchange-bittrex</artifactId>
  <version>4.3.10</version>
</dependency>

Ниже приведен код, выполняющий ключевую функцию – вычисление стоимости конкретной криптовалюте в фиатном выражении (см. Формулы, описанные в начале статьи в блоке требований):


package com.room606.cryptonaut.markets;
import com.room606.cryptonaut.exceptions.CryptonautException;
import org.knowm.xchange.currency.Currency;
import org.knowm.xchange.currency.CurrencyPair;
import org.knowm.xchange.dto.marketdata.Ticker;
import org.knowm.xchange.exceptions.CurrencyPairNotValidException;
import org.knowm.xchange.service.marketdata.MarketDataService;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.math.BigDecimal;
@Singleton
public class CryptoMarketDataService {
    private final FiatExchangeRatesService fiatExchangeRatesService;
    private final MarketDataService marketDataService;
    @Inject
    public CryptoMarketDataService(FiatExchangeRatesService fiatExchangeRatesService, MarketDataServiceFactory marketDataServiceFactory) {
        this.fiatExchangeRatesService = fiatExchangeRatesService;
        this.marketDataService = marketDataServiceFactory.getMarketDataService();
    }
    public BigDecimal getPrice(String coinCode, String fiatCurrencyCode) throws CryptonautException {
        BigDecimal price = getPriceForBasicCurrency(coinCode, Currency.USD.getCurrencyCode());
        if (Currency.USD.equals(new Currency(fiatCurrencyCode))) {
            return price;
        } else {
            return price.multiply(fiatExchangeRatesService.getFiatPrice(Currency.USD.getCurrencyCode(), fiatCurrencyCode));
        }
    }
    private BigDecimal getPriceForBasicCurrency(String coinCode, String fiatCurrencyCode) throws CryptonautException {
        Ticker ticker = null;
        try {
            ticker = marketDataService.getTicker(new CurrencyPair(new Currency(coinCode), new Currency(fiatCurrencyCode)));
            return ticker.getBid();
        } catch (CurrencyPairNotValidException e) {
            ticker = getTicker(new Currency(coinCode), Currency.BTC);
            Ticker ticker2 = getTicker(Currency.BTC, new Currency(fiatCurrencyCode));
            return ticker.getBid().multiply(ticker2.getBid());
        } catch (IOException e) {
            throw new CryptonautException("Failed to get price for Pair " + coinCode + "/" + fiatCurrencyCode + ": " + e.getMessage(), e);
        }
    }
    private Ticker getTicker(Currency base, Currency counter) throws CryptonautException {
        try {
            return marketDataService.getTicker(new CurrencyPair(base, counter));
        } catch (CurrencyPairNotValidException | IOException e) {
            throw new CryptonautException("Failed to get price for Pair " + base.getCurrencyCode()
                    + "/" + counter.getCurrencyCode() + ": " + e.getMessage(), e);
        }
    }
}

Здесь все по возможности сделано с применением собственных интерфейсов, чтобы слегка абстрагироваться от конкретных реализаций, предоставляемых проектом https://github.com/knowm/XChange.


В виду того, что на многих, если не на всех криптобиржах в обороте только ограниченный набор фиатных валют (USD, EUR, пожалуй и всё..), для окончательного ответа на вопрос пользователя необходимо добавить еще один источник данных – курсы фиатных валют, а также дополнительный конвертер. Т.е. для ответа на вопрос, сколько стоит криптовалюта WTF в RUR (целевая валюта, target currency) сейчас, придётся ответить на два подвопроса: WTF / BaseCurrency (считаем таковой USD), BaseCurrency / RUR, затем перемножить эти два значения и выдать как результат.


Для нашей первой версии сервиса будем поддерживать в качестве целевых валют пока только USD и RUR.
Так вот, для поддержки RUR целесообразно будет взять источники, релевантные географическому расположению сервиса (будем хостить и пользоваться сугубо в России). Короче говоря, нас устроит курс ЦБ. На просторах интернета был найден открытый источник таких данных, который можно потреблять как JSON. Прекрасно.


Ниже выглядит ответ сервиса на запрос курса валют на текущий момент:


{
    "Date": "2018-10-16T11:30:00+03:00",
    "PreviousDate": "2018-10-13T11:30:00+03:00",
    "PreviousURL": "\/\/www.cbr-xml-daily.ru\/archive\/2018\/10\/13\/daily_json.js",
    "Timestamp": "2018-10-15T23:00:00+03:00",
    "Valute": {
        "AUD": {
            "ID": "R01010",
            "NumCode": "036",
            "CharCode": "AUD",
            "Nominal": 1,
            "Name": "Австралийский доллар",
            "Value": 46.8672,
            "Previous": 46.9677
        },
        "AZN": {
            "ID": "R01020A",
            "NumCode": "944",
            "CharCode": "AZN",
            "Nominal": 1,
            "Name": "Азербайджанский манат",
            "Value": 38.7567,
            "Previous": 38.8889
        },
        "GBP": {
            "ID": "R01035",
            "NumCode": "826",
            "CharCode": "GBP",
            "Nominal": 1,
            "Name": "Фунт стерлингов Соединенного королевства",
            "Value": 86.2716,
            "Previous": 87.2059
        },
...

Собственно, ниже представлен код клиента CbrExchangeRatesClient:


package com.room606.cryptonaut.markets.clients;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.room606.cryptonaut.exceptions.CryptonautException;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.Client;
import io.micronaut.http.client.RxHttpClient;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.*;
@Singleton
public class CbrExchangeRatesClient {
    private static final String CBR_DATA_URI = "https://www.cbr-xml-daily.ru/daily_json.js";
    @Client(CBR_DATA_URI) @Inject
    private RxHttpClient httpClient;
    private final ObjectReader objectReader = new ObjectMapper().reader();
    public Map<String, BigDecimal> getRates() {
        try {
            //return ratesCache.get("fiatRates");
            HttpRequest<?> req = HttpRequest.GET("");
            String response = httpClient.retrieve(req, String.class).blockingSingle();
            JsonNode json  = objectReader.readTree(response);
            String usdPrice = json.get("Valute").get("USD").get("Value").asText();
            String eurPrice = json.get("Valute").get("EUR").get("Value").asText();
            String gbpPrice = json.get("Valute").get("GBP").get("Value").asText();
            Map<String, BigDecimal> prices = new HashMap<>();
            prices.put("USD", new BigDecimal(usdPrice));
            prices.put("GBP", new BigDecimal(gbpPrice));
            prices.put("EUR", new BigDecimal(eurPrice));
            return prices;
        } catch (IOException e) {
            throw new CryptonautException("Failed to obtain exchange rates: " + e.getMessage(), e);
        }
    }
}

Здесь мы инжектим RxHttpClient, компонет из комлпекта Micronaut. Он также дает нам выбор, делать асинхронную обработку запросов или блокирующую. Выбираем классическую, блокирующую:


httpClient.retrieve(req, String.class).blockingSingle();

Конфигурация


В проекте можно выделить вещи, которые меняются и влияют на бизнес-логику или на какие-то специфические аспекты. Давайте вынесем список поддерживаемых фиатных валют как свойство и будем его инжектить на старте приложения.


Следующий код будет отбрасывать коды валют, для которых мы не в силах пока что вычислить стоимость портфеля:


public BigDecimal getFiatPrice(String baseCurrency, String counterCurrency) throws NotSupportedFiatException {
    if (!supportedCounterCurrencies.contains(counterCurrency)) {
        throw new NotSupportedFiatException("Counter currency not supported: " + counterCurrency);
    }
    Map<String, BigDecimal> rates = cbrExchangeRatesClient.getRates();
    return rates.get(baseCurrency);
}

Соответственно, намерение наше – каким-то образом инжектить значение из application.yml в переменную supportedCounterCurrencies.


В первой версии был написан такой код, ниже поля класса FiatExchangeRatesService.java:


@Value("${cryptonaut.currencies:RUR}")
private String supportedCurrencies;
private final List<String> supportedCounterCurrencies = Arrays.asList(supportedCurrencies.split("[,]", -1));

Здесь placeholder cоответствует следующей структуре application.yml документа:


micronaut:
    application:
        name: cryptonaut

#Uncomment to set server port
    server:
        port: 8080
postgres:
    reactive:
        client:
            port: 5432
            host: localhost
            database: cryptonaut
            user: crypto
            password: r1ch13r1ch
            maxSize: 5
# app / business logic specific properties
cryptonaut:
    currencies: "RUR"

Запуск приложения, быстрый smoke-тест… Ошибка!


Caused by: io.micronaut.context.exceptions.BeanInstantiationException: Error instantiating bean of type  [com.room606.cryptonaut.markets.CryptoMarketDataService]

Path Taken: new MarketDataController([CryptoMarketDataService cryptoMarketDataService],FiatExchangeRatesService fiatExchangeRatesService) --> new CryptoMarketDataService([FiatExchangeRatesService fiatExchangeRatesService],MarketDataServiceFactory marketDataServiceFactory)
    at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:1266)
    at io.micronaut.context.DefaultBeanContext.createAndRegisterSingleton(DefaultBeanContext.java:1677)
    at io.micronaut.context.DefaultBeanContext.getBeanForDefinition(DefaultBeanContext.java:1447)
    at io.micronaut.context.DefaultBeanContext.getBeanInternal(DefaultBeanContext.java:1427)
    at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:852)
    at io.micronaut.context.AbstractBeanDefinition.getBeanForConstructorArgument(AbstractBeanDefinition.java:943)
    ... 36 common frames omitted
Caused by: java.lang.NullPointerException: null
    at com.room606.cryptonaut.markets.FiatExchangeRatesService.<init>(FiatExchangeRatesService.java:20)
    at com.room606.cryptonaut.markets.$FiatExchangeRatesServiceDefinition.build(Unknown Source)
    at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:1252)
    ... 41 common frames omitted

В данном случае Micronaut также как и Spring здесь действует всё таки во время запуска приложения, и значение не появится магически во время compile time. Досадно покорив себя за такую ошибку непонимания, переписываем код в рабочее состояние:


@Value("${cryptonaut.currencies:RUR}")
private String supportedCurrencies;
private List<String> supportedCounterCurrencies;

@PostConstruct
void init() {
    supportedCounterCurrencies = Arrays.asList(supportedCurrencies.split("[,]", -1));
}

Да, старый добрый друг – javax.annotation.PostConstruct, нас здесь выручил и проинжектил значение в момент, когда бин уже родился, жив и готов принимать команды, но самое приложение еще не объявило о готовности. Самый нужный момент для нас.


В целом, синтаксис, механика работы такие же как и в Spring. Наряду с этим micronaut также предлагает аннотации @Property для считывания пачки свойств в объект Map<String, String>, аннотацию @Configuration для создания классов конфигураций, Random Properties (например, можно проинжектить псевдослучайную строку как ID некой сущности, удобно, чтобы избегать каких-нибудь коллизий при деплое) и также концепцию PropertySourceLoader, т.е. источников считывания конфигураций. Ближайший аналог в Spring – это наверное ApplicationContext (xml, web, groovy, ClassPath etc.) Видно, что здесь проделана большая работа и этот аспект продуман и задокументирован развернуто и подробно.


Тесты


По документации я не особо впечатлился тем, что предлагает micronaut. Нам рекламируют Embedded Server feature, а также как писать тесты на Groovy с применением фреймворка Spock. Так как мы топим за Java, то groovy-тесты просто проходим мимо. Вообщем, я задействовал только EmbeddedServer + HttpClient из поставки Micronaut и протестировал одну из реализованных API —


GET /cryptonaut/restapi/portfolio/total.json?fiatCurrency={x}

Ключевое API, которое вычисляет итоговую стоимость криптовалютного портфеля на данный момент в некой целевой фиатной валюте.


Полный код теста ниже:


public class PortfolioReportsControllerTest {
    private static EmbeddedServer server;
    private static HttpClient client;
    @Inject
    private PortfolioService portfolioService;
    @BeforeClass
    public static void setupServer() {
        server = ApplicationContext.run(EmbeddedServer.class);
        client = server
                .getApplicationContext()
                .createBean(HttpClient.class, server.getURL());
    }
    @AfterClass
    public static void stopServer() {
        if(server != null) {
            server.stop();
        }
        if(client != null) {
            client.stop();
        }
    }
    @Test
    public void total() {
        //TODO: Seems like code smell. I don't like it..
        portfolioService = server.getApplicationContext().getBean(PortfolioService.class);
        Portfolio portfolio = new Portfolio();
        Map<String, BigDecimal> coins = new HashMap<>();
        BigDecimal amt1 = new BigDecimal("570.05");
        BigDecimal amt2 = new BigDecimal("2.5");
        coins.put("XRP", amt1);
        coins.put("QTUM", amt2);
        portfolio.setCoins(coins);
        portfolioService.savePortfolio(portfolio);
        HttpRequest request = HttpRequest.GET("/cryptonaut/restapi/portfolio/total.json?fiatCurrency=USD");
        HttpResponse<Total> rsp = client.toBlocking().exchange(request, Total.class);
        assertEquals(200, rsp.status().getCode());
        assertEquals(MediaType.APPLICATION_JSON_TYPE, rsp.getContentType().get());
        Total val = rsp.body();
        assertEquals("USD", val.getFiatCurrency());
        assertEquals(TEST_VALUE.toString(), val.getValue().toString());
        assertEquals(amt1.toString(), val.getPortfolio().getCoins().get("XRP").toString());
        assertEquals(amt2.toString(), val.getPortfolio().getCoins().get("QTUM").toString());
    }
}

Из интересных моментов стоит отметить то, что для целей теста была добавлена mock реализация интерфейса PortfolioService.java:


package com.room606.cryptonaut;

import com.room606.cryptonaut.domain.Portfolio;
import io.micronaut.context.annotation.Requires;
import javax.inject.Singleton;
import java.math.BigDecimal;
import java.util.Optional;

@Singleton
@Requires(env="test")
public class MockPortfolioService implements PortfolioService {
    private Portfolio portfolio;
    public static final BigDecimal TEST_VALUE = new BigDecimal("56.65");
    @Override
    public Portfolio savePortfolio(Portfolio portfolio) {
        this.portfolio = portfolio;
        return portfolio;
    }
    @Override
    public Portfolio loadPortfolio() {
        return portfolio;
    }
    @Override
    public Optional<BigDecimal> calculateTotalValue(Portfolio portfolio, String fiatCurrency) {
        return Optional.of(TEST_VALUE);
    }
}

Здесь стоит обратить внимание на аннотацию @Requires(env="test"), это способ включения или выключения бинов в Application Context вашего приложения. По-умолчанию, во время запуска тестов micronaut подымает приложение в окружении test, и здесь это способ указать, что данная реализация будет подключена только в тестах. Соответственно, чтобы исключать конфликт автовайринга в тестах и на запуске в реальном окружении, истинная реализация PortfolioServiceImpl была помечена взаимоисключающей аннотацией @Requires(notEnv="test"). Вот такие минимальные телодвижения позволяют гибко управлять набором компонентов и конфигураций в различных контекстах – во время тестов и на продакшне. Здесь Micronaut очень хорошо себя проявил и никаких затруднений в решении проблем не вызвал.


Остальная логика, как то – расчет итоговой стоимости, расчет цены в фиатном выражении, клиентские вызовы – были просто покрыты обычными тестами с применением mockito. Для примера один из тестов такого набора:


@Test
public void priceForUsdDirectRate() throws IOException {
    when(marketDataServiceFactory.getMarketDataService()).thenReturn(marketDataService);
    String coinCode = "ETH";
    String fiatCurrencyCode = "USD";
    BigDecimal priceA = new BigDecimal("218.58");
    Ticker targetTicker = new Ticker.Builder().bid(priceA).build();
    when(marketDataService.getTicker(new CurrencyPair(new Currency(coinCode), new Currency(fiatCurrencyCode)))).thenReturn(targetTicker);
    CryptoMarketDataService cryptoMarketDataService = new CryptoMarketDataService(fiatExchangeRatesService, marketDataServiceFactory);
    assertEquals(priceA, cryptoMarketDataService.getPrice(coinCode, fiatCurrencyCode));
}

Кеширование


Обратим внимание, что одна вещь очень хорошо поддается кешированию. Курс фиатных валют мы получаем как известно из источника данных по ЦБ. И как мы знаем, он обновляется раз в сутки и курс актуален ровно сутки. Соответственно, нет никакого смысла запрашивать эту информацию раз за разом, рискуя нарваться на троттлинг или какой-нибудь бан по IP. Здесь нам поможет механизм кеширования, который согласно документации легко подключается посредством нескольких пропертей и навешивание аннотации @Cacheable с указанием имени кеша.


Кеш

Однако, здесь все совершенно не задалось. Документация в этом аспекте сбивает с толку, где проскроллив пару экранов обнаруживаешь противоречащие друг другу куски конфигураций (appliction.yml). В качестве кеша был выбран redis, также поднимаемый в Docker-контейнере рядышком. Вот его конфигурация:
redis:
  image: 'bitnami/redis:latest'
  environment:
    - ALLOW_EMPTY_PASSWORD=yes
  ports:
    - '6379:6379'

А вот кусок кода проаннотированный @Cacheable:


@Cacheable("fiatRates")
public Map<String, BigDecimal> getRates() {
    HttpRequest<?> req = HttpRequest.GET("");
    String response = httpClient.retrieve(req, String.class).blockingSingle();
    try {
        JsonNode json  = objectReader.readTree(response);
        String usdPrice = json.get("Valute").get("USD").get("Value").asText();
        String eurPrice = json.get("Valute").get("EUR").get("Value").asText();
        String gbpPrice = json.get("Valute").get("GBP").get("Value").asText();
        Map<String, BigDecimal> prices = new HashMap<>();
        prices.put("USD", new BigDecimal(usdPrice));
        prices.put("GBP", new BigDecimal(gbpPrice));
        prices.put("EUR", new BigDecimal(eurPrice));
        return prices;
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

А вот с application.yml была самая главная загадка. Я перепробовал всяческие конфигурации. Вот такую:


caches:
    fiatrates:
        expireAfterWrite: "1h"
redis:
    caches:
        fiatRates:
            expireAfterWrite: "1h"
        port: 6379
        server: localhost

Вот такую:


#cache
    redis:
        uri: localhost:6379
        caches:
            fiatRates:
                expireAfterWrite: "1h"

И даже пробовал убирать верхний регистр букв в названии кеша. Но в результате получал один и тот же результат при запуске приложения — “Unexpected error occurred: No cache configured for name: fiatRates”:


ERROR i.m.h.s.netty.RoutingInBoundHandler - Unexpected error occurred: No cache configured for name: fiatRates
io.micronaut.context.exceptions.ConfigurationException: No cache configured for name: fiatRates
    at io.micronaut.cache.DefaultCacheManager.getCache(DefaultCacheManager.java:67)
    at io.micronaut.cache.interceptor.CacheInterceptor.interceptSync(CacheInterceptor.java:176)
    at io.micronaut.cache.interceptor.CacheInterceptor.intercept(CacheInterceptor.java:128)
    at io.micronaut.aop.MethodInterceptor.intercept(MethodInterceptor.java:41)
    at io.micronaut.aop.chain.InterceptorChain.proceed(InterceptorChain.java:147)
    at com.room606.cryptonaut.markets.clients.$CbrExchangeRatesClientDefinition$Intercepted.getRates(Unknown Source)
    at com.room606.cryptonaut.markets.FiatExchangeRatesService.getFiatPrice(FiatExchangeRatesService.java:30)
    at com.room606.cryptonaut.rest.MarketDataController.index(MarketDataController.java:34)
    at com.room606.cryptonaut.rest.$MarketDataControllerDefinition$$exec2.invokeInternal(Unknown 
...

Поиск рабочих примеров на GitHub-е или по SO не помог решить проблему. Немного покопавшись в исходниках также был растерян и пал духом. Затея провалилась, жаль. А ведь в планах ещё была задумка далее навесить код, выполняющийся по расписанию и инвалидирующий этот кеш. Писать кеширующий boilerplate-код, напрямую обращающийся к какому-нибудь скажем Redis-клиенту я не захотел, мысленно для себя посчитав, что здесь Spring Boot отхватил победу, имея более полную и разверную документацию и помощь комьюнити.


Сидим в кустах с секундомером


Для утоления собственного любопытства и чтобы подтвердить или опровергнуть то, что преподносится как весомое преимущество Micronaut – очень быстрое время старта, я решил сделать несколько замеров запуска приложения и сравнить средние показатели со Spring-ом.


Benchmarking

Здесь конечно нужно бы указать с дюжину Disclaimer-ов: о том, что я не бенчмарк-специалист, о методике запуска и замера времени старта, об условиях эксперимента (загруженность машины, конфигурация железа, ОС, прочее).

Впрочем, последнее укажу:


OS: 16.04.1-Ubuntu x86_64 x86_64 x86_64 GNU/Linux
CPU: Intel® Core(TM) i7-7700HQ CPU @ 2.80GHz
Mem: 2 плашки по 8 Gb DDR4, Speed: 2400 MHz
SSD Disk: Твердотельный накопитель PCIe NVMe M.2, 256 Гбайт


Моя оборона методика:


  1. Погасить тачку
  2. Включить тачку
  3. Старт приложения
  4. Параллельно с этим клиентский код в цикле опрашивает одну API, просто выдающую строку в ответе
  5. Как только ответ от API получен – “таймер” останавливается.
  6. В табличку тщательно заносится результат в милисекундах

И для сравнения были созданы шаблонные проекты с приближенно идентичными наборами компонентов – один Rest Controller и собственно класс – запускающий IoC-контейнер, входная точка для каждого фреймворка.


Результаты средних арифметических “времени старта” занесены в таблицу ниже:


Micronaut Spring Boot
Avg.(ms) 2708.4 2735.2
Стартап приложения cryptonaut по версии фреймворка (ms) 1082 -

Как видим, на таких примитивных примерах проектов разница совершенно незначительна – порядка 27 миллисекунд в пользу Micronaut. Возможно, разница становится ощутимее с ростом проекта и при большем количестве компонентов и связей между ними.


Что по итогу?


Подведем некоторые итоги. Основную цель, а она была, напоминаю, выстроить проект на новом фреймворке – была достигнута. На этом фреймворке вполне можно вести разработку и даже вполне наращивать приложение. Документация пока больше заточена под Groovy-адептов, и это не удивительно, если вспомнить кто разработчик. Нет такой тусовки на SO как у экосистемы проектов Spring. Но фреймворк, предлагаемые подходы, а также инструментарий вполне дружелюбен для новчика и начать писать работающий проект можно пробовать уже сейчас. Но дабы не лукавить скажу сам за себя — я пока что останусь в лагере Веснушечников. Уж больно хорошо набита рука и заточено мышление под идеологию Spring.


Что не было опробовано:


  • многие предлагаемые из коробки фишки Micronaut – всяческие service-discovery, поддержка лямбд в AWS
  • авторизация
  • попробовать написать сервис не на Java. То есть на Kotlin либо на Groovy.

Исходники проекта можно изучить тут.

Tags:
Hubs:
+14
Comments9

Articles