Jeśli pracujesz na co dzień z Javą i Hibernatem, są duże szanse, iż Twój program zgłosił Ci wyjątek LazyInitializaitonException.
Z czego on wynika i jak sobie z nim poradzić?
Najpierw przygotujmy sobie fragment kodu, w którym zpreprodukujemy dany przypadek. #
Mamy dwie encje - Comment
i Blogpost
.
@Entity
public
class
Comment
{
@Id
@GeneratedValue
private
Long
id;
private
String
author;
private
String
content;
}
@Entity
public
class
Blogpost
{
@Id
private
Long
id;
private
String
title;
private
String
content;
@OneToMany(cascade
=
{CascadeType.MERGE,
CascadeType.PERSIST})
@JoinColumn(name
=
"post_id")
private
Set<Comment>
comments;
}
Jak widać, jest między nimi prosta relacja - OneToMany.
Jeden wpis na blogu może mieć wiele komentarzy.
Przygotujmy sobie teraz proste repozytorium do pobierania blogpostów.
public
interface
BlogpostRepository
extends
JpaRepository<Blogpost,
Long>
{
}
I napiszmy prosty test.
- Tworzymy zbiór komentarzy - z jednym komentarzem.
- Oraz jedną książkę - do której przypisujemy ten komentarz.
- W teście pobieramy samą książkę, a potem próbujemy zliczyć liczbę wszystkich komentarzy.
- W efekcie dostajemy LazyInitializationException 👻
@SpringBootTest
class
BlogpostTest
{
@Autowired
BlogpostRepository
repository;
@BeforeEach
public
void
setup()
{
Set<Comment>
comments
=
Set.of(
new
Comment(1L,
"Frodo Baggins",
"One To Rule Them All!")
);
Blogpost
blogpost
=
new
Blogpost(1L,
"Atlas Shrugged",
"Who is John Galt?",
comments);
repository.save(blogpost);
}
@Test
void
throwsLazyInitException()
{
// when
Blogpost
blogpost
=
repository.getById(1L);
// then
assertThrows(
LazyInitializationException.class,
()
->
blogpost.getComments().size()
);
}
}
Po uruchomieniu tego testu zobaczymy zielony napis: TESTS PASSED ✅
Ok. A z czego to wynika? #
Relacja między Blogpost
a Comment
sprawia, iż przy pobieraniu wpisu, komentarze pobierane są w sposób Lazy.
Oznacza to, iż jeżeli nie wskażemy wprost, Hibernate nie zaciągnie tych dodatkowych wierszych do pamięci naszej aplikacji.
Wynika to z optymalizacji, które Hibernate próbuje dla nas zrobić.
Oraz z domyślnej wartości parametru fetchType
w adnotacji @OneToMany
.
Sesja Hibernatowa (otwarte połączenie do bazy danych) jest tutaj krótkotrwała i odbywa się tylko w momencie zawołania kodu: Blogpost blogpost = repository.getById(1L)
.
Potem sesja (połączenie do bazy danych) jest zamykane i w momencie, gdy próbujemy pobrać komentarze do wpisu: blogpost.getComments().size()
Hibernate nie ma już połączenia z bazą danych i informuje nas o tym wyjątkiem LazyInitializaitonException.
Jak to w takim razie naprawić? #
Rozwiązań jest kilka.
Przyjrzyjmy się im po kolei.
Rozwiązanie 1 - Założenie transakcji.
@Test
@Transactional
void
fetchesBlogpostWithCommentsInTransaction()
{
// when
Blogpost
blogpost
=
repository.getById(1L);
// then
assertEquals(1,
blogpost.getComments().size());
}
Najprostszy sposób. Przez zastosowanie adnotacji @Transactional
instruujemy Hibernate-a by przez całą metodę testową miał otwartą sesję do bazy danych.
Dzięki temu w momencie zawołania blogpost.getComments().size()
wykonywane są pod spodem kolejne zapytania SQL, które dociągają brakujące komentarze do naszej aplikacji.
Rozwiązanie 2 - Join Fetch
Minusem poprzedniego rozwiązania jest generowanie tak zwanego problemu N + 1.
Między aplikacją a bazą danych wykonywanych jest zbyt wiele zapytań.
Rozwiązaniem może być skorzystanie z polecenia JOIN FETCH
.
public
interface
BlogpostRepository
extends
JpaRepository<Blogpost,
Long>
{
@Query("SELECT b FROM Blogpost b JOIN FETCH b.comments WHERE b.id = :id")
Blogpost
getByIdWithComments(@Param("id")
Long
id);
}
W tym wypadku musimy zdefiniować dodatkowo zapytanie w BlogpostRepository
, w którym definiujemy wprost, iż chcemy by zależne encje były również od razu pobrane z bazy danych.
@Test
void
fetchesBlogpostWithCommentsInSingleCall()
{
// when
Blogpost
blogpost
=
repository.getByIdWithComments(1L);
// then
assertEquals(1,
blogpost.getComments().size());
}
Test ponownie przechodzi, a my znacznie zredukowaliśmy liczbę zapytań do bazy.
Rozwiązanie 3 - Entity Graph
Alternatywnym sposobem jest skorzystanie z konstrukcji @EntityGraph
.
Tak jak widać na poniższym fragmencie kodu.
public
interface
BlogpostRepository
extends
JpaRepository<Blogpost,
Long>
{
@EntityGraph(attributePaths
=
{"comments"})
Blogpost
getBlogpostGraphById(Long
id);
}
Efekt będzie podobny jak w JOIN FETCH, a nasz test ponownie będzie zielony.
@Test
void
fetchesBlogpostWithCommentsGraph()
{
// when
Blogpost
blogpost
=
repository.getBlogpostGraphById(1L);
// then
assertEquals(1,
blogpost.getComments().size());
}
Rozwiązanie 4 - Named Entity Graph
Możemy też skorzystać z konstrukcji Named Entity Graphs.
W tym przypadku definiujemy nazwany graf encji w definicji klasy i wskazujemy wprost, jakie dodatkowe relacje chcemy pobrać attributeNodes = { @NamedAttributeNode("comments") }
.
Nazwany graf encji @NamedEntityGraph(name = "Blogpost.comments")
wykorzystamy potem w JpaRepository
.
@Entity
@NamedEntityGraph(
name
=
"Blogpost.comments",
attributeNodes
=
{
@NamedAttributeNode("comments")
}
)
public
class
Blogpost
{
@Id
private
Long
id;
private
String
title;
private
String
content;
@OneToMany(cascade
=
{CascadeType.MERGE,
CascadeType.PERSIST})
@JoinColumn(name
=
"post_id")
private
Set<Comment>
comments;
}
public
interface
BlogpostRepository
extends
JpaRepository<Blogpost,
Long>
{
@EntityGraph("Blogpost.comments")
Blogpost
getBlogpostNamedGraphById(Long
id);
}
I tak jak poprzednio, test przechodzi na zielono.
@Test
void
fetchesBlogpostWithCommentsNamedGraph()
{
// when
Blogpost
blogpost
=
repository.getBlogpostNamedGraphById(1L);
// then
assertEquals(1,
blogpost.getComments().size());
}
Rozwiązanie 5 - FetchType.EAGER (raczej! tego nie rób)
Ostatnie, ale najmniej zalecane rozwiązanie.
Zmiana sposobu pobierania encji z Lazy na Eager: @OneToMany(..., fetch = FetchType.EAGER)
.
@Entity
public
class
Blogpost
{
@Id
private
Long
id;
private
String
title;
private
String
content;
@OneToMany(
cascade
=
{CascadeType.MERGE,
CascadeType.PERSIST},
fetch
=
FetchType.EAGER
)
@JoinColumn(name
=
"post_id")
private
Set<Comment>
comments;
}
W tym przypadku komentarze zawsze będą pobierane, gdy będziemy z bazy pobierać też wpisy na bloga.
@Test
void
fetchesCommentsEagerly()
{
// when
Blogpost
blogpost
=
repository.findById(1L).get();
// then
assertEquals(1,
blogpost.getComments().size());
}
Sprawi to, iż test będzie zielony.
Ale wydajnościowo może sprawić nam problemy.
W końcu nie zawsze będziemy chcieli pobierać razem z wpisami ich wszystkie komentarze.
W przypadku skorzystania z tej opcji, nie mamy możliwości wybory czy chcemy czy nie pobrać komentarze.
W poprzednich rozwiązaniach, to my decydujemy, kiedy będziemy dodatkowe encje z bazy danych wyciągać.
W porządku. To z czego to wszystko wynika? #
- Źródłem problemu jest tzw. lazy-loading.
- Optymalizacja, którą stosuje Hibernate przy zapewnić wysoką wydajność Twojej aplikacji.
- Niestety bez znajomości tego mechanizmu, działanie Twojej aplikacji - jak w zaprezentowanym u góry przykładzie - może być dla Ciebie zaskakujące.
Dlatego przy relacjach OneToMany, ManyToOne i ManyToMany upewnij się, iż w odpowiedni sposób rozwiązujesz kwestię relacji.