エンタープライズシステムにおいて、エンティティの変更履歴管理は監査要件やデータ追跡の観点から極めて重要な機能です。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 -.-> FEnversが提供する主要機能#
| 機能 |
説明 |
| 自動履歴テーブル生成 |
_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#
- インデックスの追加: 監査テーブルに対する検索が頻繁な場合、適切なインデックスを追加
- パーティショニング: 大量の履歴データがある場合、日付でパーティショニングを検討
- アーカイブ戦略: 古い履歴データは定期的にアーカイブテーブルに移動
- 非同期監査: 高頻度の更新がある場合、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による細かな制御を組み合わせることで、実用的な履歴管理システムを構築できます。
参考リンク#