Lazy Initialization Exception. Jak sobie z nim radzić? (Spring & Hibernate)

sztukakodu.pl 2 lat temu

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.

  1. Tworzymy zbiór komentarzy - z jednym komentarzem.
  2. Oraz jedną książkę - do której przypisujemy ten komentarz.
  3. W teście pobieramy samą książkę, a potem próbujemy zliczyć liczbę wszystkich komentarzy.
  4. 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? #

  1. Źródłem problemu jest tzw. lazy-loading.
  2. Optymalizacja, którą stosuje Hibernate przy zapewnić wysoką wydajność Twojej aplikacji.
  3. 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.

Idź do oryginalnego materiału