Команда Spring АйО перевела статью, которая отлично подойдёт тем, кто ещё не знаком со Spring Data Envers. В статье на простых примерах объясняется, как отслеживать изменения данных в приложении, используя этот инструмент.
Введение
В этой статье мы рассмотрим проект Spring Data Envers и разберемся, как извлечь из него максимум пользы.
Hibernate Envers — это расширение Hibernate ORM, которое позволяет отслеживать изменения сущностей с минимальными изменениями на уровне приложения.
Так же, как Envers интегрируется с Hibernate ORM для ведения журнала изменений сущностей, проект Spring Data Envers подключается к Spring Data JPA, чтобы добавить возможность ведения журнала изменений с использованием JPA репозиториев.
Доменная модель
Предположим, у нас есть сущность Post
, которая отмечена аннотацией @Audited
из проекта Hibernate Envers:
@Entity
@Table(name = "post",
uniqueConstraints = @UniqueConstraint(
name = "UK_POST_SLUG", columnNames = "slug"
)
)
@Audited
public class Post { ⠀
@Id
@GeneratedValue
private Long id; ⠀
@Column(length = 100)
private String title; ⠀
@NaturalId
@Column(length = 75)
private String slug; ⠀
@Enumerated(EnumType.ORDINAL)
@Column(columnDefinition = "NUMERIC(2)")
private PostStatus status;
}
Сущность Post
имеет дочернюю сущность PostComment
, которая также аннотирована @Audited
:
@Entity
@Table(name = "post_comment")
@Audited
public class PostComment { ⠀
@Id
@GeneratedValue
private Long id; ⠀
@Column(length = 250)
private String review; ⠀
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(foreignKey = @ForeignKey(name = "FK_POST_COMMENT_POST_ID"))
private Post post;
}
Скрытый текст
Как я объяснял в этой статье, мы будем использовать стратегию ValidityAuditStrategy
, так как она может ускорить выполнение запросов связанных с журналом изменений.
Чтобы включить стратегию ValidityAuditStrategy
, необходимо установить следующее свойство Hibernate-конфигурации:
properties.setProperty(
EnversSettings.AUDIT_STRATEGY,
ValidityAuditStrategy.class.getName()
);
При генерации схемы с использованием инструмента hbm2ddl, Hibernate создаст следующие таблицы в базе данных:
Каждый раз, когда транзакция завершается, создается ревизия, которая сохраняется в таблице revinfo
.
Таблица post_aud
отслеживает изменения записей в таблице post
, а таблица post_comment_aud
хранит информацию о журнале изменений для таблицы post_comment
.
Spring Data Envers Репозитории
Проект Spring Data Envers предоставляет интерфейс RevisionRepository, который ваши JPA-репозитории могут расширять, чтобы добавить возможность выполнения запросов связанных с журналом изменений.
Например, репозиторий PostRepository
расширяет JpaRepository
из Spring Data JPA и RevisionRepository
из Spring Data Envers:
@Repository
public interface PostRepository extends JpaRepository<Post, Long>,
RevisionRepository<Post, Long, Long> {
}
Точно так же PostCommentRepository
расширяет как JpaRepository
, так и RevisionRepository
из Spring Data Envers:
@Repository
public interface PostCommentRepository extends JpaRepository<PostComment, Long>,
RevisionRepository<PostComment, Long, Long> {
void deleteByPost(Post post);
}
На сервисном слое у нас есть класс PostService
, который предоставляет методы для сохранения и удаления сущностей Post
и PostComment
. Эти методы помогут нам увидеть, как работает механизм ведения журнала изменений:
@Transactional(readOnly = true)
public class PostService {
@Autowired
private PostRepository postRepository;
@Autowired
private PostCommentRepository postCommentRepository;
@Transactional
public Post savePost(Post post) {
return postRepository.save(post);
}
@Transactional
public Post savePostAndComments(Post post,
PostComment... comments) {
post = postRepository.save(post);
if (comments.length > 0) {
postCommentRepository.saveAll(Arrays.asList(comments));
}
return post;
}
@Transactional
public void deletePost(Post post) {
postCommentRepository.deleteByPost(post);
postRepository.delete(post);
}
}
Отслеживание операций INSERT, UPDATE и DELETE
При создании родительской сущности Post
вместе с двумя дочерними сущностями PostComment
:
Post post = new Post()
.setTitle("High-Performance Java Persistence 1st edition")
.setSlug("high-performance-java-persistence")
.setStatus(PostStatus.APPROVED);
postService.savePostAndComments(
post,
new PostComment()
.setPost(post)
.setReview("A must-read for every Java developer!"),
new PostComment()
.setPost(post)
.setReview("Best book on JPA and Hibernate!")
);
Hibernate сгенерирует следующие SQL-запросы:
SELECT nextval('post_SEQ')
SELECT nextval('post_comment_SEQ')
SELECT nextval('post_comment_SEQ')
INSERT INTO post (slug, status, title, id)
VALUES ( 'high-performance-java-persistence', 1, 'High-Performance Java Persistence 1st edition', 1 )
INSERT INTO post_comment (post_id, review, id)
VALUES ( 1, 'A must-read for every Java developer!', 1 ),
( 1, 'Best book on JPA and Hibernate!', 2 )
SELECT nextval('REVINFO_SEQ')
INSERT INTO REVINFO (REVTSTMP, REV)
VALUES (1726724588078, 1)
INSERT INTO post_AUD (REVEND, REVTYPE, slug, status, title, REV, id)
VALUES ( null, 0, 'high-performance-java-persistence', 1, 'High-Performance Java Persistence 1st edition', 1, 1 )
INSERT INTO post_comment_AUD (REVEND, REVTYPE, post_id, review, REV, id)
VALUES ( null, 0, 1, 'A must-read for every Java developer!', 1, 1 ),
( null, 0, 1, 'Best book on JPA and Hibernate!', 1, 2 )
В то время как Hibernate ORM выполняет INSERT-запросы для записей в таблицах post
и post_comment
, Hibernate Envers создает записи в таблицах REVINFO
, post_AUD
и post_comment_AUD
.
При изменении сущности Post
:
post.setTitle("High-Performance Java Persistence 2nd edition");
postService.savePost(post);
Hibernate сгенерирует следующие запросы:
SELECT p1_0.id, p1_0.slug, p1_0.status, p1_0.title
FROM post p1_0
WHERE p1_0.id = 1 UPDATE post
SET status = 1, title = 'High-Performance Java Persistence 2nd edition'
WHERE id = 1
SELECT nextval('REVINFO_SEQ')
INSERT INTO REVINFO (REVTSTMP, REV)
VALUES (1726724799884, 2)
INSERT INTO post_AUD (REVEND, REVTYPE, slug, status, title, REV, id)
VALUES ( null, 1, 'high-performance-java-persistence', 1, 'High-Performance Java Persistence 2nd edition', 2, 1 )
UPDATE post_AUD
SET REVEND = 2
WHERE id = 1 AND REV <> 2 AND REVEND IS NULL
Обратите внимание, что была создана новая запись в REVINFO
, которая связана с записью в post_AUD
.
А при удалении сущности Post
:
postService.deletePost(post);
Hibernate выполнит следующие запросы:
SELECT pc1_0.id, pc1_0.post_id, pc1_0.review
FROM post_comment pc1_0
WHERE pc1_0.post_id = 1
SELECT p1_0.id,p1_0.slug,p1_0.status,p1_0.title
FROM post p1_0
WHERE p1_0.id = 1
DELETE
FROM post_comment
WHERE id = 1
DELETE
FROM post_comment
WHERE id = 2
DELETE
FROM post
WHERE id = 1
INSERT
INTO REVINFO (REVTSTMP, REV)
VALUES (1726724982890, 3)
INSERT
INTO post_comment_AUD (REVEND, REVTYPE, post_id, review, REV, id)
VALUES ( null, 2, null, null, 3, 1 ),
( null, 2, null, null, 3, 2 )
INSERT
INTO post_AUD (REVEND, REVTYPE, slug, status, title, REV, id)
VALUES ( null, 2, null, null, null, 3, 1 )
UPDATE post_comment_AUD
SET REVEND = 3
WHERE id = 1 AND REV <> 3 AND REVEND IS NULL
UPDATE post_comment_AUD
SET REVEND = 3
WHERE id = 2 AND REV <> 3 AND REVEND IS NULL
UPDATE post_AUD
SET REVEND = 3
WHERE id = 1 AND REV <> 3 AND REVEND IS NULL
Загрузка ревизий с использованием Spring Data Envers
Интерфейс RevisionRepository
из Spring Data Envers предоставляет несколько методов для загрузки ревизий сущностей.
Например, если вы хотите загрузить последнюю ревизию сущности Post
, можно использовать метод findLastChangeRevision
, который унаследован от RevisionRepository
:
Revision<Long, Post> latestRevision = postRepository.findLastChangeRevision(post.getId())
.orElseThrow();
LOGGER.info("The latest Post entity operation was [{}] at revision [{}]", latestRevision.getMetadata()
.getRevisionType(), latestRevision.getRevisionNumber()
.orElseThrow());
При запуске примера вы увидите следующее сообщение в логах:
The latest Post entity operation was [DELETE] at revision [3]
Чтобы загрузить все ревизии для сущности, можно использовать метод findRevisions
, который также унаследован от RevisionRepository
:
for (Revision<Long, Post> revision : postRepository.findRevisions(post.getId())) {
LOGGER.info(
"At revision [{}], the Post entity state was: [{}]",
revision.getRevisionNumber().orElseThrow(),
revision.getEntity()
);
}
При запуске этого кода в логах появятся следующие записи:
At revision [1], the Post entity state was: [
{ id = 1,
title = 'High-Performance Java Persistence 1st edition',
slug = 'high-performance-java-persistence',
status = APPROVED
}
]
At revision [2], the Post entity state was: [
{ id = 1,
title = 'High-Performance Java Persistence 2nd edition',
slug = 'high-performance-java-persistence',
status = APPROVED
}
]
At revision [3], the Post entity state was: [
{ id = 1,
title = null,
slug = null,
status = null
}
]
Загрузка ревизий с использованием постраничной выборки
Предположим, мы создали несколько ревизий для сущности Post
:
Post post = new Post()
.setTitle("Hypersistence Optimizer, version 1.0.0")
.setSlug("hypersistence-optimizer")
.setStatus(PostStatus.APPROVED);
postService.savePost(post);
for (int i = 1; i < 20; i++) {
post.setTitle(String.format(
"Hypersistence Optimizer, version 1.%d.%d",
i / 10,
i % 10)
);
postService.savePost(post);
}
Мы можем загружать ревизии с постраничной выборкой с помощью метода findRevisions(ID id, Pageable pageable)
.
Например, чтобы получить первую страницу с ревизиями в порядке убывания, можно использовать PageRequest
, как показано в следующем примере:
int pageSize = 10;
Page<Revision<Long, Post>> firstPage = postRepository.findRevisions(
post.getId(),
PageRequest.of(0, pageSize, RevisionSort.desc())
);
logPage(firstPage);
При запуске этого кода для первой страницы мы увидим следующие ревизии:
Скрытый текст
At revision [23], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.9',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [22], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.8',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [21], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.7',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [20], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.6',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [19], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.5',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [18], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.4',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [17], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.3',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [16], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.2',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [15], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.1',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [14], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.1.0',
slug='hypersistence-optimizer',
status=APPROVED
}
]
При логировании ревизий, полученных для второй страницы:
Page<Revision<Long, Post>> secondPage = postRepository.findRevisions(
post.getId(),
PageRequest.of(1, pageSize, RevisionSort.desc())
);
logPage(secondPage);
В логах появятся следующие записи:
Скрытый текст
At revision [13], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.9',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [12], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.8',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [11], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.7',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [10], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.6',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [09], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.5',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [08], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.4',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [07], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.3',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [06], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.2',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [05], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.1',
slug='hypersistence-optimizer',
status=APPROVED
}
]
At revision [04], the Post entity state was: [
Post{id=2,
title='Hypersistence Optimizer,
version 1.0.0',
slug='hypersistence-optimizer',
status=APPROVED
}
]
Здорово, правда?
Заключение
Хотя существует множество CDC (Change Data Capture) решений для отслеживания изменений сущностей, Envers, вероятно, является самым простым вариантом, если вы уже используете Hibernate ORM.
А если вы используете Spring Data JPA, то с помощью Spring Data Envers можно добавить в ваши репозитории возможность работы с ревизиями.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Ждем всех, присоединяйтесь
Комментарии (2)
ris58h
08.10.2024 13:22Получить некоторую ревизию для
Post
- это, конечно, хорошо, а вот как предполагается получить соответствующую ей на тот момент ревизииPostComment
?UPD: конкретно в этом пример с PostComment может и не очень понятно зачем, но в случае one-to-one с дополнительной информацией о Post это может понадобиться.
LeshaRB
Надо было мне версионность, использовал либу Javers