Данная статья является переводом анонса Гевина Кингa о релизе первого milestone JPA 4 спецификации и общего вектора развития JPA.
Полноценная 4-ая версия JPA спецификации должна выйти чуть-чуть позже в этом году. Тем не менее, то, что сейчас в Milestone это основа, которая, возможно, с небольшими доработками, но уже пойдёт в релиз в этом году.
От себя скажу, что данное обновление будет довольно крупное. Я оставил свои комментарии там, где посчитал уместным.
P.S: У нас в рамках Spring АйО Академии как раз есть программа посвящённая Hibernate. Набор мы на неё ведём до конца апреля.
В ней мы тоже затронем новую версию спецификации, в частности и работу со StatelessSession и EntityAgent на практике. Я думаю, что из всего апдейта, именно стандартизация работы без Persistence Context будет самым важным для прикладных разработчиков. Остальное тоже важно, но не так как работа с EntityAgent.
Приятного чтения!
Недавно мы выпустили первую milestone-сборку Jakarta Persistence 4.0.
Jakarta Persistence — чаще всего его называют JPA — задаёт отраслевой стандарт управления persistence и object/relational mapping в Java. Это самое распространённое решение для persistence в экосистеме Java и без сравнения самый успешный API object/relational mapping в любом языке программирования. JPA 4 — без преувеличения самая значимая переработка спецификации со времён выхода JPA 2.0 в декабре 2009 года.
Эта milestone-сборка предназначена для более широкого обзора сообществом с целью собрать отзывы пользователей и реализаторов. Помните: сейчас самое время присылать feedback. JPA 4 ещё не завершён по функциям, и ни одно из описанных ниже изменений не «высечено в гранит». Не ждите финала спецификации позже в этом году, чтобы попробовать.
Посмотрим, что нового в этом релизе.
EntityAgent
Мы обещали это годами — и наконец это здесь.
Типичные жалобы на object/relational mapping по сути сводятся к одному: для меньшинства разработчиков и для некоторых видов программ managed entities и stateful persistence context «не заходят». Иногда людям нужен более прямой контроль над взаимодействием с базой данных.
Если фундаментальные операции EntityManager (persist, remove, merge, detach, lock, flush) — это операции над persistence context и лишь косвенно затрагивают базу, то фундаментальные операции EntityAgent (insert, update, delete, upsert) обходят persistence context и напрямую меняют базу. Такая модель программирования проще для понимания и рассуждений о ней.
```
var book = factory.callInTransaction(EntityAgent.class, agent -> {
return agent.get(Book.class, isbn); // book is immediately detached
});
book.title = "Hibernate in Action"; // change it
factory.runInTransaction(EntityAgent.class, agent -> {
agent.update(book); // update the database immediately
});
```
Комментарий
Очень важно, что book в примере выше сразу же "DETACHED". Это означает, что dirty-checking не сработает, что логично, т.к. работа идёт в обход Session.
Как ни странно, на моей практике, это в продуктовой разработке чаще плюс, чем минус, т.к. часто люди как раз не надеются на dirty-check, а вызывают условный repository.save() у Spring Data JPA репозитория напрямую. Нечто похожее теперь придётся делать и в случае использования EntityAgent (собственно, в примере Гевин это и делает).
Ещё один важный момент. Имея сущность Book, у которой есть коллекция авторов, в традиционных Spring Data JPA приложениях разработчик немного побаивается вызвать что-то типа book.authors().size(), т.к. это может привести либо к (в лучшем случае) LIE, либо, что на самом деле хуже, к N + 1. В случае EntityAgent же отвественность на загрузку того или иного relation-a ляжет на приложение. И я думаю, что появление возможности такого контроля это явно плюс.
В общем, если подытожить, я думаю, что Гевин и команда делают однозначно нужное движение в сторону предоставления большего контроля над выполняемыми запросами в целом, делая разработчика ближе к БД.
Тем не менее, подождём реакции Spring Data JPA на эту спецификацию. В целом Spring Framework уже начал реализовывать поддержку EntityAgent. Эта поддержка будет заслуживать отдельной статьи.
Вместе с EntityAgent появился целый зоопарк новых низкоуровневых lifecycle events вроде @PreInsert, @PostUpsert, @PreDelete и т.д.
«Static» queries
Новые @StaticQuery и @StaticNativeQuery, а также @ReadQueryOptions и @WriteQueryOptions проектировались с прицелом на Jakarta Data на первом месте.
```
@Repository
interface Library {
@StaticQuery("from Book where isbn = :isbn")
@ReadQueryOptions(lockMode = PESSIMISTIC_READ)
Book getBookWithIsbn(String isbn);
@StaticQuery("from Book where title like :title")
@ReadQueryOptions(cacheStoreMode = BYPASS)
List<Book> findBooksByTitle(String title);
@StaticQuery("delete from Trash")
@WriteQueryOptions(timeout = 30_000)
int emptyTrash();
}
```
Annotation processor вроде Hibernate Processor может делать тайп чекинг любого статического запроса относительно:
Ошибки в запросах вы узнаёте сразу, на этапе компиляции, без запуска кода.
Эти аннотации не привязаны к Jakarta Data. С помощью JPA static metamodel static query можно вызывать напрямую через EntityManager или EntityAgent типобезопасным способом.
var books = agent
.createQuery(Library_.findBooksByTitle("%Jakarta%"))
.getResultList();
int deleted = agent
.createStatement(Library_.emptyTrash())
.execute();
Конечно, static queries не обязаны возвращать entities.
```
record Summary(String title, String isbn, LocalDate date) {}
@StaticQuery("""
select title, isbn, pubDate
from Book
where title like = ?1 and pubDate > ?2
""")
List
```
```
import static org.example.Library_.retrieveSummaries;
var summaries =
manager.createQuery(retrieveSummaries("%JPA%",
LocalDate.of(2006,5,11)))
.getResultList();
```
Static queries открывают гораздо большую часть возможностей JPA, чем обычно доступно при repository-подобной абстракции. О static queries можно сказать ещё много, но нужно двигаться дальше.
Programmatic result set mappings
Аннотация @SqlResultSetMapping существует с JPA 1.0. Она помогает выразить более сложное отображение native SQL result set на Java-объекты. Но аннотации для этой задачи всегда казались мне не идеальной парой, и я годами обещал альтернативу. Она наконец пришла в виде API ResultSetMapping.
В следующем примере явный result set mapping на самом деле не нужен — это только иллюстрация идеи.
var authorResultSetMapping =
entity(Author.class,
field(Author_.ssn, "auth_ssn"),
embedded(Author_.name,
field(Name_.first, "auth_first_name"),
field(Name_.last, "auth_last_name")));
var query =
"""select
ssn as auth_ssn,
fn as auth_first_name,
ln as auth_last_name
from authors""";
var authors =
agent.createNativeQuery(query, authorResultSetMapping)
.getResultList();
Конечно, native SQL query не обязан возвращать entities.
var constructorMapping =
constructor(Summary.class,
column("isbn", String.class),
column("title", String.class),
column("author", String.class));
var query =
"select b.isbn, b.title, a.name"
+ " from books b"
+ " join book_author ba on ba.isbn = b.isbn"
+ " join authors a on ba.ssn = a.ssn";
var summaries =
manager.createNativeQuery(query, constructorMapping)
.getResultList();
Новый API приятен и типобезопасен — снова благодаря static metamodel.
С другой стороны, предположим, у нас уже есть result set mapping, заданный через почтенную аннотацию @SqlResultSetMapping.
@SqlResultSetMapping(
name = "orderResults",
entities = @EntityResult(
entityClass = Order.class,
fields = {
@FieldResult(name = "id", column = "order_id"),
@FieldResult(name = "total", column = "order_total"),
@FieldResult(name = "item", column = "order_item")
}
),
columns = @ColumnResult(name = "item_name")
)
@Entity
class Order { ... }
Новый API позволяет обращаться к таким mappings типобезопасно — опять же через static metamodel.
var orders =
entityManager.createNativeQuery(
"""
SELECT o.id AS order_id,
o.total AS order_total,
o.item_id AS order_item,
i.desc_name AS item_name
FROM orders o, order_items i
WHERE order_total > 25 AND order_item = i.id
""",
// a typesafe reference to the result set mapping
Order_._orderResults
).getResultList();
Default fetch type для to-one associations
Самая серьёзная ошибка в дизайне JPA 1.0 — сделать для ассоциаций @ManyToOne и @OneToOne значение по умолчанию fetch=EAGER. Страшно думать, сколько миллионов долларов (вполне правдоподобно — миллиард) эта одна плохая настройка по умолчанию стоила индустрии с мая 2006 года. Когда кто-то жалуется, что Hibernate или JPA генерируют запросы с кучей JOIN-ов, мне хочется крикнуть в монитор: «нет, вы должны выставить LAZY!» И я знаю: почти полностью это моя вина — я молчал и соглашался с тем, что знал неверным.
Теперь можно сделать LAZY значением по умолчанию на уровне persistence unit:
<default-to-one-fetch-type>LAZY<default-to-one-fetch-type>
или:
persistenceConfiguration.defaultToOneFetchType(FetchType.LAZY)
Пожалуйста, делайте так в каждом новом проекте.
Комментарий
Тут два момента.
- Обратите внимание, что дефолт для
@ManyToOne/@OneToOneне поменяется (с целью упрощения миграции, т.к. иначе оргомное кол-во приложений, кто надеялись наEAGERпросто сломаются) - И второе - вводитcя новый
FetchType.DEFAULT. Делается это опять же с целью, чтобы миграция была планомерной. Этот change binary-compatible и behaviorly backward compatible тоже.
Но вот это мне кажется страшно, т.к. теперь, смотря на код, Вы не сможите понять, какой же в итоге фетч тайп-то?! EAGER или LAZY? Вам теперь надо будет понимать, какой дефолт выставлен в Persistence Unit, который конечно же Вы руками не настраиваете, вы надеятесь на Spring Boot авто-конифгурацию для JPA. Короче вот тут посмотрим что получится.
Statement и TypedQuery
С этим изменением мы долго боролись.
JPA 1.0 проектировался до появления generics в Java. Поддержку generics «достроили» в JPA 2.0, и, к сожалению, мы ошиблись, оставив Query, возвращающий raw List, и введя TypedQuery как generic-подтип. Из-за этого вызовы Query.getResultList() дают предупреждения компилятора и риск "unchecked casts". Тогда мотивом была обратная совместимость с клиентами под JPA 1.0, но честно — это можно было решить лучше. Конечно, тогда никто по-настоящему не понимал Java generics — всё было новым, такие ошибки были почти неизбежны.
К тому же у JPA не было отдельного API для выполнения update и delete statements, и со временем мы пришли к мысли, что оно должно быть.
С учётом этого сделано следующее:
- введён интерфейс
Statementдля выполнения запросов без результата; - использование
Queryдля прямого выполнения statements и queries помечено как deprecated; - Мы более четко прописали, что настоящие queries — это statement-ы, возвращающие результаты. Их следует выполнять через
TypedQuery(вы кстати и так должны были так делать, чтобы избежать предупреждений компилятора).
К счастью, путь миграции с deprecated-модели простой.
Было:
List<Book> books = // ouch, unchecked cast
em.createQuery("from Book where extract(year from publicationDate) > :year")
.setParameter("year", Year.of(2000)) // now deprecated
.setMaxResults(10) // now deprecated
.setCacheRetrieveMode(CacheRetrieveMode.BYPASS) // now deprecated
.getResultList(); // ouch, compiler warning
Достаточно одной строки:
List<Book> books =
em.createQuery("from Book where extract(year from publicationDate) > :year")
.ofType(Book.class) // just add this
.setParameter("year", Year.of(2000))
.setMaxResults(10)
.setCacheRetrieveMode(CacheRetrieveMode.BYPASS)
.getResultList();
Или для statement:
int updated =
em.createQuery("delete from Temporary where timestamp > ?1")
.setParameter(1, cutoffDateTime) // now deprecated
.executeUpdate(); // now deprecated
Две строки:
int updated =
em.createQuery("delete from Temporary where timestamp > ?1")
.asStatement()
.setParameter(1, cutoffDateTime)
.execute();
Или просто:
int updated =
em.createStatement("delete from Temporary where timestamp > ?1")
.setParameter(1, cutoffDateTime)
.execute();
Мы понимаем, что подстраивать старый код под такие изменения раздражает. Но делать это срочно не обязательно: в жизненном цикле JPA 4 мы ничего не будем реально удалять. Такие правки довольно прямолинейно автоматизировать, например, чем-то вроде OpenRewrite.
get(), findMultiple() и getMultiple()
Операция find() у EntityManager или EntityAgent загружает entity по primary key и возвращает null, если entity не найдена. Иногда это полезно, но чаще отсутствие entity — ошибка, которую нужно сигнализировать через EntityNotFoundException. Именно это делает новый метод get(). Используйте get() вместо find(), если только ваш код явно не обрабатывает null от find().
Новые методы findMultiple() и getMultiple() по смыслу аналогичны find() и get() соответственно, но принимают списки primary keys и позволяют эффективно получить «пакет» entities.
List<Book> getBooks(List<String> isbns) {
return agent.getMultiple(Book.class, isbns,
CacheRetrieveMode.BYPASS,
LockModeType.OPTIMISTIC);
}
getResultCount()
Давно запрашиваемый новый метод TypedQuery.getResultCount() возвращает общее число результатов query.
setParameter() и setConvertedParameter()
Иногда полезно явно указать тип query parameter при передаче значения. Для JPQL queries это обычно не нужно, но встречается в native SQL statements и queries, где тип parameter нельзя вывести из самого запроса.
manager.createNativeStatement("update books set pub_date = :date where isbn = :ISBN")
.setParameter("date", optionalPublicationDate, LocalDate.class)
.setParameter("ISBN", isbn)
.execute();
Optional select new
Язык запросов в JPA 4 (его переопределяют как Jakarta Query) больше не требует синтаксиса select new для queries, возвращающих проекции через простой Java record. Вместо этого record-ы можно указать как query result class.
```
record Summary(String title, String isbn, LocalDate date) {}
var summaries =
agent.createQuery(
"""
select title, isbn, pubDate
from Book
where title like = ?1 and pubDate > ?2
""")
.ofType(Summary.class)
.setParameter(1, titlePattern)
.setParameter(2, minDate)
.getResultList();
```
Пример мы уже видели при разговоре о статических запросах выше.
@ExcludedFromVersioning
Иногда полезно разрешить изменять отдельное поле entity без optimistic version check. Это означает допустимость lost updates для этого поля и может повысить concurrency. Реализации позволяли это давно; теперь это стандартизовано аннотацией @ExcludedFromVersioning.
StoredProcedureQuery
Интерфейс StoredProcedureQuery получил ряд существенных улучшений для более типобезопасного вызова stored procedures. Здесь я их не разбираю.
Programmatic registration слушателей entity lifecycle callbacks
Теперь можно зарегистрировать listener для типа entity lifecycle event во время выполнения.
factory.addListener(Book.class, PostPersist.class,
book -> System.out.println("Book persisted: " + book.title));
Это особенно интересно разработчикам библиотек и фреймворков.
Для прикладных разработчиков, возможно, важнее другое: у одного entity listener class теперь может быть несколько методов-слушателей одного типа callback — например, несколько @PrePersist для разных типов entities.
Named entity graphs
Раньше named entity graph всегда задавался сложными вложенными аннотациями, которые по имени ссылались на поля entity, входящие в graph. Так по-прежнему можно, но в JPA 4 есть альтернатива: аннотировать включаемые поля graph’ом, к которому они относятся.
```
@NamedEntityGraph(name = "EmployeeWithProjectTasksAndEmployer")
@Entity // root entity of graph
public class Employee {
...
// fetched attribute node
@NamedEntityGraphAttributeNode(graph = "EmployeeWithProjectTasksAndEmployer")
@ManyToOne(fetch=LAZY) Employer employer;
// reference to subgraph defined in Project class
@NamedEntityGraphSubgraph(graph = "EmployeeWithProjectTasksAndEmployer")
@ManyToMany Set<Project> projects;
}
@NamedEntityGraph(name = "EmployeeWithProjectTasksAndEmployer")
@Entity // root entity of subgraph
public class Project {
...
// reference to subgraph defined in Task class
@NamedEntityGraphSubgraph(graph = "EmployeeWithProjectTasksAndEmployer")
@OneToMany List tasks;
}
```
Named entity graphs по-прежнему далеки от того, чтобы стать моей любимой фичей JPA, но для тех, кто ими пользуется, это, пожалуй, заметное улучшение API.
Data loading и Schema Export
В SchemaManager добавлен метод populate() — чтобы удобно заполнять schema данными из DML script.
Метод PersistenceConfiguration.exportSchema() позволяет выполнять действия по управлению schema до инстанцирования EntityManagerFactory.
Pessimistic lock scopes
PessimisticLockScope появился в JPA 2.1 и позволял просить реализацию JPA взять pessimistic lock не только на entity, но и на всё связанное состояние в join tables и collection tables. Честно говоря, я так и не до конца понял эту фичу и не представляю, когда бы ею пользовался. Для всех, кроме самых тривиальных entities, это блокирует слишком много, включая строки, которые вы даже не читаете.
Выбирая между простым deprecation этой фичи и превращением её во что-то полезнее, мы пошли по второму пути. Новая опция PessimisticLockScope.FETCHED блокирует строки join tables и collection tables только если вы реально читаете их и подтягиваете их состояние в рамках операции, которая получает lock.
JDBC fetch size и batch size
Свойства jakarta.persistence.jdbc.fetchSize и jakarta.persistence.jdbc.batchSize теперь формально описаны спецификацией как стандартный способ управлять fetch size и batch size соответственно. Это важно, потому что у одного JDBC driver-а для одной очень важной базы плохое значение по умолчанию.
Изменения в контракте container/provider
Мы изменили SPI, регулирующие контракт между Persistence Provider-ом (т.е. Hibernate) и контейнером Jakarta EE, чтобы:
- контейнер мог полностью взять на себя discovery классов, связанных с Persistence, в persistence unit — он делает это эффективнее, чем Persistence Provider;
- разделить процесс class loading и enhancement от инстанцирования
EntityManagerFactory, решая колючую проблему интеграции между CDIBeanManagerиPersistenceProvider.
Specification
Сама спецификация — критически важная документация и для пользователей, и для реализаций. В рамках постоянной работы над читаемостью spec-и переработаны и переписаны большинство разделов первых двух глав и отдельные фрагменты по всему документу.
Связь с Jakarta Data и Jakarta Query
Важно поставить работу над Jakarta Persistence 4 в контекст. Описанное здесь — часть более крупного усилия, охватывающего три спецификации: Persistence, Query и Data. Компоненты спроектированы так, чтобы идеально стыковаться и давать бесшовный переход между более декларативной repository-моделью Jakarta Data и более программной моделью Jakarta Persistence без потери type safety.
Надеюсь, вам это понравится!
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.