はじめに
この記事の対象者
- Spring Bootについてある程度理解している
- Spring Data JPAを使用してREST APIを構築している
- DBへのデータ追加、変更、削除を追跡して、履歴に対してクエリをかけたいと切に願っている
なぜこの記事を書くのか
「あるはずのデータが表示されてない。さっきまであった。なんで?」
ユーザーから恐ろしい声が届いた。
リリースしたサービスを運用していく中で、このデータが「いつDBから削除されたのか」「いつ変更されたのか」「そもそも最初から存在していたのか」を後から知りたい場面が多々発生したので、何か方法はないかと探っていたところ、今回の記事のような解決策に行き着いたので、備忘録として書き留めようと思います。
環境
- Spring Boot 3.1.4
- Spring Data JPA 3.2.4
- Spring Data Envers 3.2.4
- Hibernate Envers 6.4.4 Final
- postgresql 42.7.2
- Flyway 9.22.3
- Java 17
Spring Data Envers とは?
Spring Data EnversはSpring Data JPAプロジェクトの拡張で、Hibernate EnversをSpring Data JPAベースのアプリケーションに簡単に統合できます。Hibernate EnversはJPAエンティティへの変更を監査するためのライブラリで、バージョン管理や履歴データのサポートを提供します。つまり、データに加えられた変更を追跡し、履歴データをクエリすることができます。
要は、監視対象としてEntityへの追加、変更、削除のイベントに対してID(Revision)が振られ、後からクエリをかけて振り返れるようになるよ〜、だそうです。うん、それそれ。それがやりたい。
導入方法
今回はシンプルに、以下のようにサンプルを作ります。
- まず「車の情報の追加、変更、削除」を担うAPIを作成する
-
Spring Boot Envers
を導入し履歴の監視対象する - 履歴に対してクエリをかけられるAPIを作成する
前提
以下までの作業は終わっているものとして進めます。
- Spring initializerを用いて空のプロジェクトができている
●追跡対象データのAPIを作成する
依存関係
下記の依存を追加します。
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.4")
implementation("org.postgresql:postgresql:42.7.2")
implementation("org.flywaydb:flyway-core:9.22.3")
// more implements...
}
現時点でのymlはこんな感じ。
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${DATABASE_URL}/hoge
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
flyway:
locations: classpath:/db/migration
baseline-on-migrate: true
Entityを作成する
車の情報のEntityを作成します。
id、名前、色、製造日の4つのカラムを持つことにします。
@Entity
@Table(name = "car")
data class CarRecord(
@Id
val id: String,
val name: String,
val color: String,
val manufacturedDate: LocalDate,
)
Migration
今回はFlyway
を用いてMigration管理をします。
下記のMigrationファイルを作成し、car
テーブルを作成します。
CREATE TABLE car
(
id char varying(10) NOT NULL PRIMARY KEY ,
name char varying(20) NOT NULL,
color char varying(20) NOT NULL,
manufactured_date timestamp without time zone NOT NULL
);
Controllerを作成する
GET、POST、DELETEのエンドポイントを作成します。
@RestController
@RequestMapping("/api/car")
class CarController(
private val carService: CarService,
) {
@GetMapping
fun getCars(): List<CarRecord> {
return carService.getCars()
}
@PostMapping
fun saveCar(@RequestBody car: CarRecord) {
carService.saveCar(car)
}
@DeleteMapping
fun deleteCar(@RequestParam id: String) {
carService.deleteCar(id)
}
}
Serviceを作成する
interface CarService {
fun getCars(): List<CarRecord>
fun saveCar(car: CarRecord)
fun deleteCar(id: String)
}
@Service
class DefaultCarService(
private val carRepository: CarRepository
): CarService {
override fun getCars(): List<CarRecord> {
return carRepository.findAll()
}
override fun saveCar(car: CarRecord) {
carRepository.save(car)
}
override fun deleteCar(id: String) {
carRepository.deleteById(id)
}
}
Repositoryを作成する
JpaRepository
を継承したRepositoryのinterfaceを作成します。
@Repository
@Transactional
interface CarRepository: JpaRepository<CarRecord, String>
APIができました、アプリケーションを実行して確認しましょう。
※適宜必要な情報をenvファイルに入れてください。
source .env && ./gradlew bootrun
postmanで試しにAPIを叩いてみます。
●Enversを導入し履歴の監視対象する
ではこのcarテーブルにEnversを適用していきます。
依存の追加
Spring Boot Envers
とHibernate Envers
の依存を追加します
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.4")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.22")
implementation("org.postgresql:postgresql:42.7.2")
+ implementation("org.springframework.data:spring-data-envers:3.2.4")
+ implementation("org.hibernate.orm:hibernate-envers:6.4.4.Final")
// more implements...
}
ymlの編集
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${DATABASE_URL}/hoge
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
+ jpa:
+ properties:
+ org:
+ hibernate:
+ envers:
+ audit_table_suffix: _history
+ revision_field_name: revision_id
+ revision_type_field_name: revision_type
+ store_data_at_delete: true
flyway:
locations: classpath:/db/migration
baseline-on-migrate: true
-
audit_table_suffix
: 監視エンティティのテーブル名を分かりやすいように_history
とします。Flywayとも統一感出るし。car_history
というテーブル名になります。 -
revision_field_name
: revision番号のカラム名を分かりやすくrevision_id
とします。 -
revision_type_field_name
: revisionタイプ(INSERT
,UPDATE
,DELETE
)のカラム名を分かりやすくrevision_type
とします。 -
store_data_at_delete
: EnversはデフォルトでINSERT
とUPDATE
しか監視しません。DELETE
も監視したいので、このプロパティをtrue
にします。
プロパティの詳細は下記です
プロパティ | 概要 |
---|---|
jpa.properties.envers.audit_table_suffix |
監査エンティティのテーブル名の末尾に付加される文字列(デフォルト _aud ) |
jpa.properties.envers.revision_field_name |
リビジョン番号を保持する監査エンティティのフィールド名(デフォルト rev ) |
jpa.properties.envers.revision_type_field_name |
リビジョンのタイプを保持する監査エンティティのフィールド名(デフォルト revtype ) |
jpa.properties.envers.store_data_at_delete |
DELETE時にrevisionを記録するかどうかの真偽値(デフォルトfalse) |
Migrationファイルを追加する
①revinfo
テーブルを作成するMigrationファイルを作成
CREATE TABLE revinfo
(
rev INTEGER NOT NULL PRIMARY KEY,
revtstmp BIGINT
);
このテーブルは、アプリケーションのすべてのrevisionを管理するエンティティです。
Revisionのidとなるrev
カラム、Revisionが作成された日時revtstmp
の二つのカラムから構成されます。全てのRevisionの管理を担うテーブルです。
②car_historty
テーブルを作成するMigrationファイルを作成
CREATE TABLE car_history
(
id VARCHAR(255) NOT NULL,
revision_id INTEGER NOT NULL
revision_type SMALLINT,
color VARCHAR(255),
manufactured_date DATE,
name VARCHAR(255),
primary key (id, revision_id)
CONSTRAINT fk_revinfo FOREIGN KEY (revision_id) REFERENCES revinfo (rev)
);
このテーブルは、carテーブルへの変更の履歴が蓄積される監視エンティティです。
car
テーブルの中で監視したいカラムをスキーマとして持ち、それに加えてrevision_id
、revision_type
の二つのカラムを付加します。
-
revision_id
:yml
で設定した名前になっています。revinfo
テーブルとのリレーションを張ります。 -
revision_type
:INSERT
,UPDATE
,DELETE
からなるRevisionの種類です。
revision_type
テーブル作成後実際にRevisionが取れると、RevisionType
のenumが数値に変換されて「0, 1, 2」という数字がこのカラムに入ります。数値とTypeの関係は下記です。
数値 | enum |
---|---|
0 | INSERT |
1 | UPDATE |
2 | DELETE |
③revinfo
のSequenceを作成
CREATE SEQUENCE revinfo_seq INCREMENT BY 50;
revision_id
をインクリメントするためのSequenceを作成します。
これがないと後々エラーが出ます。
アプリケーションを起動する
Migrationの準備ができたので、アプリケーションを起動します。
source .env && ./gradlew bootrun
car
, revinfo
, car_history
の3つのテーブルが生成されたことを確認します。
Entityを監視対象にする
@Audited
アノテーションを付与して、CarRecord
を監視可能にします。
@Entity
@Table(name = "car")
+ @Audited
data class CarRecord(
@Id
val id: String,
val name: String,
val color: String,
val manufacturedDate: LocalDate,
)
Repositoryを拡張する
CarRepository
のinterfaceを、RevisionRepository
に準拠させます。
@Repository
@Transactional
interface CarRepository: JpaRepository<CarRecord, String>, RevisionRepository<CarRecord, String, Long> {
}
最初の型パラメータ (CarRecord
) はエンティティタイプを表し、2番目の型パラメータ (String
) はCarRecord
のid
プロパティのタイプを表し、最後の型パラメータ (Long
) はリビジョン番号のタイプを表します。デフォルト設定のEnversでは、リビジョン番号パラメータはInteger
またはLong
でなければなりません。
これで、CarRepository
は下記のようなメソッドが使用可能になります。
fun findRevision(id: String, revisionNumber: Long)
fun findRevisions(id: String)
fun findRevisions(id: String, pageable: Pageable)
fun findLastChangeRevision(id: String)
Revision用APIのModelを作成する
Revision用のAPIの形を再現するModelを定義します。
CarRecord
のカラムにrevisionId
, revisionType
, timestamp
を加えて宣言します。
data class CarRecordRevision(
val id: String,
val name: String,
val color: String,
val manufacturedDate: LocalDate,
val revisionId: Long,
val revisionType: RevisionType,
val timestamp: LocalDateTime,
)
Revision用Controllerを作成する
ここからは、RevisionにクエリをかけるためのAPIを作成していきます
@RestController
@RequestMapping("/api/car/revision")
class CarRevisionController(
private val carRevisionService: CarRevisionService
) {
@GetMapping
fun getCarRevisions(
@RequestParam id: String,
@RequestParam type: RevisionMetadata.RevisionType?
): List<CarRecordRevision> {
return carRevisionService.getCarRevisions(id, type)
}
@GetMapping("/latest")
fun getLatestCarRevision(
@RequestParam id: String
): CarRecordRevision {
return carRevisionService.getLatestCarRevision(id)
}
}
-
fun getCarRevisions
:車のIDを指定して、該当するidに関わるRevisionを配列で返します。任意でRevisionType
を指定することができ、INSERT
,UPDATE
,DELETE
のいずれかの文字列をパラメータとして渡すと、該当するtypeに絞ってRevisionの配列を返します。渡さなければ全て返します。 -
fun getLatestCarRevision
:車のIDを指定して、該当するidに関わる最新のRevisionを返します。なければ404レスポンスを返します。
Revision用Serviceを作成する
interface CarRevisionService {
fun getCarRevisions(
id: String,
type: RevisionType?
): List<CarRecordRevision>
fun getLatestCarRevision(id: String): CarRecordRevision
}
@Service
class DefaultCarRevisionService(
private val carRepository: CarRepository
) : CarRevisionService {
override fun getCarRevisions(
id: String,
type: RevisionType?
): List<CarRecordRevision> {
val carRecordRevisions = carRepository.findRevisions(id).stream()
.map { it.toCarRecordRevision() }
.toList()
type ?: return carRecordRevisions
return carRecordRevisions.filter { it.revisionType == type }
}
override fun getLatestCarRevision(id: String): CarRecordRevision {
try {
val latestRevision = carRepository.findLastChangeRevision(id).get()
return latestRevision.toCarRecordRevision()
} catch (e: NoSuchElementException) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message)
}
}
}
fun Revision<Long, CarRecord>.toCarRecordRevision(): CarRecordRevision {
return CarRecordRevision(
this.entity.id,
this.entity.name,
this.entity.color,
this.entity.manufacturedDate,
this.revisionId.get(),
this.metadata.revisionType,
this.requiredRevisionInstant.epochSecond.toLocalDateTime()
)
}
◯fun getCarRevisions
:
carRepository
のfindRevisions
メソッドにid
を渡し、
CarRecordRevision
型に変換して配列を返します。
◯fun getLatestCarRevision
:
carRepository
のfindLastChangeRevision
メソッドにid
を渡して
最新のRevisionを取得し、CarRecordRevision
型に変換して返します。
該当するRevisionが見つからなかった場合は404レスポンスを返します。
これでRevision用のAPIができました!
使ってみましょう。
Revisionを取得してみよう
全ての準備ができたので、アプリケーションを再起動します。
source .env && ./gradlew bootrun
①先ほど作ったAPIを使って、車の情報を一台分POSTしてみます。
すると、
-
car
テーブルにレコードが一つ追加されました -
car_hisotry
テーブルにPOSTしたcarがINSERT
された(revision_type: 0)ことがrevisionId: 552
として記録されました -
revinfo
テーブルにrev(revisionId)
とrevtstmp(更新日時)
が記録されました
②次に今POSTした車の情報を変更してみます
-
car
のcolor
がred
に変更されました -
car_hisotry
テーブルに該当するcar
がUPDATE
された(revision_type: 1)ことがrevisionId: 602
として記録されました -
revinfo
テーブルにrev(revisionId)
とrevtstmp(更新日時)
が記録されました
③次に車を削除してみます
-
car
テーブルから該当する車が削除されました -
car_hisotry
テーブルに該当するcar
がDELETE
された(revision_type: 2)ことがrevisionId: 603
として記録されました -
revinfo
テーブルにrev(revisionId)
とrevtstmp(更新日時)
が記録されました
おわりに
これで、DBへのデータ追加、変更、削除を追跡し、履歴に対してクエリをかける仕組みができました。
もちろん、「あるはずのデータがない」という状況を作らないのがベストですが、何かあった時に全く手がかりがないのは詰みです。自分たちのプロダクトが原因なのか、それとも外部的な要因なのか、これで多少は原因追及しやすくなったのではないでしょうか。
では、よいEnversライフを!
参考文献