Задача: в БД необходимо фиксировать кто создал сущность, кто её обновил, и кто её удалил.

Все знают, как взять пользователя из контекста и сунуть его в сущность. Допустим, на уровне сервиса в методе извлечь информацию о нём и «засетать» его в нужные поля (придётся везде таскать этот кусок кода по сервису), а с аспектами как‑то выглядит не явно и накладывает ряд обязательств (например, развешивание аннотаций над методами всякий раз, когда мы что‑то пытаемся сделать с сущностью (новые участники команды могут не знать о такой неявной практике, а старые забыть о ней)).

Мне хотелось полностью делегировать это приложению, но погуглив, я не нашёл какого‑то явного решения. Сейчас расскажу, как мне удалось это сделать:

Допустим у нас есть таблица с животными, которая содержит name, и уникальный код, который берётся из паспорта животного.

Давайте для начала создадим таблицу:

<changeSet id="create-table-animal.xml" author="alexander">
        <createTable tableName="animal">
            <column name="id" type="UUID">
                <constraints nullable="false" primaryKey="true"/>
            </column>
            <column name="name" type="VARCHAR(255)">
                <constraints nullable="false"/>
            </column>
            <column name="code" type="VARCHAR(255)">
                <constraints nullable="false"/>
            </column>
            <column name="created_at" type="TIMESTAMP">
                <constraints nullable="false"/>
            </column>
            <column name="updated_at" type="TIMESTAMP">
            </column>

<!— У удалённых сущностей будем просто проставлять дату удаления-->
            <column name="deleted_at" type="TIMESTAMP">
            </column>

<!— кто создал запись-->
            <column name="created_by" type="VARCHAR(255)">
            </column>
            <column name="created_by_mail" type="VARCHAR(255)">
            </column>

<!— кто обновил запись-->
            <column name="updated_by" type="VARCHAR(255)">
            </column>
            <column name="updated_by_mail" type="VARCHAR(255)">
            </column>

<!— кто удалил запись-->
            <column name="deleted_by" type="VARCHAR(255)">
            </column>
            <column name="deleted_by_mail" type="VARCHAR(255)">
            </column>
        </createTable>

<!-- уникальный код только для не удалённых животных-->
        <sql>
            create unique index unique_code on animal(code) where deleted_at is null;
        </sql>
    </changeSet>

Создадим стандартный суперкласс, который будет фиксировать дату создания и дату изменения, от которого будут наследоваться все сущности:

/**
 * Общий каркас для всех сущностей.
 */
@Getter
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractAuditableEntity {

    @CreatedDate
    @Column(updatable = false)
    @Type(type = "java.time.Instant")
    @EqualsAndHashCode.Include
    @ToString.Include
    private Instant createdAt;

    @LastModifiedDate
    @Type(type = "java.time.Instant")
    @EqualsAndHashCode.Include
    @ToString.Include
    private Instant updatedAt; 
}

Теперь создадим суперкласс для аудита пользователей:

/**
 * Суперкласс для аудита пользователей.
 */
@Setter
@Getter
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor
@MappedSuperclass
@EntityListeners(AuditUserListener.class)
public class AuditUser extends AbstractAuditableEntity {

    /**
     * ФИО кто создал.
     */
    @ToString.Include
    @Column(updatable = false)
    private String createdBy;

    /**
     * email кто создал.
     */
    @ToString.Include
    @Column(updatable = false)
    private String createdByMail;

    /**
     * ФИО кто обновил.
     */
    @ToString.Include
    private String updatedBy;

    /**
     * email кто обновил.
     */
    @ToString.Include
    private String updatedByMail;

    /**
     * ФИО кто удалил.
     */
    @ToString.Include
    private String deletedBy;

    /**
     * email кто удалил.
     */
    @ToString.Include
    private String deletedByMail;

}

Обратите внимание на AuditUserListener.class – его уже напишем мы сами.

@Configurable
@RequiredArgsConstructor(onConstructor_ = {@Lazy})
public class AuditUserListener {

    /**
     * Сервис, который отвечает за получение пользователя из контекста
     */
    private final UserBuilderFromContext userBuilderFromContext;

    private final EntityManager entityManager;

    @PrePersist
    private void beforeAnyCreate(AuditUser audit) {
        var user = userBuilderFromContext.getUserFromContext();
        audit.setCreatedBy(user.getFullName());
        audit.setCreatedByMail(user.getEmail());
    }

    @PreUpdate
    private void beforeAnyUpdate(AuditUser audit) {
        var user = userBuilderFromContext.getUserFromContext();
        audit.setUpdatedBy(user.getFullName());
        audit.setUpdatedByMail(user.getEmail());
    }

    @PreRemove
    private void beforeAnyRemove(AuditUser audit) {
        var user = userBuilderFromContext.getUserFromContext();
        audit.setDeletedBy(user.getFullName());
        audit.setDeletedByMail(user.getEmail());
        // при удалении сущность дёргается из бд по id но save  не вызывается
        // при удалении вызывается sql запрос проставляющий дату удаления
        // по этому нужен entityManager чтобы синхронизировать кеш с бд
        entityManager.flush();
    }

}

Внимание!: процедура проставления пользователя работает только в транзакции. Но т.к. мы работаем в многопоточном приложении, правильно, что все CRUD операции будут выполняться транзакционно.

Мы видим, что использовалась ленивая инициализация бинов:

@RequiredArgsConstructor(onConstructor_ = {@Lazy}

Если этого не сделать, получим проблему циклической зависимости:

Давайте отнаследуемся от суперкласса AuditUser, создав нашу entity Animal.

Если мы хотим фиксировать пользователя, который удалил сущность, то фактически удалять из бд её не будем, а значит надо будет настроить мягкое удаление (Soft delete).

/**
 * Entity Animal
 */
@Getter
@Setter
@ToString(onlyExplicitlyIncluded = true)
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
@Entity
@Table(name = "animal")
// Мягкое удаление
@SQLDelete(sql = "update animal set deleted_at=now() AT TIME ZONE 'UTC' where id = ?")
// Фильтр, чтобы не получать удалённые сущности из бд
@Where(clause = " deleted_at is null ")
public class Animal extends AuditUser {

    /**
     * Идентификатор.
     */
    @Id
    @GeneratedValue
    @ToString.Include
    @EqualsAndHashCode.Include
    protected UUID id;

    /**
     * Код животного по паспорту
     */
    @Column
    @ToString.Include
    private String code;

    /**
     * Имя животного
     */
    @Column
    @ToString.Include
    private String name;

    @Type(type = "java.time.Instant")
    @EqualsAndHashCode.Include
    @ToString.Include
    private Instant deletedAt;

}

Проверим:

curl -X 'POST' \
  'http://localhost:8080/api/v1/animal' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer ТУТ МОЙ JWT TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Мурзик",
  "code": "50 05 984 929"
}'
curl -X 'PUT' \
  'http://localhost:8080/api/v1/animal' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer ТУТ МОЙ JWT TOKEN' \
  -d '{
  "name": "Барсик",
  "code": "50 05 984 929",
  "id": "d085d72a-b15f-491b-8dbc-f6dbe3dba2e1"
}'
curl -X 'DELETE' \
  'http://localhost:8080/api/v1/animal' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer ТУТ МОЙ JWT TOKEN' \
  -H 'Content-Type: application/json' \
  -d '["d085d72a-b15f-491b-8dbc-f6dbe3dba2e1"]'

Результат

Теперь мы можем создавать любую Entity (операции, сертификаты, тикеты), наследуясь от AuditUser, и аудит пользователей будет работать. Больше не нужно добавлять аннотации ко всем методам, которые вносят изменения в сущность, кроме того, нет необходимости по сервисному слою таскать сервис получения пользователя из контекста и «сетать» его в необходимые поля. Приложение будет автоматически отслеживать пользователей, которые изменили сущность.

Комментарии (18)


  1. poxvuibr
    05.09.2023 08:17
    +3

    А зачем оставлять удалённых пользователей в той же таблице, в которой хранятся активные? В чём смысл? Почему их не копировать в отдельную таблицу?


    1. aleksandy
      05.09.2023 08:17
      +2

      Потому что автор молод, горяч и не вкусил всех прелестей и никому ненужных приседаний на ровном месте с soft-delete-ом.


      1. aleksey-stukalov
        05.09.2023 08:17

        И не читал вот эту статью: https://habr.com/ru/companies/haulmont/articles/579386/.


        1. aleksandy
          05.09.2023 08:17
          +1

          Да, хороший разбор. И как верно заметил @corporaldev в комментариях к ней

          Есть два типа людей: те, которые думают, что soft-delete - это генианльно и те, которые его уже выпилили из проекта.


          1. JavaDok Автор
            05.09.2023 08:17

            soft-delete не является deprecated и каждый инструмент подходит под решение разных задач. Мы не испытываем проблем при работе с ним.


        1. JavaDok Автор
          05.09.2023 08:17

          У нас записи удалённые остаются в бд чисто чтобы посмотреть что удалено и кто удалил (в рамках статьи). Поэтому нас часть проблем не касается.


    1. JavaDok Автор
      05.09.2023 08:17

      Если при удалении копировать запись в другую таблицу. В бд всегда найдётся куча данных, на которые ссылалась запись, придётся эти записи копировать в новую таблицу. Проще сразу использовать envers При мягком удалении не нарушается консистентность данных и нет необходимости создавать новую таблицу для каждой сущности.


      1. poxvuibr
        05.09.2023 08:17

        Проще сразу использовать envers

        Собственно об этом и был вопрос ))


  1. alamer
    05.09.2023 08:17
    +1

    https://hibernate.org/orm/envers/

    Чем не подошел?


    1. JavaDok Автор
      05.09.2023 08:17

      1. Не было задачи хранить всю историю изменений сущности, только кто создал/изменил/удалил.

      2. Отдельно таблицу заводить скажем под 10 сущностей = + 10 таблиц. И в случае чего это дополнительный join


      1. ris58h
        05.09.2023 08:17

        это дополнительный join

        И что это за случай такой, когда это является проблемой?


        1. alexzaides
          05.09.2023 08:17

          например какой нибудь тяжёлый отчёт


          1. ris58h
            05.09.2023 08:17

            Тяжёлый отчёт в реальном времени не отдаётся, а спокойно бежит в фоне.


  1. ris58h
    05.09.2023 08:17

    Зачем данные о пользователях хранить в сущности? ID недостаточно?

    Мне хотелось полностью делегировать это приложению, но погуглив, я не нашёл какого-то явного решения.

    Вроде есть какой-то аудит из коробки https://docs.spring.io/spring-data-jpa/reference/auditing.html


    1. JavaDok Автор
      05.09.2023 08:17

      Идентификатор пользователя не информативно


      1. ris58h
        05.09.2023 08:17
        +1

        Зато приводит к дупликации данных, увеличению их объёма и непоняткам, если пользователь свои данные поменял.


        1. JavaDok Автор
          05.09.2023 08:17
          +1

          Наверное, мы друг друга не поняли, идентификатор сохранять не информативно для примера, конечно, я с вами согласен, ID более чем достаточно.


    1. aleksey-stukalov
      05.09.2023 08:17

      Конечно есть! Вот тут за 2 мин показано как оно работает https://www.youtube.com/watch?v=1D5zEzLX1iY.