Nigdy nie popełniaj tego błędu mapując ID encji w JPA / Hibernate

Czy zdarzyło Ci się podczas pracy z Hibernate wygenerować taką sytuację, w której gubiłeś lub miałeś nadmiarowe encje w aplikacji względem stanu w bazie danych? Powodem mogło być błędne mapowanie identyfikatora encji i implementacja metod equals i hashcode. Nigdy o tym nie słyszałeś? To lepiej, żebyś zapoznał się z tym wpisem zanim będzie za późno!

Obiekty w Javie rozróżniane są między sobą na podstawie kontraktu equals / hashcode. Każdy obiekt w Javie posiada domyślną implementację tych metod, ale jeśli chcemy możemy je nadpisać.

Z kolei wiersze bazodanowe opierają się na kluczy głównym (primary key). Hibernate pobierając obiekty z bazy danych korzysta właśnie z tego klucza.

Podczas konwersji z wierszy bazodanowych na obiekty Javowe musimy zwrócić szczególną uwagę jak zachowuje się relacja tożsamości w obu tych światach. W tym celu musimy pochylić się nad metodami equals i hashcode.

Podejście pierwsze – brak zwrócenia uwagi na implementację equlas / hashcode – ŹLE 🙁

Wyobraźmy sobie sytuację, w której mamy utworzony jakiś Set lub Mapę, która żyje dłużej w naszej aplikacji niż pojedyncza sesja Hibernate – czyli na przykład pomiędzy dwoma żądaniami HTTP użytkownika.

Wtedy odpytując bazę danych o obiekty, w naszych kolekcjach pojawią się duplikaty.

@Entity
class Book {
    @Id
    private Long id;
    private String title;
    private String author;
    private Long year;

    // getters, setters
}

W kolekcji będą znajdować się dwie instancje klasy Book odpowiadające jednemu wierszowi w bazie danych. Podczas pobierania ich z bazy, Hibernate stworzy dla nich nowe instancje.
Stosowanie domyślnej implementacji equals i hashcode sprawi, że obiekt zostanie po prostu dodanych do zbioru (chociaż jego reprezentacja już się tam znajduje!).

Może się wtedy okazać, że zamiast 100 książek w kolekcji (których się spodziewamy na podstawie bazy danych) będzie ich większa ilość.

Co możemy więc zrobić?

Podejście drugie – oparcie o atrybuty – TEŻ BEZ SZAŁU 🙂

Drugie podejście jest takie, że opieramy metody equals & hashcode na atrybutach danej encji.

Przykładowo – w książce możemy je oprzeć o tytuł, autora czy rok wydania.

@Entity
class Book {
    @Id
    private Long id;
    private String title;
    private String author;
    private Long year;

    // getters, setters
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Book book = (Book) o;
        return Objects.equals(title, book.title) &&
            Objects.equals(author, book.author) &&
            Objects.equals(year, book.year);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), title, author, year);
    }
}

Wtedy podczas pobierania encji z bazy danych o takich samych wartościach w kolekcji będziemy mieć tylko jedną instancje. Tytuł, autor i rok będą się zgadzać, więc będzie można nadpisać poprzedni obiekt.

Co w sytuacji, gdy dla tego samego wiersza bazodanowego wartości tych atrybutów się zmienią?

Wówczas ponownie w kolekcji znajdą się dwie instancje.

To może oprzemy kontrakt o klucz główny, hm?

Opcja trzecia – oparcie equals / hashcode na kluczu głównym – LEPIEJ!

Jest to zdecydowanie lepsza opcja. Teraz niezależnie od tego, jak będą zmieniać się poszczególne atrybuty encji książką, jeśli tylko jej identyfikator będzie zgodny z tym, który już znamy (z bazy danych), wtedy mamy pewność, że w kolekcji będzie tylko jedna instancja.

@Entity
class Book {
    @Id
    private Long id;
    private String title;
    private String author;
    private Long year;

    // getters, setters
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Book book = (Book) o;
        return Objects.equals(id, book.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), id);
    }
}

Wszystko byłoby fajnie, gdyby nie kwestia dodawania do kolekcji encji, które jeszcze nie zostały zapisane do bazy danych – a tym samym nie mają jeszcze nadanego przez bazę danych id.

Spójrzmy na poniższą sytuacje.

books.put(new Book("Adam Mickiewicz", "Pan Tadeusz", 1834));
books.put(new Book("Juliusz Słowacki", "Kordian", 1834));   
// books ma tylko jedną z książek!
booksRepository.saveAll(books);

Obie instancje Book mają null w polu ID. Tym samym kolekcja books będzie zawierać tylko jedną z nich (!).

Co teraz, geniuszu?

Opcja czwarta – generowanie unikalnego atrybutu w momencie tworzenia encji – NAJLEPIEJ!

Jest na to rozwiązanie!

Polega ono na dodaniu dodatkowego atrybutu do naszych encji. Atrybutu, który będzie generowany w momencie tworzenia nowej instancji, zapisywany razem z encją do bazy danych i na podstawie którego oparty będzie kontrakt equals & hashcode.

Idealnym przykładem takiego atrybutu jest UUID.

books.put(new Book("Adam Mickiewicz", "Pan Tadeusz", 1834));  // uuid=3a4d...
books.put(new Book("Juliusz Słowacki", "Kordian", 1834));  // uuid=ed10...
// books ma obie książki!
booksRepository.saveAll(books);

W tym wypadku obie encje Book jeszcze przed zapisaniem do bazy danych mają nadane specjalnie identyfikatory rozróżniające instancje.

Natomiast w momencie czytania encji z bazy danych – wartości pól uuid również zostaną odczytane – i w momencie pracy z kolekcją books nowe instancje książek zostaną zwyczajnie nadpisane.

W ten sposób liczba obiektów w kolekcji books będzie się zgadzać, a my będziemy mieć pewność, że na pewno mówimy o tych samych instancjach.

Jak to najlepiej zaimplementować?

Oczywiście moglibyśmy do każdej encji dodawać pole uuid, ale po co? 🙂

Wystarczy stworzyć bazową klasę abstrakcyjnę z tym polem i jedynie dziedziczyć po niej.

@MappedSuperclass
@EqualsAndHashCode(of = "uuid")
public abstract class BaseEntity {

    private String uuid = UUID.randomUUID().toString();

    // getter, setter
}

@EqualsAndHashCode to adnotacja z Lomboka ułatwiająca implementację equals / hashCode.

Wtedy wystarczy, że encja Book dziedziczy z BaseEntity. I nie musi już nawet implementować u siebie metod equals / hashCode.

@Entity
public class Book extends BaseEntity {
    @Id
    private Long id;
    private String title;
    private String author;
    private Long year;
}

W takim razie po co nam klucz ID?

Bardzo dobre pytanie.

Klucz ID służy do identyfikowania obiektów w bazie danych.
Może świetnie sprawdzać się jako klucz obcy w relacjach między obiektami.
Lub jako klucz zwracany w REST API.

Natomiast pole uuid świetnie załatwia nam sprawę tożsamości obiektów i dlatego warto je dodawać do swoich encji.

Podsumowanie

  1. Podczas pracy z JPA / Hibernate wiele rzeczy może pójść nie tak.
  2. Jednym z takich elementów jest rozróżnianie tożsamości obiektów.
  3. Dlatego warto zwrócić uwagę na to jak implementujemy metody equals / hashcode w naszych encjach.
  4. Najlepszym rozwiązaniem jest zastosowanie klasy abstrakcyjnej z wygenerowanym w aplikacji atrybutem unikalności.
  5. Dzięki temu zawsze będziemy mieć pewność czy mówimy o tym samym obiekcie w aplikacji.

Author: Dariusz Mydlarz

Cześć, nazywam się Dariusz Mydlarz i od 2012 pracuję jako programista. Tworzę w ekosystemie Javy i uwielbiam systemy backendowe. Chcę w tym miejscu pomagać Ci stawać się lepszym programista. Jeśli mogę Ci jakoś pomoc, po prostu napisz do mnie.

12 Replies to “Nigdy nie popełniaj tego błędu mapując ID encji w JPA / Hibernate

    1. Jeśli zapiszesz dwie encje do kolekcji (set lub mapa) i nie zwrócisz uwagi na implementację equals / hashcode, to może się zdarzyć tak, że przy zapisie tej kolekcji do bazy danych tylko 1 z tych encji trafi do DB.
      To samo w drugą stronę. Pracując z tą kolekcją (która ma dłuższy cykl życia w Twojej aplikacji niż sesja Hibernatowa) encje, które pobierzesz z DB trafią do kolekcji jako nowe obiekty, mimo że realnie były tam już wcześniej (ale przez złą implementację equals / hashcode nie zostało to wykryte)

    2. w sumie jak ogarniasz cykl życia encji to nie musisz się martwisz o hashcode i equals. twoja porada brzmi jak „jak nie wiesz co się stało to dodaj hashcode equals” bez wskazania istoty problemu

    3. 👍 niezupełnie, ale mam nadzieję, że dla kogoś będzie to przydatne, dzięki

    1. Tak problem dotyczy sytuacji, w której baza danych generuje wartość ID, i ta nie jest znana na początku cyklu życia obiektu (do chwili persystencji do DB).

  1. Wszystko zależy od tego jakie dane chcesz zapisać w bazie danych i jaką one mają w sobie logikę. Dla niektórych wystarcza hashcode w oparciu o ID dla innych trzeba porównać większa ilość pól. A innym może się nie spodobać to że UUID może się powtórzyć … tak jest tego „znikoma” szansa.

    Jeszcze innej osobie może nie spodobać się to ze wewnętrzna kolekcja może być inna i też będzie to porównywał. Nie ma złotego środka.

    A to inny przykład:
    Weź np tabele „users”, często nie będziesz opierał się o ID w bazie danych, lecz będziesz dokonywał porównania tylko o jego username bo przecież w bazie będzie unikalny i wymagany do persistowania.

    Nie ma w tym przypadku sensu generować equals ani hasha w oparciu o id czy UUID lecz o username.

    1. Tak, racja, dzięki za uszczegółowienie 🙂 Moim celem było przekazanie głównej idei, ale to co piszesz też ma sens 👏

    1. Jest to opcja, ale krótsze numeryczne ID-ki mogą być wygodniejsze w operacji. Chociażby jako klucze obce dla innych tabel 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *