Введение в QueryDSL, начавшееся с ограничений JPA: от настройки до динамических запросов
1. Почему нужен QueryDSL?
Использование Spring Data JPA позволяет быстро разрабатывать простые функции запроса. Это связано с тем, что только по правилам наименования методов можно легко создавать методы запроса, такие как findById, findByUserIdAndStatus. На начальном этапе проекта этого подхода было достаточно для удовлетворения большинства требований.
Однако по мере роста сервиса и усложнения отношений данных начали проявляться ограничения. Особенно проблемы возникали в ситуациях, когда необходимо было одновременно запрашивать несколько таблиц. Например, когда необходимо было одновременно запрашивать информацию о заказах, подписках, настройках и т.д., обработка только с помощью методов JPA Repository часто была сложной.
Сначала данные извлекались из каждой таблицы отдельно, а затем в коде Java они комбинировались. В структуре проекта существовала связь между основными данными и настройками этапов, поэтому при каждом запросе списка приходилось дополнительно извлекать настройки этапов, что в итоге резко увеличивало количество запросов. Этот подход имел следующие серьезные проблемы.
-
Число обратных поездок в БД (Round Trip)Увеличение
-
N+1Взрыв запросов из-за проблемы
-
Избыточная памятьУвеличение использования и сложность логики запросов
Сначала объем данных был небольшой, и это не создавало больших проблем, но с увеличением общего объема данных в системе и ростом объема услуг скорость ответа заметно снизилась. Для решения этой проблемы срочно требовалась структура, в которой несколько таблиц будут запрашиваться в одном JOIN-запросе.
Конечно, мы также рассмотрели способ непосредственного использования JPQL. Однако JPQL написан на основе строк, из-за чего опечатки не могут быть обнаружены на этапе компиляции, что снижает надежность при рефакторинге, и обнаруживаются явные ограничения при обработке динамических условий. В конечном итоге мы решили внедрить QueryDSL, который позволит нам гибко создавать динамические запросы при сохранении типобезопасности.
2. Настройка QueryDSL
Из-за отсутствия настройки QueryDSL в исходном проекте мне пришлось настраивать окружение с нуля. Сначала я думал, что достаточно просто добавить зависимости в build.gradle, чтобы сразу же начать использовать, но на самом деле потребовались детальные настройки, соответствующие среде Spring Boot 3.x. Когда я просто добавил зависимости и запустил, возникла ошибка, что JPAQueryFactory не найден, и вот как я настроил его.
2-1. Добавление зависимости в build.gradle
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
sourceSets {
main {
java {
srcDirs = ['src/main/java',
'build/generated/sources/annotationProcessor/java/main']
}
}
}
Среда Spring Boot 3.x основана на Jakarta EE, а не на javax, поэтому необходимо добавлять :jakarta classifier после зависимостей querydsl-jpa и querydsl-apt. Если этот параметр пропущен, будет происходить обращение к пакету javax, что приведет к ошибке компиляции.
Кроме того, необходимо добавить настройку sourceSets вместе с набором аннотаций annotationProcessor, чтобы Gradle правильно распознал автоматически созданный каталог generated как путь к исходному коду. Если эта настройка пропущена, сборка может успешно завершиться, но в IDE возникнет проблема с импортом классов Q.
2-2. QКласс и JPAQueryFactory
Если сборка завершилась успешно, классы QКласса, такие как QOrderJpo и QMemberJpo, будут автоматически созданы по указанному пути на основе класса @Entity.
Сравнивая существующий способ строк JPQL и способ QueryDSL, разница в надежности, которую дает типовая безопасность, становится явно видимой.
// 1. String-based JPQL approach: Syntax errors cannot be caught at compile time
String jpql = "SELECT o FROM Order o WHERE o.userId = :userId";
// 2. QueryDSL approach: Safe types and compiled validation
QOrderJpo order = QOrderJpo.orderJpo;
queryFactory
.selectFrom(order)
.where(order.userId.eq(userId))
.fetch();
Таким образом, QueryDSL позволяет писать запросы на коде Java через Q-классы, что позволяет на 100% использовать автозаполнение в IDE и функции проверки компилятора, и это очень безопасно даже при рефакторинге имен полей сущностей.
Чтобы выполнить запрос на основе класса Q, созданный таким образом, необходим JPAQueryFactory, который не регистрируется автоматически в Spring, поэтому необходимо создать отдельный класс конфигурации, чтобы inject EntityManager и зарегистрировать его как bean.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
После завершения настройки JPAQueryFactory можно удобно использовать в каждом репозитории, получая его через внедрение зависимостей (@RequiredArgsConstructor).
3. Основное использование: JOIN-запрос
Первым делом, после настройки QueryDSL, я решил сложную логику JOIN для нескольких таблиц. Это применялось в ситуации, когда необходимо было получить данные о заказах вместе с информацией о пользователях, категориях и др.
List result = queryFactory
.selectFrom(order)
.innerJoin(customer).on(order.customerId.eq(customer.id))
.innerJoin(category).on(order.categoryId.eq(category.id))
.where(
order.userId.eq(userId),
order.shopId.eq(shopId)
)
.fetch();
Используя innerJoin().on() в QueryDSL, вы можете гибко связывать независимые таблицы, которые не имеют сопоставленных отношений (структура FK), указывая условия соединения напрямую. Кроме того, несколько условий, перечисленных через запятую внутри оператора where(), автоматически объединяются в AND-условия, что делает их четким выражением без вхождения в ад скобок с точки зрения читаемости.
Благодаря этому одному запросу на соединение количество обращений к базе данных, которые ранее разбивались на множество мелких вызовов, было объединено всего в 1 раз, а сложная логика сопоставления, связанная с кодом Java в памяти, исчезла, что снизило нагрузку на сервер и dramatically улучшило скорость ответа.
4. Углубление: Динамический запрос + Пейджинг
Поиск в рабочей среде чаще всего представляет собой 'Динамический запрос', в котором различные условия поиска, такие как ключевые слова, дата регистрации, статус и т. д., могут быть выбраны по желанию. В JPQL необходимо было склеивать строки с помощью if-выражений, что разрушало читаемость, но QueryDSL позволяет использовать BooleanExpression, что делает процесс аккуратным, как сборка из блоков LEGO.
JPAQuery query = queryFactory
.selectFrom(member)
.join(subscription).on(member.id.eq(subscription.memberId))
.where(
subscription.shopId.eq(shopId),
keywordContains(keyword), // returns null if empty, ignored by where clause
statusEq(status) // returns null if empty, ignored by where clause
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize());
JPAQuery countQuery = queryFactory
.select(member.count()).from(member)
.join(subscription).on(member.id.eq(subscription.memberId))
.where(
subscription.shopId.eq(shopId),
keywordContains(keyword),
statusEq(status)
);
return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchOne);
private BooleanExpression keywordContains(String keyword) {
if (!StringUtils.hasText(keyword)) return null;
String cleaned = keyword.toLowerCase().replace(" ", "");
StringExpression expr = Expressions.stringTemplate(
"replace({0}, ' ', '')", member.name.lower()
);
return expr.contains(cleaned);
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? member.status.eq(status) : null;
}
-
Ключевые моменты динамических запросов и оптимизации
1. Улучшение читаемости за счет автоматического удаления Null: where()Если вспомогательный метод, входящий в условие, возвращает null, QueryDSL автоматически удаляет это условие. Благодаря этому, даже если количество условий увеличивается, структура тела where остается чистой.
2. Оптимизация Count-запросов через PageableExecutionUtils: постраничная навигацияНеобходимо помнить, что выполнение запроса count при обработке всегда создает большую нагрузку на большие таблицы. Используя PageableExecutionUtils.getPage(), вы можете пропустить ненужный запрос count, когда это первая страница и количество данных меньше общего, или когда это последняя страница, что очень полезно для оптимизации производительности на практике.
3. Управление функциями БД через Expressions.stringTemplate: требованияПотребовался точный поиск, игнорирующий пробелы и регистр. Вместо того, чтобы загружать все данные в память приложения и обрабатывать их, мы смогли аккуратно объединить функции REPLACE и LOWER на уровне БД с помощью Expressions.stringTemplate в QueryDSL, что позволило нам элегантно справляться со сложными требованиями к обработке строк.
5. Заключение
В процессе начальной настройки возникали некоторые трудности, связанные с переходом на пакеты Jakarta в среде Spring Boot 3.x и распознаванием пути sourceSets в Gradle, что могло быть упущено. Тем не менее, решение ошибок стало отличной возможностью глубже понять принцип работы и встроенную структуру QueryDSL.
После настройки инфраструктуры я смог ощутить стабильность, которую дает безопасный тип, уверенность в том, что опечатки будут выявлены на этапе компиляции, а не времени выполнения, а также невероятное удобство динамических запросов, что позволяет ощутить ценность, превышающую простую сборку запросов. Особенно, по мере усложнения бизнес-логики, ориентированной на выборку, QueryDSL кажется самым мощным и необходимым инструментом, который заполняет пробелы Spring Data JPA.
Список литературы
• Официальная документация QueryDSL. http://querydsl.com/static/querydsl/5.0.0/reference/html_single/
• Официальная документация Spring Data JPA. https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
• Официальная документация Spring Boot 3.x – Миграция на Jakarta EE. https://docs.spring.io/spring-boot/docs/current/reference/html/
эунис