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