Використовуємо Jakarta Data в Java-застосунках

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник. Хочу поділитися з вами своїм досвідом використання такої технології як Jakarta Data.

Jakarta Data — це наступний крок у розвитку Jakarta EE (Enterprise Java), і Java-розробники дуже довго чекали її появи. У цій статті я опишу її можливості та розповім про те, як ми пробували перевести на неї свої проєкти. Сподіваюся, ця стаття буде корисною для всіх, хто хоче більше дізнатися про сучасні тенденції та зміни у світі Enterprise Java.

Що таке Jakarta Data

Незабаром вийде Jakarta EE 11. Строго кажучи, вона мала вийти в липні, але з якоїсь причини її реліз затримується. Однією з її головних фіч називають нову специфікацію Jakarta Data 1.0, яка вже вийшла в червні цього року. Однак тільки в серпні з’явився Hibernate 6.6, який реалізує цю специфікацію. Тому тільки зараз її стало можливо використовувати у ваших проєктах. Альтернативною реалізацією (провайдером) є OpenLiberty 24.0.

Jakarta Data — це спроба Enterprise Java застрибнути у вагон поїзда під назвою «Прогрес і Java-технології доступу до даних». Концепції, закладені в цю технологію, добре відомі тим розробникам, які використовують Spring Data (випущена 2008 року). У 2020 вийшла ще одна схожа технологія — Micronaut Data. Таким чином Jakarta Data спізнилася щонайменше на 16 років. З іншого боку, їй не потрібно проходити тернистий шлях спроб помилок, і вона могла взяти все краще у своїх конкурентів.

Отже, що таке Jakarta Data та в чому її переваги? Ще раз хочу підкреслити, що це специфікація, яка може бути реалізована так, як вирішать Jakarta EE провайдери. Почнемо з того, що вона має спільного з Spring Data, щоб було простіше розібратися:

  1. Підтримка реляційних і NoSQL баз даних.
  2. Підтримка базових репозиторіїв, які називаються DataRepository (аналог Repository в Spring Data) та BasicRepository (аналог CrudRepository).
  3. Jakarta Data також підтримує JPA-анотації.
  4. Тут також є Query Methods (findByName).
  5. Підтримується server-side pagination (як offset-, так і cursor-based).

Тепер поговоримо про відмінності:

  1. Для репозиторіїв необхідна анотація @Repository.
  2. Якщо в Spring Data використовуються різні анотації для різних NoSQL-технологій, то Jakarta Data базується на ще одній специфікації Jakarta NoSQL, де використовуються однотипний набір анотацій для різних NoSQL.
  3. Jakarta Data пропонує статичну мета-модель даних, що генерується під час компіляції вашого проєкту. Використання такої моделі застерігає від численних run-time помилок.
  4. У Jakarta Data з’явилася мова Jakarta Data Query Language (JDQL), яка є розширенням JPA і яка повинна підтримуватися Jakarta Data провайдерами.
  5. Ви можете використовувати LifeCycle-методи замість методів із базових репозиторіїв.
  6. Доступні анотації для налаштування Query Methods.(@OrderBy)
  7. Якщо в classpath вашого проєкту є Jakarta Validation, ви можете використовувати анотації звідти на аргументах методів у репозиторії.

Щоб познайомитися з цією технологією, спробуємо її використовувати на практиці.

Використовуємо Jakarta Data

Для випробування нової технології ми вибрали невеликий проєкт, де вже був Hibernate, і нам потрібно було оновити його на останню версію 6.6 з поточною 6.3.1.

Цікаво, що Hibernate 6.6 є Jakarta Data провайдером, але не включає її як транзитивну залежність (на відміну від JPA), тому Jakarta Data потрібно додати явно:

<dependency>
     <groupId>jakarta.data</groupId>
     <artifactId>jakarta.data-api</artifactId>
     <version>1.0.0</version>
</dependency>

Можливо, це було зроблено для того, щоб зменшити кількість залежностей у Hibernate-застосунках, адже Jakarta Data ще мало хто використовує.

Але це ще не все. Як я вже казав, реалізації (класи) репозиторіїв генеруються під час збирання (компіляції) проєкту. А це завдання можуть виконувати лише annotation processors. Одного з них — Hibernate Metamodel Generator і потрібно додати до нашого проєкту для Maven-конфігурації:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
       <annotationProcessorPaths>
           <path>
              <groupId>org.hibernate.orm</groupId>
              <artifactId>hibernate-jpamodelgen</artifactId>
              <version>${hibernate.version}</version>
           </path>
       </annotationProcessorPaths>
    </configuration>
</plugin>

Але для annotation processors важливий порядок, і оскільки ми вже маємо бібліотеки Lombok і Google Error Prone, спочатку потрібно додати їх:

<configuration>
     <annotationProcessorPaths>
         <path>
             <groupId>com.google.errorprone</groupId>
             <artifactId>error_prone_core</artifactId>
             <version>2.30.0</version>
         </path>
         <path>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
             <version>${lombok.version}</version>
         </path>
         <path>
             <groupId>org.hibernate.orm</groupId>
             <artifactId>hibernate-jpamodelgen</artifactId>
             <version>${hibernate.version}</version>
         </path>
     </annotationProcessorPaths>
</configuration>

Зараз у нас у сервісі дві сутності — City і Station, які пов’язані один з одним, тому переводити на Jakarta Data репозиторії доведеться обидві сутності. Роботу полегшує те, що ми вже маємо інтерфейси-репозиторії.:

public interface CityRepository {

      void save(City city);

      City findById(int cityId);

      void delete(int cityId);

      List<City> findAll();

      void deleteAll();

      void saveAll(List<City> cities);
}

І StationRepository:

public interface StationRepository {
       List<Station> findAllByCriteria(StationCriteria stationCriteria);

       Station findById(int cityId);

       void save(Station station);
}

У кожного є реалізація на основі Hibernate (JPA). Ми могли б успадкувати ці інтерфейси від BasicRepository або CrudRepository з Jakarta Data, де вже оголошено основні CRUD-операції. І такий варіант підходить для CityRepository, так як він використовує всі доступні CRUD-операції:

@Repository
public interface CityRepository extends CrudRepository<City, Integer> {
       void deleteAll();
}

Але тут виникло дві складності. Перша полягає в тому, що метод findById повертає Op-tional (а не сутність) в CrudRepository:

@Find
Optional<T> findById(@By(ID) K id);

Тому потрібно буде переробити весь код, який викликає findById. Тут також потрібно відзначити, що якщо метод повертає сутність (наприклад, City) і такої сутності немає, то буде викинуто виключення EmptyResultException (з Jakarta Data), а не null, як можна було б очікувати. Друга складність полягає в тому, що метод deleteAll не реалізований за умовчанням, тому нам потрібно його писати самим. Існуюча реалізація викликає іменований запит (named query):

public void deleteAll() {
     execute(session -> {
          session.createNamedQuery(Station.QUERY_DELETE_ALL, Void.class).executeUpdate();
          int deleted = session.createNamedQuery(City.QUERY_DELETE_ALL, Void.class).executeUpdate();
          LOGGER.debug("Deleted {} cities", deleted);
});
}

У Jakarta Data не підтримуються іменовані запити, тільки JPQL або JDQL, тому додамо звичайний запит на видалення:

@Query("delete from City")
void deleteAll();

Третя складність у тому, що раніше наш метод findAll() повертав List<City>, тепер Stream:

@Find
Stream<T> findAll();

Тому потрібно адаптувати наш клієнтський код до цих змін:

@Override
public List<City> findCities() {
        return cityRepository.findAll().toList();
}

У StationRepository всього три методи, тому використовувати базові CRUD-репозиторії не зовсім логічно. Адже ми в результаті дамо розробнику доступ до операцій, які йому не потрібні (або доступ до них заборонено), а це є прямим порушенням принципу інкапсуляції. Тому ми можемо залишити цей інтерфейс як є, але додати спеціальні анотації, які помітять методи як Lifecycle-методи: @Insert, @Delete, @Update, @Save:

@Repository
public interface StationRepository {
      List<Station> findAllByCriteria(StationCriteria stationCriteria);

      @Find
      Optional<Station> findById(int id);

      @Save
      void save(Station station);
}

У міграції StationRepository теж є складність — метод findAllByCriteria, який зараз реалізований з використанням JPA Criteria:

@Override
public List<Station> findAllByCriteria(StationCriteria stationCriteria) {
     return query(session -> {
         CriteriaBuilder builder = session.getCriteriaBuilder();
         CriteriaQuery<Station> criteria = builder.createQuery(Station.class);
         Root<Station> root = criteria.from(Station.class);
         List<Predicate> predicates = new ArrayList<>();

         if (stationCriteria.transportType() != null) {
             predicates.add(builder.equal(root.get(Station.FIELD_TRANSPORT_TYPE), stationCriteria.transportType()));
         }
         if (!StringUtils.isEmpty(stationCriteria.name())) {
             Join<Station, City> city = root.join(Station.FIELD_CITY);
             predicates.add(builder.equal(city.get(City.FIELD_NAME), stationCriteria.name()));
         }

         Predicate predicate = builder.and(predicates.toArray(new Predicate[] {}));
         criteria.select(root).where(predicate);
         TypedQuery<Station> query = session.createQuery(criteria);
         return query.getResultList();
});
}

У Spring Data є дуже зручний компонент Query By Example, який дозволяє писати динамічні запити без знання JPQL та JPA Criteria. У Jakarta Data такого компонента немає. Але є інший цікавий варіант. Спочатку оголосимо метод як default, який повертає порожній список:

default List<Station> findAllByCriteria(StationCriteria stationCriteria) {
    return List.of();
}

Тепер заглянемо до папки target/generated-sources/annotations. Тут окремо згенеровані реалізації репозиторіїв (вони збігаються з їхніми назвами та відрізняються лише символом підкреслення наприкінці):

@Generated("org.hibernate.processor.HibernateProcessor«)
public class CityRepository_ implements CityRepository {
    static final String DELETE_ALL = «delete from City»;

    protected @Nonnull StatelessSession session;

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

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

Зауважте, що тут відразу вказується використання StatelessSession, а не більш звичної нам Session, причому генерується метод session(), який цю сесію повертає. Про відмінності цих двох типів поговоримо трохи згодом.

І окремо є згенеровані моделі на основі сутностей:

@StaticMetamodel(City.class)
@Generated("org.hibernate.processor.HibernateProcessor«)
public interface _City extends org.itsimulator.germes.geography.model.entity._AbstractEntity {
String DISTRICT = «district»;

String NAME = «name»;

public static final String QUERY_CITY_FIND_ALL = «City.findAll»;

String REGION = «region»;

public static final String QUERY_CITY_DELETE_ALL = «City.deleteAll»;

Тут дуже зручно те, що для кожного поля генеруються поля-константи, які можна використовувати скрізь, де ми звертаємося до сутностей за назвами полів.

Тепер перейдемо до реалізації методу findAllByCriteria. Оскільки ми вже маємо доступ до StatelessSession() через виклик session(), то ми можемо його переписати так:

default List<Station> findAllByCriteria(StationCriteria stationCriteria) {
    CriteriaBuilder builder = session().getCriteriaBuilder();
    CriteriaQuery<Station> criteria = builder.createQuery(Station.class);
    Root<Station> root = criteria.from(Station.class);
    List<Predicate> predicates = new ArrayList<>();

    if (stationCriteria.transportType() != null) {
       predicates.add(builder.equal(root.get(Station_.TRANSPORT_TYPE), stationCriteria.transportType()));
    }
    if (!StringUtils.isEmpty(stationCriteria.name())) {
       Join<Station, City> city = root.join(Station_.CITY);
       predicates.add(builder.equal(city.get(City_.NAME), stationCriteria.name()));
    }

    Predicate predicate = builder.and(predicates.toArray(new Predicate[]{}));
    criteria.select(root).where(predicate);
    TypedQuery<Station> query = session().createQuery(criteria);
    return query.getResultList();
}

Зручно тут те, що ми використовуємо згенеровані поля з нових класів (Station_), що мінімізує ризик помилки.

Тепер можна видалити попередні реалізації репозиторіїв, які більше не потрібні:

  1. HibernateCityRepository
  2. HibernateStationRepository

Про тестування нового коду і знайдені проблеми та шляхи їх вирішення я розповім у другій частині цієї статті.

Висновки

Jakarta Data пропонує зручний API для написання своїх репозиторіїв (за прикладом Spring Data та Micronaut Data). Єдина відмінність — те, що всі класи-реалізації генеруються під час збирання проекту. Також генеруються копії класів сутностей. А це означає, що ви прив’язані до Hibernate і його StatelessSession. З іншого боку, це суттєвий крок уперед порівняно з існуючим JPA/Jakarta Persistence. Наскільки Jakarta Data сумісна з існуючим кодом чи конфігурацією, ви дізнаєтеся з другої частини цієї статті.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Ох, це відчуття коли SQL вже автоматично згенерувався в голові за секунди і його б просто надрукувати, але ні, тепер треба день натягувати це на черговий упоротий DSL. А на другий день виявляється, що це просто технічно неможливо.

Завжди можна виконати прямий sql.

А простий запит findByField таки простіше написати к ОРМ. + ОРМ ще й об’єкт сама замапить. Це так здається поки у вас проект це мікроскопічний круд і таблиці з 2-3 полями. Коли полів стає більше, з’ясовується, що мапити таблицю на поля вручну це геморойна і тупа праця.

Підписатись на коментарі