Команда Spring АйО перевела и адаптировала доклад "Jakarta Data and Jakarta Persistence by Gavin King" Гевина Кинга с последнего Devoxx.

В своем выступлении Гевин Кинг рассказал о преимуществах и нововведениях Jakarta Data и Jakarta Persistence.

Введение

Когда команда Hibernate работала над тем, чтобы модернизировать и перепроектировать свой продукт и создать Hibernate 6, никто не мог быть уверен, что данная инициатива встретит большой интерес в рядах разработчиков. Но оказалось, что люди заинтересованы в этой теме. А годом позже стало ясно, что на самом деле Hibernate 6 оказался исключительно успешным, и это отразилось во всех метриках. На фоне таких открытий команда разработчиков пришла к выводу что имеет смысл отойти назад и вдохнуть новую жизнь в спецификацию Jakarta, прежде всего в том, что касалось Jakarta Persistence.

Еще более интересная история произошла с Jakarta Data. Изначально Jakarta Data была спецификацией, которая интересовала немногих. Даже разработчики надеялись, что она просто исчезнет, “рассосется” сама собой. Однако она осталась, и это оказалось положительным моментом. В какой-то момент стало ясно, что Jakarta Data не уйдет в никуда, и возникла необходимость взаимодействовать с ней. К счастью, к этому времени появилась ясность в том, в каком направлении стоит продолжать ее развитие. Параллельно этой работе менялись и взгляды на репозитории и на практическую пользу их применения, которая стала очевидна не сразу. Появились новые практические идеи о том, как улучшить существующую концепцию репозиториев, и эти идеи стали катализатором изменений, которые скоро произойдут в Jakarta Data или уже произошли.

Примерно пару лет назад популярная точка зрения на репозитории выглядела примерно так: “Зачем вообще нужны репозитории? JPA EntityManager уже является generic репозиторием, определенным согласно стандарту с многочисленными реализациями, созданным во взаимодействии людьми, которые разрабатывают ведущие решения на Java ORM.”  Не было понятно, какую именно выгоду может получить разработчик, спрятав JPA за non-generic, нестандартным проприетарным фреймворком, который не давал дополнительной типобезопасности, не давал никакой дополнительной инкрементальной способности и лишь усложнял доступ к некоторым возможностям JPA. 

Главный аргумент здесь в том, что если бы было можно сделать JPA проще, люди бы это сделали. Попытка упростить JPA, впрочем, может нанести вред, если подходить к ней необдуманно, так как те операции, которые делают JPA сложным, реальны и полезны. И с этим сложно поспорить. Распространенной жалобой от разработчиков, использующих ORM, является жалоба на то, что использование библиотеки Persistence, особенно вместе с ORM решением и в stateful persistence контексте, уводит разработчика дальше от прямого контроля над его SQL и над взаимодействием с базой данных.    

Увы, Repository Framework делают эту ситуацию еще хуже. Они не могут не сделать эту ситуацию еще хуже. Они — еще один слой абстракций.

Это утверждение особенно верно, если фреймворк репозиториев пытается стать абстракцией поверх двух очень разных вещей. Одна из этих вещей — это CRUD-ориентированный API, который дает полный контроль над над моментом времени взаимодействия с базой данных, где нет persistence контекста, нет кеширования данных сущности. Вторая вещь — это stateful persistent контекст в стиле JPA. Это абсолютно разные, фундаментально разные модели программирования. Невозможно рационально и эффективно создать абстракцию поверх этих двух моделей, а если вы попытаетесь, вы только создадите все разновидности путаницы в голове пользователя. 

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

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

Вот наиболее тривиальный пример, который приходит в голову: “автор → книга” — это ассоциация многие ко многим. Или, например, “студент → профессор → класс” — это тоже ассоциации многие ко многим. Реляционные данные доверху полны такими ассоциациями. 

Запросы, которые работают с реляционными данными, не появляются через какой-то domain driven design, от верхней сущности и вниз по дереву. Реляционные данные работают не так. 

Поэтому, работая с репозиториями, мы не должны думать об отдельных  сущностях и операциях над отдельными сущностями. Это не имеет смысла для реляционных данных. У нас должна быть возможность производить операции над репозиторием, который работает над несколькими сущностями. 

Одна из главных вещей, которые предлагают нам репозитории — это возможность описать запросы внутри приложения, и ценят их именно за это.

И еще один момент. Во фреймворках репозиториев, которые сейчас существуют в мире, легко прослеживается тот факт, что они вдохновлялись Ruby on Rails, и в стремлении сделать работу с запросами наиболее простой, написать максимально тривиальный запрос, как, например, найти книгу по ее заглавию, была применена идея автоматического угадывания запроса по имени метода репозитория. К чему это в конце концов приводит, это создание языка запросов, включаемых в имя метода. Что означает “никаких пробелов, никаких знаков препинания, очень ограниченные возможности”.

Посмотрим на реальные примеры. Здесь мы видим имена методов вроде findFirstByOrderByLastNameAsc() или findTopByOrderByAgeDesc(). Это ужасно. 

Пришла пора перестать так делать. Метод должен не только выполнять свою функцию, но и нести понятную семантику. Его имя должно отражать цель и роль в коде, чтобы разработчик, пришедший в проект позже, мог легко понять его назначение и продолжить работу.

Если у разработчика нет контроля над тем, какое имя дать методу, он лишается  основной части коммуникации с тем, кто вызывает этот API. Конечно, можно вписать предназначение метода в комментарии. Но это движение назад.

Так почему репозитории нравятся людям?    

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

Возможно, эти спекуляции будут далеки от истины в некоторых случаях. Прежде всего, если базироваться на том, что говорилось десять, пятнадцать лет назад, когда эти репозитории назывались DAO, основным оправданием для их применения было то, что наличие DAO объектов было способом отделения своего кода от внутренней технологии работы с данными, способом упрощения перехода от использования, к примеру, JPA, к использованию кода JDBC напрямую или, возможно, перехода от PostgreSQL на Mongo.

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

Репозитории традиционно реализуются таким образом, чтобы не давать дополнительной типобезопасности. Причина их популярности состоит, скроее, в том, что это удобное место, куда можно сложить все запросы к данным. Размещение JPA-кода, выполняющего запросы create и устанавливающего параметры, прямо в бизнес-логике, связанной с работой persistence, часто воспринимается как неудачная и неопрятная практика.

Соответственно, репозитории — это приятный способ организации кода.

Организация кода — это важно, верно?

Однако, в прежние времена реализация слоя репозитория была настолько тривиальной и настолько прозрачной, что она не стояла на пути отладки или понимания кода. Не существовало дополнительного слоя, отделяющего разработчика  от взаимодействия с JPA и базой данных. А значит, не было и никакой запутанности между репозиториями в стиле CRUD и stateful persistence контекстом. 

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

Быстрое введение в репозитории в Jakarta Data

Технология, которая делает это возможным в данном случае — это annotation processing

Комментарий от команды Spring АйО

Речь про APT - Java Annotation Processing Tool

Да, annotation processing, в каком-то смысле, имеет плохую репутацию в некоторых кругах. Дело в том, что annotation processing на макроуровне является мощной языковой функцией. И, как любая мощная языковая функция, она может использоваться, чтобы делать что-то по-настоящему прекрасное, либо она может использоваться, чтобы делать что-то плохое и вредное. Как в случае с острым ножом.

У многих людей есть опыт в annotation processing, который выходит за грань того, что вроде как разрешается делать официально. Есть один конкретный обработчик аннотаций, который очень хорошо всем вам известен. Он очень хорошо известен, и он точно делает вещи, которые делать не разрешается.

Комментарий от команды Spring АйО

В данном случае речь идет про то, как Lombok использует APT

Что же касается Jakarta Data, очень важно упомянуть, что этот продукт был построен так, чтобы его можно было реализовать через обработчик аннотаций, которому не надо нарушать правила по поводу того, что должны делать обработчики аннотаций. Так что, если у вас был какой-то плохой опыт с какими-то другими обработчиками аннотаций, это не обязательно означает, что у вас был плохой опыт, связанный с Jakarta Data, и нет оснований полагать, что он когда-либо появится.

Итак, давайте сделаем быстрое введение в репозитории в Jakarta Data. Поговорим об использовании Jakarta Data для получения доступа к реляционным данным, и конкретно об использовании Hibernate.  

На самом деле, Jakarta Data — это гораздо больше. Jakarta Data — это generic спецификация, которая может использоваться для реализации фреймворков репозиториев в том числе для нереляционных данных, если у вас есть в этом необходимость, и вам следует взглянуть на Eclipse JNoSQL, который нацелен именно на такое применение спецификации. На данный момент имеются как минимум три реализации в Jakarta Data: одна от IBM, одна от Eclipse и одна от Hibernate.

@Repository
Interface Library {
}

Итак, как выглядит интерфейс репозитория? Только этот код. Это интерфейс с аннотацией @Repository, он не обязан ничего расширять. Хотя, если есть такая необходимость, он может это делать.

И, чтобы получить к нему доступ, вы можете инжектировать его, например, через CDI:

@Inject 
Library library;

Благодаря @Inject инжектируем через CDI (или любым другим способом)

Вы также можете захотеть инстанцировать репозиторий, используя new, и сделать это можно следующим способом:

var library = new Library_();

Но этот код специфичен для Hibernate, универсального способа сделать что-то подобное пока нет.

Первый вид распространенных операций с данными — это операции чтения. Мы хотим сказать нашей программе: “Найди мне такую-то книгу по таким-то параметрам”. Что надо для этого сделать? Нам придется написать метод, и он должен возвращать объект типа Book, именно такой тип в данном случае тип возвращает операция репозитория. И этот метод должен быть помечен аннотацией @Find.

@Repository
Interface Library {
	@Find Book book(String isbn);
}

Что здесь есть интересного и нового — это то, что параметры в этом методе проверяются на соответствие полям типа Book. Поэтому они должны иметь тот же тип и то же имя, что и поле типа Book, и это может быть провалидировано во время компиляции посредством annotation processing.

Поэтому если вы неправильно напишите isbn или почему-то подумаете, что это тип Long, а не String, или ошибетесь в чем-то еще, вы получите ошибку немедленно во время компиляции, а не во время выполнения.

И что еще важно, имя этого метода не имеет особого значения, вы можете называть эти операции, как считаете нужным:

@Repository 
interface Library {
	@Find Book book(String isbn);
	@Find Author authorForSsn(@Nonnull String ssn); //Имя метода никогда не имеет значения
}

Как вы можете видеть, один метод работает с Book, а другой с Author, а значит есть и свобода выбора: 

  • выбрать по одному репозиторию на каждую сущность

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

  • или, что особенно интересно, репозиторий, относящийся к группе взаимосвязанных сущностей

@Repository
interface Library {
	@Find Book book(String isbn);
	@Find Author authorForSsn(@Nonnull String ssn);
}

Теперь поговорим об операции update().

@Repository
interface Library {
	@Find Book book(String isbn);
	@Insert void insert(Book book);
	@Update void update(Book book); //update является отдельной операцией
}

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

Заметьте, что update — это отдельная операция, а репозитории Jakarta Data всегда stateless. В них нет persistence контекста. Никогда. Поэтому в Hibernate в описываемой реализации репозитория поддерживаются StatelessSession. Если вы хотите забрать контроль над этой StatelessSession и что-то с ней сделать, Jakarta Data также предоставляет способ это сделать. 

@Repository
interface Library {
    StatelessSession session(); //получение внутренней StatelessSession
  
    default void upsert(Book book) {
        session().upsert(book); //операция над репозиторием, реализованная пользователем
    }
}

Вы просто декларируете метод, который возвращает StatelessSession, и можете использовать его в методе по умолчанию, который вы можете назвать upsert().

Язык запросов Jakarta Data

Теперь поговорим о запросах. То, что мы видели выше — это методы запросов, базирующиеся на параметрах.

Существуют методы запросов, в которых параметры метода определяют ограничения на результаты, и это тип возвращаемого методом результата, что определяет сущность, к которой отправляется запрос. Есть также и другие возможности, о которых можно почитать в спецификации или в документации по Hibernate. Методы запросов, базирующиеся на параметрах, мапятся на JPA Criteria API. Такая практика является полностью типобезопасной.

@Repository
interface Library {
  @Find Optional<Book> book(String isbn);
  @Query("where name.first like :firstName" +
        " and name.last like :lastName")

  Author author(String firstName, String lastName);
  
  @Query("where title like ?1 and publisher.name = ?2" +
        " order by title")

  List<Book> booksByTitle(String title, String publisher);
  
}

JDQL (Jakarta Data Query Language) — это гораздо более мощный и гибкий язык. С его помощью вы можете написать запрос прямо в аннотации, но, спросите вы, где же в этом типобезопасность? Что, если имя этого параметра не соответствует типу или параметр не соответствует параметру метода? Что, если Book не имеет атрибута под названием title? Ответ прост: вы получите ошибку во время компиляции. 

Посмотрим на реализацию. 

@Repository
interface Library {

    @Find
    Optional<Book> book(String isbn);

    @Query("where name.first like :firstName" +
           " and name.last like :lastName") //ошибка компиляции

    Author author(String firstName, String lastName);

    @Query("where title like ?1 and publisher.name = ?2" +
           "order by title") ////ошибка компиляции

    List<Book> booksByTitle(String title, String publisher);

}

Под всем этим скрыты серьезные механизмы, в написание которых вложено много труда команды разработчиков пусть даже на первый взгляд построить эти механизмы не так и трудно, поскольку у IntelliJ они тоже есть. Соответственно, IntelliJ тоже может делать подобные валидации, и таким образом вы получаете два слоя валидации, которые отрабатывают каждый раз, когда вы компилируете ваш код. 

JDQL является подмножеством JPQL, ограниченным под возможность реализации на широком диапазоне реляционных и нереляционных хранилищ данных. Это подмножество может быть реализовано на весьма простых хранилищах данных. 

Что, если запрос более динамичный?

@Repository
interface Library {

  @Find Book book(String isbn);
  
  @Query("select isbn from Book"
        " where lower(title) like 21"
        " order by isbn")

  List<String> booksByTitle(String title);

}

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

@Repository
interface Library {

    @Find
    Book book(String isbn);

    @Query("select isbn from Book" +
           " where lower(title) like ?1")

    List<String> booksByTitle(String title, 
                              PageRequest page, //пагинация
                              Sort sort); //динамическая сортировка
}

У Jakarta Data есть решение и для такого случая! Можно передать объект типа PageRequest, который задает пагинацию, и также можно передать объект типа Sort, который задает динамическую сортировку, и для этих сценариев в Jakarta Data также присутствует типобезопасность. 

var isbns = library
        .booksByTitle("%hibernate%", 
                PageRequest.ofPage(1).size(20),
                _Book.isbn.asc()); //isbn - ссылка на статическую метамодель

Типобезопасность для этого сценария реализуется через статическую метамодель Jakarta. Вы уже, возможно, знакомы со статической метамоделью Jakarta Persistence, которая первоначально создавалась для использования с запросами Criteria API, и теперь в Jakarta Data есть нечто похожее. К сожалению, приходится использовать синтаксис, основанный на символе подчеркивания, то есть, если использовать JPA и Jakarta Data вместе, появятся классы Book_ и _Book, но зато здесь присутствует возможность задать порядок сортировки этого запроса, используя _Book.isbn.asc(), и она тоже полностью типобезопасна. 

Могут ли Java Record’ы быть типами сущностей?

Отвлечемся ненадолго и поговорим о некотором моменте, который не относится напрямую к теме статьи, но этот вопрос все время поднимается к тому же является весьма интересным. И состоит он в следующем: 

Могут ли сущности быть Java Record’ами? 

Мы все любим Java records. Immutable типы прекрасны. Первое, что надлежит упомянуть, это то, что immutable типы прекрасно подходят для представления деревьев. Вы можете работать со связными списками, вы можете создать дерево с многочисленными ветвями, у вас могут быть связные списки. Но у вас не может быть циркулярных референсов. С immutable объектами вы просто не сможете создать Immutable объект имея циркулярный референс.

Итак, какие данные дают хорошие деревья? Самый очевидный пример — документоориентированные БД. Они являются деревьями от природы. Но для реляционных это утверждение неверно. Как уже говорилось ранее, реляционные данные полны отношений “многие ко многим”, и это именно то, что делает реляционные данные настолько более мощными, чем иерархические модели. Эти ассоциации многие ко многим просты в представлении, естественны и являются частью модели. 

Итак, в то время как некоторые реализации Jakarta Data будут поддерживать records как типы сущностей, это не является требованием в соответствии со спецификацией, а реализация, основанная на Hibernate ORM, не будет их поддерживать.

С другой стороны, records могут использоваться для @Embeddable типов, как в JPA, и по этой причине они нужны в JPA 3.2 и, отсюда мы совершенно естественно переходим к разговору Jakarta Persistence. 

Как сделать Jakarta Persistence более простым в тестировании

JPA 3.2 сильно отличается от Jakarta Data. Команда разработчиков спецификации перенесла сюда многое из Hibernate и EclipseLink, то, что доказало свою полезность за годы и легко подвергалось стандартизации  что можно легко стандартизировать в спецификацию. Одновременно были устранены некоторые шероховатости этих APIs, в основном опять-таки с целью улучшения типобезопасности. В рамках этой статьи мы поговорим только о двух вещах.

//persistence.xml файл больше не нужен
//теперь можно создать Persistence Unit динамически в рантайме

var factory = new PersistenceConfiguration("Bookshop")
    .nonJtaDataSource("java:global/jdbc/BookshopData")
    .managedClass(Book.class)
    .managedClass(Author.class)
    .property(PersistenceConfiguration.LOCK_TIMEOUT, 5000)
    .createEntityManagerFactory();

Одна из этих вещей — упрощенное тестирование. В первую очередь, есть способ полностью сконфигурировать стартовую процедуру, persistent unit, entity manager factory, полностью сделать это через код Java. Как мы жили десятилетиями, не имея этих возможностей в спецификации? Очевидно же, что их надо туда добавить, чтобы вам больше не требовался persistence XML, чтобы запустить Jakarta Persistence, чтобы запустить любую managerFactory в ваших сырых тестах. 

Этот класс является расширяемым, так что реализации типа Hibernate или EclipseLink могут иметь свои собственные Hibernate Persistence конфигурации со своими специфическими конфигурационными опциями.

Во-вторых, раньше существовали разные моды работы со схемой БД (создание, валидация и т.д.), контролируемая настройками свойств. Теперь же появился объект, называемый SchemaManager, который можно использовать для динамической манипуляции схемы данных

Комментарий от команды Spring АйО
factory.getSchemaManager().create(true) //экспортирует схему
factory.getSchemaManager().drop(true) //выполняет зачистку

Это суперполезно в тестах. Вы также можете провалидировать схему, а еще, что даже еще более полезно, вы можете очистить с помощью операции truncate() все таблицы в вашей базе данных после прогона теста. 

factory.getSchemaManager().validate() //валидирует схему
factory.getSchemaManager().truncate() //выполняет зачистку только данных

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

И наконец, есть способ прогнать некоторые операции на транзакциях с entityManager, без необходимости писать многочисленные try … catch блоки, без необходимости беспокоиться о том, чтобы запускать это внутри Java EE контейнера. 

//callInTransaction автоматически обрабатывает транзакции и исключения
var book = factory.callInTransaction(em -> em.find(Book.class, isbn)); 

Так мы автоматически управляем транзакциями и исключениями, получая максимально простое решение.

Улучшение типобезопасности в Jakarta Persistence

Во всей работе команды Jakarta Data типобезопасность была главной и определяющей целью. И одной из первых задач в рамках поставленной цели  было найти способ предоставить type safe ссылки на атрибуты, на поля нашей сущности внутри аннотаций другой сущности. Наконец-то мы можем воспользоваться всеми преимуществами статической метамодели.

@Entity
class Book {

    @Id String isbn;

    @ManyToMany(mappedBy = Author_.BOOK) //типобезопасная ссылка на поле 
    List<Author> authors;

    String text;

}

Это особенно полезно для маппинга, когда используется ненавистное mappedBy для членов коллекции аннотаций типа многие ко многим.

Теперь уже Jakarta Persistence, а не Jakarta Data, статическая метамодель, имеет эти атрибуты, такие как BOOK (с большой буквы), сгенерированные в ней с именами полей сущности. Это сдвиг в позитивном направлении.

А как насчет именованных запросов (NamedQuery) и именованных графов сущностей (NamedEntityGraph)? 

@NamedQuery(name = "TextForIsbn", //имя типа, объявленное при помощи строки
    query = "select text from Book where isbn = ?1"
)
@NamedEntityGraph(
    name = "BookWithAuthors",
    attributeNodes = @NamedAttributeNode(value = "authors"))
@Entity
class Book {

    @Id String isbn;
  
    @ManyToMany(mappedBy = Author_.BOOK)
    List<Author> authors;

    String text;
}

Начать следует с обозначения имен типов с помощью строк. Как упростить их использование? Хуже всего то, что у них не только строковые имена типов, но и, как вы знаете, до версии 3.2 JPA не содержал способа задать возвращаемый тип именованного запроса, и приходилось делать type cast

Ответом здесь является то, что мы генерируем ссылку на типовой запрос и вставляем ее в статическую метамодель. 

//_TextForIsbn_ - типобезопасная ссылка на именованный запрос
//String - возвращаемый тип запроса выводится автоматически
String text = em.createNamedQuery(Book_._TextForIsbn_)
                .setParameter(1, "9781932394153")
                .getSingleResult();

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

Хорошо, хорошо, на самом деле мы нигде не говорили в явном виде, что запрос возвращает строку. Это лишь снова последствие факта, что Hibernate очень хорошо анализирует причинно-следственные связи во время компиляции. 

//_BookWithAuthors - типобезопасная ссылка на именованный граф
//Тип сущности Book выводится автоматически
Book book = em.find(Book_._BookWithAuthors, “9781932394153”);

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

var bookWithAuthors = em.createEntityGraph(Book.class);
bookWithAuthors.addSubgraph(Book_.authors);
//bookWithAuthors - обычно лучше, чем именованный граф сущности
Book book = em.find(bookWithAuthors, “9781932394153”);

До появления JPA 3.2 API для работы с графами тоже содержал много проблем с типобезопасностью, но они были успешно исправлены, и вы снова можете писать типобезопасный код и использованием графов сущностей.

Операции find(), refresh() и lock() также были замещены объектами со свойствами, которые используются для кастомизации поведения EntityManager.

Book book = 
    em.find(Book.class, "9781932394153", 
            LockModeType.PESSIMISTIC_READ, 
            Timeout.seconds(2), 
            CacheStoreMode.REFRESH); //типобезопасные опции

Сейчас некоторые из этих операций являются типобезопасными опциями. Они также расширяемы, так что Hibernate или EclipseLink могут добавить свои собственные типобезопасные опции, и это делает более чистым контроль над поведением этих пераций.  

Будущее: что дальше?

Итак, что же дальше? Две вещи. Конечно, вы хотите упростить процедуру получения большей выгоды от JPA при использовании Jakarta Data, что означает, что необходимо найти больше способов для выставления наружу большего количества JPA через репозитории. Команда разработки также планирует упростить реализацию Jakarta Data, переписав ее с использованием только стандартных API из JPA. В частности, команда планирует обратить внимание на StatelessEntityManager, не вошедший в 3.2, поскольку он очень остро необходим разработчикам, которые сейчас вынуждены прибегать к нестандартным возможностям Hibernate. 

В идеальном мире у разработчиков должна быть возможность реализовывать Jakarta Data поверх JPA без необходимости вызывать какие-либо нестандартные возможности внутренней реализации JPA. 

Jakarta Data должна сама по себе поддерживать stateful persistence контексты, а это по сути означает новый API.

 В дополнение к аннотациям @Add, @Insert, @Update и @Delete теперь понадобятся также @Persist, @Merge, @Remove, @Refresh, @Lock, чтобы моделировать жизненный цикл сущности, который задается JPA, и таким образом обойти стороной упомянутую в начале статьи путаницу. Будут предоставлены оба способа, но очень важно четко говорить о том, что они имеют разную семантику. Цель команды состоит в том, чтобы уменьшить эту дистанцию. В настоящее время существуют запрос-методы, базирующиеся на параметрах, которые очень ограничены в некоторых смыслах, и с другой стороны имеется также JDQL. В настоящее время команда смотрит и расследует, какие есть способы уменьшить дистанцию между ними.

Далее мы быстро пробежимся по существующим способам и идеям, чтобы увидеть, как они выглядят на практике. 

package devoxx;

import ...

@Repository
interface Library {

// 	@Find
// 	Book book(String isbn);

// 	@Find
// 	Optional<Book> bookWithTitle(String title);

// 	@Query("from Book where title like ?! order by title")
// 	List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

//	@Find Author author(String name_first, String name_last);

// 	@Save
// 	void upsert (Book book);

}

Это всего лишь интерфейс с аннотацией. Обработчик аннотаций от Hibernate генерирует CDI бин @RequestScoped.

@RequestScoped
@Generated("org.hibernate.processor.HibernateProcessor")
public class Library. implements Library {

protected @Nonnull StatelessSession session;

public Library_(@Nonnull StatelessSession session) {
  this.session = session;
}

public @Nonnull StatelessSession session() {
  return session;
}

@PersistenceUnit
private EntityManagerFactory sessionFactory;

@PostConstruct
private void openSession() { 
  session = sessionFactory.unwrap(SessionFactory.class).openStatelessSession(); 
}

@PreDestroy
private void closeSession() { session.close(); }

@Inject
Library_ { }

}

Что он сгенерирует в вашем случае, сильно зависит от библиотек, которые находятся у вас в classpath. Если бы здесь в пути сборки был, например, Quarkus, то сгенерировался бы несколько иной код, который работал бы с Quarkus. Как мы видим, в приведенном коде инжектируется StatelessSession. И, собственно,на этом все. 

Далее, добавим очень простой метод find() (по факту, раскомментируем нужный нам метод в приведенном выше коде).

package devoxx;

import ...

@Repository
interface Library {

 	@Find
 	Book book(String isbn);

// 	@Find
// 	Optional<Book> bookWithTitle(String title);

// 	@Query("from Book where title like ?! order by title")
// 	List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

//	@Find Author author(String name_first, String name_last);

// 	@Save
// 	void upsert (Book book);

}

Он находит объект типа Book по полю isbn. И вы можете видеть, что сгенерированный код действительно прост в понимании.

public class Library_ implements Library {

    /**
     * Find {@link Book} by {@link Book#isbn isbn}.
     *
     * @see devoxx.Library#book (String)
     **/
    @Override
    public Book book(@Nonnull String isbn) {

        if (isbn == null) throw new IllegalArgumentException("Null isbn");

        try {
            var _result = session.get(Book.class, isbn);
            if (result == null) throw new EmptyResultException("No 'Book' for given id (" + isbn + "]",
                    new ObjectNotFoundException((Object) isbn, "devoxx.Book"));
            return _result;

        } catch (PersistenceException exception) {

            throw new DataException(exception.getMessage(), exception);

        }
    }

    protected @Nonnull StatelessSession session;

    public Library_(@Nonnull StatelessSession session) {
        this.session = session;
    }

    public @Nonnull StatelessSession session() {
        return session;
    }
}

Это то, что вы написали бы вручную. Возможно, ваш код был бы несколько опрятнее Существует конверсия исключений, которую вы возможно не стали бы писать, если бы писали это самостоятельно. Но в остальном это всего лишь session.get(Book.class, isbn);.  Вы легко можете понять этот код, он не мешает вам наслаждаться вашим Hibernate. По факту вы можете взять этот метод, скопировать его, вставить его в интерфейс библиотеки, добавить default, и это будет работать без дальнейших изменений.

Теперь проверим, что произойдет, если поменять написание имени этого параметра на неправильное? 

Как и следовало ожидать, появится сообщение: “no matching field named ‘isb’ in entity class ‘devoxx.Book’” (Отсутствует поле с соответствующим именем ‘isb’ в классе сущности ‘devoxx.Book’).

А что случится, если дать параметру неправильный тип? Сделаем его long

Получаем сообщение “Matching field has type ‘java.lang.String’” (соответствующее поле имеет тип ‘‘java.lang.String’)

Сообщение об ошибке даже говорит нам, какой тип следует использовать. Намного лучше, чем получить эту ошибку при запуске приложения или при прогоне тестов.

Можно сделать и больше. Допустим, мы хотим найти книгу по ее заголовку. Если мы не уверены, что книга с таким заголовком существует, мы можем вернуть Optional.

package devoxx;

import ...

@Repository
interface Library {

    @Find
    Book book(String isbn);

    @Find
    Optional<Book> bookWithTitle(String title);

// 	@Query("from Book where title like ?! order by title")
// 	List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

//	@Find Author author(String name_first, String name_last);

// 	@Save
// 	void upsert (Book book);

}

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

@Override
public Optional<Book> bookWithTitle(@Nonnull String title) {

    if (title == null) throw new IllegalArgumentException("Null title");

    var _builder = session.getFactory().getCriteriaBuilder();
    var _query = _builder.createQuery(Book.class);
    var _entity = _query.from(Book.class);

    _query.where(
            _builder.equal(_entity.get(Book_.title), title)
    );

    try {
        return session.createSelectionQuery(_query).uniqueResultOptional();
    } catch (PersistenceException exception) {
        throw new DataException(exception.getMessage(), exception);
    }
}

В этом коде есть нечто, что необходимо исправить. Это использование equal().  Обычно, когда вы хотите найти книгу по ее заголовку, вы хотите получить какие-то wildcards или что-то наподобие. Вы хотите использовать паттерн поиска, и в качестве временной меры в Hibernate, пока Jakarta Data не выпустит стандартное решение, можно использовать аннотацию @Pattern

@Find
Optional<Book> bookWithTitle(@Pattern String title);

Теперь, если мы посмотрим на реализацию, она будет использовать операцию like вместо equal.

_query.where(
_builder.like(_entity.get(Book_.title), title)
);

Теперь рассмотрим пример с аннотацией @Query. Раскомментируем его в нашем примере:

package devoxx;

import ...

@Repository
interface Library {

	@Find
	Book book(String isbn);

	@Find
	Optional<Book> bookWithTitle(String title);

 	@Query("from Book where title like ?! order by title")
 	List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

//	@Find Author author(String name_first, String name_last);

// 	@Save
// 	void upsert (Book book);

}

Здесь используется простой запрос, ищущий книгу с заголовком, который удовлетворяет операции like по отношению к искомой строке. 

Обратим также внимание на маленькую особенность. Jakarta Data не требует, чтобы вы писали “from Book” в теле запроса. Вы можете написать:

@Query("where title like ?! order by title")
List<Book> books(String title);

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

Посмотрим еще раз на то, что произойдет, если неправильно написать имя сущности. IntelliJ, конечно, укажет на это, но во время компиляции будет также получено сообщение об ошибке от Hibernate.

Если неправильно написать атрибут, опять же  IntelliJ это поймает, но то же самое сделает и процессор:

Код реализации приводится ниже:

@Override
public List<Book> books(String title) {
    try {
        return session.createSelectionQuery(BOOKS_String, Book.class)
                .setParameter(1, title)
                .getResultList();
    } catch (PersistenceException exception) {
        throw new DataException(exception.getMessage(), exception);
    }
}

Опять-таки, код полностью прозрачен. Это именно такой же код, который вы написали бы сами. 

Если необходимо сослаться на поле в составе поля через базирующийся на параметрах метод запроса, можно сделать это через синтаксис, основанный на символах подчеркивания. Раскомментируем еще один пример:

package devoxx;

import ...

@Repository
interface Library {

    @Find
    Book book(String isbn);

    @Find
    Optional<Book> bookWithTitle(String title);

    @Query("from Book where title like ?! order by title")
    List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

    @Find Author author(String name_first, String name_last);

// 	@Save
// 	void upsert (Book book);

}

Сгенерированный код запроса, основанного на критериях, будет выглядеть следующим образом:

@Override
public Author author(String name$first, String name$last) {
    var _builder = session.getFactory().getCriteriaBuilder();
    var _query = _builder.createQuery(Author.class);
    var _entity = _query.from(Author.class);
    _query.where(
            name$first == null
                    ? _entity.get(Author_.name).get(Name_.first).isNull()
                    : _builder.equal(_entity.get(Author_.name).get(Name_.first), name$first),
            name$last == null
                    ? _entity.get(Author_.name).get(Name_.last).isNull()
                    : _builder.equal(_entity.get(Author_.name).get(Name_.last), name$last));
    try {
        return session.createSelectionQuery(_query).getSingleResult();
    } catch (NoResultException exception) {
        throw new EmptyResultException(exception.getMessage(), exception);
    } catch (NonUniqueResultException exception) {
        throw new jakarta.data.exceptions.NonUniqueResultException(exception.getMessage(), exception);
    }
}

Как вы можете видеть, он содержит многочисленные символы подчеркивания, как, например, в этой строке:

_entity.get(Author_.name).get(Name_.first).isNull()

Этот способ, возможно, не идеален, но он полностью типобезопасен, и в случае появления несоответствия Hibernate обязательно его заметит и укажет нам на ошибку. 

Если говорить об операциях обновления данных, они, как правило, тривиальны. В качестве примера посмотрим на операцию save(). Раскомментируем соответствующие строки в нашем примере:

package devoxx;

import ...

@Repository
interface Library {

	@Find
	Book book(String isbn);

	@Find
	Optional<Book> bookWithTitle(String title);

 	@Query("from Book where title like ?! order by title")
 	List<Book> books(String title);

// 	@Find
//	Author author (@Nonnull String ssn);

	@Find 
    Author author(String name_first, String name_last);

 	@Save
 	void upsert (Book book);

}

Операция save() присутствует в Jakarta Data наряду с insert(), update() и delete(), и логика ее примерно следующая:  “вставьте вот этот объект, если его пока нет в базе данных или обновите его, если он существует” 

@Override
public void upsert(@Nonnull Book book) {
    if (book == null) throw new IllegalArgumentException("Null book");
    try {
        session.upsert(book);
    } catch (StaleStateException exception) {
        throw new OptimisticLockingFailureException(exception.getMessage(), exception);
    } catch (PersistenceException exception) {
        throw new DataException(exception.getMessage(), exception);
    }
}

Таким образом, это маппируется на stateless сессию upsert().

А вот еще одна интересная возможность.

package devoxx;

import ...

@Repository
interface Library {

	@Find
	Book book(String isbn);

	@Find
	Optional<Book> bookWithTitle(String title);

 	@Query("from Book where title like ?! order by title")
 	List<Book> books(String title);

 	@Find
	Author author(@Nonnull String ssn);

	@Find 
    Author author(String name_first, String name_last);

 	@Save
 	void upsert (Book book);

}

В случае с уже рассмотренными операциями чтения, Hibernate рассуждал таким образом - isbn является первичным ключом, и, соответственно не может быть null. Поэтому там бросалось исключение. Заголовок же является обязательным полем, поэтому он тоже не может отсутствовать. И если написать подобный запрос для автора (Author) и его social security number (ssn), который не объявлен как non-nullable поле.

@Find
Author author (String ssn);

Тогда вы увидите, что Hibernate замечает тот факт, что это поле может являться null и примет null аргумент.  

@Override
public Author author(String ssn) {

    var _builder = session.getFactory().getCriteriaBuilder();
    var _query = _builder.createQuery(Author.class);
    var _entity = _query.from(Author.class);

    _query.where(
        ssn == null //Hibernate принимает аргумент null 
            ? _entity.get(Author_.ssn).isNull()
            : _builder.equal(_entity.get(Author_.ssn), ssn)
    );

    try {
        return session.createSelectionQuery(_query)
                      .getSingleResult();
    }
}

Если же вы хотите, чтобы здесь тоже было Nonnull, вы можете воспользоваться аннотацией @Nonnull от Jakarta. 

@Find
Author author (@Nonnull String ssn);

И для этого случая тоже будет сгенерирован корректный код. 

@Override
public Author author(@Nonnull String ssn) {

    if (ssn == null) throw new IllegalArgumentException("Null ssn"); //Hibernate отказывается принимать аргумент null
    var _builder = session.getFactory().getCriteriaBuilder();
    var _query = _builder.createQuery(Author.class);
    var _entity = _query.from(Author.class);
    _query.where(
        _builder.equal(_entity.get(Author_.ssn), ssn)
    );
}

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

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