Описание проблемы
Достаточно часто в реализации логики есть необходимость оперировать денежными единицами.
В коде приходится сталкиваться с таким представлениями:
значение в типе
String
значение числом типа -
int
,float,double
значение числом
BigDecimal
с разными правилами округленияотсутствие валюты
валюта отдельным полем в
String
значение и валюта одной строкой в
String
Это приводит к:
потери точности после запятой
накопление погрешностей при операциях
ошибкам округления
невозможности конвертации валют
невозможности в принципе проводить вычислительные операции
Иногда в коде встречаются сразу несколько вариантов представления денежных значений.
Пример:
// Проблемный код
double price1 = 0.1;
double price2 = 0.2;
System.out.println(price1 + price2); // 0.30000000000000004 - Погрешность!
BigDecimal cost = new BigDecimal("100.00");
BigDecimal discount = new BigDecimal("30.00");
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.
Буду рад собрать готовые провайдеры для используемых банков в одном месте и оформить в библиотеку.
Всем больше ООП и меньше велосипедов!