- PVSM.RU - https://www.pvsm.ru -
Достаточно часто в реализации логики есть необходимость оперировать денежными единицами.
В коде приходится сталкиваться с таким представлениями:
значение в типе 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
Для формализованной работы с деньгами в Java существует спецификация JSR-354 (Java Specification Request). Эта спецификация и библиотеки предоставляют:
интерфейс MonetaryAmount - для представления денежных единиц в валюте
интерфейс CurrencyUnit - для представления валюты
арифметические операции с деньгами
округления - несколько вариантов
конвертацию валют
формат представления денежных единиц с валютой с локализацией
Официальной реализацией (Reference Implementation) стандарта JSR-354 является библиотека Moneta [1].
Библиотека предоставляет две реализации:
Money: основан на BigDecimal. Обеспечивает высокую точность (до 2^63 десятичных знаков) и гибкость. Рекомендуется по умолчанию для большинства бизнес-приложений.
FastMoney: основан на long. Обеспечивает фиксированную точность (15 десятичных знаков) и работает в ~15 раз быстрее, чем Money. Потребляет меньше памяти. Идеален для высоконагруженных систем, где операции с деньгами являются узким местом, и где точности в 15 знаков достаточно
Все возможности реализации можете посмотреть в документации [2] или в коде библиотеки. Здесь же я опишу, как применять объекты, выполнять сериализацию/десериализацию, конвертировать для хранения в БД, выполнять конвертацию валют, форматировать представление и выполнять тонкую настройку объектов.
Основные объекты типы с которыми придется работать в коде это:
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 легко сделать по аналогии.
Идея очень простая:
Делаем сам конвертер, которые раскладывает данные из 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>
Достаточно зарегистрировать бин.
/**
Регистрируем модуль сериализации для MonetaryAmount - JSR-354
/
@Bean
Module moneyModule() {
return new MoneyModule();
}
В библиотеке есть множество конфигураций форматов сериализации. Единственная рекомендация - всегда разделять значение и валюту на отдельные поля.
Для тех, кто генерирует 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 это конвертация валют на основе курсов за указанные даты.
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());
}
Данное исключение выбрасывается когда не найден курс по входящим параметрам: из валюты 1 в валюту 2 на дату d для конкретного провайдера.
Для корректной работы сервиса необходимо корректно обрабатывать данную ситуацию. Выбранная стратегия зависит от конкретных требований к вашему сервису и услуги, которую он предоставляет. Вот некоторый список стратегий:
|
Стратегия |
Надежность |
Сложность |
Сценарий |
|
Fail Fast |
Высокая |
Низкая |
Критичные финансовые транзакции. Прерываем сразу |
|
Fallback-провайдер |
Очень высокая |
Средняя |
Высокие требования к доступности. Используем несколько источников данных |
|
Кэширование |
Высокая |
Средняя/Высокая |
Допустима работа на устаревших данных (предрасчеты, дашборды). Последний известный курс |
|
Через кросс-валюту |
Средняя/Высокая |
Высокая |
Работа с экзотическими валютами, отсутствие прямых пар |
|
Default или Null-object |
Низкая |
Низкая |
Некритичные, справочные операции |
Использование JSR-354 и библиотеки Moneta позволяет избавиться от целого класса ошибок, связанных с деньгами, стандартизировать код, упростить арифметические операции и конвертацию валют. Это современный и надежный подход для Java-приложений.
Помимо этого библиотека предоставляет расширения с уже готовыми финансовыми операциями такими как: расчет процента, расчет сложного процента, аннуитетные платежи и многое другое.
Буду рад собрать готовые провайдеры для используемых банков в одном месте и оформить в библиотеку.
Всем больше ООП и меньше велосипедов!
Автор: rrash84
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/spring/430313
Ссылки в тексте:
[1] Moneta: https://javamoney.github.io/ri.html
[2] документации: https://github.com/JavaMoney/jsr354-ri/blob/master/moneta-core/src/main/asciidoc/userguide.adoc
[3] GitHub проекта Moneta: https://github.com/JavaMoney/jsr354-ri
[4] Jackson Money Module: https://github.com/zalando/jackson-datatype-money
[5] список доступных провайдеров валют: https://github.com/JavaMoney/javamoney-lib
[6] Источник: https://habr.com/ru/articles/946108/?utm_source=habrahabr&utm_medium=rss&utm_campaign=946108
Нажмите здесь для печати.