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

# はじめに
::: note 
この記事の対象者
- 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を作成する

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

```kotlin: 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はこんな感じ。
```yaml: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つのカラムを持つことにします。

```kotlin: 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`テーブルを作成します。
```sql: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のエンドポイントを作成します。

```kotlin: 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を作成する

```kotlin: 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を作成します。

```kotlin: CarController.kt
@Repository
@Transactional
interface CarRepository: JpaRepository<CarRecord, String>
```
APIができました、アプリケーションを実行して確認しましょう。
※適宜必要な情報をenvファイルに入れてください。

```bash
source .env && ./gradlew bootrun
```
postmanで試しにAPIを叩いてみます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/f3bdcd0c-020e-fc09-6262-c276c2a3c0b8.png)

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

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

## 依存の追加
`Spring Boot Envers`と`Hibernate Envers`の依存を追加します

```diff_kotlin: 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の編集

```diff_yaml: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はデフォルトで`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ファイルを作成
```sql: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ファイルを作成
```sql: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_id`、`revision_type`の二つのカラムを付加します。
- `revision_id`：`yml`で設定した名前になっています。`revinfo`テーブルとのリレーションを張ります。
- `revision_type`：`INSERT`, `UPDATE`, `DELETE`からなるRevisionの種類です。

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

### ③`revinfo`のSequenceを作成
```sql:V4__create_sequence_revinfo.sql
CREATE SEQUENCE revinfo_seq INCREMENT BY 50;
```
`revision_id`をインクリメントするためのSequenceを作成します。
これがないと後々エラーが出ます。

## アプリケーションを起動する
Migrationの準備ができたので、アプリケーションを起動します。
```bash
source .env && ./gradlew bootrun
```
`car`, `revinfo`, `car_history`の3つのテーブルが生成されたことを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/cfa1ba1a-793f-80de-a0d7-7632e5e495a9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/d2af287e-07cb-32df-935a-b7abf2e6e574.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/d4aca96e-260c-0dae-5946-80f51a1fb312.png)

## Entityを監視対象にする

`@Audited`アノテーションを付与して、`CarRecord`を監視可能にします。
```diff_kotlin: 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`に準拠させます。


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

}
```
最初の型パラメータ (`CarRecord`) はエンティティタイプを表し、2番目の型パラメータ (`String`) `はCarRecord`の`id`プロパティのタイプを表し、最後の型パラメータ (`Long`) はリビジョン番号のタイプを表します。デフォルト設定のEnversでは、リビジョン番号パラメータは`Integer`または`Long`でなければなりません。

これで、`CarRepository`は下記のようなメソッドが使用可能になります。

```kotlin
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`を加えて宣言します。

```kotlin: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を作成していきます
```kotlin: 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を作成する
```kotlin: 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`：
　　`carRepository`の`findRevisions`メソッドに`id`を渡し、
　　`CarRecordRevision`型に変換して配列を返します。
    
◯`fun getLatestCarRevision`：
　　`carRepository`の`findLastChangeRevision`メソッドに`id`を渡して
　　最新のRevisionを取得し、`CarRecordRevision`型に変換して返します。
　　該当するRevisionが見つからなかった場合は404レスポンスを返します。

これでRevision用のAPIができました！
使ってみましょう。

# Revisionを取得してみよう
全ての準備ができたので、アプリケーションを再起動します。
```bash
source .env && ./gradlew bootrun
```
①先ほど作ったAPIを使って、車の情報を一台分POSTしてみます。

すると、
- `car`テーブルにレコードが一つ追加されました
- `car_hisotry`テーブルにPOSTしたcarが`INSERT`された（revision_type: 0）ことが`revisionId: 552`として記録されました
- `revinfo`テーブルに`rev(revisionId)`と`revtstmp(更新日時)`が記録されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/222ce33c-ff84-18b6-5e8c-d0f54aad72d8.png)

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

- `car`の`color`が`red`に変更されました
- `car_hisotry`テーブルに該当する`car`が`UPDATE`された（revision_type: 1）ことが`revisionId: 602`として記録されました
- `revinfo`テーブルに`rev(revisionId)`と`revtstmp(更新日時)`が記録されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/fddfaf2e-3cba-730f-6f7a-b62a99da911c.png)

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

- `car`テーブルから該当する車が削除されました
- `car_hisotry`テーブルに該当する`car`が`DELETE`された（revision_type: 2）ことが`revisionId: 603`として記録されました
- `revinfo`テーブルに`rev(revisionId)`と`revtstmp(更新日時)`が記録されました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/e6abd0e1-4af2-c7c6-0d7c-6b630f318329.png)

# おわりに

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

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

では、よいEnversライフを！

# 参考文献
https://docs.spring.io/spring-data/envers/docs/current/reference/html/#dependencies

https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#envers-configuration

https://note.com/b1a9idps/n/n113313c445fb

https://medium.com/@hkn94/entity-auditing-in-spring-boot-with-hibernate-envers-4b127c58fe7d

https://www.intentwise.com/blog/technology/hibernate-envers-and-spring-boot/