Совсем недавно статью про ORM-фреймворк Jimmer Михаила Поливахи, эксперта сообщества Spring АйО, опубликовал Baeldung.
Перед вами переработанная и дополненная версия, подготовленная специально для сообщества Spring АйО. В ней Михаил раскрывает ключевые особенности Jimmer: отсутствие JPA-наследия, декларативные DTO и гибкий DSL и потенциальную интеграцию со Spring.
1. Введение
В этом руководстве мы рассмотрим ORM-фреймворк Jimmer. На момент написания статьи этот ORM является относительно новым, но уже обладает многообещающими фичами. Мы ознакомимся с философией Jimmer, а затем приведём несколько примеров его использования.
2. Общая архитектура
Прежде всего, Jimmer не является реализацией JPA. Это означает, что Jimmer не поддерживает все функции JPA. Например, в Jimmer отсутствует механизм отслеживания изменений (dirty checking) в привычном понимании. Тем не менее, стоит отметить, что в Jimmer, как и в Hibernate, используется множество схожих концепций. Это сделано намеренно, чтобы упростить переход от Hibernate. В целом, знание JPA будет полезно для понимания принципов работы Jimmer.
Например, в Jimmer существует понятие сущности, хотя её структура и подход к реализации сильно отличаются от Hibernate. Такие концепции, как ленивые загрузки (lazy loading) или каскадные операции (cascading), в Jimmer отсутствуют. Причина в том, что из-за особенностей архитектуры Jimmer эти механизмы теряют свою актуальность. Скоро мы в этом убедимся.
И последнее в этом разделе: Jimmer поддерживает несколько СУБД, включая MySQL, Oracle, PostgreSQL, SQL Server, SQLite и H2.
3. Пример сущности
Как уже упоминалось, Jimmer во многом отличается от Hibernate и многих других ORM-фреймворков; у него есть несколько ключевых принципов проектирования. Первый из них заключается в том, что сущности служат единственной цели — отобразить в Java приложении схему, которая лежит в основе базы данных. Однако важный момент здесь в том, что мы не указываем способ взаимодействия с сущностью с помощью аннотаций. Вместо этого Jimmer требует от разработчика предоставить всю информацию, необходимую для построения запроса, который будет выполнен в месте вызова.
Что это означает на практике? Чтобы понять, давайте рассмотрим следующую сущность в Jimmer:
import org.babyfish.jimmer.client.TNullable;
import org.babyfish.jimmer.sql.Column;
import org.babyfish.jimmer.sql.Entity;
import org.babyfish.jimmer.sql.GeneratedValue;
import org.babyfish.jimmer.sql.GenerationType;
import org.babyfish.jimmer.sql.Id;
import org.babyfish.jimmer.sql.JoinColumn;
import org.babyfish.jimmer.sql.ManyToOne;
import org.babyfish.jimmer.sql.OneToMany;
@Entity
public interface Book {
@Id
@GeneratedValue(strategy = GenerationType.USER)
long id();
@Column(name = "title")
String title();
@Column(name = "created_at")
Instant createdAt();
@ManyToOne
@JoinColumn(name = "author_id")
Author author();
@TNullable
@Column(name = "rating")
Long rating();
@OneToMany(mappedBy = "book")
List<Page> pages();
// equals and hashcode implementation
}
Как можно заметить, здесь используются аннотации, схожие с JPA, но обратите внимание на импорты. Однако отсутствует одна важная деталь — мы не указываем каскадность для связей, таких как, например, pages
в данном случае. То же самое касается типа загрузки (ленивая или жадная) — он не задаётся при объявлении. Также мы не можем указать атрибуты insertable
или updatable
в аннотации @Column
, как это обычно делается в JPA, и так далее.
Мы не делаем этого потому, что Jimmer ожидает, что вся необходимая информация будет явно указана в момент выполнения соответствующей операции. Мы подробно рассмотрим это в следующих разделах.
Подводя итог раздела: Наша сущность Book
существует в основном только для того, чтобы сказать Jimmer‑у: «У нас есть в БД табличка book
, она определенным образом связана с табличками, которые представлены сущностями Page
и Author
. У Book
есть такие‑то поля и т. д.». Потом на основе этих сущностей Jimmer будет генерировать на этапе сборки большое количество синтетики, которую мы будем использовать. Об этом позже.
4. Язык DTO
Ещё одна особенность, которая сразу бросается в глаза — это то, что Book
представлен в виде интерфейса, а не класса. Это сделано намеренно, поскольку в Jimmer не предполагается работа с сущностями напрямую, то есть мы не должны их инстанцировать.
Вместо этого предполагается, что и для чтения, и для записи данных мы будем использовать некие DTO. Под DTO мы понимаем какой‑то «value object», единственной задачей которого является отображение состояния — либо того состояния, что мы хотим сохранить, либо той структуры состояния, которую мы хотим прочитать. Если сейчас это кажется сложным — ничего страшного, ниже будут примеры.
То есть важный момент какой — эти DTO должны точно соответствовать той структуре, с которой мы хотим взаимодействовать при обращении к базе данных. Грубо говоря, запись данных в Jimmer происходит посредством DTO
→ Entity
, а чтение посредством Entity
→ DTO
.
Давайте рассмотрим пример (пока не обращайте внимания на конкретные вызовы API):
public void saveAdHocBookDraft(String title) {
Book book = BookDraft.$.produce(bookDraft -> {
bookDraft.setCreatedAt(Instant.now());
bookDraft.setTitle(title);
bookDraft.setAuthor(AuthorDraft.$.produce(authorDraft -> {
authorDraft.setId(1L);
}));
bookDraft.setId(1L);
});
sqlClient.save(book);
}
В целом, для большинства операций взаимодействия с базой данных нам необходимо использовать SqlClient.
В приведенном выше примере мы создаём временную (ad-hoc) DTO через интерфейс BookDraft
. Интерфейсы BookDraft
и AuthorDraft
были сгенерированы Jimmer автоматически — это не написанный вручную код. Как уже было упомянуто, генерация происходит на этапе компиляции: с помощью Java Annotation Processing Tool, если используется Java, или через Kotlin Symbol Processing, если используется Kotlin.
Эти два сгенерированных интерфейса позволяют создать DTO произвольной структуры, который затем Jimmer внутренне преобразует в сущность Book. То есть мы действительно сохраняем сущность, просто не создаем её вручную. SqlClient
, основной способ взаимодействия с Jimmer, в основном работает с сущностями, просто их за нас генерирует Jimmer.
5. Обработка null
Кроме того, Jimmer сохраняет только те компоненты, которые присутствуют в DTO. Теперь должно стать более прозрачно, почему такие вещи как каскадирование операций, insertable
/updatable
флаги и т.д. не имеют смысла в рамках архитектуры Jimmer-а: всё, что мы хотим либо сохранить, либо прочитать — определяется структурой DTO, а не аннотациями на полях сущности. То же самое касается и Lazy Loading — мы должны уже на этапе подгрузки сущности понимать, что нам будет нужно, а что нет, и это хорошо.
Кроме того, в Jimmer проводится чёткое различие между свойством, которое вообще не было установлено в DTO, и свойством, которое было явно установлено в null
. Иными словами, если мы не хотим включать определенное скалярное свойство в сгенерированный SQL-запрос, мы просто создаём DTO без явного указания этого свойства. Под скалярными свойствами подразумеваются поля, не представляющие собой связи:
public void insertOnlyIdAndAuthorId() {
Book book = BookDraft.$.produce(
bookDraft -> {
bookDraft.setAuthor(AuthorDraft.$.produce(authorDraft -> {
authorDraft.setId(1L);
}));
bookDraft.setId(1L);
});
sqlClient.insert(book);
}
Сгенерированный SQL-запрос INSERT для Book в приведённом выше случае будет выглядеть следующим образом:
INSERT INTO BOOK(ID, author_id) VALUES(?, ?)
Если мы явно установим скалярное свойство в null
, то Jimmer включит это свойство в соответствующий INSERT/UPDATE-запрос и присвоит ему значение null
:
public void insertExplicitlySetRatingToNull() {
Book book = BookDraft.$.produce(bookDraft -> {
bookDraft.setAuthor(AuthorDraft.$.produce(authorDraft -> {
authorDraft.setId(1L);
}));
bookDraft.setRating(null);
bookDraft.setId(1L);
});
sqlClient.insert(book);
}
Сгенерированный SQL-запрос INSERT в этом случае будет выглядеть следующим образом:
INSERT INTO BOOK(ID, author_id, rating) VALUES(?, ?, ?)
Обратите внимание, что в INSERT-запрос включено свойство rating
. Значение этого свойства в подготовленном JDBC-запросе будет установлено в null
.
И последнее: для свойств, представляющих связи (нескалярные свойства), поведение гораздо сложнее и заслуживает отдельного рассмотрения.
6. Проблема «DTO Explosion»
Опытные разработчики могут сразу заметить проблему: подход Jimmer к работе с базой данных подразумевает создание множества DTO — по одной для каждой уникальной операции. Ответ — не совсем так. Хотя действительно потребуется немало DTO, необходимость в их ручном написании как в примерах выше можно существенно сократить. Причина в том, что в Jimmer предусмотрен специальный язык описания DTO.
Да, у Jimmer есть специальный DSL, который позволяет вам описывать структуру желаемых DTO на этом специальном DSL. Позже из этих файлов будут на этапе сборки сгенерированы реальные Java типы, которые можно исопльзовать как DTO.
Вот пример использования этого DSL:
export com.baeldung.jimmer.models.Book
-> package com.baeldung.jimmer.dto
BookView {
#allScalars(Book)
author {
id
}
pages {
#allScalars(Page)
}
}
Генерация POJO на основе этого DSL происходит во время компиляции, как уже сказано, так же как и генерация *Draft абстракций как в примерах из предыдущего раздела.
В данной разметке, например, мы указали Jimmer включить все скалярные поля в генерируемый DTO с помощью инструкции #allScalars
. Помимо этого, мы также указали, что в DTO должно присутствовать только ID
автора, а не сам объект Author
. Коллекция страниц (pages
) будет включена в DTO практически полностью - будут включены все скалярные поля сущности Page
.
Таким образом, действительно, в Jimmer нам требуется множество DTO для описания желаемого поведения в каждом конкретном случае. Однако мы можем либо создавать их вручную по мере необходимости, либо полагаться на POJO, которые плагин компилятора сгенерирует за нас во время сборки. Опять же, всё это достигается либо путём APT, либо KSP, в зависимости от языка, на котором Вы предпочитаете писать код - Java или Kotlin.
7. Чтение данных
До этого момента мы говорили только о способах сохранения данных в базу. Теперь давайте рассмотрим процесс чтения. Чтобы прочитать данные, нам также необходимо точно указать, какую информацию нужно извлечь, с помощью DTO. Именно структура DTO сообщает Jimmer, какие поля следует получить. Если поле отсутствует в DTO, оно извлекаться не будет (опять же, вспоминаем концепцию с lazy loading и почему она теряет актуальность):
public List<BookView> findAllByTitleLike(String title) {
List<BookView> values = sqlClient.createQuery(BookTable.$)
.where(BookTable.$.title()
.like(title))
.select(BookTable.$.fetch(BookView.class))
.execute();
return values;
}
Здесь мы используем DTO BookView
из предыдущего раздела. BookView
— это DTO. Мы не писали его сами. Jimmer сгенерировал его за нас. То есть в случае выше мы просто проинструктировали Jimmer: «Сгенерируй и выполни такой SQL запрос, который позволит мне, как разработчику, дальше получить данные, которые описаны в рамках данной структуры BookView
».
Опять же, мы можем не писать на этом DSL языке и не генерировать ничего. Мы также можем указать, какие столбцы необходимо прочитать, с помощью временного API Fetcher. Он очень похож на тот, что мы использовали при записи данных в базу, по сути, это такой же ad-hoc API для создания DTO только уже для чтения данных (отсюда и имя - Fetcher
):
public List<BookView> findAllByTitleLikeProjection(String title) {
List<Book> books = sqlClient.createQuery(BookTable.$)
.where(BookTable.$.title()
.like(title))
.select(BookTable.$.fetch(Fetchers.BOOK_FETCHER.title()
.createdAt()
.author()))
.execute();
return books.stream()
.map(BookView::new)
.collect(Collectors.toList());
}
С помощью API Object Fetcher мы по-прежнему указываем необходимые для чтения столбцы на call site-e, а не в декларации сущности. Философия одна и та же как при чтении, так и при записи графа сущностей.
8. Управление транзакциями
Наконец, кратко рассмотрим, как Jimmer управляет транзакциями. В целом, Jimmer не имеет собственного встроенного механизма управления транзакциями. Вместо этого он активно использует инфраструктуру управления транзакциями, предоставляемую Spring Framework. И здесь кстати важный момент для современных приложений — Jimmer задизайнен таким образом, чтобы иметь нативную и бесшовную интеграцию со Spring.
Например, рассмотрим использование локального управления транзакциями (не распределённого, например через XADataSource), что является наиболее частым сценарием. В этом случае Jimmer опирается на возможности TransactionSynchronizationManager
из Spring и привязку транзакционного соединения к текущему потоку.
Подводя итог: традиционное использование аннотации @Transactional из Spring будет корректно работать с Jimmer. Также возможно императивное управление транзакциями с помощью TransactionTemplate
из Spring. Вы можете спокойно оборачивать методы, которые работают с SqlClient
в @Transactional
и автоматически Jimmer будет осведомлен о текущем контексте транзакции в методе.
9. Заключение
В этой статье мы рассмотрели ORM-фреймворк Jimmer. Как мы увидели, Jimmer предлагает уникальный подход к работе с данными. В то время как JPA и особенно Hibernate выражают способы взаимодействия с базой данных в основном через аннотации, Jimmer требует от разработчика предоставления всей необходимой информации динамически — в месте вызова. Для этого Jimmer использует DTO, которые обычно генерируются самим Jimmer с помощью его языка DTO. Тем не менее, их также можно создавать вручную, по мере необходимости. Что касается управления транзакциями, Jimmer полагается на инфраструктуру Spring Framework. Поэтому интегрировать Jimmer в приложения на Spring будет относительно просто.
Как всегда, исходный код, связанный с этой статьёй, доступен на GitHub.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано
atues
Забавно и интересно. Возможно, это могло бы служить альтернативой Hibernate. Но сдается мне - не стрельнет. Ну т.е. отдельные энтузиасты и воспользуются. Кому интересно поковыряться и у кого в наличии куча времени. Но не они формируют ситуацию. Вот пример (немного из другой области): уж сколько лет ругают QWERTY-раскладку. И заслуженно ругают. Только воз и ныне там. И будет оставаться там не смотря ни на какие аргументы.
Это вовсе не означает, что автор проделал пустую работу. Повторюсь - это забавно и интересно. Без таких любознательных людей ничего нового никогда и не появлялось бы.
Плюсую с удовольствием и жду новых идей :)