JavaRush /Курсы /Hibernate deep-dive /История Product и ...

История Product и PurchaseOrder

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

1. От ревизий к читаемой истории

Когда номер ревизии, snapshot и RevisionType уже не выглядят магией, остаётся самый практичный вопрос: как превратить всё это в историю, которую можно читать глазами. Если вы прямо сейчас откроете базу и посмотрите на PRODUCT_AUD или PURCHASE_ORDER_AUD, вы увидите таблицы, строки, REV, REVTYPE, колонки сущности… и внутреннего гуманитария в вас может слегка придавить тоской. Это нормально: Envers хранит историю как данные, но наша задача — превратить эти данные в понятную прикладную историю: «цена менялась так», «статус заказа шёл таким путём». Мы сделаем это без UI, без отчётных систем и без “вынесем в отдельный сервис и перепишем всё на Kotlin”. Просто аккуратный сервисный код, который читает ревизии и собирает timeline.

В голове полезно держать простую цепочку. Бизнес-операция в сервисе меняет entity, Hibernate делает dirty checking, на flush / commit уходят SQL-команды, а Envers рядом добавляет записи в audit-таблицы. И уже потом мы можем читать историю как отдельный read-use-case.

flowchart TD
    %% Поток появления ревизий и чтения истории через Envers
    A["@Transactional сервис
меняет Product / PurchaseOrder"] --> B["flush / commit"] B --> C["Основные таблицы:
UPDATE product, UPDATE purchase_order"] B --> D["Envers:
INSERT REVINFO
INSERT *_AUD"] D --> E["AuditReader читает ревизии
и восстанавливает снимки"] E --> F["Мы собираем timeline DTO
для истории цены/статуса"]

Обратите внимание на одну вещь, которая здесь важна только как напоминание: ревизия создаётся не “на entity”, а на транзакцию. Если вы в одном @Transactional-методе изменили и товар, и заказ, они могут получить одинаковый номер ревизии. Это удобно: один номер ревизии начинает вести себя как один логический шаг изменения.

Но здесь важно не сделать слишком широкий вывод. Envers хорош именно как узкий инфраструктурный механизм: он детерминированно фиксирует уже случившееся изменение persistence layer и не принимает бизнес-решения за приложение. Как только в ORM lifecycle начинают прятать произвольные side effects, прозрачность модели быстро исчезает.

2. История Product: цена и статус

Когда мы говорим «история товара», на практике мы почти всегда имеем в виду историю бизнес-состояния: цена, видимость, статус (ACTIVE/ARCHIVED). Именно за этим обычно и приходят в audit: кто-то поменял цену, а потом все спорят, «какая была вчера» и «почему маржа ушла в минус». Базовая механика уже знакома: AuditReader умеет достать ревизию, snapshot и время изменения. Для товара удобнее сразу собрать один read-model, в котором рядом лежат цена, статус, момент ревизии и RevisionType.

Сырой путь через getRevisions(...) и find(...) никуда не делся. Но для истории товара обычно полезнее сразу читать ревизионные строки целиком: так не приходится в каждом следующем методе отдельно склеивать snapshot, timestamp и тип операции.

История товара как read-model

import org.hibernate.envers.RevisionType;

import java.math.BigDecimal;
import java.time.Instant;

// Одна строка истории товара (read-model), которую удобно выводить как timeline
public record ProductRevisionRow(
        Number revision,              // Номер ревизии
        Instant revisionTime,         // Время ревизии
        RevisionType revisionType,    // Тип операции Envers
        BigDecimal price,             // Цена на момент ревизии
        String status                 // Статус на момент ревизии
) {
}

Такой DTO сразу задаёт правильную границу: наружу уходит не историческая entity, а конкретная строка timeline. Исторический snapshot по-прежнему остаётся «фотографией прошлого», а не managed-объектом для save(). Поэтому Product лучше использовать внутри query-сервиса как промежуточный материал для маппинга, а не как публичный контракт.

import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.DefaultRevisionEntity;
import org.hibernate.envers.RevisionType;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Service
public class ProductAuditQueryService {

    private final EntityManager entityManager;

    public ProductAuditQueryService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional(readOnly = true)
    public List<ProductRevisionRow> loadProductRevisionRows(Long productId) {
        // Берём сразу строки истории с метаданными ревизии, а не только список номеров
        List<?> rows = AuditReaderFactory.get(entityManager)
                .createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.id().eq(productId))
                .addOrder(AuditEntity.revisionNumber().asc())
                .getResultList();

        List<ProductRevisionRow> result = new ArrayList<>();
        for (Object row : rows) {
            // Исторически Envers возвращает Object[], это нормальная часть API
            Object[] triple = (Object[]) row;

            // 0: снимок сущности на момент ревизии
            Product snapshot = (Product) triple[0];
            // 1: ревизионная сущность (REVINFO)
            DefaultRevisionEntity revInfo = (DefaultRevisionEntity) triple[1];
            // 2: тип изменения (ADD/MOD/DEL)
            RevisionType type = (RevisionType) triple[2];

            result.add(new ProductRevisionRow(
                    revInfo.getId(),
                    Instant.ofEpochMilli(revInfo.getTimestamp()),
                    type,
                    snapshot.getPrice().getAmount(),
                    snapshot.getStatus().name()
            ));
        }
        return result;
    }
}

Пара слов про .forRevisionsOfEntity(Product.class, false, true). false говорит: нужны не только сами сущности, но и метаданные ревизии с RevisionType. true оставляет в выдаче и физические удаления, если они вообще бывают. В проекте с soft delete DEL может не встретиться, но сам контракт от этого не становится хуже.

И да, Object[] здесь — нормальная часть API Envers, а не повод переписывать всё из принципа. Важна дисциплина: разобрали строку рядом с запросом, сразу превратили её в ProductRevisionRow и дальше по коду живёте уже с типизированной моделью.

История цены как timeline

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
public class ProductHistoryFormatter {

    private final ProductAuditQueryService auditQueryService;

    public ProductHistoryFormatter(ProductAuditQueryService auditQueryService) {
        this.auditQueryService = auditQueryService;
    }

    @Transactional(readOnly = true)
    public List<String> formatProductPriceTimeline(Long productId) {
        // Сначала получаем “сырой” read-model (DTO-строки) из Envers
        List<ProductRevisionRow> rows = auditQueryService.loadProductRevisionRows(productId);

        // Затем форматируем в строки, удобные для чтения глазами/логирования
        List<String> lines = new ArrayList<>();
        for (ProductRevisionRow row : rows) {
            lines.add("rev=" + row.revision()
                    + " time=" + row.revisionTime()
                    + " type=" + row.revisionType()
                    + " price=" + row.price()
                    + " status=" + row.status());
        }
        return lines;
    }
}

Если вы выведете такие строки (например, в debug-логах лаборатории), они будут выглядеть примерно так:

rev=101 time=2026-03-21T12:40:10Z type=ADD price=199.99 status=ACTIVE
rev=105 time=2026-03-21T13:05:42Z type=MOD price=179.99 status=ACTIVE
rev=112 time=2026-03-21T14:10:03Z type=MOD price=179.99 status=ARCHIVED

Здесь уже хорошо видно, зачем вообще нужен read-model поверх Envers: история начинает читаться как timeline, а не как техническая таблица. И если цена и статус изменились в одной транзакции, это нормально попадёт в одну ревизию: Envers фиксирует состояние на момент коммита, а не каждое промежуточное движение внутри метода. Если нужно понять, что именно поменялось, сравнивают соседние строки timeline, а не ждут от Envers скрытого “diff по полям”.

3. История PurchaseOrder: статусы заказа

С заказами обычно интереснее, чем с товарами: статус заказа — это почти всегда отражение реального бизнес-процесса. Даже в лабораторном проекте статусы выглядят как “принят”, “оплачен”, “в сборке”, “отменён”, “доставлен” (или упрощённый набор). И когда что-то пошло не так, вопрос звучит не “какая цена была”, а “в каком порядке и когда менялись статусы”. Здесь нам обычно не нужен RevisionType на каждой строке: чаще важнее сам маршрут статусов во времени. Поэтому для заказа вполне хватает прямого чтения списка ревизий и snapshot на каждой из них. Это как раз хороший контраст с товаром: где-то нужен более богатый ряд событий, а где-то достаточно честной timeline статусов.

DTO для истории статуса заказа

Сделаем максимально ясный DTO, чтобы история статуса выглядела как timeline.

import java.time.Instant;

// Одна точка истории статуса заказа (минимально нужные поля)
public record OrderStatusHistoryLine(
        Number revision,      // Номер ревизии Envers
        Instant revisionTime, // Время ревизии
        String status         // Статус заказа на момент ревизии
) {
}

Теперь query-сервис для заказов. Он будет почти таким же, как для товаров, только читаем PurchaseOrder и берём status.

import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.DefaultRevisionEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Service
public class OrderAuditQueryService {

    private final EntityManager entityManager;

    public OrderAuditQueryService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional(readOnly = true)
    public List<OrderStatusHistoryLine> loadOrderStatusHistory(Long orderId) {
        // AuditReader позволяет читать историю по ревизиям
        AuditReader reader = AuditReaderFactory.get(entityManager);

        List<OrderStatusHistoryLine> lines = new ArrayList<>();
        for (Number rev : reader.getRevisions(PurchaseOrder.class, orderId)) {
            // Снимок заказа на момент rev (читать можно, менять бессмысленно)
            PurchaseOrder snapshot = reader.find(PurchaseOrder.class, orderId, rev);

            // Timestamp ревизии берём из REVINFO
            DefaultRevisionEntity revInfo = reader.findRevision(DefaultRevisionEntity.class, rev);

            // В read-model выносим только статус и метаданные ревизии
            lines.add(new OrderStatusHistoryLine(
                    rev,
                    Instant.ofEpochMilli(revInfo.getTimestamp()),
                    snapshot.getStatus().name()
            ));
        }
        return lines;
    }
}

И это уже практически готовая “дорожная карта заказа”. Если заказ менял статус четыре раза, у вас будет четыре строки. Если статус меняли “взад-вперёд” (такое в реальных системах бывает чаще, чем хочется), история покажет это честно, а не “последний updatedAt”.

Если заказ аудируется целиком, но нужен только статус

Это частый реальный вопрос. Мы можем аудировать PurchaseOrder целиком (потому что он важен), но прикладной запрос может хотеть только статус. И здесь опять выигрывает DTO: мы читаем snapshot целиком (он всё равно формируется Envers), но наружу отдаём только нужное поле.

Если вы начнёте возвращать наружу “целый исторический заказ”, вы быстро придёте к проблемам сериализации и лишнего объёма. Поэтому и для заказа лучше сразу держать историю как read-model: статус, время, ревизия.

Ревизии группируют изменения

Для заказа это особенно полезно. Представьте, что в одной транзакции вы изменили статус заказа и обновили какие-то поля (например, totalAmount или snapshot адреса). Всё попадёт в одну ревизию. Это означает: “в рамках одного логического шага было такое состояние”.

Если вы позже захотите объяснять аудит как “события”, ревизия почти готова быть “id события”. Но сегодня мы не превращаем Envers в event sourcing (это другой жанр кино). Мы просто используем ревизию как порядок и “момент”.

Soft delete: MOD вместо DEL

Очень легко ожидать, что если сущность “удалили”, то в истории обязательно будет REVTYPE=DEL. В системах с физическим DELETE так часто и будет. Но в нашем проекте мы уже познакомились с soft delete, где “удаление” — это изменение состояния (deleted=true или аналог). Поэтому Envers видит это как обычную модификацию audited-сущности. Для истории это выглядит как ещё одна точка timeline, где, например, товар был активен, а потом стал скрытым/удалённым.

Это не баг, а очень логичное отражение модели. Если бизнес говорит “мы не удаляем товар, мы его архивируем”, то и история должна показывать “изменили состояние на архивное”, а не “стерли строку из мира”. Практически это означает: когда вы читаете историю товара, стоит включать в timeline поле, которое отражает “видимость” или deleted, если это важно. Но даже если вы этого не делаете, вы увидите дополнительную ревизию, и это уже сигнал: “что-то случилось”.

4. Типичные ошибки при чтении прикладной истории через Envers

Ошибка №1: возвращать наружу “исторические entity” как есть.
Технически AuditReader.find(Product.class, id, rev) возвращает объект типа Product или PurchaseOrder, и очень хочется “сэкономить” и отдать его дальше. Но это почти гарантированно приводит к путанице: кто-то воспринимает его как managed, кто-то пытается сериализовать граф, кто-то лезет в lazy-связи. Гораздо безопаснее сразу превращать историю в DTO и возвращать только нужные поля.

Ошибка №2: ожидать, что getRevisions(Class, id) вернёт снимки, а не номера.
getRevisions(Class, id) даёт список номеров ревизий, а не список сущностей. Это как оглавление книги: там номера страниц, но не текст. Снимок вы получаете отдельным вызовом find(Entity, id, revision).

Ошибка №3: путать ревизию Envers с @Version для optimistic locking.
Ревизия — это “историческая запись”, создаваемая при изменениях audited-сущностей. @Version — это механизм конкурентной защиты от lost update. Они могут обе выглядеть как “какой-то номер”, но семантика совершенно разная. Если перепутать, можно построить очень красивую, но очень неверную интерпретацию данных.

Ошибка №4: пытаться увидеть DEL там, где используется soft delete.
Если вы “удаляете” сущность через флаг deleted=true, Envers запишет MOD, потому что с точки зрения ORM это обычный UPDATE. Это нормально. Если вам принципиально нужен “исторический факт удаления”, то он отражается в значении audited-поля, а не в REVTYPE.

Ошибка №5: оставлять в коде Object[] из Envers-запроса как публичный контракт.
Envers query API действительно отдаёт Object[], но это не значит, что такой массив должен путешествовать по коду дальше пары строк. Сразу преобразуйте Object[] в нормальный DTO (record) — и ваш будущий вы (или коллега) скажет вам спасибо, возможно даже без сарказма.

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