1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JPAのデザインパターンとライフサイクル

Posted at

はじめに

最近、「JPAの事実と誤解」という講義を聴講し、その内容を自分なりに整理しております。

  • 機械翻訳を通じて作成しているため、誤字脱字があるかもしれません

JPAの基盤となる3つのパターン

前回の記事では、JPAがどのような場合に適切な選択肢であり、どのような設計を持っているのかを簡単に見てまいりました。

改めてこれを簡単に整理しながら、JPAがどのように構成されているのかを確認いたします。

以下の例では、JPAの実装体としてHibernateを使用いたします。

Data Mapper Pattern

  • ドメインオブジェクトとデータベース間のデータを変換し、送信する役割を果たします

image.png

これはJPAにおけるRepository層(Repository Layer)を通して実装されます。

Springでは特に@Repositoryというアノテーションを提供しており、その背景についても説明されています。

Indicates that an annotated class is a "Repository", originally defined by Domain-Driven Design (Evans, 2003) as "a mechanism for encapsulating storage, retrieval, and search behavior which emulates a collection of objects".

  • この概念はDomain-Driven Design(DDD)を通じて登場しました
  • オブジェクトのコレクション(Collection)を模倣する保存、取得、および検索動作をカプセル化するメカニズムを提供いたします
    • カプセル化とは、保存、取得、検索などの内部動作を隠蔽し、Javaのメモリ上で管理することを意味します(例:List、Setなど)

通常、Springでは次のように@Repositoryを通じて該当部分を定義いたします。

@Entity
public class User {
    @Id
    private Long id;
    private String name;

次のようにUserがある場合

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 名前でユーザーを検索するカスタムメソッド
    List<User> findByName(String name);
}

次のように定義いたします。

データベース(DB)にどのようにアクセスするのか、どのようなSQL文(SQL statement)を作成したのか、また、データベースから取得したデータをJavaのメモリ上のオブジェクトにどのようにマッピングするのか、全く知りません。

全てがカプセル化されているためです。

ここまでが、Data Mapper PatternがJPAに適用された例でございました。

DAO vs Repository

この2つの違いは混同される可能性があります。
コメントでは次のように説明されています。

Teams implementing traditional Jakarta EE patterns such as "Data Access Object" may also apply this stereotype to DAO classes, though care should be taken to understand the distinction between Data Access Object and DDD-style repositories before doing so. This annotation is a general-purpose stereotype and individual teams may narrow their semantics and use as appropriate.

  • 従来のJakarta EEパターン(例:Data Access Object, DAO)を使用する場合でも、@Repositoryを付与しても問題ありません

  • しかし、DAOとドメイン駆動設計(DDD)スタイルのリポジトリ(Repository)は概念上の違いがございますので、これらを十分に区別した上で使用する必要がございます

Repository

  • Repositoryはドメインモデルの観点から保存、取得、検索機能を抽象化し、アプリケーションアーキテクチャにおける役割を明確にいたします
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);
}

以前に説明した通り、すべてがカプセル化されております。

DAO

  • DAOは、データベースとの具体的な技術的相互作用(SQLなど)に焦点を合わせます
@Commpont
public class UserDao {

    private final JdbcTemplate jdbcTemplate;

    public UserDao(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<User> findByName(String name) {
        String sql = "SELECT id, name FROM users WHERE name = ?";
        return jdbcTemplate.query(sql, new Object[]{name}, new RowMapper<User>() {
            @Override
            public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                User user = new User();
                user.setId(rs.getLong("id"));
                user.setName(rs.getString("name"));
                return user;
            }
        });
    }
}

DBへのアクセスから呼び出し、Javaのメモリ(オブジェクト)へのマッピングまで、すべてが露出しております。

これがDAOとRepositoryの違いと言えます。

Identity Map

  • これは、同一のデータベース内のデータが重複してロードされるのを防ぐという概念です

データベース上では同じデータでも、
Javaのメモリ上では異なるオブジェクトとなります。

例えば、DBのuser_id = 1でuser_name = "haroya"というデータがあったとしても

User user1 = userDao.findById(1);  // DBから取得(新しいオブジェクトを生成)
User user2 = userDao.findById(1);  // 再びDBから取得(また別のオブジェクトを生成)
assert user1 != user2;  // 異なるオブジェクトインスタンス

Javaのメモリ上では別々のオブジェクトとして扱われます。

しかし、JPAはIdentity Map(アイデンティティマップ)を適用することにより、基本的に同一のデータは同一のオブジェクトとして扱うことを保証しております。

User user1 = userMapper.findById(1);  // DBから取得
User user2 = userMapper.findById(1);  // DBを照会せず、キャッシュされたオブジェクトを返す
assert user1 == user2;  // 同一のオブジェクトインスタンス

image.png

下記のように一次キャッシュ(First-Level Cache)で管理されると説明されており、実装体は

package org.hibernate.engine.internal.StatefulPersistenceContextに定義されております。

image.png

以下のようにキーに基づいて定義されます。

image.png

そして、そのキーはエンティティのIDそのものです。

@Entity
public class User {
    @Id
    private Long id;
    private String name;

もしUserオブジェクトが以前のように存在しており、
IDが1であれば、それは同一のオブジェクトとして扱われます。
そして、もしそれが一次キャッシュ(保存されたマップ、正確には永続性コンテキスト)に存在している場合、該当するオブジェクトが返されるということです。

非常に簡単に図示すると、

image.png

次のようにEntityKeyにより、どのオブジェクトかを管理します。

Identity Map (Map)の構成

Key: EntityKey(User, 1)

  • Type: User
  • Identifier: 1

Value: EntityHolderImpl

  • entityKey: EntityKey(User, 1)
  • descriptor: EntityPersister(Userのマッピング情報)
  • entity: 実際のUserインスタンス
  • proxy: プロキシオブジェクト(遅延読み込み時)
  • entityEntry: エンティティの状態およびメタデータ
  • entityInitializer: 遅延読み込み初期化用オブジェクト
  • state: EntityHolderState(例:MANAGED)

image.png

1次キャッシュのフロー

次のように、findByIdでUserオブジェクトを取得するように呼び出した場合

image.png

まず、Identity Mapを確認し、存在しなければDBに問い合わせを行います。

image.png

その後、次のように返却されます。

以降、同一セッション内でリクエストが発生した場合は、Identity Mapにキャッシュされたデータが返却されます。

image.png

これにより、以下のような利点が得られます。

  • DB呼び出しの最小化: 同一エンティティを再度取得する際にキャッシュ済みのオブジェクトを利用することで、不要なデータベースクエリを削減します
  • データ整合性の維持: 同一エンティティに対して同一のインスタンスを再利用することで、データ状態の一貫性が保証されます
  • パフォーマンスの向上: DBアクセスの回数を減らすことで、アプリケーション全体のパフォーマンスが向上します

Identity Mapの詳細な構造は、以降の図面では省略いたします。

Unit of Work

  • アプリケーション内でドメインオブジェクトの変更履歴を追跡いたします

image.png

image.png

新しいオブジェクトが検出されると、元のコピー(snapshotオブジェクト)を作成いたします。

図で表すと、次のようになります。

image.png

以下のようにコピーされます。
たとえ内部の値が変更されたとしても、

image.png

もしflush();で変更内容をDBに反映しようとする場合、

IDを通じて同じデータであるかを確認し、最適化されたクエリによりDBに反映いたします。
これはorg.hibernate.internal.SessionImplで確認することが可能です。

image.png

image.png

このメカニズムに関する詳細な説明は、以前の記事にてご参照いただければと存じます。

transactionとUnit of Work(作業単位)

一般的にJPA(またはHibernate)を使用する際は、トランザクションの範囲の設定が非常に重要です。
JPAで言う**「作業単位 (Unit of Work)」**とは、エンティティの生成、修正、削除といった一連の操作を一つの論理的なグループとしてまとめて処理することを意味いたします。

この作業単位は通常、データベーストランザクションと同じ範囲に設定する必要があり、これによりデータの整合性と一貫性が維持されます。

 @Transactional
    public Student registerStudent(String name) {
        Student student = new Student();
        student.setName(name);
        studentRepository.save(student); 
        return student;
    }
  • Springでは、@Transactionalアノテーションを用いて作業単位を定義し、データベースのライフサイクルとJPAのエンティティライフサイクル(Transient、Managed、Detached、Removed)を一致させることが可能です

JPAの4つの状態(ライフサイクル)

先ほどの内容を通じて、Persistence Context(永続性コンテキスト)がどのように構成されているかを把握いたしました。

今回は、オブジェクトの4つの状態について整理してまいります。

大まかな流れは以下の通りです。

image.png

テストに使用するStudentエンティティは、次の通りです。

@Entity
public class Student {

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

    private String name;

    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;
    }
}

Jpaのエンティティとは? DDDのエンティティと同じでしょうか?

  • 結論として、同じではありません
  • @EntityがValue Objectのように使われる場合もあれば、エンティティとして使われる場合もあります
  • 単に@Entityは、データベースのテーブルやカラムにどのオブジェクトをマッピングするかを定義する技術的な部分に過ぎません

Transient

  • 非永続状態(Transient)は、newキーワードで生成されたオブジェクトを指します

image.png

以下のように考えることができます。
用語が付いているだけで、通常定義しているJavaオブジェクト(Object)です。
これらは後にヒープメモリが解放されると、ガベージコレクターによって回収されるオブジェクトとなります。

image.png

非永続オブジェクトは、永続性コンテキストが管理する永続状態(Persistent)を作り出すことが可能です。

Persistence

  • 永続状態(Persistent)とは、Identity MapにオブジェクトのIDを登録し、永続性コンテキスト(Persistence Context)が管理する状態を指します

image.png

大きく2種類ございます。

  1. Transient(非永続状態)のオブジェクトを永続状態に登録する
  2. find();を使用して一次キャッシュやDBからデータを取得する

Transient(非永続状態)のオブジェクトを永続状態に登録する

image.png

newで生成した非永続オブジェクトを永続状態(永続性コンテキストで管理)に登録する方法がございます。

テストコードは以下のようになっております。

image.png

ところで、該当のテストコードのログを確認すると、以下のようになっております。

image.png

あれ?なぜクエリが発生したのでしょうか?
それは、永続性コンテキストに登録する(Identity Mapに格納する)ためには必ずIDが必要となるからです。

永続状態で管理するためには必ず識別子 @ID が必要です

永続性コンテキストがエンティティを管理するためには、必ず識別子(@Id)の値が必要となります。

したがって、
@GeneratedValue(strategy = GenerationType.IDENTITY) の戦略の場合、次のようにクエリが発生します。

IDENTITY戦略のクエリ発生

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

IDENTITY戦略は、基本的にデータベースにIDの生成を委譲するため、INSERTクエリが発生いたします。

[永続性コンテキスト]
┌───────────────────────────────────────────────┐
│                                               │
│  [1次キャッシュ]                                │
│  ┌─────────────────┐                          │
│  │                 │                          │
│  │  @Id | Entity   │                          │
│  │  (空)          │                          │
│  │                 │                          │
│  └─────────────────┘                          │
│                                               │                     
│                                               │
└───────────────────────────────────────────────┘

次のように1次キャッシュが空の場合、

[永続性コンテキスト]
┌───────────────────────────────────────────────┐
│                                               │
│  [1次キャッシュ]                              │
│  ┌─────────────────┐                         │
│  │                 │                         │
│  │  @Id | Entity   │                         │
│  │   1  | User     │ ← DBから取得したIDが割り当てられる │
│  │                 │                         │
│  └─────────────────┘                         │
│                                               │
│  [書き込み遅延SQLストア]                      │
│  ┌─────────────────┐                         │
│  │                 │                         │
│  │  INSERT SQL     │                         │
│  │  即時実行       │ ← すぐにDBにSQLが実行される       │
│  │                 │                         │
│  └─────────────────┘                         │
│                                               │
└───────────────────────────────────────────────┘

データベースにINSERTクエリが発生し、直ちに該当のIDをキーとして使用いたします。

これに関して、公式ドキュメントには以下のように記載されております。

IDENTITY 戦略の注意点

image.png

  • 問題点 1: Extended Persistence Context の問題

    IDENTITY カラムは、エンティティが実際にデータベースに挿入されるまで識別子の値が決定されません。
    そのため、長期間にわたって複数のエンティティを管理する Extended Persistence Context(拡張永続性コンテキスト)の場合、挿入タイミングに制約が生じ、状態管理に問題が発生する可能性がございます。

  • 問題点 2: バッチ INSERT 処理が不可能

    IDENTITY 戦略を使用すると、各エンティティの挿入ごとに識別子生成が必要となるため、Hibernateは複数のINSERT文を一度にバッチ処理することができません。
    その結果、大量のエンティティを一括挿入する際、パフォーマンス向上の効果を得にくくなります。

それでは、他の生成戦略はどうでしょうか?

GenerationTypeの戦略種類

image.png

基本的には、以下のような戦略がございます。

識別子生成戦略別のSQL実行タイミング

各識別子生成戦略ごとのSQL実行タイミングは以下の通りです。

生成戦略 識別子生成クエリ 識別子生成時点 INSERTクエリ 書き込み遅延サポート
IDENTITY INSERT persist() 即時 persist() 即時
SEQUENCE SELECT NEXT VALUE persist() 即時 commit 時点
TABLE SELECT & UPDATE persist() 即時 commit 時点
UUID なし(アプリケーション) persist() 即時 commit 時点
AUTO 方言により決定 方言により決定 方言により決定 -

SEQUENCE 戦略のクエリ発生

image.png

このようにシーケンス戦略を設定し、テストを実行すると、

image.png

以下のようになります。

 next value for student_seq

クエリが発生することを確認できます。

  1. 永続性コンテキストは、Studentエンティティを管理するために識別子が必要です。
  2. @SequenceGeneratorに指定された "STUDENT_SEQ" から次の値を取得します。
    -> SQL: select next value for STUDENT_SEQ
  3. 取得した識別子の値をStudentエンティティに割り当てます。
  4. Studentエンティティを永続性コンテキストの1次キャッシュに保存します。
  5. INSERT SQLは書き込み遅延ストアに保持されます。

UUID 戦略のクエリ発生

image.png

このようにUUID戦略を使用する場合、どのタイミングでクエリが発生するでしょうか?

そのいかなるクエリも発生しません。
なぜなら、識別子(@Id)はUUIDとしてアプリケーション側で生成されるためです。

Table 戦略のクエリ発生

image.png

以下のように記述し、テストを実行すると、

image.png

以下のようなクエリが発生することが確認できます。

select tbl.GEN_VAL 
from id_gen tbl 
where tbl.GEN_NAME=? for update
  1. id_gen テーブルから、特定の GEN_NAME を持つ行の現在の識別子値(GEN_VAL)を読み込みます。
    同時に、for update を使用してこの行に対してロックをかけます。
update id_gen 
set GEN_VAL=?  
where GEN_VAL=? 
  and GEN_NAME=?
  1. 先に読み込んだ値を元に、新しい識別子値へ更新します。

Table 戦略の問題点

ここで、Table 戦略の問題点を把握することができます。

直接値を読み込み、更新する過程でロックが使用されます。
そのため、同時実行性が高い環境ではロック競合および追加のデータベース呼び出しが発生し、パフォーマンス低下につながる可能性がございます。

公式ドキュメントにも、以下のように説明されています。

image.png

  • TABLE ジェネレータは、複数のデータベースで同一に使用できるという利点(移植性)がありますが、実際には識別子値を生成するために別のテーブルを使用します
  • この別テーブルから次の識別子値を取得するために、別のトランザクションを実行し、そのテーブルの特定の行に対して 行レベルロック(row-level lock) をかける必要があります

これらの理由から、Table 戦略は十分に注意して使用する必要がございます。

では、直接IDを指定するとどうなるでしょうか?

image.png

このように、何らかの方法で@Id(識別子)を付与するだけで、

image.png

どのようなクエリも発生しません。

生成戦略によるクエリ発生の整理

  • 永続状態にするためには、Identity Mapで識別するための@Id(識別子)が必要です
  • 識別子がデータベース依存の方式であれば、DBにクエリが発生し、
  • アプリケーション依存の方式であれば、クエリは発生しません

Id (キー)生成戦略

代理キー (Surrogate Key) vs 自然キー (Natural Key)

  • 代理キー (Surrogate Key) : ビジネス上の意味を持たない識別子

    • 下記のテーブル構造において、id は学生の意味とは全く関係がありません
    @Entity
    public class Student {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY) 
        private Long id;
        
        private String name;
    }
    
  • 自然キー (Natural Key) : ビジネス上の意味を持つ識別子

    • 下記のテーブル構造において、studentNumber はビジネス上の意味を持っています
```java
@Entity
public class Student {

    @Id
    @NaturalId  // Hibernateでは自然キーとして認識
    @Column(name = "student_number", nullable = false, unique = true)
    private Long studentNumber;

代理キーを使用すべき理由

  • ビジネス(ドメイン)は常に変化しうるためです
  • もし自然キーが複雑な場合、インデックス作成などデータベース側の問題に柔軟に対応できなくなります

このような理由から、代理キーを使用することが望ましいと考えられます。

単純キー (Simple Key) と複合キー (Compound Key)

  • 単純キー (Simple Key): 識別子が1つの場合
@Entity
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private Long id;
  • 複合キー (Compound Key): 識別子が複数の場合
@Entity
@IdClass(StudentId.class)
public class Student {

    @Id
    private String department;    // 例: 学科コード

    @Id
    private String studentNumber;   // 例: 学籍番号

単純キーを複合キーより好む理由

  • ビジネス(ドメイン)は常に変更される可能性があります
  • エンティティ間の関係設定の観点でも、単純キーの方が一貫性があり、直感的に管理できます

このような理由から、単純キーを好むと考えられます。

生成キー (Generated Key) vs 割り当てキー (Assigned Key)

  • 生成キー (Generated Key): JPAまたはDBが生成するキー
    @Entity
    public class Student {
    
        // データベースが自動的に生成するキー(例:auto-increment)
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    }
    
- **割り当てキー (Assigned Key)**: アプリケーションまたはドメインロジックにより直接割り当てられるキー

```java
@Entity
public class Student {
    // アプリケーションで直接割り当てるキー(例: 学籍番号)
    @Id
    @NaturalId // Hibernateでは自然キーとしても使用
    @Column(name = "student_number", nullable = false, unique = true)
    private Long studentNumber;

生成キーを好む理由

  • 管理や安定性の観点から、生成キー方式は直感的です
  • ビジネス(ドメイン)は常に変更される可能性があるためです

このような理由から、生成キー方式が優れていると考えられます。

find(); を使用して 1次キャッシュとDBからデータを取得する

image.png

find(); を使用して、DBからデータを取得し、1次キャッシュに保持する方法があります。

動作は次の通りです。

  1. 1次キャッシュ(Identity Map)に対象のオブジェクトが存在するかを確認します

    image.png

    存在すれば、そのオブジェクトを返却します。

    1次キャッシュのこの動作により、作業範囲(トランザクション)内では識別子の同一性に応じて同一のオブジェクトが返却されることが保証されます。
    また、1次キャッシュにオブジェクトが既に存在する場合、SELECTクエリは発生しません。

  2. もし存在しなければ、データベースに対してSELECTクエリを発行し、データを取得してJavaオブジェクトにマッピングした後、永続性コンテキストに登録し、返却します。

    image.png

    image.png

    image.png

もしDBにも存在しなければ、nullが返されます。

Removed 状態

image.png

remove(); メソッドを通じ、永続状態のエンティティを削除対象としてマークすることができます。

  • 次に flush(); を呼び出すと、データベースに対して DELETE クエリが発行され、実際に削除されます
  • remove(); は永続状態にあるエンティティを削除対象に変更するだけであり、依然として永続性コンテキストで管理されるオブジェクトです。
  • つまり、永続状態でないオブジェクトには remove(); を使用することはできません

つまり、JPAで削除を実行するためには、必ず識別子(@Id)を用いてエンティティを永続状態に登録する必要があるため、ほとんどの場合 find(); を通じたSELECTクエリが発生します。

image.png
image.png
package org.hibernate.event.internal.EntityStatus を確認すると、動作構造を詳しく把握することができます。

以下は、永続状態のエンティティを削除する際の動作を示しています。

image.png

次のようなテストコードがあります。

Student findStudent = entityManager.find(Student.class, 1L);
entityManager.remove(findStudent);

これにより、エンティティが Removed 状態に変更されます。
その後、以下のように Removed 状態に設定されたエンティティを取得しようとすると、

Student removedStudent = entityManager.find(Student.class, student.getId());
assertNull(removedStudent);

永続性コンテキスト(1次キャッシュ)からも値は見つかりません。

image.png

しかしながら、これはあくまで永続性コンテキスト上で管理されているオブジェクトであり、flush() を呼び出さない限り、データベース上からは削除されません。
該当テストコードのログを確認すると、以下のように DELETE クエリは発生していません。

image.png

Removed 状態のオブジェクトを再び永続化する

通常、Removed 状態になったオブジェクトを再び永続状態に戻すことはほとんどありませんが、理解を深めるために、以下のような状況を考えてみましょう。

ポイントは、永続性コンテキスト(Identity Map)に登録されているか否かです。
次のようなコードは、

image.png

entityManager.remove(student);

状態遷移: Managed → Removed
remove() を呼び出すと、エンティティの状態は Removed に変更され、削除対象としてマークされます。
(ただし、まだ flush() を呼び出していないため、データベース上では実際には削除されていません。)

entityManager.detach(student);

状態遷移: Removed → Detached
detach() を呼び出すと、永続性コンテキストから該当エンティティが分離され、Detached 状態になります。
この時点で、削除予約(Removed 状態)はキャンセルされます。

Student reattachedStudent = entityManager.merge(student);

状態遷移: Detached → Managed
merge() を呼び出すと、Detached 状態のエンティティが再び永続性コンテキストにマージされ、新たな Managed インスタンス(reattachedStudent)が返されます。
この際、同一の識別子(ID)の値が保持されます。

Destached

image.png

以前のテストコードで使用したように、

entityManager.detach(student);

というメソッドを使用しますが、これは永続性コンテキストにおいて該当オブジェクトの管理を中止することを意味いたします。

つまり、Identity Map(1次キャッシュ)からそのオブジェクトが除外されます。

image.png
この後、以下のようなリクエストが発生すると、

image.png

永続性コンテキスト内から対象オブジェクトが除外された状態となります。

entityManager.clear();

다음과 같은 메서드를 쓴다면 영속성 컨텍스트에서 관리되는 모든 객체를 영속성 컨텍스트에서 제거한다.

次のようなメソッドを使用すると、永続性コンテキストで管理されているすべてのオブジェクトが永続性コンテキストから除外されます。

非永続状態 (Transient) と 準永続状態 (Detached) の違い
最も大きな違いは、準永続状態では識別子 (id) が存在するため、再登録時に INSERT クエリが発生しないという点です。

flush() メソッド

image.png

  • 永続性コンテキストには、元の状態のスナップショットと該当エンティティが保存されています
  • flush() を呼び出すと、メモリ上の変更点を比較し、実際のデータベースに反映するためのクエリが実行されます

簡単に言えば、これまでの変更内容をデータベースと同期させる作業であると言えます。

次回の記事では、永続性の伝播 (Cascade) について整理いたします。

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?