JavaRush /Курсы /Hibernate deep-dive /Чтение истории через Audit...

Чтение истории через AuditReader

Hibernate deep-dive
21 уровень , 3 лекция
Открыта

1. Роль AuditReader

Как только граница аудита определена, быстро выясняется следующая практическая боль: руками читать *_AUD неудобно.

Если вы хоть раз открывали audit-таблицу и пытались «на глаз» восстановить историю, вы знаете это чувство: вроде всё рядом, но мозг начинает тихо просить отпуск. *_AUD — это данные в формате «для машины»: удобно хранить, но неудобно читать, особенно когда нужна не одна строка, а последовательность событий. AuditReader делает то же самое, но в человеческом API: даёт список ревизий, позволяет взять снимок сущности на ревизии и помогает построить понятную timeline изменений, не заставляя вас вручную склеивать REVINFO, REVTYPE и колонки из *_AUD.

Самая важная мысль: Envers хранит историю как данные, значит и читать мы их хотим как данные — через API, в Java-коде, внутри сервисного сценария, который можно тестировать и развивать.

Чтобы визуально уложить модель в голову, можно представить такой поток:

flowchart TD
    A["Service method
@Transactional(readOnly=true)"] --> B[EntityManager] B --> C["AuditReaderFactory.get(em)"] C --> D[AuditReader] D --> E["REVINFO + *_AUD"] E --> F["Снимки entity на ревизиях"]

Получение AuditReader в Spring-приложении

AuditReader не живёт сам по себе. Он появляется «поверх» вашего обычного EntityManager (а точнее — поверх Hibernate Session, который внутри него). Поэтому главный практический момент: чтобы получить AuditReader, у вас должен быть живой EntityManager в контексте транзакции. В нашем курсовом baseline spring.jpa.open-in-view=false, так что рассчитывать на «а вдруг оно как-нибудь прочитается из веб-слоя» нельзя и не нужно — читаем историю там, где ей место: в сервисе, внутри @Transactional(readOnly = true).

В проекте Commerce Persistence Lab удобно держать код чтения истории в пакете com.example.commerce.audit.query: это именно query-логика, а не изменение состояния.

Минимальный каркас сервиса для чтения Envers-истории может выглядеть так:

import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.springframework.stereotype.Service;

@Service
class ProductAuditQueryService {
    // EntityManager нужен «живой», поэтому сервис предполагает вызов из @Transactional
    private final EntityManager em;

    ProductAuditQueryService(EntityManager em) {
        // Инжектим обычный EntityManager; AuditReader строится поверх него
        this.em = em;
    }

    AuditReader reader() {
        // Получаем AuditReader, привязанный к текущему EntityManager/Session
        return AuditReaderFactory.get(em);
    }
}

Обратите внимание на самоиронию ситуации: чтобы путешествовать по прошлому, нам нужен… обычный EntityManager. Никаких порталов, молний и машины DeLorean — только Spring и Hibernate. Скучно, зато работает.

2. Ревизии и снимки сущности

Список ревизий getRevisions(entityClass, id)

Первый шаг чтения истории обычно очень приземлённый: мы хотим узнать, на каких ревизиях сущность вообще менялась. И Envers даёт для этого максимально прямой метод: AuditReader#getRevisions(Class, id). Он возвращает список номеров ревизий (List<Number>). Это не «версии сущности» и не @Version, а именно ревизии Envers — глобальные точки истории, которые создаются при коммите транзакций, где менялись audited-сущности.

Есть два нюанса, которые важно проговорить сразу, иначе дальше будет путаница. Во-первых, список ревизий может быть не подряд: 1, 2, 3 — это мечта, а реальность скорее 12, 18, 27, потому что ревизия общая для всей системы, а не только для одного товара. Во-вторых, ревизия создаётся на транзакцию: если в одной транзакции вы поменяли и Product, и PurchaseOrder, у них будет один и тот же номер ревизии.

Пример метода, который возвращает ревизии товара:

import java.util.List;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Number> getProductRevisions(long productId) {
    // Возвращаем именно номера ревизий Envers, а не @Version
    // reader() — это AuditReader, полученный через AuditReaderFactory.get(EntityManager)
    return reader().getRevisions(
            com.example.commerce.catalog.entity.Product.class,
            productId
    );
}

Чтобы закрепить разницу между «ревизия» и «версия», полезно один раз посмотреть на них в сравнении:

Понятие Где хранится Зачем нужно Меняется когда
@Version (optimistic locking) В основной таблице сущности Защищает от lost update При каждом UPDATE сущности (в рамках ORM-обновления)
Envers revision (REVINFO.id) В REVINFO + *_AUD Хранит историю состояний Когда коммитится транзакция с изменением audited-данных

Если держать это в голове, станет проще не перепутать «механизм корректности конкурентной записи» и «механизм историчности».

Снимок на ревизии reader.find(entityClass, id, revision)

Когда мы получили номера ревизий, следующий логичный шаг — взять снимок сущности на выбранной ревизии. Для этого есть метод AuditReader#find(Class, id, revision). Он возвращает объект сущности, заполненный данными из audit-таблиц, то есть это реконструированное состояние «как было тогда». Такой объект стоит воспринимать как read-only снимок: его можно читать, сравнивать, превращать в DTO, показывать пользователю или сохранять в отчёт, но вот идея «изменю его и сделаю save» — это прямой билет в клуб странных багов (и мы туда не стремимся).

И здесь важно помнить границу аудита. Envers восстанавливает не «полностью оживлённую сущность из прошлого», а тот исторический срез, который вы реально сохраняли. Если у Product в историю попали только price, status и deleted, а у PurchaseOrder исключены items, то именно этот срез вы и читаете — поэтому дальше так естественно маппить историю в узкие DTO/read-model.

Самое приятное в find(...) — он читается так, будто вы делаете обычный entityManager.find(...), только с параметром времени в виде revision. Прямолинейно и без лишней магии.

Пример для Product:

import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public com.example.commerce.catalog.entity.Product getProductAtRevision(
        long productId, Number revision
) {
    // find(...) возвращает «снимок» из *_AUD на указанной ревизии
    // Этот объект лучше воспринимать как read-only данные для отображения/сравнения
    return reader().find(
            com.example.commerce.catalog.entity.Product.class,
            productId,
            revision
    );
}

На уровне мышления это удобно представлять как «открыть фотоальбом». Основная таблица — это «текущее фото профиля», а Envers — архив снимков. find(..., revision) — это «покажи мне фото на странице №17».

3. История как события: типы и запросы

RevisionType и запрос forRevisionsOfEntity(...)

Иногда одного снимка недостаточно. Например, вы строите ленту событий и хотите видеть не только состояние на ревизии, но и тип изменения: это было добавление (ADD), модификация (MOD) или удаление (DEL). В Envers это выражено через RevisionType (по смыслу это почти то же, что и REVTYPE, только в виде enum-а). Чтобы получить RevisionType, обычно используют audit-запрос, который возвращает не только сущность, но и метаданные ревизии.

Здесь появляется важная конструкция: reader.createQuery().forRevisionsOfEntity(...). У неё есть два булевых флага, которые часто путают, поэтому проговорим их человеческим языком. Если selectEntitiesOnly=true, вы получаете только список сущностей (без revision entity и без revision type). Если selectEntitiesOnly=false, Envers вернёт строки вида Object[], где лежат: 1) снимок сущности, 2) revision entity, 3) RevisionType. Второй флаг selectDeletedEntities отвечает, включать ли в выдачу удаления.

Базовый запрос для истории одного Product по id выглядит так:

import java.util.List;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Object[]> getProductRevisionRows(long productId) {
    // Структура каждой строки результата (Object[]):
    // row[0] — снимок сущности на ревизии (Product)
    // row[1] — revision entity (обычно DefaultRevisionEntity)
    // row[2] — тип изменения (RevisionType: ADD/MOD/DEL)
    return reader().createQuery()
            .forRevisionsOfEntity(
                    com.example.commerce.catalog.entity.Product.class,
                    false,   // хотим не только сущность
                    true     // хотим видеть DEL, если он был
            )
            // Фильтруем историю по конкретному id, иначе получим «историю всего на свете»
            .add(AuditEntity.id().eq(productId))
            .getResultList();
}

Да, Object[] — это не самый романтичный формат данных в мире. Но это честная цена за то, что мы пока не строим сложную инфраструктуру вокруг истории. В прикладном use case такие строки обычно сразу превращают в нормальную timeline-модель, но здесь важнее понять саму механику.

AuditEntity: фильтрация и сортировка истории

AuditEntity — это критерийный DSL Envers для audit-запросов. Он похож на Criteria API по духу, но гораздо проще по задаче: мы описываем условия именно для истории, а не для живых таблиц. В практическом смысле AuditEntity нужен почти всегда, потому что без фильтра вы легко получите историю всех товаров разом, а это уже похоже на «случайно распечатал интернет на принтере».

Самый частый фильтр — по id (AuditEntity.id().eq(...)). А самое частое упорядочивание — по номеру ревизии, чтобы события шли по времени. Обычно хочется asc, то есть от ранних к поздним, чтобы читать историю слева направо, как нормальный человек.

Добавим сортировку:

import java.util.List;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Object[]> getProductRevisionRowsOrdered(long productId) {
    // То же самое, что и в предыдущем методе, но с явной сортировкой по номеру ревизии
    return reader().createQuery()
            .forRevisionsOfEntity(
                    com.example.commerce.catalog.entity.Product.class,
                    false, // возвращаем Object[] (entity + revision entity + type)
                    true   // включаем удалённые сущности (DEL), если они были
            )
            .add(AuditEntity.id().eq(productId))
            // Сортируем от ранних ревизий к поздним, чтобы получить «естественную» ленту
            .addOrder(AuditEntity.revisionNumber().asc())
            .getResultList();
}

Теперь мы получим строки истории в естественном порядке. И да — «порядок по ревизии» в Envers обычно равен «порядку по времени», потому что ревизия растёт. Это не замена timestamp’у (о нём ниже), но отличный практический порядок.

4. Метаданные ревизий и модели

Дата и время ревизии

Номер ревизии удобен как «порядковый номер события», но люди всё-таки чаще задают вопрос «когда это произошло?». Envers хранит timestamp ревизии (в REVINFO), а AuditReader умеет его отдавать. Самый прямой способ — reader.getRevisionDate(revision), который возвращает java.util.Date (да, немного ретро, но это нормально: Hibernate не всегда обязан быть на острие моды java.time).

На практике мы почти всегда сразу переводим это в Instant, чтобы дальше жить в мире java.time.

Мини-пример:

import java.time.Instant;
import java.util.Date;
import org.hibernate.envers.AuditReader;

public Instant revisionInstant(AuditReader reader, Number revision) {
    // Hibernate/Envers отдаёт java.util.Date, поэтому чаще сразу конвертируем в Instant
    Date date = reader.getRevisionDate(revision);
    return date.toInstant();
}

А если вы получаете строки Object[] через forRevisionsOfEntity(..., false, ...), то во второй ячейке обычно лежит DefaultRevisionEntity. Из него можно взять id и timestamp:

import java.time.Instant;
import org.hibernate.envers.DefaultRevisionEntity;

public Instant revisionInstant(DefaultRevisionEntity rev) {
    // timestamp хранится в миллисекундах epoch
    return Instant.ofEpochMilli(rev.getTimestamp());
}

Это удобнее, когда вы строите список событий и не хотите делать отдельный вызов getRevisionDate(...) для каждого номера ревизии.

Простая модель строки истории

Сырые Object[] хороши как «проверка, что Envers работает». Но в реальном коде (даже учебном) лучше быстро превратить их в понятную структуру, иначе история будет жить у вас в проекте в виде магии индексов: row[0], row[1], row[2]. Это почти гарантированно приводит к ошибкам, особенно когда вы вернётесь к коду через неделю и спросите себя: «А что такое row[1]? И почему оно не Long?».

Давайте введём минимальную модель строки истории. В Java 25 очень удобно использовать record:

import java.time.Instant;
import org.hibernate.envers.RevisionType;

public record AuditRevisionRow<T>(
        // Снимок сущности на конкретной ревизии (из *_AUD)
        T entitySnapshot,
        // Номер ревизии Envers (обычно это REVINFO.id)
        int revision,
        // Тип изменения: ADD / MOD / DEL
        RevisionType revisionType,
        // Время ревизии, конвертированное в java.time
        Instant revisionAt
) {}

А теперь — маленький mapper из Object[] в AuditRevisionRow<Product>. Важно: здесь мы предполагаем стандартную конфигурацию Envers, где revision entity — DefaultRevisionEntity.

import java.time.Instant;
import org.hibernate.envers.DefaultRevisionEntity;
import org.hibernate.envers.RevisionType;

public AuditRevisionRow<com.example.commerce.catalog.entity.Product> mapProductRow(Object[] row) {
    // row[0] — snapshot сущности на ревизии (Product)
    var snapshot = (com.example.commerce.catalog.entity.Product) row[0];

    // row[1] — revision entity (метаданные ревизии: id и timestamp)
    var revEntity = (DefaultRevisionEntity) row[1];

    // row[2] — тип изменения (ADD/MOD/DEL)
    var revType = (RevisionType) row[2];

    // Собираем нормальную типизированную модель вместо «магии индексов»
    return new AuditRevisionRow<>(
            snapshot,
            revEntity.getId(),
            revType,
            Instant.ofEpochMilli(revEntity.getTimestamp())
    );
}

Технически это выглядит как «лишние движения». Практически — это экономия нервов: дальше по коду вы работаете с revisionAt и revisionType, а не с загадочными индексами массива.

Поведенческие нюансы ревизий

Envers фиксирует ревизию на транзакцию, а не на каждое «шевеление» сущности внутри метода. Это означает, что если вы в одном @Transactional методе три раза поменяли цену товара (100110105), то в истории вы увидите не три события, а одно — финальное состояние на момент фиксации транзакции. Для истории это логично: снаружи мира важен итог, а не то, как вы туда пришли в процессе исполнения кода. Если вам нужен «внутритранзакционный лог», это уже отдельный класс задач.

Ещё один важный нюанс: ревизия общая. Если в одной транзакции вы поменяли товар и заказ, то обе audit-таблицы получат строки с одним и тем же номером ревизии. Это очень удобно для прикладной интерпретации: вы можете связать изменения «в одном бизнес-событии». Но это же и причина, почему ревизии конкретной сущности выглядят «дыряво» (например, 5, 9, 14): между ними могли быть ревизии других сущностей.

И наконец, помните связь с soft delete из предыдущего дня. Если «удаление» реализовано как product.deleted=true, то Envers увидит это как MOD, потому что с точки зрения БД это UPDATE. DEL чаще появится только при физическом DELETE (если вы его вообще делаете для audited-сущностей).

5. Типичные ошибки при чтении истории через AuditReader

Почти все проблемы с чтением Envers-истории происходят не из-за «сложности Envers», а из-за смешивания двух миров: текущей ORM-модели и исторической модели.

Ошибка №1: путать ревизию Envers с @Version.
Ревизия — это номер исторического события, а версия — механизм защиты от конкуренции. Когда разработчик начинает сравнивать их или пытаться «найти ревизию по версии», он, по сути, склеивает два разных слоя ответственности и получает кашу в голове и в коде.

Ошибка №2: вызывать AuditReaderFactory.get(...) вне транзакции и удивляться странным эффектам.
В нашем baseline open-in-view=false, и это означает, что «живой EntityManager» — не фоновая услуга, а явная часть дизайна. Чтение истории — это обычное чтение из БД, просто через другие таблицы, поэтому транзакция нужна хотя бы для предсказуемости инициализации и работы с EntityManager.

Ошибка №3: вернуть наружу List<Object[]> как результат сервиса.
Это соблазнительно, потому что «ну оно же работает». Но этим вы создаёте себе бомбу замедленного действия: следующий слой начнёт читать row[2] как String, потом окажется, что там RevisionType, затем кто-то перепутает порядок — и вы получите баг, который очень неприятно дебажить. Лучше один раз маппить строки истории в понятный record.

Ошибка №4: пытаться «редактировать прошлое».
Исторический снимок — это не объект для дальнейшего сохранения. Его лучше воспринимать как фотографию: можно рассматривать, можно сравнивать две фотографии, можно подписать дату, но если вы начнёте «подрисовывать» усы на фото и потом пытаться загрузить его обратно как новый профиль — получится странно. В коде это выглядит как попытка изменить snapshot и вызвать repository.save(snapshot), и это почти всегда ошибка проектирования.

Ошибка №5: ожидать, что getRevisions(...) вернёт «ревизии подряд».
Ревизии общие, поэтому они редко будут 1..N для одной сущности. Если вы это не учитываете, то легко написать код, который считает «следующая ревизия = текущая + 1» и внезапно ломается на реальной истории. Правильная стратегия — идти по списку ревизий, который возвращает Envers, а не придумывать номера самостоятельно.

1
Задача
Hibernate deep-dive, 21 уровень, 3 лекция
Недоступна
Список ревизий товара через `AuditReader`
Список ревизий товара через `AuditReader`
1
Задача
Hibernate deep-dive, 21 уровень, 3 лекция
Недоступна
Снимок товара на конкретной ревизии
Снимок товара на конкретной ревизии
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ