💾 Розділ 16 · Питання #29

Що таке projection в JPA

Проєкція (Projection) у JPA та Spring Data — це механізм вибірки суворо визначеної підмножини полів або обчислюваних виразів замість завантаження повної сутності. Використання п...

Проєкція (Projection) у JPA та Spring Data — це механізм вибірки суворо визначеної підмножини полів або обчислюваних виразів замість завантаження повної сутності. Використання проєкцій — один із найпотужніших способів оптимізації продуктивності: воно скорочує мережевий трафік між СУБД та JVM, економить оперативну пам’ять купи (Heap) і повністю виключає накладні витрати Hibernate на відстеження змін (Dirty Checking) та керування контекстом персистентності (PersistenceContext).


🟢 Junior Level

Навіщо потрібні проєкції

Коли ви виконуєте стандартний запит SELECT u FROM User u, Hibernate:

  1. Виконує SELECT * з усіма колонками таблиці (включно з важкими текстовими описами, аватарами та метаданими).
  2. Створює повноцінний екземпляр Java-об’єкта User.
  3. Зберігає точний дублікат стану в знімок loadedState кешу L1 для відстеження Dirty Checking.
  4. Прив’язує до об’єкта проксі-обробники зв’язків.

Якщо вам потрібно лише відобразити ім’я та email у таблиці користувачів, 90% цієї роботи — марне витрачання ресурсів. Проєкція запитує лише те, що потрібно:

// Проєкція в Java Record:
public record UserSummary(String name, String email) {}

// JPQL через конструктор (Constructor Expression):
TypedQuery<UserSummary> query = entityManager.createQuery("""
    SELECT new com.example.dto.UserSummary(u.name, u.email)
    FROM User u
    """, UserSummary.class);

List<UserSummary> summaries = query.getResultList();
// Згенерований SQL: SELECT name, email FROM users;
// Жодного L1 кешу, жодних знімків, мінімальний трафік!

🟡 Middle Level

5 різновидів проєкцій у JPA та Spring Data

1. Нетипізований масив Object[] (Raw Array)

List<Object[]> rows = entityManager.createQuery("SELECT u.name, u.email FROM User u").getResultList();
String name = (String) rows.get(0)[0]; // Небезпечно: легко помилитися індексом

Мінуси: Відсутність типобезпечності, схильність до зламу при рефакторингу.

2. Проєкція на базі jakarta.persistence.Tuple

Дозволяє звертатися до колонок за рядковими псевдонімами (псевдо-типобезпечно):

List<Tuple> tuples = entityManager.createQuery("""
    SELECT u.name AS userName, u.email AS userEmail FROM User u
    """, Tuple.class).getResultList();

String name = tuples.get(0).get("userName", String.class);

3. DTO Constructor Expression (SELECT new ...)

Класичний JPA-стандарт. Працює як зі звичайними класами, так і з Java Records:

public record UserInfoDto(Long id, String email) {}

@Query("SELECT new com.example.dto.UserInfoDto(u.id, u.email) FROM User u")
List<UserInfoDto> fetchUsers();

Обов’язкова умова: У JPQL необхідно вказувати повне ім’я класу з пакетом (new com.example.dto.UserInfoDto(...)).

4. Закриті інтерфейсні проєкції Spring Data (Closed Projections)

Spring Data дозволяє оголосити простий інтерфейс із гетерами:

public interface UserContactView {
    String getName();
    String getEmail();
}

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserContactView> findAllByActiveTrue();
}

Spring Data аналізує гетери інтерфейсу і генерує оптимальний SQL, що запитує лише колонки name та email.

5. Динамічні проєкції (Dynamic Projections)

Один метод репозиторію може повертати різні проєкції залежно від переданого класу:

public interface UserRepository extends JpaRepository<User, Long> {
    <T> List<T> findByStatus(String status, Class<T> type);
}

// Використання:
List<UserSummary> list1 = repo.findByStatus("ACTIVE", UserSummary.class);
List<UserDetailDto> list2 = repo.findByStatus("ACTIVE", UserDetailDto.class);

🔴 Senior Level

Пастка відкритих проєкцій (Open Projections) у Spring Data

Spring Data підтримує так звані «відкриті проєкції» через SpEL-вирази (Spring Expression Language):

// ❌ ПРИХОВАНА ДЕГРАДАЦІЯ ПРОДУКТИВНОСТІ:
public interface UserFullNameView {
    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

Чому це небезпечно:

  • При Closed Projection (String getFirstName()) Spring Data оптимізує SQL: SELECT first_name FROM users.
  • При Open Projection з @Value Spring Data не може знати, які саме поля знадобляться виразу SpEL під час обчислення.
  • Наслідок: Spring Data змушений виконати повний SELECT *, вивантажити всю сутність з усіма колонками в пам’ять, створити JDK Dynamic Proxy і лише потім обчислити вираз у JVM! Виграш у продуктивності обнуляється.

Проєкції та колекції: Обмеження специфікації JPA

Специфікація JPA прямо забороняє передавати колекції в конструкторні проєкції:

// ❌ ПОМИЛКА КОМПІЛЯЦІЇ JPQL:
public record OrderWithItemsDto(Long orderId, List<ItemDto> items) {}

// Спроба написати таке завершиться помилкою парсингу:
@Query("SELECT new com.example.OrderWithItemsDto(o.id, o.items) FROM Order o")
List<OrderWithItemsDto> findOrders();

Як правильно проєктувати відносини $1:N$:

  1. Підхід у два запити: Завантажити DTO замовлень, потім окремим запитом завантажити DTO позицій із WHERE order_id IN (:ids) і згрупувати в пам’яті через Collectors.groupingBy().
  2. Використання Hibernate TupleTransformer: У Hibernate 6 перетворити плаский результат JOIN в ієрархічний DTO за допомогою кастомного ResultListTransformer.
  3. Використання бібліотеки Blaze-Persistence: Розширення поверх JPA, що офіційно підтримує вкладені DTO-колекції прямо в запитах.

4 Tricky Questions

1. Чи будуть сутності або DTO, отримані через конструкторну проєкцію (SELECT new UserDto(...)), відстежуватися механізмом Dirty Checking?

Відповідь:

  1. Категорично ні.
  2. Об’єкти DTO не є сутностями JPA (@Entity). Вони інстанціюються через звичайний виклик Java-конструктора new UserDto(...) прямо із сирих колонок ResultSet.
  3. Вони не реєструються в карті StatefulPersistenceContext.entitiesByKey і не створюють записів EntityEntry.
  4. Будь-які зміни полів у такому DTO не призведуть до генерації SQL UPDATE при коміті транзакції.

2. Чому у високонавантажених системах (HighLoad) використання Java Records переважніше за інтерфейсні проєкції Spring Data?

Відповідь:

  1. Для інтерфейсних проєкцій Spring Data на кожен рядок результату створює динамічний JDK-проксі (java.lang.reflect.Proxy), що огортає Map<String, Object>.
  2. Створення проксі та звернення до гетерів через Reflection створює відчутний оверхед по CPU та алокації пам’яті при вибірках від 10 000 рядків.
  3. Java Records компілюються в нативний байткод. Створення рекорд-об’єкта через конструктор виконується миттєво з нульовим рефлексивним оверхедом та оптимальним пакуванням пам’яті в купі JVM.

3. Чи можна об’єднати пагінацію (Pageable) та інтерфейсну проєкцію в Spring Data JPA репозиторії?

Відповідь:

  1. Так, повністю підтримується.
  2. Метод Page<UserView> findByActive(boolean active, Pageable pageable) коректно генерує два запити:
    • SELECT COUNT(id) FROM users WHERE active = ? (для підрахунку загальної кількості сторінок).
    • SELECT id, name, email FROM users WHERE active = ? LIMIT ? OFFSET ? (точкова вибірка потрібних колонок сторінки).

4. Що станеться, якщо в класі DTO типи аргументів конструктора не збіжаться за типами даних із колонками JPQL запиту (наприклад, Long у конструкторі проти Integer із бази)?

Відповідь:

  1. Hibernate намагається знайти конструктор із точним збігом типів аргументів через Java Reflection.
  2. Автоматичного розширення примітивних типів (наприклад, приведення Integer до Long або BigDecimal до Double) рушій JPA не робить.
  3. Якщо конструктор із сигнатурою, що точно відповідає типам виразів SELECT, не знайдено, запит впаде в Runtime із винятком: org.hibernate.query.SemanticException: Unable to locate appropriate constructor on class [com.example.UserDto].

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Проєкції в JPA дозволяють вибирати тільки необхідні поля замість завантаження повної сутності з усіма колонками. Це знижує мережевий трафік, економить пам’ять купи JVM і виключає накладні витрати на Dirty Checking у кеші L1. Основні способи реалізації: DTO Constructor Expressions (через Java Records SELECT new ...) та закриті інтерфейси Spring Data. Важливий senior-нюанс: відкриті інтерфейсні проєкції зі SpEL-анотацією @Value нівелюють оптимізацію, оскільки Spring змушений вивантажувати всю сутність цілком. Для read-only операцій та віддачі даних у REST API проєкції в Java Records є абсолютним стандартом індустрії».

Ключові метрики для Production-моніторингу

  • Allocated Memory per Request (GC Allocation Rate): заміна повних сутностей на DTO-проєкції знижує виділення пам’яті в 3–5 разів.
  • Database Network Traffic / Egress Bytes: зменшення обсягу мережевого трафіку між сервером застосунків та СУБД.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Проєкції оновлюють базу даних через dirty checking» — DTO не керуються сесією Hibernate, dirty checking для них не працює.
  • ❌ «Використовую відкриті проєкції з @Value(“#{target…}”) заради максимальної продуктивності» — Навпаки, @Value викачує весь рядок таблиці цілком.
  • ❌ «У конструкторну проєкцію можна легко передати вкладену колекцію @OneToMany» — Заборонено JPA, призведе до помилки компіляції запиту.
  • ❌ «Для read-only звітів краще завантажувати повні JPA-сутності» — Грубий антипатерн, що призводить до перевантаження пам’яті та ризику N+1.

Пов’язані теми