Каждый раз, когда необходимо сделать сервис на Java, работающий с реляционной базой, я не могу определиться, прямо как та обезъяна, которая хотела быть и умной, и красивой. Хочется делать запросы на обычном SQL, по-минимуму обкладываясь различными "магическими" аннотациями, но при этом лень самому писать RowMapper'ы, готовить PreparedStatement'ы или JdbcTemplate, и тому подобное, за что любят обзывать Java многословной. И каждый раз руки тянутся к Spring Data JDBC, который, вроде как, и был задуман как нечто среднее. Но с ним тоже, зачастую, можно вляпаться в какую-то ерунду на ровном месте.
Потребовалось мне сохранять новые записи в таблицу. Казалось бы, в чем вопрос - берешь CrudRepository и все у тебя работает из коробки. Но на практике возникло несколько нюансов, например:
прежде всего, надо теперь активно использовать целую пачку аннотаций для разметки энтити (@Id, @Table, @Column, @InsertOnlyProperty, и т.д.)
я предпочитаю использовать records для хранения данных, а в этом случае получается, что надо для поля id делать отдельный метод withId, котрый вернет новую рекорду с заполненым id.
хочется получать id из соответствующей sequence в базе данных
Вот на последнем пункте я и хотел бы более детально остановиться. Приученный к плохому хорошему в JPA, я ожидал, что настройка генерации поля id в Spring Data JDBC делается такими же настройками стратегии генерации. Но нет, читаем документацию и выясняем, что Spring Data JDBC умеет работать только со столбцами с автоинкрементом. Для всего остального предлагается использовать BeforeConvert listener. За деталями пришлось идти к всезнайке Google.
Google первой строкой выдал мне ссылку на блог некоего Thorben Janssen (заранее извиняюсь, если это кто-то известный, а я его не знаю - у меня плохая память на имена). И посмотрев на пример кода, я, если честно, немного офи.. удивился. До этого, все запросы в SpringData JDBC выглядели чистенько и аккуратненько, а тут снова JdbcTemplate и ручной парсинг результата.
Я не поверил и полез смотреть примеры от самого Spring в GitHub. Их пример, хоть и выглядит чуть чище, но все равно это ручная работа с JDBC :
@Bean
BeforeConvertCallback<Customer> idGeneratingCallback(DatabaseClient databaseClient) {
return (customer, sqlIdentifier) -> {
if (customer.id() == null) {
return databaseClient.sql("SELECT primary_key.nextval") //
.map(row -> row.get(0, Long.class)) //
.first() //
.map(customer::withId);
}
return Mono.just(customer);
};
}
Возникает вопрос - если уж все равно надо самому писать дополнительный запрос к базе, чтоб получить значени для id и вставлять его в энтити перед сохранением, то почему бы не сделать все это более явно и в едином стиле с другими запросами? В итоге у меня получился вот такой вариант:
public record MyEntity(long id, String someData, ...) {}
@org.springframework.stereotype.Repository
public interface MyRepository extends Repository<MyEntity, Long> {
@Query("SELECT nextval('myentity_seq')")
long getNextMyEntityId();
@Modifying
@Query("INSERT INTO my_entities (id, some_data) VALUES (:#{newEntity.id}, :#{newEntity.someData})")
boolean insert(@Param("newEntity") MyEntity newEntity);
}
@Service
public class MyService {
private final MyRepository repository;
public long saveNewEntity(String someData) {
var entity = new MyEntity(
repository.getNextMyEntityId(),
someData
);
if (repository.insert(entity)) {
return entity.id();
}
throw new RuntimeException("Can't save");
}
}
Мне кажется, такой вариант лучше, поскольку
логика по формированию новой энтити в одном месте, а не разнесена по разным бинам;
все запросы находятся в одном месте и выполнены в едином стиле;
все аннотации, относящиеся к фреймворку тоже собраны в одном месте (в репозитории), а сам класс с данными абсолютно чистый.
А вы что думаете?
PS:
Изначально я этим всем заморочился только из-за того, что мне требовалось вернуть id созданной записи. Потому как иначе, запрос на вставку превращался бы в что-то типа:
INSERT INTO my_entities (id, some_data) VALUES (nextval('myentity_seq'), :someData)
И первая мысль была - воспользоваться средствами СУБД с помощью insert with returning. Но у нас легаси база и там это не заработало.
Комментарии (38)
mmMike
10.01.2023 14:01хочется получать id из соответствующей sequence в базе данных
я ожидал, что настройка генерации поля id в Spring Data JDBC делается такими же настройками стратегии генерации.Я наверное не понял в чем сложности.
Вот кусок кода в SbpingBoot приложении (аналогичный в без Spring c чистым hibernate).
Работающий кусок
работает и с Oracle и PostgreSQL базой.
Обычный SEQUENCE "CREATE SEQUENCE SBPAY_SEQ.."Правда не совсем стандартно (вызов hibernate) для Spring.
@Entity @Table(name = "SBPAY_C2B") @Setter @Getter @ToString public class C2BTable implements Serializable { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_gen") @SequenceGenerator(name = "seq_gen", sequenceName = "SBPAY_SEQ", allocationSize = 1) @Column(name = "ID", updatable = false, nullable = false) private Long id; ...... @Service public class C2BTableService { @PersistenceContext private EntityManager em; @Transactional public void save(C2BTable table) { if (table.getId() == null) { log.trace("save new:" + table); em.persist(table); } else { log.trace("update:" + table); em.merge(table); } }
vat78 Автор
10.01.2023 14:05Как раз jpa (hibernate) и не хочется использовать..
mmMike
10.01.2023 14:23я могу и ошибаться, но мне казалось, что Repository автоматически тянет за собой hibernate (код, инициализация и пр.). Использовать hibernate (через Repository) исключительно для native запросов - это как то наплевать на потребляемы программой ресурсы. Лучше уж голый JDBC сразу.
Хотя.. хозяин - барин.
mmMike
10.01.2023 14:07nextval(
Если конечно заведомо знать, что работаешь с конкретной БД, то можно и хардкодить синтаксис.
Но может оказаться что в других БД (Oracle, в частности) другой синтаксис получения значения из sequencevat78 Автор
10.01.2023 15:30Если использовать SQL для запросов, то, конечно, придется затачиваться под конкретную базу данных. Но если при этом все запросы собраны в одном месте, а не разбросаны по коду, то миграция на другую базу не занимает много времени.
Зато полная свобода в оптимизации запросов с учетом специфики конкретной базы
Akon32
10.01.2023 14:03public record MyEntity
Выглядит как оксюморон в контексте Domain-Driven Design.
DDD различает и противопоставляет объекты-значения (value objects), которые иммутабельны и не имеют идентичности, и сущности (entity), которые могут быть различными при одинаковых значениях полей, и поэтому имеют идентификаторы.
Насколько я понимаю, record в java - это для объектов-значений. У них не может быть идентификатора (у них нет идентичности: 2 record с одинаковыми полями не могут различаться), и нет проблем с его установкой.
vat78 Автор
10.01.2023 15:41В концепции DDD и гексагональной архитектуры entity запросто может быть особым типом dto для взаимодействия с внешним хранилищем. А для dto рекорды вполне себе удобны, при остуствии дата-классов. Хотя, согласен, что, могут быть сценарии, когда это может привести к проблемам
GerrAlt
10.01.2023 14:47Есть ощущение что вы пытаетесь сидеть на двух стульях - с одной стороны хочется чтобы как ORM - Repository, Entity и работа на уровне объектов, а с другой чтоб было видно конкретный sql и писать по-сути его. Если хочется именно sql то для вашей задачи у Connection (https://docs.oracle.com/en/java/javase/19/docs/api/java.sql/java/sql/Connection.html) есть метод prepareStatement(String sql, int autoGeneratedKeys), он похоже что делает то что вам нужно. Подозреваю что все развитые библиотеки-помошники в работе с JDBC вкурсе об этой возможности и скорее всего у них есть более удобный способ утилизировать эти возможности.
vat78 Автор
10.01.2023 15:56> вы пытаетесь сидеть на двух стульях
именно ) И пока Spring Data JDBC мой основной кандидат на средство, которое позволит этого достичь.
> Подозреваю что все развитые библиотеки-помошники в работе с JDBC вкурсе об этой возможности
Собственно удивление, что Spring Data JDBC это не умеет и предлагает это решать, на мой взгляд, кривовато, и стало причиной этой статьиGerrAlt
10.01.2023 16:54А чем вас не устраивает использование JPA? Хочется на уровне энтити работать - пожалуйста, хочется запрос какой-то особенный - есть native query
Все ради того чтобы экономить строчки на аннотациях?
vat78 Автор
11.01.2023 14:41Если нет потребности обновлять энтити в базе, то вся мощь JPA становится не особо нужна, даже наоборот начинает мешать. А если не пользуешься основным функционалом большой библиотеки, то как-то избыточно ее в проект затаскивать ради каких-то второстепенных удобств, которая она может дать
MadMaxLab
10.01.2023 14:52+2Позволю себе небольшой совет - попробуйте сделать пет проект на JPA с чистого листа, используя рекомендуемые сообществом best practice и не пытаясь принести туда привычные вам наработки в виде кастомных sequence и record в виде entity.
Это позволит продвинуться дальше и понять, к примеру, что entity по задумке фреймворка мутабельный объект и генерация ID это еще малая беда. Дальше можно словить проблемы с Lazy Loading, апдейту связей итд.
Этим всем конечно можно не пользоваться, но тогда встает вопрос нужен ли вам этот JPA.
Я вот им пользуюсь много лет в проде, но до сих пор для себя не ответил однозначно на этот вопрос.vat78 Автор
10.01.2023 15:47Я очень много использовал JPA и использую, когда это оправдано. JPA это идеальное решение для CRUD сервисов.
Но когда нужны сложные запросы к базе, то даже на голом jdbc получается проще писать и поддерживать. Именно поэтому Spring Data JDBC манит своей идеей получить преймущества из обоих мировa_belyaev
10.01.2023 16:30Там еще веселье будет, если нужно много записей вставлять. Hibernate может делать что-то типа кэша ID, так что не бегает за каждым ID в базу. Если такого добиваться вручную, придется немного повозиться.
А еще с batch операциями придется повозиться, кстати.vat78 Автор
10.01.2023 18:48да для батчей тут совсем примитивные вещи только, приходится на обычный jdbc переключаться
znepok
11.01.2023 14:19Как раз таки JPA абсолютно избыточное решение для CRUD. Если вы используете ORM для CRUD, значит вы не поняли суть и философию ORM, как и суть Entity.
vat78 Автор
11.01.2023 15:06На мой взгляд, задача классического ORM (и JPA в частности) - "синхронизировать" состояние объектов приложения (энтити) с соответствующими записями в таблицах базе, учитывая все их связи между собой. А это, по сути, и есть CRUD операции.
Поэтому очень хотелось бы, чтоб вы как-то более детально раскрыли ваше видение философии ORM и суть энтити
znepok
11.01.2023 20:13Spring Data Jdbc, Jooq, MyBatis, ... Все они тоже "синхронизируют" состояние объектов с записями в таблицах, учитывая их связи. Но они не ORM!
Иногда встречаю людей, которые считают Jooq/MyBatis ORM библиотеками, только потому что ORM это про маппинг, а они как раз таки автоматически конвертируют resultset в entity объект, да ещё и с поддержкой связей.
Суть ORM в реализации паттерна Unit Of Work, в object-level транзакционности (бизнес транзакция). ORM позволяет реализовывать бизнес-логику (не путать с use-case логикой приложения) без переживаний "а как и когда это сохранится в базу". ORM позволяет выстраивать логику так, словно никакого хранилища нет вовсе. Это кардинально отличается от CRUD.
Понятное дело, что при комите транзакции в БД уйдут insert/update/select/delete запросы, но с таким подходом можно любую библиотеку для доступа к данным называть ORM!
CRUD сервисы, как правило, это сервисы с очень простой бизнес логикой или вовсе без неё. Классическая слоенка контроллер-сервис-репозиторий, где даже сервисный слой зачастую лишний. Такие сервисы настолько просты, что придумали даже Spring Data Rest. Само собой, и в таких сервисах можно ORM использовать, но тогда ORM будет использоваться исключительно как маппер с автоматическим билдингом запросов. Минимум телодвижений для реализации персистентности в сервисе.
Если же мы говорим о приложениях со сложной размашистой бизнес логикой (привет монолитам и микромонолитам), в которых граф сущностей не просто удобный доступ к данным в таблицах БД, а необходимость для реализации всех бизнес правил с сохранением консистентности данных на уровне бизнес транзакции (не путать с БД транзакцией), то добро пожаловать в гости, ORM.
Касательно сущностей, я всегда сталкиваюсь с мнением, что "entity = таблица". Почему-то разработчики даже не задумываются о том, что к одной и той же таблице можно сделать разные Entity под разные контексты бизнес логики. А когда их сущности разростаются десятками связей, начинаюи ругать Hibernate за тормознутость.
Наверное, для данной дискуссии нужно больше предметности, примеры приложений.
vat78 Автор
12.01.2023 10:26Спасибо, теперь я понял в чем различие наших взглядов. Просто я к CRUD отношу еще сервисы, которые что-то берут из базы, меняют и сохраняют изменения обратно. Т.е. когда у нас объекты-энтити мутабельные. Для таких сценариев да, ORM идеальное решение.
А вот Spring Data Jdbc как раз не про "синхронизацию" с базой. Это просто более простой способ делать jdbc запросы и мапить результаты на объекты. Тут нет энтити в понимании ORM, т.к. у них нет жизненного цикла. Поэтому для сервисов, в которых база, это лишь как еще одна внешняя система (например, некий аналитический сервис), я и предпочитаю использовать немутабельные ентити и что-то полегче, чем полноценный ORM.
sshikov
10.01.2023 19:56>привычные вам наработки в виде кастомных sequence
Представьте, что база не ваша, и вообще легаси. Какие sequence дали, такими и приходится пользоваться. Ваш подход вполне осмысленный, но не универсальный (как наверное и любой другой).
itatsiy
10.01.2023 18:51В postgres можно делая INSERT вернуть значение последовательности:
@Data @Accessors(chain = true) public static class Note { private Long id; private String data; }
public Note save(Note entity) { var sql = """ INSERT INTO notes (id, data) VALUES (default, :data) RETURNING id """; var params = new MapSqlParameterSource().addValue("data", entity.getData()); return namedParameterJdbcTemplate.query(sql, params, x -> { if (x.next()) { entity.setId(x.getLong("id")); } return entity; }); }
Таким образом, сделать все за один запрос.
Если необходим батч, то прийдется заранее получить IDки:SELECT nextval('notes_id_seq') FROM generate_series(1, :size)
oxff
10.01.2023 19:34Извините, но я никак не пойму в чём у вас проблема. В соответствии с документацией, для авто-инкрементных полей не нужно писать дополнительного кода вообще. У вас ведь Postgres, почему вы не можете просто объявить поля соответственно, используя легаси
SERIAL
или современныйGENERATED ... AS IDENTITY
?Сиквенс у вас уже есть, и вы его пытаетесь использовать. А тип
SERIAL
это просто синтаксический сахар, который создаёт сиквенс и объявляет поле какNOT NULL DEFAULT nextval('seq_name')
Плюс этого в том, что вам не нужно делать дополнительный запрос к БД для получения ID.
Throwable
11.01.2023 10:11Можно оптимизировать, как делают многие JPA: сделайте SQL sequence сразу с шагом 10, и кешируйте промежуточные значения. Однако, в этом случае могут появляться "дырки" и будет нарушен порядок по Id в случае нескольких инстансов.
vooft
11.01.2023 11:22Почему бы просто не использовать UUID и генерировать его прямо на клиенте? Или, если хочется последовательные id, то можно и ULID.
Drinkast
11.01.2023 14:25а какую БД используете? и наскольког Legacy?
default nextval('seq') для id должно бы помочь, если используете sequence.
panzerfaust
Обратите внимание на JOOQ в следующий раз. Ноль магии аннотаций, максимум помощи строгой типизации. Вот код, который инсертит и возвращает ID новых строк. Это Котлин, но разницы никакой.
К слову, можно возвращать строки целиком, если есть задача сразу же вернуть новые энтити. И классы энтити аннотировать никак не надо.
panzerfaust
Упустил момент про легаси базу. Но тогда можно вызвать сначала nextval. В общем в итоге получается ровно то же, что у вас.
vat78 Автор
JOOQ пока ни разу нигде не попадался, потому не могу его сравнить со спринговым JDBC. Но в приведенном примере не увидел, какое преймущество он добавляет. При этом запрос на SQL, на мой взгляд, проще поддерживать, т.к. SQL больше людей знает, чем api JOOQ
PrinceKorwin
Сила JOOQ в том, что синтаксис запроса к БД условно проверяет компилятор. При рефактоинге модели данных у вас код не скопилируется.
sshikov
Ну ради объективности, у меня например при разработке база недоступна. Не хочу сказать, что JOOQ никуда не годится (наоборот, мне нравится), но свои ограничения у этого подхода тоже есть.
mrsantak
У вас нет скрипта нактывающего чистую базу? Мы например для генерации jooq классов просто написали небольшой скриптик, который поднимает чистую бд в докере через testcontainers, накатывает flyway миграции, и запускает генерилку jooq'а.
sshikov
У меня вообще нет ни одной своей базы. Все базы, с которыми я работаю — не мои. И я не рассчитываю, что их схема когда-то будет зафиксирована или известная мне заранее. Вы исходите из своих представлений, что эти базы — часть вашего проекта. Это не всегда так.
panzerfaust
Попробуйте чисто ради тяги к открытиям. Я для себя такие плюсы JOOQ отметил:
Нет ничего неявного. Ты четко видишь запрос и четко понимаешь, когда он выполнится и что будет потом
Нет рефлексии и программирования на аннотациях
При некоторой помощи Котлина можно очень красиво оформлять билдинг сложных кондишнов, когда множество клауз опциональны и зависят от чего-то
Помощь от компилятора, как отметили в соседнем комменте
Полный контроль над маппингом из кортежа в джава-объект
Когда совсем уж хитровылизанный запрос, то можно в обход DSL записать его как обычный SQL-квери
Недостатки:
Писать вычурные аналитические запросы с CTE и оконками неудобно. Больше воюешь с DSL, а не с самой задачей. Но в то же время я еще ни разу не сдавался и таки писал запрос на DSL.
Раньше джук писал запросы, которые ок для Пострегрса, но не ок для Кликхауса. Буквально лишняя пара круглых скобок - и Кликхаус не может распарсить вопрос. Но это наверное уже исправили или к Клике или в Джуке.