Implementacja obsługi waluty z Java Money/Currency (JSR-354) i Spring Data JPA

0

Cześć,

Próbuję zaimplementować w efektywny sposób obsługę waluty z wykorzystaniem Java Money/Currency API, które jest specjalnie do tego stworzone: https://www.baeldung.com/java-money-and-currency
W swoim projekcie korzystam z Spring Data JPA oraz Postgresa jako bazy danych.

W swoim modelu domeny Venue chciałbym mieć obsługę kwoty oraz waluty jako jedno pole/atrybut z typem danych właśnie jako jedna z dostępnych klas Java Money API: Money.
Od strony Postgresa chciałbym żeby Spring Data JPA mapował pole

private Money money;

na 2 osobne kolumny w tabeli Venue, z tym że moim zdaniem można zrobić to jeszcze lepiej.
Po dość długim szukaniu rozwiązań, udało mi się to zaimplementować właśnie w taki sposóbp poprzez stworzenie konwertera/mappera z wykorzystaniem biblioteki Jadira:
https://i.ibb.co/DGpgFXd/currency-implementation.png

Niestety, jak widzicie, moim zdaniem można byłoby zrobić to jeszcze lepiej, bo w obecnej sytuacji w tabeli Venue symbol waluty jest bez sensu powtarzany, co sprawia, że tabela nie jest w pełni znormalizowana, bo symbol waluty np. PLN powtarza się za każdym razem, co powoduje niepotrzebne zwiększenie pamięci w bazie.

Myślałem nad tym, żeby ulepszyć to na następujące sposoby.

  1. Zamiast tych dwóch kolumn tj. money_currency i money, stworzyć obiekty domenowe Currency i Price lub użyć junction table/tabeli łącznikowej, oraz zrobić odpowiednie mapowania w entity Venue.
    W ten sposób miałbym jedną tabelę Currency z walutami, która nie zmieniałaby się, przez co uniknąłbym powtarzania się symbolu waluty jak w obecnym rozwiązaniu.
  2. Zrezygnować całkowicie z Java Money/Currency API, bo mapowanie jak widać jest dość problematyczne i użyć klasycznego rozwiązania z osobnymi tabelami Currency, Price i Venue lub tabelą łącznikową, jak wyżej, tylko bez tego API.

Czy mielibyście jakiś pomysł w jaki sposób można byłoby zmapować typ Money z API Java Money właśnie na taką postać zamiast 2 kolumn?

Wyglądałoby to mniej więcej w taki sposób:

table venue
--------------
id
name


table price
------------
id
venue_id
amount
currency_id


table currencies
----------------
id
name
abbreviation_sign

Czy mielibyście jakieś ciekawe pomysły na rozwiązanie tego problemu?

Venue Entity

import lombok.*;
import org.hibernate.annotations.*;
import org.javamoney.moneta.Money;

import javax.persistence.*;
import javax.persistence.Entity;
import java.time.LocalDateTime;
import java.util.UUID;

@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TypeDefs(value = {
        @TypeDef(name = "moneyAmountWithCurrencyType", typeClass = CustomPersistentMoneyAmountAndCurrency.class)
})
public class Venue {

    @Id
    @GeneratedValue
    @Column(columnDefinition = "uuid", updatable = false)
    private UUID id;

    private String name;

    @Basic( optional = false )
    @Columns(columns = {
            @Column( name = "money_currency", nullable = false, length = 3 ),
            @Column( name = "money", nullable = false )
    })
    @Type(type = "moneyAmountWithCurrencyType")
    private Money money;

    private int capacity;

    @Column(updatable = false)
    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    VenueDto dto() {
        MoneyDto moneyDto = MoneyDto.builder()
                .amount(money.getNumber().toString())
                .currency(money.getCurrency().getCurrencyCode())
                .build();
        return VenueDto.builder()
                .uuid(id.toString())
                .name(name)
                .money(moneyDto)
                .capacity(capacity)
                .build();
    }
}

CustomPersistentMoneyAmountAndCurrency (Mapper/Konwerter typu Java Money/Currency API klasy Money na dwie kolumny w tabeli Venue)

import org.jadira.usertype.moneyandcurrency.legacyjdk.columnmapper.BigDecimalBigDecimalColumnMapper;
import org.jadira.usertype.moneyandcurrency.moneta.columnmapper.StringColumnCurrencyUnitMapper;
import org.jadira.usertype.spi.shared.AbstractMultiColumnUserType;
import org.jadira.usertype.spi.shared.ColumnMapper;
import org.javamoney.moneta.Money;

import javax.money.CurrencyUnit;
import javax.money.MonetaryAmount;
import java.math.BigDecimal;

public class CustomPersistentMoneyAmountAndCurrency extends AbstractMultiColumnUserType<MonetaryAmount> {

    private static final ColumnMapper<?, ?>[] COLUMN_MAPPERS = new ColumnMapper<?, ?>[] { new StringColumnCurrencyUnitMapper(), new BigDecimalBigDecimalColumnMapper() };

    private static final String[] PROPERTY_NAMES = new String[]{ "currency", "number" };

    @Override
    protected ColumnMapper<?, ?>[] getColumnMappers() {
        return COLUMN_MAPPERS;
    }

    @Override
    protected Money fromConvertedColumns(Object[] convertedColumns) {
        CurrencyUnit currencyUnitPart = (CurrencyUnit) convertedColumns[0];
        BigDecimal amountPart = (BigDecimal) convertedColumns[1];
        return Money.of(amountPart, currencyUnitPart);
    }

    @Override
    protected Object[] toConvertedColumns(MonetaryAmount value) {

        return new Object[] { value.getCurrency(), value.getNumber().numberValue(BigDecimal.class) };
    }

    @Override
    public String[] getPropertyNames() {
        return PROPERTY_NAMES;
    }
}

VenueDto

@EqualsAndHashCode(callSuper = true)
@ToString
@Builder
@Value
@AllArgsConstructor
@Relation(collectionRelation = "venues")
public class VenueDto extends ResourceSupport {
    private final String uuid;

    @NotNull
    @NotEmpty
    private final String name;

    @Valid
    private final MoneyDto money;

    @NotNull
    private final int capacity;
}

MoneyDto

@Getter
@Builder
@Value
@EqualsAndHashCode
@ToString
public class MoneyDto {
    @NotEmpty
    @NotNull
    @Digits(integer=7, fraction=2)
    @NumberFormat(style = NumberFormat.Style.NUMBER)
    private final String amount;

    @NotEmpty
    @NotNull
    private final String currency;
}

VenueRepository

interface VenueRepository extends Repository<Venue, Integer> {
    Venue save(Venue venue);
    Optional<Venue> findById(UUID id);
//    Optional<List<Venue>> findByMoneyLessThan(Money money);
    Page<Venue> findAll(Pageable pageable);
    long deleteById(UUID id);

    default Venue findOneOrThrow(UUID uuid) {
        Optional<Venue> venue = findById(uuid);
        if (venue.isPresent()) {
            return venue.get();
        } else {
            throw new VenueNotFoundException(uuid.toString());
        }
    }
}

Z góry dzięki za pomoc! ;)

2

Hmm, ale chyba właśnie po to używasz tego typu, żeby zapisywać informację o currency, czy się mylę? To żadna redundancja.

0

Tak jest, tylko chciałbym uniknąć powtarzania symbolu waluty za każdym razem, kiedy ktoś dodaje nowy rekord np.
PLN
PLN
USD
PLN
PLN

Czyli Twoim zdaniem to pierwsze rozwiązanie, które zrobiłem nie jest wcale złe, i niepotrzebnie komplikuję sytuację próbując jeszcze bardziej znormalizować przez rozbicie na encje Price i Currencies i już istneijące Venue?

0

Koncepcyjnie dla mnie jest OK. Sam kod mógłby być lepszy, ale to inny temat :)

0

Dzięki wielkie. Co poprawiłbyś w kodzie? Zrefaktoruję przy okazji tak jak powinno być? ;)

1
thorbjorn napisał(a):

Tak jest, tylko chciałbym uniknąć powtarzania symbolu waluty za każdym razem, kiedy ktoś dodaje nowy rekord np.
PLN
PLN
USD
PLN
PLN

Czyli Twoim zdaniem to pierwsze rozwiązanie, które zrobiłem nie jest wcale złe, i niepotrzebnie komplikuję sytuację próbując jeszcze bardziej znormalizować przez rozbicie na encje Price i Currencies i już istneijące Venue?

I co zyskasz?
Wymienisz 3 znaki ascii czyli pewnie 3 bajty na jeden klucz zajmujący 4 lub 8 bajtów. Jeśli użyjesz krótszego klucza to zajmie to 2 lub 1 bajt. Czyli zyskasz maksymalnie 2 bajty na wierszu (A możesz stracić 5)

A co stracisz?
Będziesz musiał robić dodatkowego joina do dodatkowej tabeli. O Ile będziesz miał mało danych to będzie to mały problemem. Jak będziesz miał dużo danych to nie powinno to być dużym problemem bo baza danych wczyta taką pomocniczą tabelkę do pamięci i nie będzie robić dodatkowych odczytów z dysku. Ale jestem pewien że ten RAM można lepiej wykorzystać niż na bezsensowne tabelki :)

0

@KamilAdam: Racja zdecydowanie. Po prostu od jakiegoś czasu, zwłaszcza przez czytanie książek o architekturze komputerów i optymalizacji, szukam nieraz na siłę miejsca, gdzie można to robić :D

2
default Venue findOneOrThrow(UUID uuid) {
        Optional<Venue> venue = findById(uuid);
        if (venue.isPresent()) {
            return venue.get();
        } else {
            throw new VenueNotFoundException(uuid.toString());
        }
    }

Po 1 po kiego grzyba taka metoda w DAO? Dao powinno zwrócić Optionala, i dopiero "na górze" coś się robi to się decyduje co zrobić.
Są 3/4 generalnie sposoby obsługi Optionala:
1)Jakiś orElse czy tam orElseGet() -> daje defaultową wartość w przypadku pustego Optionala.
2)ifPresent - wykonuje akcje (wykorzystajac interface Consumer<T> ) Optional coś zawiera
3)orElseThrow -wyrzuca wyjątek
4)Możesz wywołac też akcje w przypadku pustego Optionala, w Javie 8 wymaga ifologi, a od Javy 9 masz metode która ma dwie lambdy - jako parametry, jedną gdy Optional coś zawiera a druga jeśli jest pusty, nazywa się ifPresentOrElse

@Getter
@Builder
@Value
@EqualsAndHashCode
@ToString
public class MoneyDto {
    @NotEmpty
    @NotNull
    @Digits(integer=7, fraction=2)
    @NumberFormat(style = NumberFormat.Style.NUMBER)
    private final String amount;

    @NotEmpty
    @NotNull
    private final String currency;
}

Kolejne lombokowe rzygowizny - po co builder na klasie z dwoma polami? Czemu adnotacja value, nie możesz po ludzku stworzyć klasy z polami final i konstruktorem? W IntelliJ ctrl + insert i już Ci podpowie konstruktor. I czemu amount jako String? WTF

0
scibi92 napisał(a):
default Venue findOneOrThrow(UUID uuid) {
        Optional<Venue> venue = findById(uuid);
        if (venue.isPresent()) {
            return venue.get();
        } else {
            throw new VenueNotFoundException(uuid.toString());
        }
    }

Po 1 po kiego grzyba taka metoda w DAO? Dao powinno zwrócić Optionala, i dopiero "na górze" coś się robi to się decyduje co zrobić.
Są 3/4 generalnie sposoby obsługi Optionala:
1)Jakiś orElse czy tam orElseGet() -> daje defaultową wartość w przypadku pustego Optionala.
2)ifPresent - wykonuje akcje (wykorzystajac interface Consumer<T> ) Optional coś zawiera
3)orElseThrow -wyrzuca wyjątek
4)Możesz wywołac też akcje w przypadku pustego Optionala, w Javie 8 wymaga ifologi, a od Javy 9 masz metode która ma dwie lambdy - jako parametry, jedną gdy Optional coś zawiera a druga jeśli jest pusty, nazywa się ifPresentOrElse

@Getter
@Builder
@Value
@EqualsAndHashCode
@ToString
public class MoneyDto {
    @NotEmpty
    @NotNull
    @Digits(integer=7, fraction=2)
    @NumberFormat(style = NumberFormat.Style.NUMBER)
    private final String amount;

    @NotEmpty
    @NotNull
    private final String currency;
}

Kolejne lombokowe rzygowizny - po co builder na klasie z dwoma polami? Czemu adnotacja value, nie możesz po ludzku stworzyć klasy z polami final i konstruktorem? W IntelliJ ctrl + insert i już Ci podpowie konstruktor. I czemu amount jako String? WTF

  1. Ok. Zrobię upgrade do Javy 11, i zastosuję ten sposób, bo korzystam z 8. Przy tej metodzie korzystałem z przykładu stąd: https://github.com/jakubnabrdalik/hentai

  2. Co jest złego w Lomboku?

  3. Amount jako String, dlatego, że dziwnym trafem Jackson ma problem z serializacją BigDecimal i obsługą błędu w walidacji w przypadku, kiedy klient przekaże jakiś String w amount, zamiast liczby. Powoduje to, że ciężko ten błąd obsłużyć normalną wiadomością, którą mam w ExceptionController z @ControllerAdvice, dlatego, że HttpMessageNotReadableException jest wyrzucany przed uruchomieniem walidacji. Oczywiście można dopisać metodę w ExceptionController, która obsłuży HttpMessageNotReadableException poprzez @ExceptionHandler(HttpMessageNotReadableException.class), tylko, że zależy mi na zastosowaniu MessageSource i i18n i obsługi wiadomości błędów w kilku językach z plików .properties.

{
	"name": "Testabc",
	"money": {
		"amount": "akaka",
		"currency": "USD"
	},
	"capacity": 200
}

Błąd:

{
    "timestamp": "2020-07-25T17:38:02.704+0000",
    "status": 400,
    "error": "Bad Request",
    "message": "JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String \"aksja\": not a valid representation; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.math.BigDecimal` from String \"aksja\": not a valid representation\n at [Source: (PushbackInputStream); line: 4, column: 13] (through reference chain:
    "path": "/venues"
}

Poza tym, coś strasznie agresywnie reagujesz pisząc odpowiedzi typu "WTF' :D. Żart oczywiście ;)

1

Niedługo wymyślicie Koledzy automatyczną strzelbę na słonia z opcją na muchy. Kolejny mega-wątek się rodzi, a nikt nie podał Convetera + Embedded.

Generalnie wynoszenie tego do innych tabel, ale również do mocno rozbudowanego kodu, to chore.
To jest arytmetyka na jednym z podstawowych bytów (gratuluję górze, ze to po 25 latach zauważyła - choć moja ocenia jest ... na dole), i musi być jak błysk ciupagi, i bez szarpania GC.

z moneyDto tzreba zostawić tyle, i wbudować do JPA

@Getter
@EqualsAndHashCode
@Serializable
// @ToString -> reczny będzie nieco lepszy
@Embeddable <--- to nowe, adnotacja JPA
public class MoneySomething {
    @NotEmpty
    @NotNull
    private final BigDecimal amount;

    @NotEmpty
    @NotNull
   @LEngth = 3 albo 4, zależy od wybranego kodu. Ja głosuję za zwykłym ludzkim PLN, EUR, AUD
    private final String currency;

   String toString() { ..... przywołuj se co chcesz };
}

https://www.baeldung.com/jpa-embedded-embeddable
Chyba final jest sprzeczny z duchem JPA ...

oraz

@Converter
	public class MoneySomethingConverter implements 
	  AttributeConverter<Money, MoneySomething > {
}

https://www.baeldung.com/jpa-attribute-converters

UPDATE: osobiście chodzę koło tego Money / Currency jak pies koło jeża. Odczuwam to jako overdesign jak dawniejsze Date / Calendar, po czym do dziś jest kac.
Albo jak Locale, która ma ileś tam megabajtów. na niezamieszkałe wyspy na Pacyfiku

0

@AnyKtokolwiek: Z @Embedded też właśnie znalazłem rozwiązanie i wiesz co, chyba faktycznie zrezygnuję z Money/Currency na rzecz tego, bo z Money/Currency więcej z nim problemów jest zwłaszcza w przypadku integracji z JPA, bo chyba zaczynam kombinować za dużo, co widać z resztą :)

1

Amount jako String, dlatego, że dziwnym trafem Jackson ma problem z serializacją BigDecimal i obsługą błędu w walidacji w przypadku, kiedy klient przekaże jakiś String w amount, zamiast liczby. Powoduje to, że ciężko ten błąd obsłużyć normalną wiadomością, którą mam w ExceptionController z @ControllerAdvice, dlatego, że HttpMessageNotReadableException jest wyrzucany przed uruchomieniem walidacji. Oczywiście można dopisać metodę w ExceptionController, która obsłuży HttpMessageNotReadableException poprzez @ExceptionHandler(HttpMessageNotReadableException.class), tylko, że zależy mi na zastosowaniu MessageSource i i18n i obsługi wiadomości błędów w kilku językach z plików .properties.

Tak, zwraca 400 po rzuceniu wyjątku. Ale i tak uważam że powinien to być BigDecimal, a walidację i tak robisz najpierw na froncie więc nie powinno się móc wyslać normalnie nie-liczby.

Co jest złego w Lomboku?

Poza tym że wzbuda odruch wymiotny, nie nie widać tego kodu w Javie przed kompilacją więc utrudnia analize kodu z użyciem IDE, nie możesz debugować tego(jak postawisz break-point na konstruktorze jeśli nie masz go w kodzie Javovym? ) oraz to że ludzie nawalają te tony inwokacji adnotacji bez wiedzy po co to robią? To nic :)
I wiem o czym piszę, bo obecnie pracuje w utrzymaniu projektu który od dawna jest na produkcji :D

0
scibi92 napisał(a):

Co jest złego w Lomboku?

Poza tym że wzbuda odruch wymiotny, nie nie widać tego kodu w Javie przed kompilacją więc utrudnia analize kodu z użyciem IDE, nie możesz debugować tego(jak postawisz break-point na konstruktorze jeśli nie masz go w kodzie Javovym? ) oraz to że ludzie nawalają te tony inwokacji adnotacji bez wiedzy po co to robią? To nic :)
I wiem o czym piszę, bo obecnie pracuje w utrzymaniu projektu który od dawna jest na produkcji :D

Też pracuję z projektami używającymi lomboka i kompletnie się nie zgodzę.
Jeśli chodzi o analizę kodu to ją wręcz ułatwia, bo nie masz zbędnego boilerplate'u. Jak zdebugować? Kliknąć przycisk o nazwie 'delombok', postawić breakpointa i po zakończeniu procesu debugowania cofnąć zmianę.
To, że ludzie dowalają adnotacji nie wiedząc co robią to jest akurat wina konkretnych osób, a nie narzędzia, które jest po prostu łatwe w użyciu. To tak jakby narzekać na to, że ludzie używają pił mechanicznych zamiast ręcznych, argumentując tym, że za prosto się nimi tnie i czasem ktoś zrobi coś, co wcale nie było potrzebne.

1

Jak zdebugować? Kliknąć przycisk o nazwie 'delombok', postawić breakpointa i po zakończeniu procesu debugowania cofnąć zmianę.

No świetnie, i walczymi z problemami nie istniejącymi w innych warunkach. No i pytanie - czy zadziało to jak debugujemy zdalnie?

Jeśli chodzi o analizę kodu to ją wręcz ułatwia, bo nie masz zbędnego boilerplate'u.

No dobrze, to jak znajdziesz wywołanie np. konstuktora którego nie ma w kodzie Javovym? No i pytanie czy napisanie:

public final class CurrencyAmount {
   private final BigDecimal amount;
   private final String currency;

   public CurrencyAmount(BigDecimal amout, String currency){
     this.amount = amount;
    this.currency = currency;
  }

  public BigDecimal getAmount() { return amount; }
  public String currency getCurrency() { return currency;}
}

To taki bolesny boilerplate, skoro IDe wygeneruje? Nie wspomne nawet o klasach które sa kompontentami, np. klient REST, tam wstrzykujesz prywatne obiekty bez getterów. Zresztą lombok czasami jest przydatny, ale do takie rzeczy jak @Wither, albo @Builder.

0
scibi92 napisał(a):

No świetnie, i walczymi z problemami nie istniejącymi w innych warunkach.

Każde nowe narzędzie wprowadza problemy, które nie istniałyby bez tego narzędzia. Przykładem są chociażby komputery.

Akurat dla mnie i dla bardzo wielu innych programistów w tym wypadku zalety przeważają nad wadami. Szczególnie, że w przypadku delombokizacji to jest proces zajmujący 2-3 sekundy.

No i pytanie - czy zadziało to jak debugujemy zdalnie?

Jeśli chodzi o zdalne debugowanie to podejrzewam, że mogłoby być trudniej. Gdybym miał potencjalną potrzebę zdalnego debugowania struktur tworzonych przez Lomboka, to te konkretne struktury pisałbym jawnie.

No dobrze, to jak znajdziesz wywołanie np. konstuktora którego nie ma w kodzie Javovym?

https://stackoverflow.com/questions/38744874/find-usages-of-lombok-generated-constructor-in-intellij

No i pytanie czy napisanie:

public final class CurrencyAmount {
   private final BigDecimal amount;
   private final String currency;

   public CurrencyAmount(BigDecimal amout, String currency){
     this.amount = amount;
    this.currency = currency;
  }

  public BigDecimal getAmount() { return amount; }
  public String currency getCurrency() { return currency;}
}

To taki bolesny boilerplate, skoro IDe wygeneruje?

To co tutaj podałeś to jest akurat bardzo prosta klasa. Oczywiście im jest klasa prostsza, tym Lombok daje mniejszy zysk. Mimo to nawet tutaj jeśli mogę się pozbyć tego kodu, to chętnie to zrobię.

Nie wspomne nawet o klasach które sa kompontentami, np. klient REST, tam wstrzykujesz prywatne obiekty bez getterów. Zresztą lombok czasami jest przydatny, ale do takie rzeczy jak @Wither, albo @Builder.

No właśnie, ja z tych adnotacji korzystam dość często. Przykładowo ręcznie pisać builderów mi się nie chce, bo ilość roboty przy tym vs dodanie jednej adnotacji jest dość duża. Natomiast nawet gdybym z tych konkretnych adnotacji nie korzystał, to i tak Lombok wprowadza w moim kodzie większy porządek.

1
thorbjorn napisał(a):
  1. Amount jako String, dlatego, że dziwnym trafem Jackson ma problem z serializacją BigDecimal i obsługą błędu w walidacji w przypadku, kiedy klient przekaże jakiś String w amount, zamiast liczby.

Dla międzynarodowych pozycji walutowych i tak trzeba "poprosić" serializery aby nie przechodziło to przed double, jak ma zestandaryzowane JSON.

Jackson, spotkałem zapewnienie, że można w tym względzie kontrolować, ale nie doszło do użycia tej wiedzy.
Do polskich kwot miejmy nadzieję double jeszcze nie wyleci w powietrze (w niedokładności), ale japońskie jeny, czy inne co bardziej drobne waluty?

1 użytkowników online, w tym zalogowanych: 0, gości: 1