Каждый раз, когда необходимо сделать сервис на 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)


  1. panzerfaust
    10.01.2023 13:02
    +3

    Обратите внимание на JOOQ в следующий раз. Ноль магии аннотаций, максимум помощи строгой типизации. Вот код, который инсертит и возвращает ID новых строк. Это Котлин, но разницы никакой.

    val newIds: List<Long> = dsl.insertInto(TABLE_NAME)
      .columns(COLUMN1, COLUMN2, ...)
      .values(value1, value2, ...)
      .onConflictDoNothing()
      .returning(ID)
      .fetch()
      .map{it[ID]}

    К слову, можно возвращать строки целиком, если есть задача сразу же вернуть новые энтити. И классы энтити аннотировать никак не надо.


    1. panzerfaust
      10.01.2023 14:01

      Упустил момент про легаси базу. Но тогда можно вызвать сначала nextval. В общем в итоге получается ровно то же, что у вас.

      val newId = dsl.nextval(MY_TABLE_SEQ)
      dsl.insertInto(TABLE_NAME)
        .columns(ID, COLUMN2, ...)
        .values(newId, value2, ...)
        .execute()
      return newId


      1. vat78 Автор
        10.01.2023 15:25
        +2

        JOOQ пока ни разу нигде не попадался, потому не могу его сравнить со спринговым JDBC. Но в приведенном примере не увидел, какое преймущество он добавляет. При этом запрос на SQL, на мой взгляд, проще поддерживать, т.к. SQL больше людей знает, чем api JOOQ


        1. PrinceKorwin
          10.01.2023 17:00

          Сила JOOQ в том, что синтаксис запроса к БД условно проверяет компилятор. При рефактоинге модели данных у вас код не скопилируется.


          1. sshikov
            10.01.2023 19:53

            Ну ради объективности, у меня например при разработке база недоступна. Не хочу сказать, что JOOQ никуда не годится (наоборот, мне нравится), но свои ограничения у этого подхода тоже есть.


            1. mrsantak
              10.01.2023 21:17

              У вас нет скрипта нактывающего чистую базу? Мы например для генерации jooq классов просто написали небольшой скриптик, который поднимает чистую бд в докере через testcontainers, накатывает flyway миграции, и запускает генерилку jooq'а.


              1. sshikov
                10.01.2023 21:32

                У меня вообще нет ни одной своей базы. Все базы, с которыми я работаю — не мои. И я не рассчитываю, что их схема когда-то будет зафиксирована или известная мне заранее. Вы исходите из своих представлений, что эти базы — часть вашего проекта. Это не всегда так.


        1. panzerfaust
          10.01.2023 17:16
          +2

          Попробуйте чисто ради тяги к открытиям. Я для себя такие плюсы JOOQ отметил:

          • Нет ничего неявного. Ты четко видишь запрос и четко понимаешь, когда он выполнится и что будет потом

          • Нет рефлексии и программирования на аннотациях

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

          • Помощь от компилятора, как отметили в соседнем комменте

          • Полный контроль над маппингом из кортежа в джава-объект

          • Когда совсем уж хитровылизанный запрос, то можно в обход DSL записать его как обычный SQL-квери

          Недостатки:

          • Писать вычурные аналитические запросы с CTE и оконками неудобно. Больше воюешь с DSL, а не с самой задачей. Но в то же время я еще ни разу не сдавался и таки писал запрос на DSL.

          • Раньше джук писал запросы, которые ок для Пострегрса, но не ок для Кликхауса. Буквально лишняя пара круглых скобок - и Кликхаус не может распарсить вопрос. Но это наверное уже исправили или к Клике или в Джуке.


  1. 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);
        }
      }


    1. vat78 Автор
      10.01.2023 14:05

      Как раз jpa (hibernate) и не хочется использовать..


      1. mmMike
        10.01.2023 14:23

        я могу и ошибаться, но мне казалось, что Repository автоматически тянет за собой hibernate (код, инициализация и пр.). Использовать hibernate (через Repository) исключительно для native запросов - это как то наплевать на потребляемы программой ресурсы. Лучше уж голый JDBC сразу.
        Хотя.. хозяин - барин.


        1. vat78 Автор
          10.01.2023 15:00

          Spring Data JDBC не использует Hibernate. И reflection, вроде, тоже


    1. mmMike
      10.01.2023 14:07

      nextval(

      Если конечно заведомо знать, что работаешь с конкретной БД, то можно и хардкодить синтаксис.
      Но может оказаться что в других БД (Oracle, в частности) другой синтаксис получения значения из sequence


      1. vat78 Автор
        10.01.2023 15:30

        Если использовать SQL для запросов, то, конечно, придется затачиваться под конкретную базу данных. Но если при этом все запросы собраны в одном месте, а не разбросаны по коду, то миграция на другую базу не занимает много времени.
        Зато полная свобода в оптимизации запросов с учетом специфики конкретной базы


  1. Akon32
    10.01.2023 14:03

    public record MyEntity

    Выглядит как оксюморон в контексте Domain-Driven Design.

    DDD различает и противопоставляет объекты-значения (value objects), которые иммутабельны и не имеют идентичности, и сущности (entity), которые могут быть различными при одинаковых значениях полей, и поэтому имеют идентификаторы.

    Насколько я понимаю, record в java - это для объектов-значений. У них не может быть идентификатора (у них нет идентичности: 2 record с одинаковыми полями не могут различаться), и нет проблем с его установкой.


    1. vat78 Автор
      10.01.2023 15:41

      В концепции DDD и гексагональной архитектуры entity запросто может быть особым типом dto для взаимодействия с внешним хранилищем. А для dto рекорды вполне себе удобны, при остуствии дата-классов. Хотя, согласен, что, могут быть сценарии, когда это может привести к проблемам


  1. 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 вкурсе об этой возможности и скорее всего у них есть более удобный способ утилизировать эти возможности.


    1. vat78 Автор
      10.01.2023 15:56

      > вы пытаетесь сидеть на двух стульях

      именно ) И пока Spring Data JDBC мой основной кандидат на средство, которое позволит этого достичь.


      > Подозреваю что все развитые библиотеки-помошники в работе с JDBC вкурсе об этой возможности


      Собственно удивление, что Spring Data JDBC это не умеет и предлагает это решать, на мой взгляд, кривовато, и стало причиной этой статьи


      1. GerrAlt
        10.01.2023 16:54

        А чем вас не устраивает использование JPA? Хочется на уровне энтити работать - пожалуйста, хочется запрос какой-то особенный - есть native query

        Все ради того чтобы экономить строчки на аннотациях?


        1. vat78 Автор
          11.01.2023 14:41

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


  1. MadMaxLab
    10.01.2023 14:52
    +2

    Позволю себе небольшой совет - попробуйте сделать пет проект на JPA с чистого листа, используя рекомендуемые сообществом best practice и не пытаясь принести туда привычные вам наработки в виде кастомных sequence и record в виде entity.
    Это позволит продвинуться дальше и понять, к примеру, что entity по задумке фреймворка мутабельный объект и генерация ID это еще малая беда. Дальше можно словить проблемы с Lazy Loading, апдейту связей итд.

    Этим всем конечно можно не пользоваться, но тогда встает вопрос нужен ли вам этот JPA.

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


    1. vat78 Автор
      10.01.2023 15:47

      Я очень много использовал JPA и использую, когда это оправдано. JPA это идеальное решение для CRUD сервисов.
      Но когда нужны сложные запросы к базе, то даже на голом jdbc получается проще писать и поддерживать. Именно поэтому Spring Data JDBC манит своей идеей получить преймущества из обоих миров


      1. a_belyaev
        10.01.2023 16:30

        Там еще веселье будет, если нужно много записей вставлять. Hibernate может делать что-то типа кэша ID, так что не бегает за каждым ID в базу. Если такого добиваться вручную, придется немного повозиться.

        А еще с batch операциями придется повозиться, кстати.


        1. vat78 Автор
          10.01.2023 18:48

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


      1. znepok
        11.01.2023 14:19

        Как раз таки JPA абсолютно избыточное решение для CRUD. Если вы используете ORM для CRUD, значит вы не поняли суть и философию ORM, как и суть Entity.


        1. vat78 Автор
          11.01.2023 15:06

          На мой взгляд, задача классического ORM (и JPA в частности) - "синхронизировать" состояние объектов приложения (энтити) с соответствующими записями в таблицах базе, учитывая все их связи между собой. А это, по сути, и есть CRUD операции.

          Поэтому очень хотелось бы, чтоб вы как-то более детально раскрыли ваше видение философии ORM и суть энтити


          1. znepok
            11.01.2023 20:13

            Spring 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 за тормознутость.

            Наверное, для данной дискуссии нужно больше предметности, примеры приложений.


            1. vat78 Автор
              12.01.2023 10:26

              Спасибо, теперь я понял в чем различие наших взглядов. Просто я к CRUD отношу еще сервисы, которые что-то берут из базы, меняют и сохраняют изменения обратно. Т.е. когда у нас объекты-энтити мутабельные. Для таких сценариев да, ORM идеальное решение.
              А вот Spring Data Jdbc как раз не про "синхронизацию" с базой. Это просто более простой способ делать jdbc запросы и мапить результаты на объекты. Тут нет энтити в понимании ORM, т.к. у них нет жизненного цикла. Поэтому для сервисов, в которых база, это лишь как еще одна внешняя система (например, некий аналитический сервис), я и предпочитаю использовать немутабельные ентити и что-то полегче, чем полноценный ORM.


    1. sshikov
      10.01.2023 19:56

      >привычные вам наработки в виде кастомных sequence
      Представьте, что база не ваша, и вообще легаси. Какие sequence дали, такими и приходится пользоваться. Ваш подход вполне осмысленный, но не универсальный (как наверное и любой другой).


  1. 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)


    1. vat78 Автор
      10.01.2023 18:55

      Да, но как отметил в ps, в этом конкретном случае на старой версии Oracle средствами базы не удалось найти способа id вытащить из insert запроса. Если только процедуру в базе писать


      1. itatsiy
        11.01.2023 05:56

        Да, видел, как пример написал, возможно кому-то будет полезно.


  1. oxff
    10.01.2023 19:34

    Извините, но я никак не пойму в чём у вас проблема. В соответствии с документацией, для авто-инкрементных полей не нужно писать дополнительного кода вообще. У вас ведь Postgres, почему вы не можете просто объявить поля соответственно, используя легаси SERIAL или современный GENERATED ... AS IDENTITY?

    Сиквенс у вас уже есть, и вы его пытаетесь использовать. А тип SERIAL это просто синтаксический сахар, который создаёт сиквенс и объявляет поле как NOT NULL DEFAULT nextval('seq_name')

    Плюс этого в том, что вам не нужно делать дополнительный запрос к БД для получения ID.


  1. Throwable
    11.01.2023 10:11

    Можно оптимизировать, как делают многие JPA: сделайте SQL sequence сразу с шагом 10, и кешируйте промежуточные значения. Однако, в этом случае могут появляться "дырки" и будет нарушен порядок по Id в случае нескольких инстансов.


  1. vooft
    11.01.2023 11:22

    Почему бы просто не использовать UUID и генерировать его прямо на клиенте? Или, если хочется последовательные id, то можно и ULID.


  1. Drinkast
    11.01.2023 14:25

    а какую БД используете? и наскольког Legacy?

    default nextval('seq') для id должно бы помочь, если используете sequence.


    1. vat78 Автор
      11.01.2023 15:15

      oracle 8-ка


      1. Mikl_m13
        11.01.2023 16:25

        O, тогда отлично все делаете - default seq.nextval не работает - только с Oracle 12c, jooq может даже не знать про особенности и ограничения синтаксиса sql Oracle 8, uuid не поддерживается как тип и т.д.