Hibernate — очень мощный и функциональный ORM (Object-Relational Mapping) фреймворк. Он связывает базы данных с помощью объектно-ориентированных языков программирования. Однако многие, начиная с ним работать, натыкаются на проблемы производительности или отсутствия нужной функциональности. Многие из этих проблем появляются просто из-за того, что разработчики не умеют его «готовить». 

Меня зовут Андрей Зяблин, я главный разработчик в Magnit Tech. В статье я хочу поделиться рецептами работы с Hibernate и Spring Data JPA: они помогут решить многие проблемы, возникающие при использовании фреймворка.  

Оптимизация чтения больших объёмов данных из БД  

Чтение больших объемов данных из базы данных — довольно частая задача. Решается она разными способами: например, большие объёмы данных извлекаются постранично либо используют для извлечения данных «чистый» JDBC. Минусы этих подходов очевидны: пагинация — это лишние запросы к БД, а при использовании «чистого» JDBC теряются все преимущества ORM. Однако Hibernate предоставляет более эффективный способ. Фреймворк даёт возможность не складывать данные в коллекцию, а стримить их, используя Java Stream. По сути мы получаем Database Undirectional Cursor. По скорости этот подход не уступает «чистому» JDBC. 

Код на самом деле очень простой. С  помощью EntityManager создаем Query, а дальше вызываем метод getResultStream в защищенном блоке. Дальше с помощью forEach перебираем записи и выводим на консоль количество, которое мы считали. 

Пример кода: 

var startTime = System.currentTimeMillis(); 
MutableInteger counter = new MutableInteger(0); 
try (var bigTableStream = entityManager 
	.createQuery("from BigTable", BigTable.class) 
	.getResultStream()) { 
	bigTableStream.forEach(bigTable -> counter.incrementAndGet()); 
	log.info("record fetched {}, time spent {} ", counter.get(), (System.currentTimeMillis() - startTime) / 1000); 
} 

Вывод: 

record fetched 10000000, time spent 12 

Тут 10 млн записей считано за вполне приемлемое время, и по памяти потребления никакого нет. 

То же самое можно сделать с использованием JPA-репозитария. В репозитарии объявляем метод, возвращающий Stream, а дальше всё как в предыдущем примере. 

Пример кода: 

public interface BigTableRepository extends CrudRepository<BigTable, Long> { 
	@Query("select b from BigTable b") 
	Stream<BigTable> getAll(); 
 
}
var startTime = System.currentTimeMillis(); 
MutableInteger counter = new MutableInteger(0); 
try (var bigTableStream = bigTableRepository.getAll()) { 
	bigTableStream.forEach(bigTable -> counter.incrementAndGet()); 
	log.info("record fetched {}, time spent {} ", counter.get(), (System.currentTimeMillis() - startTime) / 1000); 
}

Для некоторых БД есть нюансы при использовании Stream API. Например, драйвер Postgres по умолчанию считывает все записи. Чтобы этого избежать, нужно указывать размер фетча — fetch size. Эту проблему можно решить, установив глобальную настройку spring.jpa.properties.hibernate.jdbc.fetch_size либо установив параметр в коде. 

Для Hibernate код будет выглядеть так:

public void fetchAllByHibernate() { 
	var startTime = System.currentTimeMillis(); 
	MutableInteger counter = new MutableInteger(0); 
	try (var bigTableStream = entityManager 
    	.createQuery("from BigTable", BigTable.class) 
    	.setHint(AvailableHints.HINT_FETCH_SIZE, 50) 
    	.getResultStream()) { 
    	bigTableStream.forEach(bigTable -> counter.incrementAndGet()); 
    	log.info("record fetched {}, time spent {} ", counter.get(), (System.currentTimeMillis() - startTime) / 1000); 
	} 
}

Здесь размер фетча устанавливается в методе setHint. 

Для Spring JPA код изменится следующим образом:

public interface BigTableRepository extends CrudRepository<BigTable, Long> { 
 
	@QueryHints( 
    	@QueryHint(name = HINT_FETCH_SIZE, value = "25") 
	) 
	@Query("select b from BigTable b") 
	Stream<BigTable> getAll(); 
 
}

Здесь размер фетча устанавливается аннотацией QueryHints. 

В результате этих манипуляций записи будут фетчится порциями по указанному количеству записей, и всё будет в порядке. Это актуально для Postgres, MySQL. Для Oracle вообще всё великолепно, и данные стримятся без указания размера фетча. 

Некоторые нюансы и советы при использовании Stream API: 

  • используйте try-with-resources statement, 

  • используйте hint fetch size, 

  • не используйте stream.filter, вместо этого используйте условия в запросах, 

  • не используйте в стримах сортировки, группировки и т.п.,

  • эта технология не поддерживает отношения OneToMany, ManyToMany. 

Оптимизация Hibernate 

StatelessSession 

Hibernate предоставляет много возможностей, которые делают работу с данными простой и удобной. Но, с другой стороны, эти возможности добавляют много скрытой логики, которая влияет на производительность. Иногда такая функциональность, как сравнение состояния объектов (dirty checks), выталкивание контекста хранения (flushes), кэш первого уровня и т.п. не нужны. Например, для обмена между системами мы берём из одной системы огромное количество данных и записываем в другую. В этих случаях имеет смысл использовать StatelessSession.  

StatelessSession не предоставляет кэш первого уровня, сравнение состояния объектов (dirty checks) или write-behind. Он также не обеспечивает ленивую загрузку связей и не использует кэши второго и третьего уровня. Любая операция также не вызывает никаких событий жизненного цикла или перехватчиков.  

StatelessSession позволяет оптимизировать как операции чтения, так и операции записи. Для большего повышения производительности можно совместить StreamAPI и StatelessSession.   

Вот пример кода на чтение.  

public void fetchStateless() { 
	var sessionFactory = entityManagerFactory.unwrap(SessionFactory.class); 
	var statelessSession = sessionFactory.openStatelessSession(); 
	statelessSession.getTransaction().begin(); 
	var startTime = System.currentTimeMillis(); 
	MutableInteger counter = new MutableInteger(0); 
	try (var bigTableStream = statelessSession 
    	.createQuery("from BigTable", BigTable.class) 
    	.setHint(AvailableHints.HINT_FETCH_SIZE, 50) 
    	.getResultStream()) { 
    	bigTableStream.forEach(bigTable -> counter.incrementAndGet()); 
    	log.info("record fetched {}, time spent {} ", counter.get(), (System.currentTimeMillis() - startTime) / 1000); 
	} 
	statelessSession.getTransaction().commit(); 
}

Мы просто получаем SessionFactory, далее получаем экземпляр StatelessSession и дальше все как в предыдущем разделе. То есть мы стримим так же, вызывая метод getResultStream. 

Пример кода на запись:

public void writeStateless() { 
	var sessionFactory = entityManagerFactory.unwrap(SessionFactory.class); 
	var statelessSession = sessionFactory.openStatelessSession(); 
	statelessSession.getTransaction().begin(); 
	BigTable record = BigTable.builder().name("insert").build(); 
	statelessSession.insert(record); 
	record.setName("update"); 
	statelessSession.update(record); 
	statelessSession.getTransaction().commit(); 
}

Если мы посмотрим на код, то увидим там вызов методов Insert и Update. В данном случае Hibernate ничего не кэширует, и при вызове этих методов запросы сразу же уходят в базу данных. Кроме этого, здесь не делается dirty checks. Если граф сущностей большой, то операция dirty checks может стоить дорого, а поскольку эта операция не производится, то производительность может сильно вырасти. 

Batch Insert/Update

А можно ли ещё ускориться? А пожалуйста :) Можно использовать пакетные операции. Опять же, включить это очень просто. Пример кода: 

public void insertStateless() { 
	var sessionFactory = entityManagerFactory.unwrap(SessionFactory.class); 
	var statelessSession = sessionFactory.openStatelessSession(); 
	statelessSession.setJdbcBatchSize(7); 
	statelessSession.getTransaction().begin(); 
	for (long i = 1; i <= 100; i++) { 
    	BigTable record = BigTable.builder().name("value" + i).build(); 
    	statelessSession.insert(record); 
	} 
	statelessSession.getTransaction().commit(); 
} 

Здесь пакетная операция включается в коде.  

statelessSession.setJdbcBatchSize(7);

В логах видим: 

Type:Prepared, Batch:True, QuerySize:1, BatchSize:7 

Query:["insert into "BIG_TABLE" ("NAME","id") values (?,?)"] 

Params:[(value92,102),(value93,103),(value94,104),(value95,105),(value96,106),(value97,107),(value98,108)] 

 Пакетные операции также можно включить глобально: 

spring: 
  jpa: 
	properties: 
  	hibernate: 
    	order_inserts: true 
    	jdbc.batch_size: 7

Эта опция может сильно ускорить операции вставки/обновления за счёт уменьшения количества обращений к БД. 

Ещё здесь включается опция order_inserts (для обновления order_updates) для того, чтобы объединить все операторы вставки одного и того же типа сущности. 

Да, такие красивые логи включаются с помощью net.ttddyy:datasource-proxy. 

DynamicUpdate 

Когда приложение запускается, Hibernate генерирует операторы SQL для операций CRUD всех объектов. Эти инструкции SQL генерируются один раз и кэшируются в памяти для повышения производительности. Сгенерированный SQL-оператор обновления включает все столбцы сущности.  

Иногда такая операция обновления может стоить очень дорого, особенно когда сущность содержит много полей или размер полей большой (например, LOB). Решить эту проблему поможет аннотация для сущности @DynamicUpdate. Если сущность помечена этой аннотацией, Hibernate не использует кэшированный SQL: он будет генерировать каждый раз оператор SQL при обновлении объекта. Но плюс этого в том, что сгенерированный SQL будет содержать только измененные столбцы. 

Пример кода: 

@Entity 
@Table(name = "DYNAMIC_TABLE") 
@DynamicUpdate 
public class DynamicTable {

Однако, чтобы определить измененные столбцы, Hibernate необходимо отслеживать состояние текущего объекта, что может повлечь за собой накладные расходы. Так что выбирать вам. Или накладные расходы на отслеживание, или на запрос, содержащий все столбцы. Обычно, если много столбцов, то операция записи дорогая, так что это себя оправдывает.  

Проекции  

Проекции, подобно @DynamicUpdate, тоже позволяют уменьшить количество столбцов, только не на запись, а на чтение, что может существенно оптимизировать потребление памяти.  

Выбирать необходимые столбцы позволяет технология DTO-проекций. 

Зачастую DTO используют следующим образом: выбирают сущности и затем выполняют преобразование с помощью какой-либо библиотеки. Но проекции позволяют этого избежать. 

Например, в БД есть сущность:

public class WideTable { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_WIDE_TABLE", allocationSize = 1) 
	private Long id; 
 
	private String field1; 
 
	private String field2; 
 
	private String field3; 
 
	private String field4; 
} 

Из этой сущности нужно выбрать два поля: field2 и field3. 

Создадим DTO-класс (здесь хорошо подходит record, можете использовать lombook). 

public record WideTableDto(String field2, String field3) { 
 
}

Метод в репозитарии: 

public interface WideTableRepository extends CrudRepository<WideTable, Long> { 
 
	List<WideTableDto> findByField1(String field1); 
 
}

Вызовем метод и увидим, что Hibernate чудесным образом построил нужный запрос и корректно смаппил DTO. 

public void fetchWideTableDto() { 
	var field1 = "field1"; 
	wideTableRepository.save(WideTable.builder().field1(field1).field2("field2").field3("field3").field4("field4").build()); 
	wideTableRepository.findByField1(field1).stream().map(WideTableDto::toString).forEach(log::info); 
}

Hibernate: insert into "WIDE_TABLE" ("field1","field2","field3","field4","id") values (?,?,?,?,?) 

Hibernate: select wt1_0."field2",wt1_0."field3" from "WIDE_TABLE" wt1_0 where wt1_0."field1"=? 

Всё произошло без всяких дополнительных преобразований и без использования лишних библиотек.  

Проблема N+1 

Это краеугольный камень Hibernate. Наверно, в поисковиках это самый частый вопрос о Hibernate. 

Суть проблемы. Например, есть две сущности: заказчик и операции заказчика.

@Entity 
@Table(name = "CUSTOMER") 
public class Customer { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_CUSTOMER", allocationSize = 1) 
	private Long id; 
 
	@Column(name = "NAME") 
	private String name; 
 
	@OneToMany 
	private List<Operation> operations; 
 
 
}
@Entity 
@Table(name = "OPERATION") 
public class Operation { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_OPERATION", allocationSize = 1) 
	private Long id; 
 
	@Column(name = "QUANTITY") 
	private Long quantity; 
 
	@ManyToOne 
	@JoinColumn(name = "customer_id") 
	private Customer customer; 
 
}

Выберем все операции клиентов в цикле: 

В этом случае на каждую итерацию Hibernate выполняет запрос:  

select o1_0."customer_id",o1_1."id",c1_0."id",c1_0."name",o1_1."quantity" from "customer_operations" o1_0 join "operation" o1_1 on o1_1."id"=o1_0."operations_id" left join "customer" c1_0 on c1_0."id"=o1_1."customer_id" where o1_0."customer_id"=? 

В итоге Hibernate выполняет N дополнительных запросов, где N — количество объектов, возвращаемых явным запросом. 

Решение проблемы. Здесь я хочу привести два варианта решения, которые, по моему мнению, самые простые и надёжные. 

В Hibernate есть замечательная аннотация BatchSize. Аннотация BatchSize(size=n) означает, что Hibernate будет считывать записи (в нашем случае operations) пачками по n записей по мере необходимости. Теперь сущность Customer будет выглядеть так:

@Entity 
@Table(name = "CUSTOMER") 
public class Customer { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_CUSTOMER", allocationSize = 1) 
	private Long id; 
 
	@Column(name = "NAME") 
	private String name; 
 
	@OneToMany 
	@BatchSize(size = 50) 
	private List<Operation> operations; 
 
 
}

А запрос стал таким: 

select o1_0."customer_id",o1_1."id",c1_0."id",c1_0."name",o1_1."quantity" from "customer_operations" o1_0 join "operation" o1_1 on o1_1."id"=o1_0."operations_id" left join "customer" c1_0 on c1_0."id"=o1_1."customer_id" where o1_0."customer_id" in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 

Hibernate построил запрос с оператором IN, в котором 50 параметров. В результате запросов к БД будет в 50 раз меньше 

Hibernate позволяет задать стратегию извлечения связанных сущностей аннотацией Fetch. Стратегия FetchMode.SUBSELECT также позволяет эффективно решить проблему N + 1.  

FetchMode.SUBSELECT означает, что Hibernate будет считывать записи коллекции одним запросом. Данный способ похож на BatchSize, но оператор in использует подзапрос на базе основного запроса. Сущность в этом случае выглядит так: 

@Entity 
@Table(name = "CUSTOMER") 
public class Customer { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_CUSTOMER", allocationSize = 1) 
	private Long id; 
 
	@Column(name = "NAME") 
	private String name; 
 
	@OneToMany 
	@Fetch(FetchMode.SUBSELECT) 
	private List<Operation> operations; 
 
}

А запрос стал таким: 

select o1_0."customer_id",o1_1."id",c2_0."id",c2_0."name",o1_1."quantity" from "customer_operations" o1_0 join "operation" o1_1 on o1_1."id"=o1_0."operations_id" left join "customer" c2_0 on c2_0."id"=o1_1."customer_id" where o1_0."customer_id" in (select c1_0."id" from "customer" c1_0) 

Теперь связанные сущности выбираются вообще одним запросом. 

Сравнение подходов. FetchMode.SUBSELECT формирует один запрос, но не работает с пагинацией (баг Hibernate), BatchSize не имеет проблем с пагинацией, но формирует несколько запросов.

Адаптация чужих моделей

Бывают случаи, когда другая команда или даже другая организация присылает вам свои библиотеки, содержащие модели и API. У вас в БД уже есть подходящая таблица, которая отличается названиями полей, например. 

Допустим, есть модель:

@Entity 
@Table(name = "OUTER_TABLE") 
public class OuterTable { 
 
	@Id 
	private Long id; 
 
	private String name; 
 
} 

Её нужно замапить на таблицу: 

 

В этом случае помогает старый-добрый маппинг в XML. В шестом Hibernate этот подход также работает. 

Для этого создаём в ресурсах файл outertable.hbm.xml.

<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE hibernate-mapping SYSTEM "classpath://org/hibernate/hibernate-mapping-3.0.dtd"> 
<hibernate-mapping> 
	<class name="com.tander.hibernate.model.OuterTable" table="MAIN_TABLE" > 
    	<id name="id" column="ID" type="long"> 
    	</id> 
    	<property name="name" column="MAIN_NAME" type="string" /> 
	</class> 
</hibernate-mapping>

И прописываем его в application.yml 

spring: 
  jpa: 
	mapping-resources: outertable.hbm.xml 

Создаём репозитарий:

public interface OuterTableRepository extends CrudRepository<OuterTable, Long> { 
}

Вставляем запись: 

outerTableRepository.save(OuterTable.builder().id(1L).name("name").build());

Репозитарий, вставка. Видим, что Hibernate чудесным образом вставляет запись в нужную таблицу. 

insert into "MAIN_TABLE" ("MAIN_NAME","ID") values (?,?) 

Избавляемся от SQL в Java-коде  

Иногда приходится писать SQL/HQL код в репозитариях. Лично мне это не нравится, даже с учетом того, что теперь в Java есть текстовые блоки (в Java-коде должен быть только Java-код, по-моему, так :) К счастью, есть возможность вынести запросы в ресурсы. 

Для примера вынесем в ресурсы нативный запрос.

@Query(nativeQuery = true, value = "select * from BIG_TABLE") 
List<BigTable> getAllNative(); 

Создадим в ресурсах файл queries.hbm.xml 

<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE hibernate-mapping SYSTEM "classpath://org/hibernate/hibernate-mapping-3.0.dtd"> 
<hibernate-mapping> 
	<sql-query name="BigTable.getAllNative"> 
   	<return  entity-name="com.tander.hibernate.model.BigTable" alias="b"/> 
   	<![CDATA[ 
      	select * from BIG_TABLE 
   	]]> 
	</sql-query> 
</hibernate-mapping>

Обратите внимание, что запрос в xml должен именоваться следующим образом: <Название Сущности>.<Имя метода репозитария>.  

 В application.yml прописываем нужный ресурс как в предыдущем примере:

Код репозитария изменится так: 

@Query(nativeQuery = true) 
List<BigTable> getAllNative();

Далее сработает магия Spring, который сам найдёт и выполнит нужный запрос.

var records =   bigTableRepository.getAllNative(); 
log.info("record fetched {} ", records.size());

Вывод: 

select * from BIG_TABLE

 record fetched 10 

 И никакого SQL-кода в Java.

Использование вычисляемых полей

Зачастую требуется включить в сущность значение, вычисляемое на основе других полей и даже других сущностей. Для этого hibernate предоставляет аннотацию @Formula. Вот некоторые примеры ее использования. 

Базовая сущность:

@Entity 
@Table(name = "FORMULA_TABLE") 
public class FormulaTable { 
 
	@Id 
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence") 
	@SequenceGenerator(name = "sequence", sequenceName = "SQ_FORMULA_TABLE", allocationSize = 1) 
	private Long id; 
 
	@Column(name="PRICE") 
	private BigDecimal price; 
 
	@Column(name="QUANTITY") 
	private BigDecimal quantity; 
 
}

Вычисление на основе полей сущности. Например, нам нужно получить стоимость: перемножить цену на количество. Для этого можно использовать @Formula: 

@Formula("price * quantity") 
private BigDecimal cost;

Hibernate построил запрос с вычисляемым полем: 

select ft1_0."id",ft1_0.price * ft1_0.quantity,ft1_0."PRICE",ft1_0."QUANTITY" from "FORMULA_TABLE" ft1_0 

Вычисления на основе нативных функций. Иногда возникает необходимость использовать функции СУБД, которые не поддерживает Hibernate, например SECURE_RAND. @Formula может помочь и в этом случае.  Добавим поле в сущность:

@Formula("SECURE_RAND(quantity)") 
private int secureRand; 

Hibernate построил запрос, к которому идёт вызов этой функции  

select ft1_0."id",ft1_0."PRICE",ft1_0."QUANTITY",SECURE_RAND(ft1_0.quantity) from "FORMULA_TABLE" ft1_0   

Вычисления на основе подзапроса. Порой возникает необходимость в каких-либо сложных вычислениях. Например, надо найти количество всех сущностей дешевле текущей. @Formula и здесь может помочь. 

Добавим поле в сущность: 

@Formula("(select count(*) from FORMULA_TABLE f where f.price < price)") 
private int cheaperCount;

Здесь в @Formula прописан коррелированный скалярный подзапрос. Hibernate построил запрос с подзапросом. 

select ft1_0."id",(select count(*) from FORMULA_TABLE f where f.price < ft1_0.price),ft1_0."PRICE",ft1_0."QUANTITY" from "FORMULA_TABLE" ft1_0   

Вычисляемые поля на основе Formula — мощный инструмент, но использовать его надо с осторожность, поскольку могут появиться db scpecific-запросы. То есть переход с одной базы на другую уже будет сложным. Ну и опять же, SQL-код в Java.

Criteria API  

Зачастую при работе с Сriteria API программисты обращаются к столбцам напрямую через строку «название столбца». Такие запросы не проверяются компилятором на корректность, поэтому любая опечатка может сломать запрос из-за отсутствия столбца с таким названием в момент исполнения запроса. 

Пример кода:

public interface CustomerRepository extends JpaSpecificationExecutor<Customer> { 
 
}
customerRepository.findAll((root, query, cb) -> cb.and(cb.between(root.get("id"), 0L, 100L), 
	cb.like(root.get("name"), "%main%")));

Видно, что здесь используется выражения со строковыми литералами: root.get("id") и root.get("name").  Причём такой код я встречаю довольно часто. Очевидно, что на этапе компиляции это не проверишь, и при переименовании поля программа будет неработоспособна.    

Чтобы решить эту проблему, необходимо использовать Criteria API в связке с Metamodel API. Metamodel AP на основе сущностей генерирует метамодели, которые мы можем потом использовать в Criteria API. Для этого надо подключить зависимости и настроить директорий для генерации файлов.

ext { 
	jpamodelgenVersion = '6.4.4.Final' 
}
implementation "org.hibernate:hibernate-jpamodelgen:${jpamodelgenVersion}" 
annotationProcessor "org.hibernate:hibernate-jpamodelgen:${jpamodelgenVersion}"
sourceSets.main.java.srcDirs += layout.buildDirectory.dir("/generated") 
 
compileJava { 
	options.generatedSourceOutputDirectory = layout.buildDirectory.dir("/generated") 
}

Последняя секция указывает, что мы подключаем еще одну директорию исходников, generated, и туда будут копироваться сгенерированные метамодели. 

В исходном коде нужно поменять одну строку.

customerRepository.findAll((root, query, cb) -> cb.and(cb.between(root.get(Customer_.id), 0L, 100L), 
	cb.like(root.get(Customer_.name), "%main%")));

То есть вместо литералов мы используем поля метамодели: Customer_.id и Customer _.name. Во-первых, это удобно, то есть нам не надо помнить названия полей, будет всплывающая подсказка. Во-вторых, если название полей поменяются, то у нас всё упадет уже на этапе компиляции. А это хорошо.

Чем заменить Сriteria API 

Criteria API — мощный инструмент, но при этом громоздкий и неудобный. Конечно, кому как, но по мне он контринтуитивный :) Ниже я хочу рассказать о более юзабельном инструменте для работы с БД — QueryDSL Predicate. Предикаты позволяют работать с элементами базы данных как с обычными полями класса. При сборке gradle создаёт специальные классы зависимостей, через которые и происходит поиск нужных записей в БД. 

Подключим QueryDSL к проекту.

ext { 
	queryDslVersion = '5.1.0' 
}
implementation("com.querydsl:querydsl-core:${queryDslVersion}") 
implementation("com.querydsl:querydsl-jpa:${queryDslVersion}") 
annotationProcessor("com.querydsl:querydsl-apt:${queryDslVersion}:jakarta")

Код будет выглядеть так: 

public interface CustomerRepository extends QuerydslPredicateExecutor<QCustomer> { 
 
}
public void fetchCustomer() { 
	var customer = QCustomer.customer; 
 	customerRepository.findAll(customer.id.between(1L, 100L).and(customer.name.like("%2%"))); 
}

По сути построение запроса превращается в цепочку операторов. Это интуитивно понятно и проще смотрится. 

К сожалению, есть проблема в том, что Query DSL не совсем допилили для Jakarta. Но проект в общем-то развивается, и думаю, что они его запилят.

Некоторые моменты использования перехватчиков и обработчиков событий

Spring предоставляет механизм обработки событий при работе с сущностями из коробки. Но этот механизм работает только с событиями, которые инициируются через репозитарий Spring. Hibernate, в свою очередь, предоставляет механизмы, перехватывающие эти события, но работают они по умолчанию вне контекста Spring. К счастью, подружить перехватчики Hibernate со Spring несложно. 

Например, нам надо перехватить все события обновления в БД и информацию об обновлении передать в спринговый сервис. 

Для этого реализуем интерфейс PostUpdateEventListener. Класс будет перехватывать все события на обновления в БД и дёргать сервис, который просто выводит информацию в лог.

@Component 
@RequiredArgsConstructor 
public class HibernateEventListener implements PostUpdateEventListener { 
 
	private final LogService logService; 
 
	private final EntityManagerFactory entityManagerFactory; 
 
	@Override 
	public void onPostUpdate(PostUpdateEvent event) { 
    	logService.info("update entity {} with id {}", event.getEntity().getClass().getSimpleName(), event.getId()); 
	} 
 
	@Override 
	public boolean requiresPostCommitHandling(EntityPersister persister) { 
    	return false; 
	} 
 
 
}

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

@PostConstruct 
public void init() { 
	SessionFactoryImplementor sessionFactory = entityManagerFactory.unwrap(SessionFactoryImplementor.class); 
	EventListenerRegistry registry = sessionFactory.getServiceRegistry().getService(EventListenerRegistry.class); 
	registry.getEventListenerGroup(EventType.POST_UPDATE).prependListener(this); 
}

Выполним запросы на обновление: 

@Transactional 
public void updateAll() { 
	customerRepository.findAll().forEach(customer -> { 
    	customer.setName(customer.getName().concat(" new")); 
    	customerRepository.saveAndFlush(customer); 
	}); 
}

В логах видим, что обработчик срабатывает и работает в контексте Spring: 

2024-05-01T18:35:50.186+03:00  INFO 28520 --- [customer] [       main] c.tander.hibernate.service.LogService : update entity Customer with id 107 

Hibernate: update "CUSTOMER" set "NAME"=? where "id"=? 

2024-05-01T18:35:50.187+03:00  INFO 28520 --- [customer] [       main] c.tander.hibernate.service.LogService : update entity Customer with id 108 

Hibernate: update "CUSTOMER" set "NAME"=? where "id"=? 

2024-05-01T18:35:50.187+03:00  INFO 28520 --- [customer] [       main] c.tander.hibernate.service.LogService : update entity Customer with id 109 

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

Комментарии (9)


  1. Dmitry2019
    18.05.2024 01:41

    Спасибо, очень полезная статья.


  1. Sigest
    18.05.2024 01:41
    +5

    Как по мне, SQL код в Java коде лучше по двум причинам: во-первых все в одном месте - захотел использовать метод и сразу видишь что он там вытаскивает из бд, и не надо бегать по папкам проекта и искать мапинг этого запроса. Во-вторых - этот ужасный xml. В чем удобство его читать? Столько лишнего всего - схемы, ненужная мне метаинформация, ради одной строчки кода я должен глазами видеть в 3 раза больше бесполезных данных, в течение дня это сильно утомляет. Как вспомню доаннотационные времена спринга или javaee так вздрагиваю.


    1. ris58h
      18.05.2024 01:41

      Маппинг, xml. Вы из какого года пишите?


      1. Sigest
        18.05.2024 01:41
        +2

        Прежде чем задавать вопрос, статью прочтите, мой коммент относится к разделу «избавляемся от sql в джава коде».


    1. zyablin_av Автор
      18.05.2024 01:41

      Всё бы так, но есть пара нюансов

      1. Далеко не всегда SQL запросы занимают одну строчку

      2. При использовании db-specific native query при переключении с СУБД на СУБД (что очень актуально в наше время) придётся править java код, в случае же сохранения запросов в xml ресурсах переключение делается на уровне конфигов.


      1. Sigest
        18.05.2024 01:41
        +1

        Со вторым соглашусь отчасти. Это действительно удобнее, но только в случае если есть необходимость менять БД. Но вот сколько я уже проектов сменил и ни разу не было надобности переключаться с одной БД на другую. И я стараюсь специфичные запросы не писать. И если вдруг такая необходимость возникла бы, то я думаю эта задача очень серьезная, которая потребует внимания всей команды, независимо от того, где прописаны запросы, в конфигах или аннотациях. Потому как потребуется миграция самих данных, а также менять файлы миграции схем и таблиц (всякие флайвей и т.д.), тестирование этого перехода. Короче, сродни пожару.

        А с первым все равно не согласен. Но это индивидуально и дело вкуса.

        И спасибо за статью, давно такой хорошей информации по хиберу не было. Хотя я сам стараюсь его в проектах не использовать, полюбил jooq, но все же когда-нибудь наверное придется с ним работать.


  1. Absolutely17
    18.05.2024 01:41

    Спасибо, отличный набор хороших решений!

    Дополнительно про Criteria API и JPA Metamodel: если по какой-то причине не хочется использовать JPA Metamodel, то можно заиспользовать @FieldNameConstants от Lombok, что также даст ошибку компиляции при изменении имени поля.


  1. avtor88
    18.05.2024 01:41

    Спасибо за статью! А использование stream при чтении большой таблицы работает с нативными запросами?


    1. zyablin_av Автор
      18.05.2024 01:41

      Работает без проблем