Для будущих студентов курса "Java Developer. Professional" подготовили перевод полезного материала.
Также приглашаем принять участие в открытом уроке на тему "Введение в Spring Data jdbc"
Spring Data JDBC был анонсирован в 2018 году. Целью было предоставить разработчикам более простую альтернативу JPA, продолжая при этом следовать принципам Spring Data. Подробнее узнать о мотивах, лежащих в основе проекта, вы можете в документации.
В этой статье я покажу несколько примеров использования Spring Data JDBC. Здесь не будет подробного руководства, но, надеюсь, приведенной информации хватит, чтобы попробовать его самостоятельно. Очень хорошо, если вы уже знакомы со Spring Data JPA. Исходный код вы можете найти в github.
Для быстрого старта я использовал этот шаблон.
Предварительная подготовка
Из зависимостей нам нужны data-jdbc
— стартер, flyway
для управления схемой и драйвер postgres
для подключения к базе данных.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'org.postgresql:postgresql'
}
Далее настраиваем приложение для подключения к базе данных:
# application.yml
spring:
application:
name: template-app
datasource:
url: jdbc:postgresql://localhost:5432/demo_app?currentSchema=app
username: app_user
password: change_me
driver-class-name: org.postgresql.Driver
Маппинг сущностей
Для этого примера будем использовать следующую таблицу:
create table book (
id varchar(32) not null,
title varchar(255) not null,
author varchar(255),
isbn varchar(15),
published_date date,
page_count integer,
primary key (id)
);
И соответствующий java-класс (обратите внимание, что @Id
импортируется из org.springframework.data.annotation.Id
):
// Book.java
public class Book {
@Id
private String id;
private String title;
private String author;
private String isbn;
private Instant publishedDate;
private Integer pageCount;
}
Однако, если мы запустим тест:
// BookRepositoryTest.java
@Test
void canSaveBook() {
var book = Book.builder().author("Steven Erikson").title("Gardens of the Moon").build();
var savedBook = bookRepository.save(book);
assertThat(savedBook.getId()).isNotBlank();
assertThat(savedBook.getAuthor()).isEqualTo(book.getAuthor());
assertThat(savedBook.getTitle()).isEqualTo(book.getTitle());
assertThat(savedBook).isEqualTo(bookRepository.findById(savedBook.getId()).get());
}
То увидим ошибку — ERROR: null value in column "id" violates not-null constraint. Это происходит, потому что мы не определили ни способ генерации id ни значение по умолчанию. Поведение Spring Data JDBC в части идентификаторов немного отличается от Spring Data JPA. В нашем примере нужно определить ApplicationListener
для BeforeSaveEvent
:
// PersistenceConfig.java
@Bean
public ApplicationListener<BeforeSaveEvent> idGenerator() {
return event -> {
var entity = event.getEntity();
if (entity instanceof Book) {
((Book) entity).setId(UUID.randomUUID().toString());
}
};
}
Теперь тест пройдет, потому что поле Id заполняется. Полный список поддерживаемых событий жизненного цикла смотрите в документации.
Методы запросов
Одной из особенностей проектов Spring Data является возможность определять методы запросов в репозиториях. Spring Data JDBC использует здесь несколько иной подход. Для демонстрации определим метод запроса в BookRepository
:
Optional<Book> findByTitle(String title);
И если запустим соответствующий тест:
@Test
void canFindBookByTitle() {
var title = "Gardens of the Moon";
var book = Book.builder().author("Steven Erikson").title(title).build();
var savedBook = bookRepository.save(book);
assertThat(bookRepository.findByTitle(title).get()).isEqualTo(savedBook);
}
Получим ошибку — Caused by: java.lang.IllegalStateException: No query specified on findByTitle
. В настоящее время Spring Data JDBC поддерживает только явные запросы, задаваемые через @Query. Напишем sql-запрос для нашего метода:
@Query("select * from Book b where b.title = :title")
Optional<Book> findByTitle(@Param("title") String title);
Тест пройден! Не забывайте об этом при создании репозиториев.
Примечание переводчика: в Spring Data JDBC 2.0 появилась поддержка генерации запросов по именам методов.
Связи
Для работы со связями Spring Data JDBC также использует другой подход. Основное отличие в том, что отсутствует ленивая загрузка. Поэтому если вам не нужна связь в сущности, то просто не добавляйте ее туда. Такой подход основан на одной из концепций предметно-ориентированного проектирования (Domain Driven Design), согласно которой сущности, которые мы загружаем, являются корнями агрегатов, поэтому проектировать надо так, чтобы корни агрегатов тянули за собой загрузку других классов.
Один-к-одному
Для связей "один-к-одному" и "один-ко-многим" используется аннотация @MappedCollection
. Сначала посмотрим на "один-к-одному". Класс UserAccount
будет ссылаться на Address
. Вот соответствующий sql:
create table address
(
id varchar(36) not null,
city varchar(255),
state varchar(255),
street varchar(255),
zipcode varchar(255),
primary key (id)
);
create table user_account
(
id varchar(36) not null,
name varchar(255) not null,
email varchar(255) not null,
address_id varchar(36),
primary key (id),
constraint fk_user_account_address_id foreign key (address_id) references address (id)
);
Класс UserAccount
выглядит примерно так:
// UserAccount.java
public class UserAccount implements GeneratedId {
// ...other fields
@MappedCollection(idColumn = "id")
private Address address;
}
Здесь опущены другие поля, чтобы показать маппинг address
. Значение в idColumn
— это имя поля идентификатора класса Address
. Обратите внимание, что в классе Address
нет ссылки на класс UserAccount
, поскольку агрегатом является UserAccount
. Это продемонстрировано в тесте:
//UserAccountRepositoryTest.java
@Test
void canSaveUserWithAddress() {
var address = stubAddress();
var newUser = stubUser(address);
var savedUser = userAccountRepository.save(newUser);
assertThat(savedUser.getId()).isNotBlank();
assertThat(savedUser.getAddress().getId()).isNotBlank();
var foundUser = userAccountRepository.findById(savedUser.getId()).orElseThrow(IllegalStateException::new);
var foundAddress = addressRepository.findById(foundUser.getAddress().getId()).orElseThrow(IllegalStateException::new);
assertThat(foundUser).isEqualTo(savedUser);
assertThat(foundAddress).isEqualTo(savedUser.getAddress());
}
Один-ко-многим
Вот sql, который будем использовать для демонстрации связи "один-ко-многим":
create table warehouse
(
id varchar(36) not null,
location varchar(255),
primary key (id)
);
create table inventory_item
(
id varchar(36) not null,
name varchar(255),
count integer,
warehouse varchar(36),
primary key (id),
constraint fk_inventory_item_warehouse_id foreign key (warehouse) references warehouse (id)
);
В этом примере на складе (warehouse) есть много товаров/объектов (inventoryitems). Поэтому в классе Warehouse
мы также будем использовать @MappedCollection
для InventoryItem
:
public class Warehouse {
// ...other fields
@MappedCollection
Set<InventoryItem> inventoryItems = new HashSet<>();
public void addInventoryItem(InventoryItem inventoryItem) {
var itemWithId = inventoryItem.toBuilder().id(UUID.randomUUID().toString()).build();
this.inventoryItems.add(itemWithId);
}
}
public class InventoryItem {
@Id
private String id;
private String name;
private int count;
}
В этом примере мы устанавливаем поле id
во вспомогательном методе addInventoryItem
. Можно также определить ApplicationListener
для класса Warehouse
с обработкой BeforeSaveEvent
, в котором установить поле id
для всех InventoryItem
. Вам не обязательно делать в точности так, как сделано у меня. Посмотрите тесты с демонстрацией некоторых особенностей поведения связи "один-ко-многим". Главное то, что сохранение или удаление экземпляра Warehouse
влияет на соответствующие InventoryItem
.
В нашем случае InventoryItem
не должен знать о Warehouse
. Таким образом, у этого класса есть только те поля, которые описывают его. В JPA принято делать двусторонние связи, но это может быть громоздким и провоцировать ошибки, если вы забудете поддерживать обе стороны связи. Spring Data JDBC способствует созданию только необходимых вам связей, поэтому обратная связь "многие-к-одному" здесь не используется.
Многие-к-одному и многие-ко-многим
В рамках этого руководства я не буду вдаваться в подробности о связях "многие-к-одному" или "многие ко многим". Я советую избегать связей "многие-ко-многим" и использовать их только в крайнем случае. Хотя иногда они могут быть неизбежны. Оба этих типа связей реализуются в Spring Data JDBC через ссылки на Id
связанных сущностей. Поэтому имейте ввиду, что здесь вам предстоит еще немного потрудиться.
Заключение
Если вы использовали Spring Data JPA, то большая часть из того, что я рассказал, должна быть вам знакома. Я уже упоминал ранее, что Spring Data JDBC стремится быть проще, и поэтому отсутствует ленивая загрузка. Помимо этого, отсутствует кеширование, отслеживание "грязных" объектов (dirty tracking) и сессии (session). Если в Spring Data JDBC вы загружаете объект, то он загружается полностью (включая связи) и сохраняется тогда, когда вы сохраняете его в репозиторий. Примеры, которые я показал, очень похожи на свои аналоги в JPA, но помните, что многие концепции Spring Data JPA отсутствуют в Spring Data JDBC.
В целом мне нравится Spring Data JDBC. Признаю, что это может быть не лучший выбор для всех приложений, однако я бы рекомендовал его попробовать. Как человек, который в прошлом боролся с ленивой загрузкой и dirty tracking, я ценю его простоту. Я думаю, что это хороший выбор для простых предметных областей, которые не требуют большого количества нестандартных запросов.
На этом пока все, спасибо за чтение! Надеюсь, вы нашли это руководство полезным и оно будет отправной точкой для использования Spring Data JDBC.
Подробнее о курсе "Java Developer. Professional"
Записаться на открытый урок "Введение в Spring Data jdbc"
ArchDemon
А если в таблице хранится древовидная структура, то выгрузка любого объекта приводит к выгрузке всей таблицы??? Или связи связей уже не выгружаются?