Задача: в БД необходимо фиксировать кто создал сущность, кто её обновил, и кто её удалил.
Все знают, как взять пользователя из контекста и сунуть его в сущность. Допустим, на уровне сервиса в методе извлечь информацию о нём и «засетать» его в нужные поля (придётся везде таскать этот кусок кода по сервису), а с аспектами как‑то выглядит не явно и накладывает ряд обязательств (например, развешивание аннотаций над методами всякий раз, когда мы что‑то пытаемся сделать с сущностью (новые участники команды могут не знать о такой неявной практике, а старые забыть о ней)).
Мне хотелось полностью делегировать это приложению, но погуглив, я не нашёл какого‑то явного решения. Сейчас расскажу, как мне удалось это сделать:
Допустим у нас есть таблица с животными, которая содержит 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)
alamer
05.09.2023 08:17+1https://hibernate.org/orm/envers/
Чем не подошел?
JavaDok Автор
05.09.2023 08:17Не было задачи хранить всю историю изменений сущности, только кто создал/изменил/удалил.
Отдельно таблицу заводить скажем под 10 сущностей = + 10 таблиц. И в случае чего это дополнительный join
ris58h
05.09.2023 08:17это дополнительный join
И что это за случай такой, когда это является проблемой?
ris58h
05.09.2023 08:17Зачем данные о пользователях хранить в сущности? ID недостаточно?
Мне хотелось полностью делегировать это приложению, но погуглив, я не нашёл какого-то явного решения.
Вроде есть какой-то аудит из коробки https://docs.spring.io/spring-data-jpa/reference/auditing.html
JavaDok Автор
05.09.2023 08:17Идентификатор пользователя не информативно
ris58h
05.09.2023 08:17+1Зато приводит к дупликации данных, увеличению их объёма и непоняткам, если пользователь свои данные поменял.
JavaDok Автор
05.09.2023 08:17+1Наверное, мы друг друга не поняли, идентификатор сохранять не информативно для примера, конечно, я с вами согласен, ID более чем достаточно.
aleksey-stukalov
05.09.2023 08:17Конечно есть! Вот тут за 2 мин показано как оно работает https://www.youtube.com/watch?v=1D5zEzLX1iY.
poxvuibr
А зачем оставлять удалённых пользователей в той же таблице, в которой хранятся активные? В чём смысл? Почему их не копировать в отдельную таблицу?
aleksandy
Потому что автор молод, горяч и не вкусил всех прелестей и никому ненужных приседаний на ровном месте с soft-delete-ом.
aleksey-stukalov
И не читал вот эту статью: https://habr.com/ru/companies/haulmont/articles/579386/.
aleksandy
Да, хороший разбор. И как верно заметил @corporaldev в комментариях к ней
JavaDok Автор
soft-delete не является deprecated и каждый инструмент подходит под решение разных задач. Мы не испытываем проблем при работе с ним.
JavaDok Автор
У нас записи удалённые остаются в бд чисто чтобы посмотреть что удалено и кто удалил (в рамках статьи). Поэтому нас часть проблем не касается.
JavaDok Автор
Если при удалении копировать запись в другую таблицу. В бд всегда найдётся куча данных, на которые ссылалась запись, придётся эти записи копировать в новую таблицу. Проще сразу использовать envers При мягком удалении не нарушается консистентность данных и нет необходимости создавать новую таблицу для каждой сущности.
poxvuibr
Собственно об этом и был вопрос ))