Jeśli pracujesz na co dzień z Javą i Hibernatem, są duże szanse, że 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, że przy pobieraniu wpisu, komentarze pobierane są w sposób Lazy.
Oznacza to, że jeśli 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, że 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, że 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ę, że w odpowiedni sposób rozwiązujesz kwestię relacji.