エンタープライズシステムにおいて、エンティティの変更履歴管理は監査要件やデータ追跡の観点から極めて重要な機能です。Spring Data JPA Enversは、Hibernate Enversを活用することで、@Auditedアノテーションを付与するだけで履歴テーブルを自動生成し、AuditReaderによる柔軟な履歴データの取得を実現します。

本記事では、Hibernate Enversの導入から@Auditedによる履歴追跡の設定、AuditReaderを使った履歴データのクエリ方法、@RevisionEntityによるリビジョン情報のカスタマイズ、特定時点のデータ復元、そして@NotAuditedによる除外設定まで、実践的な実装パターンを網羅的に解説します。

実行環境と前提条件

項目 バージョン
Java 17以上(推奨: 21)
Spring Boot 3.4.x
Hibernate ORM 6.6.x
Hibernate Envers 6.6.x(hibernate-ormと同一バージョン)
データベース PostgreSQL 16 / MySQL 8.x / H2(開発用)

前提条件として、Spring Bootプロジェクトが作成済みで、spring-boot-starter-data-jpa依存関係が含まれていることを想定しています。

Hibernate Enversの概要

Hibernate EnversはHibernateの拡張モジュールで、エンティティの変更履歴を自動的に別テーブルに記録する機能を提供します。

graph TB
    subgraph "アプリケーション層"
        A[Service / Repository]
    end
    
    subgraph "Hibernate層"
        B[Hibernate ORM]
        C[Hibernate Envers]
    end
    
    subgraph "データベース層"
        D[(メインテーブル<br/>products)]
        E[(監査テーブル<br/>products_AUD)]
        F[(リビジョンテーブル<br/>REVINFO)]
    end
    
    A --> B
    B --> C
    C --> D
    C --> E
    C --> F
    E -.-> F

Enversが提供する主要機能

機能 説明
自動履歴テーブル生成 _AUDサフィックス付きの監査テーブルを自動作成
トランザクション単位のリビジョン 同一トランザクション内の変更を1つのリビジョンとしてグループ化
履歴クエリAPI AuditReaderによる柔軟な履歴データ検索
ポイントインタイムクエリ 特定リビジョン時点のエンティティ状態を復元
変更種別の記録 INSERT / UPDATE / DELETE を識別するREVTYPEカラム

Hibernate Enversの導入

依存関係の追加

build.gradleにHibernate Enversの依存関係を追加します。Spring Boot 3.4.xでは、Hibernate 6.6.xが使用されるため、バージョン指定は不要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    
    // Hibernate Envers
    implementation 'org.hibernate.orm:hibernate-envers'
    
    runtimeOnly 'org.postgresql:postgresql'
    // 開発用にH2を使用する場合
    // runtimeOnly 'com.h2database:h2'
}

Mavenを使用する場合は、pom.xmlに以下を追加します。

1
2
3
4
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-envers</artifactId>
</dependency>

アプリケーション設定

application.ymlにEnvers関連の設定を追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/envers_demo
    username: postgres
    password: postgres
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        # Envers固有の設定
        org:
          hibernate:
            envers:
              # 監査テーブルのサフィックス(デフォルト: _AUD)
              audit_table_suffix: _AUD
              # リビジョンテーブル名(デフォルト: REVINFO)
              revision_field_name: REV
              revision_type_field_name: REVTYPE
              # 削除時にデータを保持するか
              store_data_at_delete: true

主要な設定プロパティは以下のとおりです。

プロパティ デフォルト値 説明
audit_table_suffix _AUD 監査テーブルの接尾辞
store_data_at_delete false DELETE時に全カラムの値を保持
revision_field_name REV リビジョン番号カラム名
revision_type_field_name REVTYPE 操作種別カラム名
default_schema なし 監査テーブル用のスキーマ

@Auditedアノテーションによる履歴テーブルの自動生成

基本的なエンティティへの適用

監査対象のエンティティにクラスレベルで@Auditedアノテーションを付与します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package com.example.envers.entity;

import jakarta.persistence.*;
import org.hibernate.envers.Audited;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Audited
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(length = 500)
    private String description;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    // Getters and Setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Integer getStockQuantity() {
        return stockQuantity;
    }

    public void setStockQuantity(Integer stockQuantity) {
        this.stockQuantity = stockQuantity;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }
}

自動生成されるテーブル構造

アプリケーション起動時に、Enversは以下のテーブルを自動生成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- メインテーブル
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            VARCHAR(100) NOT NULL,
    price           DECIMAL(10, 2) NOT NULL,
    description     VARCHAR(500),
    stock_quantity  INTEGER NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    updated_at      TIMESTAMP NOT NULL
);

-- リビジョン情報テーブル(Enversが自動生成)
CREATE TABLE REVINFO (
    REV         INTEGER NOT NULL PRIMARY KEY,
    REVTSTMP    BIGINT NOT NULL
);

-- 監査テーブル(Enversが自動生成)
CREATE TABLE products_AUD (
    id              BIGINT NOT NULL,
    REV             INTEGER NOT NULL,
    REVTYPE         SMALLINT NOT NULL,
    name            VARCHAR(100),
    price           DECIMAL(10, 2),
    description     VARCHAR(500),
    stock_quantity  INTEGER,
    created_at      TIMESTAMP,
    updated_at      TIMESTAMP,
    PRIMARY KEY (id, REV),
    CONSTRAINT fk_products_aud_rev FOREIGN KEY (REV) REFERENCES REVINFO(REV)
);

REVTYPEの値と操作種別

REVTYPEカラムには操作種別が数値で記録されます。

定数名 操作種別 説明
0 ADD INSERT 新規レコード作成
1 MOD UPDATE 既存レコード更新
2 DEL DELETE レコード削除
sequenceDiagram
    participant App as アプリケーション
    participant Envers as Hibernate Envers
    participant Main as メインテーブル
    participant Aud as 監査テーブル
    participant Rev as REVINFOテーブル

    App->>Envers: product.save()
    Envers->>Rev: 新規リビジョン番号を採番
    Envers->>Main: INSERTを実行
    Envers->>Aud: INSERT(REVTYPE=0)を実行
    Note over Aud: id, REV, REVTYPE=0, 全カラム値

AuditReaderによる履歴データの取得

AuditReaderはHibernate Enversが提供する履歴データ取得用のAPIです。EntityManagerから取得して使用します。

AuditReaderの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.envers.service;

import com.example.envers.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.RevisionType;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class ProductAuditService {

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * AuditReaderを取得する
     */
    private AuditReader getAuditReader() {
        return AuditReaderFactory.get(entityManager);
    }

    /**
     * 指定したエンティティの全リビジョン番号を取得
     */
    public List<Number> getRevisions(Long productId) {
        AuditReader auditReader = getAuditReader();
        return auditReader.getRevisions(Product.class, productId);
    }

    /**
     * 指定したリビジョン時点のエンティティを取得
     */
    public Product findAtRevision(Long productId, Number revision) {
        AuditReader auditReader = getAuditReader();
        return auditReader.find(Product.class, productId, revision);
    }

    /**
     * エンティティが作成されたリビジョン番号を取得
     */
    public Number getFirstRevision(Long productId) {
        List<Number> revisions = getRevisions(productId);
        return revisions.isEmpty() ? null : revisions.get(0);
    }

    /**
     * エンティティの最新リビジョン番号を取得
     */
    public Number getLatestRevision(Long productId) {
        List<Number> revisions = getRevisions(productId);
        return revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);
    }
}

AuditQueryによる高度な履歴検索

AuditReader.createQuery()を使用すると、複雑な条件で履歴データを検索できます。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
package com.example.envers.service;

import com.example.envers.dto.ProductRevisionDto;
import com.example.envers.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.DefaultRevisionEntity;
import org.hibernate.envers.RevisionType;
import org.hibernate.envers.query.AuditEntity;
import org.hibernate.envers.query.AuditQuery;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Service
@Transactional(readOnly = true)
public class ProductAuditQueryService {

    @PersistenceContext
    private EntityManager entityManager;

    private AuditReader getAuditReader() {
        return AuditReaderFactory.get(entityManager);
    }

    /**
     * 特定エンティティの全履歴を取得(リビジョン情報付き)
     */
    @SuppressWarnings("unchecked")
    public List<ProductRevisionDto> getProductHistory(Long productId) {
        AuditReader auditReader = getAuditReader();
        
        // forRevisionsOfEntity の第2引数: falseでエンティティとリビジョン情報を別々に取得
        // 第3引数: trueで削除されたエンティティも含める
        List<Object[]> results = auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.id().eq(productId))
                .addOrder(AuditEntity.revisionNumber().asc())
                .getResultList();

        List<ProductRevisionDto> history = new ArrayList<>();
        for (Object[] row : results) {
            Product product = (Product) row[0];
            DefaultRevisionEntity revisionEntity = (DefaultRevisionEntity) row[1];
            RevisionType revisionType = (RevisionType) row[2];

            history.add(new ProductRevisionDto(
                    product,
                    revisionEntity.getId(),
                    new Date(revisionEntity.getTimestamp()),
                    revisionType
            ));
        }
        return history;
    }

    /**
     * 特定の日時以降に変更されたエンティティを取得
     */
    @SuppressWarnings("unchecked")
    public List<Product> findModifiedAfter(Date date) {
        AuditReader auditReader = getAuditReader();
        
        return auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, true, false)
                .add(AuditEntity.revisionProperty("timestamp").ge(date.getTime()))
                .add(AuditEntity.revisionType().eq(RevisionType.MOD))
                .getResultList();
    }

    /**
     * 価格が変更されたリビジョンのみを取得
     */
    @SuppressWarnings("unchecked")
    public List<Object[]> findPriceChanges(Long productId) {
        AuditReader auditReader = getAuditReader();
        
        return auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.id().eq(productId))
                .add(AuditEntity.property("price").hasChanged())
                .addOrder(AuditEntity.revisionNumber().asc())
                .getResultList();
    }

    /**
     * 特定の価格帯のエンティティ履歴を取得
     */
    @SuppressWarnings("unchecked")
    public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
        AuditReader auditReader = getAuditReader();
        
        return auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, true, false)
                .add(AuditEntity.property("price").ge(minPrice))
                .add(AuditEntity.property("price").le(maxPrice))
                .addOrder(AuditEntity.revisionNumber().desc())
                .setMaxResults(100)
                .getResultList();
    }

    /**
     * 削除されたエンティティの一覧を取得
     */
    @SuppressWarnings("unchecked")
    public List<Object[]> findDeletedEntities() {
        AuditReader auditReader = getAuditReader();
        
        return auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.revisionType().eq(RevisionType.DEL))
                .addOrder(AuditEntity.revisionNumber().desc())
                .getResultList();
    }
}

DTOクラスの定義

履歴データを扱うためのDTOクラスを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.example.envers.dto;

import com.example.envers.entity.Product;
import org.hibernate.envers.RevisionType;

import java.util.Date;

public class ProductRevisionDto {

    private final Long productId;
    private final String name;
    private final String price;
    private final Integer stockQuantity;
    private final Integer revisionNumber;
    private final Date revisionDate;
    private final String revisionType;

    public ProductRevisionDto(Product product, Integer revisionNumber, 
                               Date revisionDate, RevisionType revisionType) {
        this.productId = product != null ? product.getId() : null;
        this.name = product != null ? product.getName() : null;
        this.price = product != null && product.getPrice() != null 
                ? product.getPrice().toString() : null;
        this.stockQuantity = product != null ? product.getStockQuantity() : null;
        this.revisionNumber = revisionNumber;
        this.revisionDate = revisionDate;
        this.revisionType = convertRevisionType(revisionType);
    }

    private String convertRevisionType(RevisionType type) {
        return switch (type) {
            case ADD -> "作成";
            case MOD -> "更新";
            case DEL -> "削除";
        };
    }

    // Getters
    public Long getProductId() {
        return productId;
    }

    public String getName() {
        return name;
    }

    public String getPrice() {
        return price;
    }

    public Integer getStockQuantity() {
        return stockQuantity;
    }

    public Integer getRevisionNumber() {
        return revisionNumber;
    }

    public Date getRevisionDate() {
        return revisionDate;
    }

    public String getRevisionType() {
        return revisionType;
    }
}

リビジョン情報のカスタマイズ(@RevisionEntity)

デフォルトのリビジョンエンティティ(REVINFOテーブル)には、リビジョン番号とタイムスタンプのみが記録されます。監査要件として「誰が」「どこから」変更したかを記録するには、カスタムリビジョンエンティティを作成します。

カスタムリビジョンエンティティの作成

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
package com.example.envers.entity;

import jakarta.persistence.*;
import org.hibernate.envers.RevisionEntity;
import org.hibernate.envers.RevisionNumber;
import org.hibernate.envers.RevisionTimestamp;

import java.io.Serializable;
import java.time.Instant;

@Entity
@Table(name = "custom_revision_info")
@RevisionEntity(CustomRevisionListener.class)
public class CustomRevisionEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @RevisionNumber
    private Long rev;

    @RevisionTimestamp
    @Column(name = "rev_timestamp", nullable = false)
    private Long revisionTimestamp;

    @Column(name = "username", length = 100)
    private String username;

    @Column(name = "ip_address", length = 45)
    private String ipAddress;

    @Column(name = "user_agent", length = 500)
    private String userAgent;

    @Column(name = "request_uri", length = 500)
    private String requestUri;

    @Column(name = "correlation_id", length = 36)
    private String correlationId;

    // Getters and Setters
    public Long getRev() {
        return rev;
    }

    public void setRev(Long rev) {
        this.rev = rev;
    }

    public Long getRevisionTimestamp() {
        return revisionTimestamp;
    }

    public void setRevisionTimestamp(Long revisionTimestamp) {
        this.revisionTimestamp = revisionTimestamp;
    }

    public Instant getRevisionInstant() {
        return Instant.ofEpochMilli(revisionTimestamp);
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getIpAddress() {
        return ipAddress;
    }

    public void setIpAddress(String ipAddress) {
        this.ipAddress = ipAddress;
    }

    public String getUserAgent() {
        return userAgent;
    }

    public void setUserAgent(String userAgent) {
        this.userAgent = userAgent;
    }

    public String getRequestUri() {
        return requestUri;
    }

    public void setRequestUri(String requestUri) {
        this.requestUri = requestUri;
    }

    public String getCorrelationId() {
        return correlationId;
    }

    public void setCorrelationId(String correlationId) {
        this.correlationId = correlationId;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof CustomRevisionEntity that)) return false;
        return rev != null && rev.equals(that.rev);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

RevisionListenerの実装

RevisionListenerインターフェースを実装して、リビジョン作成時に追加情報を設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package com.example.envers.entity;

import jakarta.servlet.http.HttpServletRequest;
import org.hibernate.envers.RevisionListener;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

public class CustomRevisionListener implements RevisionListener {

    private static final String ANONYMOUS_USER = "anonymous";
    private static final String SYSTEM_USER = "SYSTEM";
    private static final String UNKNOWN = "UNKNOWN";

    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity revision = (CustomRevisionEntity) revisionEntity;

        // ユーザー名の設定
        revision.setUsername(resolveUsername());

        // HTTPリクエスト情報の設定
        HttpServletRequest request = getCurrentHttpRequest();
        if (request != null) {
            revision.setIpAddress(resolveIpAddress(request));
            revision.setUserAgent(truncate(request.getHeader("User-Agent"), 500));
            revision.setRequestUri(truncate(request.getRequestURI(), 500));
            revision.setCorrelationId(request.getHeader("X-Correlation-Id"));
        } else {
            revision.setIpAddress(UNKNOWN);
        }
    }

    private String resolveUsername() {
        try {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.isAuthenticated()) {
                String name = authentication.getName();
                if (!ANONYMOUS_USER.equals(name)) {
                    return name;
                }
            }
        } catch (Exception e) {
            // SecurityContextが利用できない場合は無視
        }
        return SYSTEM_USER;
    }

    private HttpServletRequest getCurrentHttpRequest() {
        try {
            ServletRequestAttributes attrs = 
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            return attrs != null ? attrs.getRequest() : null;
        } catch (Exception e) {
            return null;
        }
    }

    private String resolveIpAddress(HttpServletRequest request) {
        // プロキシ経由の場合はX-Forwarded-Forヘッダーを優先
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            // 複数のIPがある場合は最初のものを取得
            return xForwardedFor.split(",")[0].trim();
        }
        
        String xRealIp = request.getHeader("X-Real-IP");
        if (xRealIp != null && !xRealIp.isEmpty()) {
            return xRealIp;
        }
        
        return request.getRemoteAddr();
    }

    private String truncate(String value, int maxLength) {
        if (value == null) {
            return null;
        }
        return value.length() <= maxLength ? value : value.substring(0, maxLength);
    }
}

カスタムリビジョン情報の取得

カスタムリビジョンエンティティを使用して履歴情報を取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.example.envers.service;

import com.example.envers.dto.AuditHistoryDto;
import com.example.envers.entity.CustomRevisionEntity;
import com.example.envers.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
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
@Transactional(readOnly = true)
public class AuditHistoryService {

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * カスタムリビジョン情報付きの履歴を取得
     */
    @SuppressWarnings("unchecked")
    public List<AuditHistoryDto> getDetailedHistory(Long productId) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        List<Object[]> results = auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.id().eq(productId))
                .addOrder(AuditEntity.revisionNumber().desc())
                .getResultList();

        List<AuditHistoryDto> history = new ArrayList<>();
        for (Object[] row : results) {
            Product product = (Product) row[0];
            CustomRevisionEntity revision = (CustomRevisionEntity) row[1];
            RevisionType revisionType = (RevisionType) row[2];

            history.add(AuditHistoryDto.builder()
                    .entityId(product != null ? product.getId() : null)
                    .entityData(product)
                    .revisionNumber(revision.getRev())
                    .revisionTimestamp(revision.getRevisionInstant())
                    .username(revision.getUsername())
                    .ipAddress(revision.getIpAddress())
                    .requestUri(revision.getRequestUri())
                    .operationType(revisionType.name())
                    .build());
        }
        return history;
    }

    /**
     * 特定ユーザーによる変更履歴を取得
     */
    @SuppressWarnings("unchecked")
    public List<Object[]> findByUsername(String username) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        return auditReader.createQuery()
                .forRevisionsOfEntity(Product.class, false, true)
                .add(AuditEntity.revisionProperty("username").eq(username))
                .addOrder(AuditEntity.revisionNumber().desc())
                .setMaxResults(100)
                .getResultList();
    }
}

AuditHistoryDto の実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
package com.example.envers.dto;

import com.example.envers.entity.Product;

import java.time.Instant;

public class AuditHistoryDto {

    private Long entityId;
    private Product entityData;
    private Long revisionNumber;
    private Instant revisionTimestamp;
    private String username;
    private String ipAddress;
    private String requestUri;
    private String operationType;

    private AuditHistoryDto() {}

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private final AuditHistoryDto instance = new AuditHistoryDto();

        public Builder entityId(Long entityId) {
            instance.entityId = entityId;
            return this;
        }

        public Builder entityData(Product entityData) {
            instance.entityData = entityData;
            return this;
        }

        public Builder revisionNumber(Long revisionNumber) {
            instance.revisionNumber = revisionNumber;
            return this;
        }

        public Builder revisionTimestamp(Instant revisionTimestamp) {
            instance.revisionTimestamp = revisionTimestamp;
            return this;
        }

        public Builder username(String username) {
            instance.username = username;
            return this;
        }

        public Builder ipAddress(String ipAddress) {
            instance.ipAddress = ipAddress;
            return this;
        }

        public Builder requestUri(String requestUri) {
            instance.requestUri = requestUri;
            return this;
        }

        public Builder operationType(String operationType) {
            instance.operationType = operationType;
            return this;
        }

        public AuditHistoryDto build() {
            return instance;
        }
    }

    // Getters
    public Long getEntityId() {
        return entityId;
    }

    public Product getEntityData() {
        return entityData;
    }

    public Long getRevisionNumber() {
        return revisionNumber;
    }

    public Instant getRevisionTimestamp() {
        return revisionTimestamp;
    }

    public String getUsername() {
        return username;
    }

    public String getIpAddress() {
        return ipAddress;
    }

    public String getRequestUri() {
        return requestUri;
    }

    public String getOperationType() {
        return operationType;
    }
}

特定時点のデータ復元

Enversの強力な機能の1つが、特定のリビジョン時点のエンティティ状態を復元できることです。これにより、誤操作からのデータ回復や、過去の状態の確認が可能になります。

ポイントインタイムリカバリの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
package com.example.envers.service;

import com.example.envers.entity.Product;
import com.example.envers.repository.ProductRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@Service
public class ProductRecoveryService {

    @PersistenceContext
    private EntityManager entityManager;

    private final ProductRepository productRepository;

    public ProductRecoveryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * 指定したリビジョン時点の状態に復元(新しいリビジョンとして保存)
     */
    @Transactional
    public Product restoreToRevision(Long productId, Number targetRevision) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        // 指定リビジョン時点のエンティティを取得
        Product historicalProduct = auditReader.find(Product.class, productId, targetRevision);

        if (historicalProduct == null) {
            throw new IllegalArgumentException(
                    "リビジョン " + targetRevision + " の時点でエンティティが存在しません");
        }

        // 現在のエンティティを取得
        Product currentProduct = productRepository.findById(productId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "ID " + productId + " のエンティティが見つかりません"));

        // 履歴データで現在のエンティティを更新
        currentProduct.setName(historicalProduct.getName());
        currentProduct.setPrice(historicalProduct.getPrice());
        currentProduct.setDescription(historicalProduct.getDescription());
        currentProduct.setStockQuantity(historicalProduct.getStockQuantity());

        // 保存(新しいリビジョンが作成される)
        return productRepository.save(currentProduct);
    }

    /**
     * 指定した日時時点の状態に復元
     */
    @Transactional
    public Product restoreToDate(Long productId, Instant targetDate) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        // 指定日時時点で有効なリビジョン番号を取得
        Number revisionAtDate = auditReader.getRevisionNumberForDate(
                Date.from(targetDate));

        if (revisionAtDate == null) {
            throw new IllegalArgumentException(
                    "指定された日時 " + targetDate + " より前のリビジョンが存在しません");
        }

        return restoreToRevision(productId, revisionAtDate);
    }

    /**
     * 削除されたエンティティを復元
     */
    @Transactional
    public Product restoreDeletedEntity(Long productId) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        // 該当エンティティのリビジョン一覧を取得
        List<Number> revisions = auditReader.getRevisions(Product.class, productId);

        if (revisions.isEmpty()) {
            throw new IllegalArgumentException(
                    "ID " + productId + " の履歴が見つかりません");
        }

        // 削除直前のリビジョンを取得(最後から2番目)
        if (revisions.size() < 2) {
            throw new IllegalArgumentException("復元可能な履歴がありません");
        }

        Number lastValidRevision = revisions.get(revisions.size() - 2);
        Product deletedProduct = auditReader.find(Product.class, productId, lastValidRevision);

        if (deletedProduct == null) {
            throw new IllegalArgumentException("復元対象のデータが見つかりません");
        }

        // 新しいエンティティとして保存(IDは新規採番)
        Product restoredProduct = new Product();
        restoredProduct.setName(deletedProduct.getName());
        restoredProduct.setPrice(deletedProduct.getPrice());
        restoredProduct.setDescription(deletedProduct.getDescription());
        restoredProduct.setStockQuantity(deletedProduct.getStockQuantity());

        return productRepository.save(restoredProduct);
    }

    /**
     * 2つのリビジョン間の差分を取得
     */
    @Transactional(readOnly = true)
    public ProductDiff compareRevisions(Long productId, Number fromRevision, Number toRevision) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        Product fromProduct = auditReader.find(Product.class, productId, fromRevision);
        Product toProduct = auditReader.find(Product.class, productId, toRevision);

        return new ProductDiff(fromProduct, toProduct, fromRevision, toRevision);
    }

    /**
     * リビジョン間の差分を表すクラス
     */
    public record ProductDiff(
            Product fromState,
            Product toState,
            Number fromRevision,
            Number toRevision
    ) {
        public boolean hasNameChanged() {
            if (fromState == null || toState == null) return true;
            return !java.util.Objects.equals(fromState.getName(), toState.getName());
        }

        public boolean hasPriceChanged() {
            if (fromState == null || toState == null) return true;
            return fromState.getPrice() == null 
                    ? toState.getPrice() != null
                    : !fromState.getPrice().equals(toState.getPrice());
        }

        public boolean hasStockQuantityChanged() {
            if (fromState == null || toState == null) return true;
            return !java.util.Objects.equals(
                    fromState.getStockQuantity(), toState.getStockQuantity());
        }
    }
}

REST APIでの復元エンドポイント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.example.envers.controller;

import com.example.envers.entity.Product;
import com.example.envers.service.ProductRecoveryService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.Instant;

@RestController
@RequestMapping("/api/products")
public class ProductRecoveryController {

    private final ProductRecoveryService recoveryService;

    public ProductRecoveryController(ProductRecoveryService recoveryService) {
        this.recoveryService = recoveryService;
    }

    /**
     * 指定リビジョンへの復元
     * POST /api/products/{id}/restore?revision=5
     */
    @PostMapping("/{id}/restore")
    public ResponseEntity<Product> restoreToRevision(
            @PathVariable Long id,
            @RequestParam Integer revision) {
        
        Product restored = recoveryService.restoreToRevision(id, revision);
        return ResponseEntity.ok(restored);
    }

    /**
     * 指定日時への復元
     * POST /api/products/{id}/restore-to-date?date=2026-01-10T10:00:00Z
     */
    @PostMapping("/{id}/restore-to-date")
    public ResponseEntity<Product> restoreToDate(
            @PathVariable Long id,
            @RequestParam Instant date) {
        
        Product restored = recoveryService.restoreToDate(id, date);
        return ResponseEntity.ok(restored);
    }

    /**
     * 削除されたエンティティの復元
     * POST /api/products/{id}/restore-deleted
     */
    @PostMapping("/{id}/restore-deleted")
    public ResponseEntity<Product> restoreDeleted(@PathVariable Long id) {
        Product restored = recoveryService.restoreDeletedEntity(id);
        return ResponseEntity.ok(restored);
    }
}

@NotAuditedによる除外設定

エンティティ内のすべてのフィールドを履歴追跡する必要がない場合、@NotAuditedアノテーションで特定のフィールドやリレーションを監査対象から除外できます。

フィールドレベルの除外

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.envers.entity;

import jakarta.persistence.*;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Audited
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(length = 500)
    private String description;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    // キャッシュ用の計算フィールド(履歴追跡不要)
    @NotAudited
    @Column(name = "cached_total_sales")
    private Long cachedTotalSales;

    // 一時的なフラグ(履歴追跡不要)
    @NotAudited
    @Column(name = "is_featured")
    private Boolean isFeatured;

    // 内部処理用のメタデータ(履歴追跡不要)
    @NotAudited
    @Column(name = "last_sync_at")
    private LocalDateTime lastSyncAt;

    // Getters and Setters(省略)
}

リレーションの除外

監査対象のエンティティが他のエンティティとリレーションを持つ場合、関連エンティティの監査設定に注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.envers.entity;

import jakarta.persistence.*;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;
import org.hibernate.envers.RelationTargetAuditMode;

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

@Entity
@Table(name = "orders")
@Audited
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "order_number", nullable = false, unique = true)
    private String orderNumber;

    // 監査対象のリレーション
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    // 監査対象外のリレーション(ログ情報など)
    @NotAudited
    @OneToMany(mappedBy = "order")
    private List<OrderStatusLog> statusLogs = new ArrayList<>();

    // 監査対象外のエンティティへの参照(Customerが@Auditedでない場合)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
    private Customer customer;

    // Getters and Setters(省略)
}

RelationTargetAuditModeの使い分け

リレーション先のエンティティが監査対象でない場合の設定方法です。

モード 説明 使用ケース
AUDITED(デフォルト) リレーション先も監査対象である必要がある 通常のリレーション
NOT_AUDITED リレーション先が監査対象でなくてもOK マスターデータへの参照など
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Entity
@Table(name = "products")
@Audited
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Categoryエンティティが@Auditedでない場合でもエラーにならない
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
    private Category category;
    
    // ...
}

埋め込みオブジェクトの部分的な監査

@Embeddedオブジェクト内の特定フィールドのみを監査対象にすることもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.envers.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import org.hibernate.envers.NotAudited;

@Embeddable
public class Address {

    @Column(name = "postal_code", length = 10)
    private String postalCode;

    @Column(name = "prefecture", length = 50)
    private String prefecture;

    @Column(name = "city", length = 100)
    private String city;

    @Column(name = "street", length = 200)
    private String street;

    // 緯度経度は頻繁に更新されるため監査対象外
    @NotAudited
    @Column(name = "latitude")
    private Double latitude;

    @NotAudited
    @Column(name = "longitude")
    private Double longitude;

    // Getters and Setters(省略)
}

よくある誤解とアンチパターン

誤解1: @Auditedを付けるだけで完全な履歴が取れる

store_data_at_deleteオプションがfalse(デフォルト)の場合、DELETE時にはIDとリビジョン情報のみが記録され、削除前のデータは保存されません。

1
2
3
4
5
6
7
8
9
# application.yml - DELETE時もデータを保持する設定
spring:
  jpa:
    properties:
      hibernate:
        org:
          hibernate:
            envers:
              store_data_at_delete: true

誤解2: 監査テーブルに直接SQLでアクセスしても問題ない

監査テーブル(*_AUD)のデータ構造はEnversの内部仕様に依存しています。直接SQLでデータを操作すると、整合性が崩れる可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 悪い例: 監査テーブルに直接アクセス
@Query(value = "SELECT * FROM products_AUD WHERE id = :id", nativeQuery = true)
List<Object[]> findAuditRecords(@Param("id") Long id);

// 良い例: AuditReaderを使用
public List<Object[]> getProductHistory(Long productId) {
    AuditReader auditReader = AuditReaderFactory.get(entityManager);
    return auditReader.createQuery()
            .forRevisionsOfEntity(Product.class, false, true)
            .add(AuditEntity.id().eq(productId))
            .getResultList();
}

誤解3: トランザクションを分けると別リビジョンになる

Enversはトランザクション単位でリビジョンを管理します。同一トランザクション内の複数の変更は、すべて同じリビジョンに記録されます。

1
2
3
4
5
6
7
8
9
// 同一リビジョンに記録される
@Transactional
public void updateMultipleProducts(List<Product> products) {
    for (Product product : products) {
        product.setPrice(product.getPrice().multiply(BigDecimal.valueOf(1.1)));
        productRepository.save(product);
    }
    // すべての変更が1つのリビジョンとして記録される
}

アンチパターン1: 大量データの一括更新

監査テーブルは更新のたびにレコードが増加します。大量データを頻繁に更新する場合、監査テーブルが肥大化してパフォーマンスに影響します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// アンチパターン: 大量の一括更新
@Transactional
public void updateAllPrices(BigDecimal multiplier) {
    List<Product> products = productRepository.findAll();
    for (Product product : products) {
        product.setPrice(product.getPrice().multiply(multiplier));
        productRepository.save(product);
    }
    // 1万件の商品があれば、1万レコードが監査テーブルに追加される
}

アンチパターン2: 監査不要なフィールドの監査

キャッシュや一時フラグなど、ビジネス上重要でないフィールドも監査すると、不要なデータが蓄積されます。

1
2
3
4
5
6
7
8
// 改善例: 不要なフィールドを@NotAuditedで除外
@NotAudited
@Column(name = "view_count")
private Long viewCount;

@NotAudited
@Column(name = "last_viewed_at")
private LocalDateTime lastViewedAt;

アンチパターン3: RevisionListenerでのDB操作

RevisionListener内でデータベース操作を行うと、予期しない動作やパフォーマンス問題が発生する可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// アンチパターン: RevisionListener内でのDB操作
public class BadRevisionListener implements RevisionListener {
    @Override
    public void newRevision(Object revisionEntity) {
        // これはやってはいけない
        auditLogRepository.save(new AuditLog(...));
    }
}

// 良い例: リビジョンエンティティへの情報設定のみ
public class GoodRevisionListener implements RevisionListener {
    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;
        rev.setUsername(getCurrentUsername());
        rev.setIpAddress(getCurrentIpAddress());
    }
}

まとめと実践Tips

Envers導入時のチェックリスト

項目 確認内容
依存関係 hibernate-enversが追加されているか
エンティティ 監査対象に@Auditedが付与されているか
リレーション 関連エンティティの監査設定は適切か
除外設定 不要なフィールドに@NotAuditedが設定されているか
DELETE設定 store_data_at_deleteの設定は要件に合っているか
リビジョン情報 カスタムリビジョンエンティティが必要か

パフォーマンス最適化のTips

  1. インデックスの追加: 監査テーブルに対する検索が頻繁な場合、適切なインデックスを追加
  2. パーティショニング: 大量の履歴データがある場合、日付でパーティショニングを検討
  3. アーカイブ戦略: 古い履歴データは定期的にアーカイブテーブルに移動
  4. 非同期監査: 高頻度の更新がある場合、Enversの代わりにイベントベースの非同期監査を検討

本番環境でのベストプラクティス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# application-prod.yml
spring:
  jpa:
    hibernate:
      ddl-auto: validate  # 本番では自動DDL変更を無効化
    properties:
      hibernate:
        org:
          hibernate:
            envers:
              store_data_at_delete: true
              audit_table_suffix: _AUDIT  # 命名規則を明確に
              default_schema: audit       # 監査テーブル用の専用スキーマ

Hibernate Enversは、エンティティの変更履歴を透過的に管理できる強力なツールです。@Auditedアノテーションの付与だけで履歴テーブルが自動生成され、AuditReaderを通じて柔軟な履歴検索が可能になります。監査要件の複雑さに応じて、カスタムリビジョンエンティティや@NotAuditedによる細かな制御を組み合わせることで、実用的な履歴管理システムを構築できます。

参考リンク