Pro Деньги. JSR-354

от автора

Описание проблемы

Достаточно часто в реализации логики есть необходимость оперировать денежными единицами.

В коде приходится сталкиваться с таким представлениями:

  • значение в типе String

  • значение числом типа —  int, float,double

  • значение числом BigDecimal с разными правилами округления

  • отсутствие валюты

  • валюта отдельным полем в String

  • значение и валюта одной строкой в String

Это приводит к:

  • потери точности после запятой

  • накопление погрешностей при операциях

  • ошибкам округления

  • невозможности конвертации валют

  • невозможности в принципе проводить вычислительные операции

Иногда в коде встречаются сразу несколько вариантов представления денежных значений.

Пример:

// Проблемный код double price1 = 0.1; double price2 = 0.2; System.out.println(price1 + price2); // 0.30000000000000004 - Погрешность!  final var cost = new BigDecimal("100.00"); final BigDecimal discount = new BigDecimal("30.00"); final BigDecimal result = cost.divide(discount); // ArithmeticException: Non-terminating decimal expansion

Объекты Money и Currency

Для формализованной работы с деньгами в Java существует спецификация JSR-354 (Java Specification Request). Эта спецификация и библиотеки предоставляют:

  • интерфейс MonetaryAmount — для представления денежных единиц в валюте

  • интерфейс CurrencyUnit — для представления валюты

  • арифметические операции с деньгами

  • округления — несколько вариантов

  • конвертацию валют

  • формат представления денежных единиц с валютой с локализацией

Официальной реализацией (Reference Implementation) стандарта JSR-354 является библиотека Moneta.

Библиотека предоставляет две реализации:

  • Money: основан на BigDecimal. Обеспечивает высокую точность (до 2^63 десятичных знаков) и гибкость. Рекомендуется по умолчанию для большинства бизнес-приложений.

  • FastMoney: основан на long. Обеспечивает фиксированную точность (15 десятичных знаков) и работает в ~15 раз быстрее, чем Money. Потребляет меньше памяти. Идеален для высоконагруженных систем, где операции с деньгами являются узким местом, и где точности в 15 знаков достаточно

Все возможности реализации можете посмотреть в документации или в коде библиотеки. Здесь же я опишу, как применять объекты, выполнять сериализацию/десериализацию, конвертировать для хранения в БД, выполнять конвертацию валют, форматировать представление и выполнять тонкую настройку объектов.

Подключение и использование

Основные объекты типы с которыми придется работать в коде это:

  • MonetaryAmount

  • CurrencyUnit

Подключение библиотеки

<!-- https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-core --> <dependency>     <groupId>org.javamoney</groupId>     <artifactId>moneta</artifactId>     <type>pom</type> </dependency> <!-- https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-core --> <dependency>     <groupId>org.javamoney.moneta</groupId>     <artifactId>moneta-core</artifactId> </dependency>

Настройка библиотеки

При запуске приложения будет выдаваться предупреждения, что не настроен MathContext и будет применен DefaultMathContext.

Контекст отвечает за настройки точности числового значения денежных единиц и способа округления.

Чтобы настроить контекст в Spring приложении, нужно добавить в ресурсы javamoney.properties файл. Минимальные параметры, которые нужно задать:

  • org.javamoney.moneta.Money.defaults.precision=DECIMAL128

  • org.javamoney.moneta.Money.defaults.roundingMode=HALF_EVEN

Эти настройки будут применяться ко всем создаваемым объектам MonetaryAmount. При необходимости можно переопределить конфигурацию в моменте создания экземпляра объекта.

Примеры создания

Валюта

CurrencyUnit currencyEUR = Monetary.getCurrency("EUR");

или с применением объекта Locale

CurrencyUnit currencyUSD = Monetary.getCurrency(Locale.US);

Так же можно создавать свои валюты и регистрировать их для применения. Например для работы с BitCoin или валютами непризнанных республик. Можете добавить их локализованные названия, числовые коды валют, буквенные представления и символьные глифы. Примеры смотрите в документации.

Денежная единица

Как уже говорилось выше, в библиотеки есть 2 реализации интерфейса MonetaryAmount. Это:

  • Money

  • FastMoney

Различия в реализациях и случаях применения читайте в документации. Вкратце скажу, что у них разная точность, занимаемый объем памяти и скорость работы.

Статическая фабрика для Money:

final var money = Money.of(200.20, "USD");

Статическая фабрика для FastMoney:

final var fastMoney = FastMoney.of(200.20, "USD");

Так же можете создать экземпляр с настроенным MathContext в месте:

final var money = Monetary.getAmountFactory(Money.class)      .setCurrencyUnit("CHF").setNumber(200)      .setContext(MonetaryContextBuilder.of()           .set(MathContext.DECIMAL128).build())      .create();

И примеры использования:

final MonetaryAmount amount1 = Money.of(100, "USD"); final MonetaryAmount amount2 = Money.of(50, "USD");  // Сложение final MonetaryAmount sum = amount1.add(amount2); // 150 USD  // Вычитание final var diff = amount1.subtract(amount2); // 50 USD  // Умножение на скаляр final var multiplied = amount1.multiply(2.5); // 250 USD  // Деление на скаляр final var divided = amount1.divide(2); // 50 USD  // Сравнение boolean isGreater = amount1.isGreaterThan(amount2); // true

Хранение в БД

В реализации библиотеки нет инструментов, для хранения типа MonetaryAmount в БД.

Попробуем сами разобраться. Глянем на статические фабрики — они требуют 2 параметра: значение и валюту или локаль. Потому и в БД хранить лучше 2 отдельных поля.

Общие принципы

Практически в 99% случаев лучшей стратегией является раздельное хранение точной суммы и кода валюты в отдельных полях. Это решает все проблемы с точностью, обеспечивает возможность формировать любые запросы и является наиболее понятным и поддерживаемым подходом. (Почему именно так можем обсудить в комментах или поговорите с ИИ)

Однако для поддержания необходимой точности, нужно использовать предназначенные для этого типы данных:

SQL (общее)

два отдельных поля

DECIMAL(19, 4) + CHAR(3)

PostgreSQL

два отдельных поля

NUMERIC(19, 4) + CHAR(3)

MongoDB

вложенный документ

amount: NumberDecimal(…), currency: string

Elasticsearch

вложенный объект

amount: scaled_float, currency: keyword

Приведу пример MongoDb. Для PostgreSql и ORM легко сделать по аналогии.

MongoDb

Идея очень простая:

  1. Делаем сам конвертер, которые раскладывает данные из MonetaryAmount в BSON документ. И в обратную сторону — из полей BSON документа создает статической фабрикой экземпляр MonetraryAmount

  2. Зарегистрировать класс конвертера в конфигурации MongoDb, чтобы Spring автоматом применял его.

Конвертер

@NoArgsConstructor(access = AccessLevel.PRIVATE) public class MonetaryAmountConversion {      public static final String AMOUNT = "amount";     public static final String CURRENCY = "currency";      @ReadingConverter     public enum ReadConverter implements Converter<Document, MonetaryAmount> {          INSTANCE;          @Nullable         @Override         public MonetaryAmount convert(@Nullable Document source) {             if (source == null) {                 return null;             }             return Money.of(                     requireNonNull(source.get(AMOUNT, Decimal128.class).bigDecimalValue()),                     requireNonNull(source.getString(CURRENCY))             );         }     }       @WritingConverter     public enum WriteConverter implements Converter<MonetaryAmount, Document> {          INSTANCE;          @Nullable         @SneakyThrows         @Override         public Document convert(@Nullable MonetaryAmount source) {             if (source == null) {                 return null;             }             final var document = new Document();             document.put(AMOUNT, source.getNumber().numberValue(BigDecimal.class));             document.put(CURRENCY, source.getCurrency().getCurrencyCode());             return document;         }     }   } 

Регистрация конвертера

@Bean MongoCustomConversions mongoCustomConversions() {     return new MongoCustomConversions(             List.of(                     MonetaryAmountConversion.ReadConverter.INSTANCE,                     MonetaryAmountConversion.WriteConverter.INSTANCE             )     ); }

Сериализация/Десериализация

Не менее важно обрабатывать и формировать транспорты для API.

С этим намного проще.

Подключение либы

За нас уже всё сделано и в хорошей реализации.

<!-- https://mvnrepository.com/artifact/org.zalando/jackson-datatype-money -->  <dependency>      <groupId>org.zalando</groupId>      <artifactId>jackson-datatype-money</artifactId>  </dependency>

Регистрация для Jackson

Достаточно зарегистрировать бин.

/**   Регистрируем модуль сериализации для MonetaryAmount - JSR-354  / @Bean Module moneyModule() {     return new MoneyModule(); }

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

Поддержка OpenApi

Для тех, кто генерирует OpenAPI документацию на основе контроллеров, важно описать трансляцию типа MonetaryAmount swagger схему.

Для этого нужно сконфигурировать описание для OpenApi

@Configuration @SuppressWarnings("unchecked") public class OpenApiConfiguration {      static {         // Представление MonetaryAmount в документации         SpringDocUtils.getConfig().replaceWithSchema(MonetaryAmount.class, new ObjectSchema()                 .addProperty("amount", new NumberSchema()                         .description("Сумма, выраженная в виде десятичного числа основных денежных единиц")                         .format("decimal")                         .example(99.96)                 )                 .addProperty("currency", new StringSchema()                         .description("Трехбуквенный код валюты в соответствии с       ISO-4217")                         .format("ISO-4217")                         .example("USD")                 )                 .required(List.of("amount", "currency"))                 .description("Денежная единица")         );     } }

Конвертация валют

Наверное наиболее полезная и обширная тема в применении реализации JSR-354 это конвертация валют на основе курсов за указанные даты.

SPI и Провайдеры

Money API предоставляет несколько SPI интерфейсов, реализовав которые, вы можете подключить любой источник курсов валют (например ЦБ РФ или Нацбанк РК). Так же в библиотеке уже присутствует несколько реализаций, которые могут стать хорошим примером для своих расширений. (Что такое SPI и как регистрировать реализации здесь рассказывать не буду).

Общий алгоритм идеи примерно такой:

  • Реализация LoaderService обновляет данные курсов валют с конкретного банка с заданным периодом. Нужно написать парсер данных и способ их кэширования.

  • Реализация ExchangeRateProvider регистрируется как SPI реализация и вызывается при использовании конкретного источника курсов валют. Описываем как по входящим параметрам получить данные из кэша и выполнить конвертацию

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

Пример использования:

// Получаем провайдер курсов (например, от ECB) ExchangeRateProvider ecbRateProvider = MonetaryConversions.getExchangeRateProvider("ECB");  // Создаем суммы для конвертации MonetaryAmount amountInEur = Money.of(100, "EUR"); MonetaryAmount amountInUsd = Money.of(100, "USD");  try {     // Конвертируем EUR -> USD     MonetaryAmount convertedAmount = amountInEur.with(ecbRateProvider.getExchangeRate("EUR", "USD"));     System.out.println(convertedAmount); // e.g., 110.05 USD      // Сравниваем суммы в разных валютах     boolean isEqual = amountInEur.isEqualTo(convertedAmount); // false     boolean isEquivalent = amountInEur.equals(convertedAmount); // false (разная валюта)  } catch (CurrencyConversionException e) {     // Обработка ситуации, когда курс для валютной пары не найден     System.err.println("Курс конвертации не доступен: " + e.getMessage()); }

Стратегии обработки CurrencyConversionException

Данное исключение выбрасывается когда не найден курс по входящим параметрам: из валюты 1 в валюту 2 на дату d для конкретного провайдера.

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

Стратегия

Надежность

Сложность

Сценарий

Fail Fast

Высокая

Низкая

Критичные финансовые транзакции. Прерываем сразу

Fallback-провайдер

Очень высокая

Средняя

Высокие требования к доступности. Используем несколько источников данных

Кэширование

Высокая

Средняя/Высокая

Допустима работа на устаревших данных (предрасчеты, дашборды). Последний известный курс

Через кросс-валюту

Средняя/Высокая

Высокая

Работа с экзотическими валютами, отсутствие прямых пар

Default или Null-object

Низкая

Низкая

Некритичные, справочные операции

Заключение

Использование JSR-354 и библиотеки Moneta позволяет избавиться от целого класса ошибок, связанных с деньгами, стандартизировать код, упростить арифметические операции и конвертацию валют. Это современный и надежный подход для Java-приложений.

Помимо этого библиотека предоставляет расширения с уже готовыми финансовыми операциями такими как: расчет процента, расчет сложного процента, аннуитетные платежи и многое другое.

Ссылки

P.S.

Буду рад собрать готовые провайдеры для используемых банков в одном месте и оформить в библиотеку.

Всем больше ООП и меньше велосипедов!


ссылка на оригинал статьи https://habr.com/ru/articles/946108/