Команда 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)


  1. LeshaRB
    08.10.2024 13:22

    Надо было мне версионность, использовал либу Javers


  1. ris58h
    08.10.2024 13:22

    Получить некоторую ревизию для Post - это, конечно, хорошо, а вот как предполагается получить соответствующую ей на тот момент ревизии PostComment?

    UPD: конкретно в этом пример с PostComment может и не очень понятно зачем, но в случае one-to-one с дополнительной информацией о Post это может понадобиться.