Описание проблемы
Достаточно часто в реализации логики есть необходимость оперировать денежными единицами.
В коде приходится сталкиваться с таким представлениями:
-
значение в типе
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 (общее) |
два отдельных поля |
|
|
PostgreSQL |
два отдельных поля |
|
|
MongoDB |
вложенный документ |
amount: |
|
Elasticsearch |
вложенный объект |
amount: |
Приведу пример MongoDb. Для PostgreSql и ORM легко сделать по аналогии.
MongoDb
Идея очень простая:
-
Делаем сам конвертер, которые раскладывает данные из
MonetaryAmountвBSONдокумент. И в обратную сторону — из полейBSONдокумента создает статической фабрикой экземплярMonetraryAmount -
Зарегистрировать класс конвертера в конфигурации 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/
Добавить комментарий