9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Data Envers】超簡単!JPAデータエンティティの変更を監視する

Last updated at Posted at 2024-04-02

はじめに

この記事の対象者

  • 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)が振られ、後からクエリをかけて振り返れるようになるよ〜、だそうです。うん、それそれ。それがやりたい。

導入方法

今回はシンプルに、以下のようにサンプルを作ります。

  1. まず「車の情報の追加、変更、削除」を担うAPIを作成する
  2. Spring Boot Enversを導入し履歴の監視対象する
  3. 履歴に対してクエリをかけられるAPIを作成する

前提

以下までの作業は終わっているものとして進めます。

  • Spring initializerを用いて空のプロジェクトができている

●追跡対象データのAPIを作成する

依存関係

下記の依存を追加します。

build.gradle.kts
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はこんな感じ。

application.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つのカラムを持つことにします。

CarRecord.kt
@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テーブルを作成します。

V2__create_table_car.sql
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のエンドポイントを作成します。

CarController.kt
@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を作成する

CarService.kt
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を作成します。

CarController.kt
@Repository
@Transactional
interface CarRepository: JpaRepository<CarRecord, String>

APIができました、アプリケーションを実行して確認しましょう。
※適宜必要な情報をenvファイルに入れてください。

source .env && ./gradlew bootrun

postmanで試しにAPIを叩いてみます。

image.png

●Enversを導入し履歴の監視対象する

ではこのcarテーブルにEnversを適用していきます。

依存の追加

Spring Boot EnversHibernate Enversの依存を追加します

build.gradle.kts
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の編集

application.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はデフォルトでINSERTUPDATEしか監視しません。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ファイルを作成

V2__create_table_revinfo.sql
CREATE TABLE revinfo
(
    rev      INTEGER NOT NULL PRIMARY KEY,
    revtstmp BIGINT
);

このテーブルは、アプリケーションのすべてのrevisionを管理するエンティティです。
Revisionのidとなるrevカラム、Revisionが作成された日時revtstmpの二つのカラムから構成されます。全てのRevisionの管理を担うテーブルです。

car_histortyテーブルを作成するMigrationファイルを作成

V3__create_table_car_history.sql
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_idrevision_typeの二つのカラムを付加します。

  • revision_idymlで設定した名前になっています。revinfoテーブルとのリレーションを張ります。
  • revision_typeINSERT, UPDATE, DELETEからなるRevisionの種類です。

revision_type
テーブル作成後実際にRevisionが取れると、RevisionTypeのenumが数値に変換されて「0, 1, 2」という数字がこのカラムに入ります。数値とTypeの関係は下記です。

数値 enum
0 INSERT
1 UPDATE
2 DELETE

revinfoのSequenceを作成

V4__create_sequence_revinfo.sql
CREATE SEQUENCE revinfo_seq INCREMENT BY 50;

revision_idをインクリメントするためのSequenceを作成します。
これがないと後々エラーが出ます。

アプリケーションを起動する

Migrationの準備ができたので、アプリケーションを起動します。

source .env && ./gradlew bootrun

car, revinfo, car_historyの3つのテーブルが生成されたことを確認します。

image.png
image.png
image.png

Entityを監視対象にする

@Auditedアノテーションを付与して、CarRecordを監視可能にします。

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に準拠させます。

CarRepository
@Repository
@Transactional
interface CarRepository: JpaRepository<CarRecord, String>, RevisionRepository<CarRecord, String, Long> {

}

最初の型パラメータ (CarRecord) はエンティティタイプを表し、2番目の型パラメータ (String) はCarRecordidプロパティのタイプを表し、最後の型パラメータ (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を加えて宣言します。

CarRecordRevision.kt
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を作成していきます

CarRevisionController.kt
@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を作成する

CarRevisionService.kt
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
  carRepositoryfindRevisionsメソッドにidを渡し、
  CarRecordRevision型に変換して配列を返します。

fun getLatestCarRevision
  carRepositoryfindLastChangeRevisionメソッドに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(更新日時)が記録されました

image.png

②次に今POSTした車の情報を変更してみます

  • carcolorredに変更されました
  • car_hisotryテーブルに該当するcarUPDATEされた(revision_type: 1)ことがrevisionId: 602として記録されました
  • revinfoテーブルにrev(revisionId)revtstmp(更新日時)が記録されました

image.png

③次に車を削除してみます

  • carテーブルから該当する車が削除されました
  • car_hisotryテーブルに該当するcarDELETEされた(revision_type: 2)ことがrevisionId: 603として記録されました
  • revinfoテーブルにrev(revisionId)revtstmp(更新日時)が記録されました
    image.png

おわりに

これで、DBへのデータ追加、変更、削除を追跡し、履歴に対してクエリをかける仕組みができました。

もちろん、「あるはずのデータがない」という状況を作らないのがベストですが、何かあった時に全く手がかりがないのは詰みです。自分たちのプロダクトが原因なのか、それとも外部的な要因なのか、これで多少は原因追及しやすくなったのではないでしょうか。

では、よいEnversライフを!

参考文献

9
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?