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 методе три раза поменяли цену товара (100 → 110 → 105), то в истории вы увидите не три события, а одно — финальное состояние на момент фиксации транзакции. Для истории это логично: снаружи мира важен итог, а не то, как вы туда пришли в процессе исполнения кода. Если вам нужен «внутритранзакционный лог», это уже отдельный класс задач.
Ещё один важный нюанс: ревизия общая. Если в одной транзакции вы поменяли товар и заказ, то обе 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, а не придумывать номера самостоятельно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ