Java Persistence. Часть 1. JDBC vs Hibernate vs JPA: не только CRUD, но и проблемы ORM

Может, у меня не очень много опыта с разными системами в продашкне, но за 15 лет я успел посмотреть на разные подходы с взаимодействием Java-программ и данных. И сегодняшняя статья будет как раз о сопоставлении этих разных подходов: классического JDBC, чистого Hibernate и Hibernate + JPA.

Для начала разберёмся с терминами.

JDBC

JDBC (Java Database Connectivity) – стандартный API для работы с базами данных в Java. Он появился ещё в 1997 году как часть JDK 1.1 и позволил Java-приложениям выполнять SQL-запросы независимо от конкретной СУБД.

JDBC предоставляет низкоуровневый доступ к данным, и оставляет много работы программистам: нужно самостоятельно открывать соединения, управлять транзакциями, писать SQL-запросы и потом преобразовывать результаты запроса в Java-объекты.

Hibernate

HibernateORM-фреймворк (Object-Relational Mapping), первая версия которого вышла в 2001 году.

Главная цель Hibernate – избавить разработчика от рутинного преобразования данных между таблицами базы данных и объектами в Java. Вместо ручного чтения ResultSet и заполнения объектов программист сопоставляет таблицы с объектами в Java, а Hibernate сам генерирует необходимые запросы к базе.

JPA

JPA (Java Persistence API) – это спецификация, появившаяся в 2006 году в составе Java EE 5. Она описывает единый стандарт для ORM в Java: аннотации сущностей, жизненный цикл объектов, язык запросов JPQL, интерфейс EntityManager и другие механизмы.

JPA – это не готовая библиотека, а набор правил и интерфейсов. Для её работы нужна конкретная реализация. Самой популярной реализацией JPA является Hibernate, хотя существуют и другие проекты, например EclipseLink и OpenJPA.

JDBC до сих пор остаётся фундаментом большинства Java-решений для работы с базами данных. Современные ORM-фреймворки в конечном итоге используют JDBC для отправки SQL на сервер базы данных. Да, и Hibernate, с которым можно работать согласно JPA, тоже под капотом создаёт обычные запросы и низкоуровневые вопросы решает с JDBC.

Дальше предлагаю сравнить три разных подхода к стандартным задачам при работе с базой данных. Сравнивать будет JDBC, чистый Hibernate и Hibernate по стандартам JPA.

Работа с соединениями и транзакциями

Начнём с самого главного – соединения с базой. Ведь ничего нельзя сделать с данными без этого этапа.

Для установки соединения в JDBC используется класс Connection, а получается конкретное соединение из DriverManager, который выбирает нужный драйвер для конкретной базы.

По умолчанию JDBC работает в режиме autoCommit=true. Каждый SQL-запрос сразу фиксируется в базе. Поэтому для работы с транзакциями нужно указать SetAutoCommit(false). Тогда при необходимости можно вызывать rollback для соединения – это основной объект для JDBC.

Connection connection = null;
try {
connection = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/library",
"user",
"password"
);
connection.setAutoCommit(false);
//работа с объектами
connection.commit();
} catch (Exception e) {
if (connection != null) {
connection.rollback();
}
} finally {
if (connection != null) {
connection.close();
}
}


Чтобы не прописывать каждый раз адрес базы и прочие данные, можно вынести выбор драйвера в класс-фабрику:

public class JdbcConnectionFactory {
private static final String URL = "jdbc:mysql://localhost:3306/library";
private static final String USER = "root";
private static final String PASSWORD = "password";
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
}

Тогда в DAO будет так:

try (Connection connection = JdbcConnectionFactory.getConnection()) {
connection.setAutoCommit(false);
// работа с Book, Author, BookReview
connection.commit();
}

Теперь перейдём к Hibernate. Здесь основным объектом является сессия – Session, который мы получаем из SessionFactory. В сессии есть намного больше данных, чем хранит «голое» соединение в JDBC – самое главное, тут есть контекст. Уже в рамках сессии создаем транзакцию Transaction.

Session session = null;
Transaction transaction = null;
try {
session = sessionFactory.openSession();
transaction = session.beginTransaction();
//работа с объектами
transaction.commit();
} catch (Exception e) {
if (transaction != null) {
transaction.rollback();
}
} finally {
if (session != null) {
session.close();
}
}

Конечно, тут нам тоже надо зафиксировать адрес базы для подключения, но это делается на уровень выше.

public class HibernateUtil {
private static final SessionFactory sessionFactory =
new Configuration()
.configure("hibernate.cfg.xml")
.buildSessionFactory();
public static SessionFactory getSessionFactory() {
return sessionFactory;
}
}

Тогда в DAO:

try (Session session = HibernateUtil.getSessionFactory().openSession()) {
Transaction tx = session.beginTransaction();
// работа с объектами
tx.commit();
}

И, наконец, в JPA главным объектом становится EntityManager. Его получаем из EntityManagerFactory. Уже из этого объекта получаем транзакцию EntityTransaction.

EntityManager entityManager = null;
EntityTransaction transaction = null;
try {
entityManager = entityManagerFactory.createEntityManager();
transaction = entityManager.getTransaction();
transaction.begin();
// работа с объектами
transaction.commit();
} catch (Exception e) {
if (transaction != null) {
transaction.rollback();
}
} finally {
if (entityManager != null) {
entityManager.close();
}
}

Фабрику менеджера получаем из класса с утилитами:

public class JpaUtil {
private static final EntityManagerFactory entityManagerFactory =
Persistence.createEntityManagerFactory("library-unit");
public static EntityManagerFactory getEntityManagerFactory() {
return entityManagerFactory;
}
}

Но тут надо понимать, что сама JPA не умеет подключаться к базе, поэтому ей требуется какой-то ещё механизм. Например, такой механизм предлагает Hibernate (рассмотрели выше).

И тогда в DAO:

EntityManager em = JpaUtil.getEntityManagerFactory().createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
// работа с объектами
tx.commit();
em.close();

Получается, что JDBC напрямую позволяет управлять соединениями (и это больше проклятие, чем благословение для программистов). Для Hibernate аналог – сессия, а для JPA – EntityManager.

Но под капотом Hibernate всё равно в рамках сессии устанавливает то же соединение с JDBC, просто это скрыто от наших глаз. А в JPA EntityManager так же создаёт сессию (так как речь именно про связку с Hibernate), которая (принцип матрёшки) опять устанавливает соединение. То есть каждый из этих подходов просто изолирует ещё один слой от разработчиков.

Параметры для самого соединения с базой в JDBC обычно описывают через свой ConnectionFactory, в Hibernate настройки JDBC для SessionFactory передают через hibernate.cfg.xml, а в JPA настройки для EntityManagerFactory обычно лежат в persistence.xml.

И хотя уровни абстракции разные, работы для разработчика здесь не меньше от продвинутого использования JPA. Вся магия начинается дальше.

Получение данных из базы

Теперь, когда мы подключились к базе, можно начинать работать с самими данными. И здесь начинаются более заметные различия, которые и оправдывают использование более современных подходов.

Рассмотрим работу с таблицей, где хранится список книг. И попробуем получить весь список – методgetAll().

В обычном JDBC нам нужно подготовить Statement или PreparedStatement на основе строки с SQL-запросом к базе. После этого получаем ResultSet и каждую запись обрабатываем вручную, приводя к нужному типу, доставая значения для каждой колонки. В итоге у нас нет никакой изоляции от структуры базы в этом коде.

public List<Book> getAll() throws SQLException {
List<Book> books = new ArrayList<>();
String sql = "SELECT * FROM book";
try (Connection connection = JdbcConnectionFactory.getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Book book = new Book();
book.setId(rs.getLong("id"));
book.setTitle(rs.getString("title"));
book.setYear(rs.getInt("year"));
books.add(book);
}
}
return books;
}

В Hibernate всё намного проще: мы просто возвращаем список книг методом list() – уже получаем заполненные объекты Book. Запрос пишется на языке HQL, который схож с SQL, но всё же не такой же и позволяет орудовать не именами таблиц и их колонок, а именами сущностей и их полей. Об этом ниже в статье.

public List<Book> getAll() {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session
.createQuery("FROM Book", Book.class)
.list();
}
}

В JPA похожий подход, но тут мы используем язык JPQL, который тоже немного отличается и от HQL и от SQL.

public List<Book> getAll() {
EntityManager em = JpaUtil.getEntityManagerFactory().createEntityManager();
try {
return em.createQuery( "SELECT b FROM Book b",Book.class)
.getResultList();
} finally {
em.close();
}
}

Намного удобнее, быстрее, и исключаются ошибки при написании кода. Если вы ошиблись здесь с типами, это будет сразу видно – код не скомпилируется. В случае с JDBC узнать о проблеме можно только, когда приведение типов не сработает.

Теперь рассмотрим пример с параметром. Например, получим одну книгу по её идентификатору в базе: getById(Long id).

В обычном JDBC метод будет выглядеть так:

public Book getById(Long id) throws SQLException {
String sql = "SELECT * FROM book WHERE id = ?";
try (Connection connection = JdbcConnectionFactory.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
Book book = new Book();
book.setId(rs.getLong("id"));
book.setTitle(rs.getString("title"));
book.setYear(rs.getInt("year"));
return book;
}
}
return null;
}

И здесь видно главное преимущество PreparedStatement. Этот класс не только исключает инъекцию при установке параметров, но и позволяет прописывать их изначально знаками вопроса, куда потом подставляются нужные значения. В случае с обычным Statement от инъекции приходится защищаться самим, а параметры подставлять в запрос конкатенацией строк:

String sql = "SELECT * FROM book WHERE id = " + userInput;

Но что если строка такая:

1 OR 1=1

Тогда получаем совсем не тот запрос, который мы хотели:

SELECT * FROM book WHERE id = 1 OR 1=1

Поэтому обычные Statement уже не используют. Хотя в контексте устаревшего подхода JDBC говорить «уже не используют» можно вообще про всё. Чтобы доказать этот аргумент, предлагаю взглянуть на тот же метод, но в Hibernate:

public Book getById(Long id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(Book.class, id);
}
}

Метод get() позволяет доставать запись по первичному ключу, но можно использовать HQL и расписать происходящее подробнее:

Book book = session.createQuery("SELECT b FROM Book b WHERE b.id = :id",Book.class)
.setParameter("id", id)
.uniqueResult();

По аналогии с PreparedStatement мы не указываем в запросе сами значения, а заменяем их именованными параметрами (имя после двоеточия), которые потом можно заполнить с setParameter.

public Book getById(Long id) {
EntityManager em = JpaUtil.getEntityManagerFactory().createEntityManager();
try {
return em.find(Book.class, id);
} finally {
em.close();
}
}

И опять же если хочется написать код явно, это можно сделать:

em.createQuery(
"SELECT b FROM Book b WHERE b.id = :id",
Book.class)
.setParameter("id", id)
.getSingleResult();

Подход к параметрам тот же: название после двоеточия. Но getSingleResult() в этом примере выбросит исключение, если запись не найдена.

Получение данных из двух таблиц с отношением One-to-Many

Получать данные из одной таблицы не так уж и сложно. Но как часто это требуется в реальных задачах? Рассмотрим связь обзоров BookReview и книг Book. Связь один-ко-многим.

В Hibernate/JPA связь мы обозначаем сразу аннотацией при описании модели:

@Entity
public class Book {
@Id
private Long id;
private String title;
@OneToMany(mappedBy = "book")
private List<BookReview> reviews = new ArrayList<>();
}
@Entity
public class BookReview {
@Id
private Long id;
private String text;
private Integer rating;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
}

И тогда достать данные довольно просто:

public List<Book> getAllWithReviews() {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("SELECT DISTINCT b " +
"FROM Book b " +
"LEFT JOIN FETCH b.reviews ",
Book.class
).list();
}
}

Мы всего лишь используем связь с LEFT JOIN FETCH, требуя вместе с книгами достать обзоры.

Но в JDBC всё не столь элегантно:

public List<Book> getAllWithReviews() throws SQLException {
String sql = "SELECT b.id AS book_id, b.title AS book_title, " +
"r.id AS review_id, r.text AS review_text, " +
"r.rating AS review_rating " +
"FROM book b " +
"LEFT JOIN book_review r ON r.book_id = b.id " +
"ORDER BY b.id, r.id";
Map<Long, Book> booksById = new LinkedHashMap<>();
try (Connection connection = JdbcConnectionFactory.getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Long bookId = rs.getLong("book_id");
Book book = booksById.get(bookId);
if (book == null) {
book = new Book();
book.setId(bookId);
book.setTitle(rs.getString("book_title"));
book.setReviews(new ArrayList<>());
booksById.put(bookId, book);
}
Long reviewId = rs.getLong("review_id");
if (reviewId != 0) {
BookReview review = new BookReview();
review.setId(reviewId);
review.setText(rs.getString("review_text"));
review.setRating(rs.getInt("review_rating"));
review.setBook(book);
book.getReviews().add(review);
}
}
}
return new ArrayList<>(booksById.values());
}

Здесь мы сначала должны сами полностью прописать корректный SQL-запрос, а потом отслеживать, какие обзоры принадлежат каким книгам, и в цикле обработки ResultSet наполнять коллекции. Здесь преимущества JPA/Hibernate становятся ещё более очевидными. Но фишка не только в сокращении объёма кода, но и в логике, которую увидим в следующем пункте.

Сохранение в базу

Сохранение записей в базу с JDBC происходит всё с тем же PreparedStatement и прописыванием SQL-запроса.

public void save(Book book) throws SQLException {
String sql = "INSERT INTO book (title, year) VALUES (?, ?)";
try (Connection connection = JdbcConnectionFactory.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
connection.setAutoCommit(false);
ps.setString(1, book.getTitle());
ps.setInt(2, book.getYear());
ps.executeUpdate();
connection.commit();
} catch (Exception e) {
throw e;
}
}

В Hibernate можно использовать метод save, и просто передаём в него нужный объект, который запишется в базу – метод вернёт его id.

public void save(Book book) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.save(book);
transaction.commit();
} catch (Exception e) {
if (transaction != null) {
transaction.rollback();
}
throw e;
}
}

Но также в нём можно использовать persist – стандартный метод из JPA:

public void save(Book book) {
EntityManager em = JpaUtil.getEntityManagerFactory().createEntityManager();
EntityTransaction transaction = null;
try {
transaction = em.getTransaction();
transaction.begin();
em.persist(book);
transaction.commit();
} catch (Exception e) {
if (transaction != null && transaction.isActive()) {
transaction.rollback();
}
throw e;
} finally {
em.close();
}
}

Но как быть с коллекциями? В JDBC все записи нужно сохранять отдельно: и книги и обзоры. Получаем id новой записи для книги, и добавляем её в каждый обзор.

public void saveBookWithReview(Book book, BookReview review) throws SQLException {
String bookSql = "INSERT INTO book (title, year) VALUES (?, ?)";
String reviewSql = "INSERT INTO book_review (book_id, text, rating) VALUES (?, ?, ?)";
try (Connection connection = JdbcConnectionFactory.getConnection()) {
connection.setAutoCommit(false);
try (PreparedStatement bookPs =
connection.prepareStatement(
bookSql,
Statement.RETURN_GENERATED_KEYS)) {
bookPs.setString(1, book.getTitle());
bookPs.setInt(2, book.getYear());
bookPs.executeUpdate();
ResultSet keys = bookPs.getGeneratedKeys();
if (keys.next()) {
book.setId(keys.getLong(1));
}
}
try (PreparedStatement reviewPs =
connection.prepareStatement(reviewSql)) {
reviewPs.setLong(1, book.getId());
reviewPs.setString(2, review.getText());
reviewPs.setInt(3, review.getRating());
reviewPs.executeUpdate();
}
connection.commit();
} catch (Exception e) {
throw e;
}
}

То же можно делать и в Hibernate: сохранить сначала книгу, потом объект Book с заполненным id поместить в BookReview и сохранить обзоры. Но есть метод проще. Чтобы он работал, нужно настроить каскад для двух сущностей.

@OneToMany(mappedBy = "book", cascade = CascadeType.PERSIST)
private List<BookReview> reviews = new ArrayList<>();

Можно также добавить метод для добавления обзора в коллекцию книг, чтобы не делать это вручную в сервисе.

public void addReview(BookReview review) {
reviews.add(review);
review.setBook(this);
}
public void saveBookWithReview(Book book, BookReview review) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
book.addReview(review);
session.save(book);
transaction.commit();
} catch (Exception e) {
if (transaction != null) {
transaction.rollback();
}
throw e;
}
}

Hibernate сам сохранит Book, получит id книги, сохранит BookReview с нужным book_id, так как мы связали обе функции и поместили в коллекцию книги её обзор. Нам даже не нужен DAO для BookReview в этом случае. Конечно, то же самое можно делать и с множеством обзоров, а не только с одним.

public void saveBookWithReview(Book book, BookReview review) {
EntityManager em = JpaUtil.getEntityManagerFactory().createEntityManager();
EntityTransaction transaction = null;
try {
transaction = em.getTransaction();
transaction.begin();
book.addReview(review);
em.persist(book);
transaction.commit();
} catch (Exception e) {
if (transaction != null && transaction.isActive()) {
transaction.rollback();
}
throw e;
} finally {
em.close();
}
}

Но и это ещё не всё. Hibernate и JPA позволяют отслеживать изменения в объектах, которые мы получили в базе и вообще не вызывать отдельный метод под сохранение или обновление. В JDBC такой концепции вообще нет. Есть SQL, есть объект, но JDBC не знает, что в какой момент соответствует какой строчке в базе, менялся ли он – это решает программист. А в

persist() vs merge()

У Hibernate и JPA есть понятие сущности, и сущность может находиться в нескольких состояниях.

Transient – объект существует только в памяти Java, но Hiberrnate о нём не знает.

Book book = new Book();
book.setTitle("Pride and Prejudice");

Managed (Persistent) – управляемый объект. Hibernate отслеживает изменения, связанные с этим объектом и автоматически загружает их в контекст, когда мы делаем коммит транзакции. Этот процесс отслеживания изменений называется Dirty Checking.

Перевести созданный объект в это состояние можно с помощью метода persist:

session.persist(book);

или

em.persist(book);

Если мы получаем объект из EntityManager или сессии, то он сразу возвращается управляемым:

Book book = em.find(Book.class, 1L);

или

Book book = session.get(Book.class, 1L);

Detached – объект всё ещё существует в памяти, но Hibernate его уже не отслеживает. Происходит после закрытия сессии или EntityManager.

Book book = em.find(Book.class, 1L);
em.close();
book.setTitle("New title");

Изменения не сохранятся в базу

Removed – объект, помеченный на удаление.

em.remove(book);

или

session.remove(book);

Метод persist() создаёт новую управляемую сущность. Его можно применять только к transiet-объектам, но не к detached-объектам. Так как detached-объект существует в базе, повторное добавление его в контекст приведёт к ошибке.

Метод merge() просит менеджера найти в контексте (или загрузить из базы) объект по id и вернуть managed-объект. Он не перезаписывает наш изначальный объект.

Book detachedBook = ...
Book managedBook = em.merge(detachedBook);

Здесь две переменные хранят два разных объекта. И отслеживаемый именно второй.

managedBook.setTitle("New");
detachedBook.setTitle("Very New");

Сохранится первое изменение, но не второе.

Эти инструменты позволяют отслеживать все изменения в объектах и не вызывать методы сохранения, удаления и обновления вручную. В JDBC такого функционала нет.

Но! Стоит отметить, что сам по себе ни merge, ни persist не гарантируют сохранения в базу. Для сохранения необходимо сделать коммит (commit) транзакции.

Есть также метод flush, который синхронизирует Persistence Context с базой данных, но изменение не сохранено навсегда, пока не произошёл commit. Если после flush будет rollbak, то изменения откатятся. И если после flush мы не делаем commit, а просто закрываем сессию или транзакцию, то изменения тоже откатятся. Hibernate думает, что раз коммита не было, то изменения нельзя сохранять. Но вручную flush вызывать перед commit не нужно, ведь это делается автоматически.

Правильно будет работать так в сервисе:

Book book = dao.getById(1L);
book.setTitle("New title");
dao.update(book);

И на уровне DAO:

public Book getById(Long id) {
Session session = sessionFactory.openSession();
try {
return session.get(Book.class, id);
} finally {
session.close();
}
}
public void update(Book book) {
Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();
session.merge(book);
tx.commit();
session.close();
}

SQL vs HQL vs JPQL

Выше я уже упоминал, что JDBC использует чистый SQL, в Hibernate можно писать на его языке HQL, а JPA предлагает свой вариант – JPQL. Они мало различаются по синтаксису: всё те же FROM, WHERE, GROUP BY, ORDER BY – порядок построения запросов одинаковый. Но всё же есть различия.

Основное отличие в том, что в JDBC мы обращаемся к таблицам в базе и их полям. А в HQL/JPQL обращаемся к сущностям (классам Java) и их полям.

SELECT *
FROM book
WHERE year > 2000
SELECT b
FROM Book b
WHERE b.year > 2000

Здесь Book – название класса, b – алиас. По сути мы работаем с сущностями, как с переменными, обращаясь к их полям через точку.

Hibernate позволяет сократить SELECT, не упоминая первую строчку, если нужно выбрать объект целиком:

FROM Book
WHERE year > 2000

Алиас тоже совсем не обязателен, если в запросе всего одна сущность.

Работа со связями

И опять значительные сокращения происходят именно в работе с несколькими таблицами в одном запросе. Классический SQL-запрос выглядит так:

SELECT b.*
FROM book b
JOIN author a ON a.id = b.author_id
WHERE a.name = 'Jane Austen'

В HQL это всего лишь:

FROM Book
WHERE author.name = :name

В JPQL:

SELECT b
FROM Book b
WHERE b.author.name = :name

ORM сама понимает, какие таблицы нужно соединить, так как мы связали классы с помощью аннотаций @Entity и @Table с таблицами в базе.

Fetch Join, LazyInitializationException и проблема N+1

Одна из самых популярных возможностей HQL и JPQL – это FETCH JOIN. В SQL джойн выглядит так:

SELECT *
FROM book b
LEFT JOIN book_review r
ON r.book_id = b.id

В HQL и JPQL это будет:

SELECT DISTINCT b
FROM Book b
LEFT JOIN FETCH b.reviews
SELECT DISTINCT b
FROM Book b
LEFT JOIN FETCH b.reviews

ORM не только выполняет JOIN, но и сразу заполняет коллекцию без дополнительных запросов. С помощью этого инструмента можно избежать LazyInitializationException.

LazyInitializationException возникает, если мы пытаемся обратиться к коллекции или другому объекту, который связан с текущим, но сессия уже закрылась.

По умолчанию Hibernate использует ленивую загрузку, то есть подгружает объект только в момент обращения к нему. Изначально он вместо коллекции предоставляет прокси, и при обращении объекта формирует новый запрос в базу – но сессии уже нет. Запрос выполнить невозможно, и мы получаем исключение.

Избежать этого можно с EAGER-загрузкой, что можно быть опасным и подгрузить слишком много лишних связанных данных. Можно работать внутри сессии, а это не всегда возможно, так как при обращении к данным с фронта сессии придётся хранить слишком долго. А можно просто в нужные моменты делать FETCH JOIN.

В JDBC такой проблемы вообще нет, потому что там мы собираем объекты самостоятельно – весь сет строк возвращается сразу после JOIN.

FETCH JOIN также решает другую проблему для загружаемой связи. Если мы подгружаем книги без него и потом хотим в цикле получить все их коллекции, то для каждого обращения Hibernate обращается к базе.

List<Book> books = session.createQuery("FROM Book", Book.class).list();
for (Book book : books) {
System.out.println(book.getReviews().size());
}

Во время выполнения кода к базе создаются следующие запросы:

SELECT * FROM book_review WHERE book_id = 1
SELECT * FROM book_review WHERE book_id = 2
SELECT * FROM book_review WHERE book_id = 3

Это N+1 проблема, в которой к одному основному запросу добавляется ещё N действий. Но так как FETCH JOIN заполняет коллекции сразу же, эта проблема исключена. К сожалению, это не панацея. Иногда при загрузке сразу нескольких коллекций можем получить MultipleBagFetchException, так как ORM не всегда может корректно собрать объектный граф из одного JOIN-запроса.

MultipleBagFetchException

Разберём работу с сущностью

@Entity
public class Book {
@Id
private Long id;
@OneToMany(mappedBy = "book")
private List<BookReview> reviews;
@OneToMany(mappedBy = "book")
private List<BookTag> tags;
}

Вроде бы очевидно, что запрос для сбора всех данных будет:

SELECT DISTINCT b
FROM Book b
LEFT JOIN FETCH b.reviews
LEFT JOIN FETCH b.tags

Hibernate составляет SQL-запрос и получает декартово произведение.

SELECT *
FROM book b
LEFT JOIN review r ON r.book_id = b.id
LEFT JOIN tag t ON t.book_id = b.id

В результате для каждой книги мы получаем reviews.size * tags.size строчек со всеми возможными комбинациями. Проблема получается потому что после этого Hiberante нужно устранить все дубли и сложить в коллекцию, что невозможно для таких коллекций List. Поэтому он выкидывает исключение MultipleBagFetchException.

Строго говоря, проблема возникает не у любых List, а у коллекций типа Bag – обычных списков без @OrderColumn. Именно такие коллекции Hibernate не может одновременно корректно восстановить после нескольких FETCH JOIN. @OrderColumn не решает проблему полностью, но может помочь Hibernate.

Решить проблемы можно, загружая лишь одну коллекцию, а другую подгружать по ходу дела (вернуть N+1 проблему – так по крайней мере код работает).

Также можно заменить List на Set, так как в множествах возможно сохранять только уникальные значения. Но это меняет семантику коллекции и не всегда может быть удобным в работе с уже существующей системой.

Третий вариант – создать DTO и вытаскивать не объекты, а строки, а потом собирать DTO-объекты вручную, как мы бы делали с классическим JDBC. Получается, что классика не во всех случаях плохая – иногда это самый оптимальный выход.

В этой задаче DTO будет следующим:

public class BookDto {
private Long id;
private String title;
private List<BookReviewDto> reviews = new ArrayList<>();
private List<BookTagDto> tags = new ArrayList<>();
}

А решение задачи:

List<BookDto> books = em.createQuery(
"SELECT new com.example.BookDto(b.id, b.title) " +
"FROM Book b",
BookDto.class
).getResultList();
List<BookReviewRow> reviews = em.createQuery(
"SELECT new com.example.BookReviewRow(b.id, r.id, r.text) " +
"FROM Book b " +
"JOIN b.reviews r",
BookReviewRow.class
).getResultList();
List<BookTagRow> tags = em.createQuery(
"SELECT new com.example.BookTagRow(b.id, t.id, t.name) " +
"FROM Book b " +
"JOIN b.tags t",
BookTagRow.class
).getResultList();
Map<Long, BookDto> map = books.stream()
.collect(Collectors.toMap(
BookDto::getId,
Function.identity(),
(a, b) -> a,
LinkedHashMap::new
));
for (BookReviewRow row : reviews) {
BookDto book = map.get(row.getBookId());
if (book != null) {
book.getReviews().add(
new BookReviewDto(row.getReviewId(), row.getText())
);
}
}
for (BookTagRow row : tags) {
BookDto book = map.get(row.getBookId());
if (book != null) {
book.getTags().add(
new BookTagDto(row.getTagId(), row.getName())
);
}
}
return new ArrayList<>(map.values());

Хотя в этом варианте больше кода, он обычно оптимальнее первого варианта с возвращением N+1, так как лучше масштабируется. Но это показывает, насколько важно знать все подходы к работе с данными, а не только полагаться, что более новые инструменты предоставят изящные универсальные решения. И на этой умной мыслью завершаю этот конспект.

Оставить комментарий