JPAを使ったデータアクセスでポイントとなる永続コンテキストについて

先週書いたエントリJava EE6標準の範囲でフルスタックのWebアプリケーションが簡単に作成できることを確かめてみました。 - 達人プログラマーを目指してで、Java EE6の標準仕様を使うだけで、かなりシンプルにデータのCRUD処理を行うアプリケーションが作成できることを紹介しました。ただし、前回は全体のアプリケーションを紹介しただけなので、細かい仕掛けについては解説しきれませんでした。今回は、前回に引き続き特にJPAを使ったデータベースアクセスの部分がどうなっているのかをもう少し掘り下げて解説してみたいと思います。
なお、この場で宣伝ですが、8月10日(水)にGlassfishユーザーグループの勉強会にてお話をさせていただくことになりました。
GlassFish Japan Users Group 勉強会 2011 Summer : ATND
私はJava EE6を使った開発について説明させていただく予定ですが、JavaEE開発や最近の軽量なアプリケーションサーバーについて興味のある方は是非ご参加ください。

従来のDAOを使ったデータアクセスとJPAとの根本的な違いを理解する

以前に、O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある - 達人プログラマーを目指してO/Rマッピングフレームワークの分類について説明しました。この分類の中では、JPAは完全O/Rマッピングというものに属します。
しかし、現状日本においてはJPAは普及しておらず、多くの場合はSQL中心か、静的O/Rマッピングに分類されるフレームワーク(ここではDAOフレームワークと呼ぶことにします。)を使ってデータアクセスすることが一般的です。このようなDAOフレームワークを使ってデータアクセスを行う場合、基本的にはデータベースのSELECT、INSERT、UPDATE、DELETEの呼び出しをカプセル化するAPIが提供されます。言い換えれば、こうしたDAOフレームワークが行ってくれるのは個々のDML文の呼び出しを簡易化し、場合によってはオブジェクトに値を詰め替えてくれるというところまでです。したがって、たとえば、アプリケーションがデータの更新を行いたい場合は、どこかで必ず明示的にUPDATE文の実行を行うDAOのメソッドを呼び出す必要があります。
一方、JPAの場合アプリケーションから明示的にデータベースのDML文の発行をコントロールすることはしません。その代り、JPAには永続コンテキストというエンティティの状態を管理するメモリ上の入れ物が存在しています。JPAの動作を正しく理解するためには、まず、この永続コンテキストというものを理解することが大切です
以下の図に示すように、概念的には永続コンテキストはエンティティのIDをキーとして個々のエンティティのインスタンスが保持されていると考えることができます。このように永続コンテキスト中で管理された(Managed)状態のエンティティ(図では赤い色で示してあります。)に対しては、エンティティに対する状態変更や削除指令が自動的記録されます。そして、トランザクションのコミット前など、適切なタイミングで自動的にデータベースに対してDML文が発行されることでデータの同期が行われます。

ですから、JPAの場合は明示的にCRUDに相当する指令を行うAPIを呼び出すのではなく、エンティティと永続コンテキストとの関連付けをコントロールするAPIが提供されているのですが、このAPIは大部分EntityManagerというインターフェースとして提供されています。EntityManagerの持つ代表的なメソッドの意味は次の通りです。

メソッド 意味 よくある誤解
persist() newされた直後のエンティティを新たに永続コンテキスト中で管理された状態にする。 DBに対してINSERT文を直ちに発効する指令ではない。ただし、コミットまでに結果的にINSERTになる。
find() 永続コンテキスト中のエンティティをIDで取得する。エンティティが永続コンテキスト中になければ、SELECTされ、その後エンティティは管理された状態になる。 DBに対してSELECT文を単に発効する指令ではない。
remove() 永続コンテキスト中のエンティティを削除予定としてマークする。 DBに対して直ちにDELETE文を単に発効する指令ではない。ただし、コミットまでに結果的にDELETEになる。
merge() Detachedな状態のエンティティを永続コンテキスト中のエンティティの状態に設定する。 DBに対して直ちにUPDATE文を単に発効する指令ではない。ただし、コミットまでに結果的にUPDATEになる場合がある。

このAPIの意味を理解するには、まず、エンティティには以下の4つの状態があるという点を知っておく必要があります。

  • New(新規)状態 → 単にエンティティのインスタンスをnewしただけの状態。永続コンテキスト中で管理されていないため、状態を変更してもDBには一切反映されない。
  • Managed(管理された)状態 → 永続コンテキスト中で状態が管理された状態。適切なタイミングでDML文の犯行によりDBと同期がとられる。
  • Removed(削除された)状態 → 永続コンテキスト中で削除が予約された状態。
  • Detached(切り離された)状態 → 一旦Managedな状態にあったエンティティが永続コンテキストから切り離された状態。永続コンテキストがclose()により終了したり、clear()により明示的にコンテキストから除外されたり*1した場合にこの状態となる。

そして、EntityManagerの各メソッドを呼び出すことで、このエンティティの状態がどのように遷移するのかを理解する必要があります。

ScrumToy(リファクタリング版)のCRUD動作を理解する

以上の点が分かれば、先週紹介したScrumToyのCRUD動作がどのように実現されているのかを理解することができます。

エンティティのINSERSTがいつ行われるか

まず、わかりやすい例として、関連のもっともルートになっているProjectエンティティを新規に作成するケースを考えてみます。この場合、ScrumManagerImplクラスの以下のメソッド中で、新規作成処理が実行されています。

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void saveCurrentProject() {
        assertThatEntityIsNotNull(currentProject);
        if (!currentProject.isNew()) return;
        
        projectRepository.persist(currentProject);
    }

ここで、projectRespositoryのpersistメソッドは実際にはEntityManagerのpersist()を呼び出しているだけです。Projectを新規に作成した場合、主キーはまだ割り当てられていないため、以下のisNew()の判定がtrueとなってpersist()の呼び出しが実行されます。

    public boolean isNew() {
        return getId() == null;
    }

この場合、currentProjectにはJSFの画面から入力されたデータが自動的に格納された状態になっています。したがって、

  • persist()の呼び出しでNew状態からManaged状態に遷移
  • saveCurrentProject()の呼び出しがトランザクション境界となっているため、このメソッド終了後に自動的にINSERTが発行される。

という動作になります。実際、INSERTが発行されたことを以下のログで確認できます。

詳細レベル (低): INSERT INTO projects (end_date, NAME, start_date) VALUES (?, ?, ?)
	bind => [3 parameters bound]
詳細レベル (低): values IDENTITY_VAL_LOCAL()

これはpersist()とINSERT文の呼び出しが対応しているため、わかりやすいのですが、両者が常に対応していると誤解を招きやすいところでもあります。今度はProjectに含まれるSprintを新規に挿入するメソッドを見ています。

    @TransactionAttribute(TransactionAttributeType.REQUIRED)    
    @Override
    public void saveCurrentSprint() {
        assertThatEntityIsNotNull(currentProject);
        assertThatEntityIsNotNull(currentSprint);
        if (!currentSprint.isNew()) return;
        
        currentProject.addSprint(currentSprint);
    }

ここで、ProjectエンティティのaddSprint()メソッドは、以下のように単にProjectとSprintの関連付けを行っているだけで、データベースアクセスのことは一切関係ない点に注意してください。

    public boolean addSprint(Sprint sprint) {
        if (sprint != null && !sprints.contains(sprint)) {
            sprints.add(sprint);
            sprint.setProject(this);
            return true;
        }
        return false;
    }

しかし、この場合もログを確認すると結果としてSprintに対してINSERTが発行され永続化されていることがわかります。

詳細レベル (低): INSERT INTO sprints (daily_meeting_time, end_date, gained_story_points, GOALS, iteration_scope, NAME, start_date, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
	bind => [8 parameters bound]
詳細レベル (低): values IDENTITY_VAL_LOCAL()

この場合にINSERTが発行されるのは次のような理屈になっています。

  • 最初からcurrentProjectはManagedな状態になっている。
  • ManagedなcurrentProjectに対してNew状態のSprintが関連付けされる。
  • ProjectとSprintとの間の関連はCASCADE指定されているため、自動的にSprintがManagedな状態になる。
  • トランザクションコミット時に新規にManagedな状態になったSprintのインスタンスが永続化され、INSERTが発行される。

JPAの永続コンテキストの動作を理解していないと魔法のようなのですが、Managedな状態のエンティティは自動的にDBと同期がとられるという点に注意してください。

実は何もしていなくてもUPDATEが行われている

以上を理解した上で、次にUPDATEが行われるケースについて見てみます。どのエンティティでも同じなので再度Projectエンティティを保存する以下のメソッドを見てください。

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void saveCurrentProject() {
        assertThatEntityIsNotNull(currentProject);
        if (!currentProject.isNew()) return;
        
        projectRepository.persist(currentProject);
    }

勘の良い方はお気づきかと思いますが、編集画面経由でエンティティを更新する場合にはIDが既に割り当てられており、isNew()の判定がfalseとなるため、このメソッドでは何も行わずに終了してしまいます。ただし、このメソッドがトランザクション内で実行されているということがポイントです。このため、もしcurrentProjectの状態がDBから読み込んだ時点と変更があると判断された場合には自動的にUPDATEが発行され同期がとられます。

詳細レベル (低): UPDATE projects SET NAME = ? WHERE (ID = ?)
	bind => [2 parameters bound]

なお、ここでは編集画面で名前フィールドのみ変更を加えたため、NAMEフィールドのみが更新の対象となっています。*2

DELETEの動きについて

DELETEの実行の仕方もINSERTの場合と同様にルートのエンティティであるProjectとその他のエンティティでは少し異なります。Projectの場合は、削除ボタンクリック時に以下のメソッドを実行することで、remove()メソッドが呼び出されることで、エンティティがManaged状態からRemoved状態となり、DELETEが行われます。

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void removeProject(Project project) {
        projectRepository.remove(projectRepository.findById(project.getId()));
    }

なお、Projectに含まれる各エンティティとの関連はすべてCASCADE指定されているため、Projectを削除すると連動してそれに紐づくSprint、Story、Taskはすべて自動的に削除されます。こうした動作を普通のDAOで実装するのはかなり骨の折れる仕事ですが、JPAの場合は非常に簡単に処理できるのです。

詳細レベル (低): DELETE FROM tasks WHERE (ID = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM tasks WHERE (ID = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM tasks WHERE (story_id = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM stories WHERE (ID = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM stories WHERE (ID = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM sprints WHERE (ID = ?)
	bind => [1 parameter bound]
詳細レベル (低): DELETE FROM projects WHERE (ID = ?)
	bind => [1 parameter bound]

一方、入れ子のエンティティを削除する場合はJPA2の新機能であるorphanRemovalの機能を使うことで単に親のコレクションから削除するだけで、関連付けが削除されると同時に子供エンティティ自身もDELETEされます。

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void removeTask(Task task) {
        assertThatEntityIsNotNull(currentStory);

        currentStory.removeTask(task);
    }

たとえば、StoryとTaskの関連は以下のようにアノテーションが指定されています。

    @OneToMany(mappedBy = "story", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Task> tasks = new ArrayList<Task>();

orphanRemoval = trueの指定に注意してください。(これがfalseの場合は単に関連付けのみが削除されTask自身はremoveされません。)

2種類の永続コンテキストの持続期間について

ここまでの説明で、

  • エンティティが永続コンテキストの中で管理される
  • Managedなエンティティは何もしなくてもコミット前などに同期がとられる
  • JPAのEntityManagerは直接DML文の発行を制御するのではなくエンティティと永続コンテキストとの関連付けを制御する

という点をご理解いただけたと思います。そうすると、永続コンテキストのインスタンスはいつ生成されていつまで保持されるかということが疑問として浮かびます。
コンテナによって管理される永続コンテキストには以下の2種類があることを次に説明します。

  • TRANSACTION永続コンテキスト トランザクションごとに別々の永続コンテキストが生成される。トランザクション終了時に自動的にclose()される。
  • EXTENDED永続コンテキスト ステートフルセッションBeanのインスタンスとともに生成される。ステートフルセッションBeanが破棄されるまでずっと保持される。

前者は@PersistenceContextあるいは、@PersistenceContext(type = PersistenceContextType.TRANSACTION)という指定によりEntityManagerがインジェクションされた場合に使われます。これがデフォルトであり、任意のEJBに対してインジェクションすることができます。一方、後者は@PersistenceContext(type = PersistenceContextType.EXTENDED)指定されたフィールドを持つステートフルBeanでのみ利用することができます。
両者の動作を図示すると以下のようになります。
まず、TRANSACTIONスコープの永続コンテキストを利用する場合は以下のような動作となります。

一方、EXTENDEDな永続コンテキストを利用する場合は以下のような動作となります。

両者を比べてみると、直感的には後者の方が状態管理が明らかにシンプルなことがわかります。データベースのトランザクションの持続期間によらずにエンティティを編集する会話スコープの期間ずっと永続コンテキストが保持されていれば、先にみたように単にエンティティ自身の状態をビジネスロジック中で操作してやるだけで、透過的にデータアクセスを行うことができます。一方、TRANSACTIONスコープの永続コンテキストの場合、個々のトランザクション終了時にコンテキストが終了してしまします。このため、もともとManagedな状態にあったエンティティは自動的にDetachedな状態のエンティティになってしまいます。この状態になると、文字通り永続コンテキストと切り離された状態にあるため、

  • 状態を変更してもそのままではDBに反映されない。
  • Lazyな関連により初期化されていない関連にアクセスすると例外となる。

といった状態となります。そして、たとえばフォームを入力後DBの更新に反映させるためには明示的にmerge()を呼び出すことで再度別の永続コンテキストと関連づける必要があります。
このようにEXTENDEDには本来優れた特性があるのですが、Java EE6以前はほとんど活用されることがありませんでした*3。それは、ステートフルBeanの扱いがきわめて面倒だったということが第一の理由として挙げられます。
しかし、Java EE6でCDIの会話スコープと組み合わせることでステートフルBeanをかなり簡単に利用することができるようになりました。今回紹介したScrumToyのリファクタリング版では、ScrumManagerImplというステートフルBeanを利用しており、この中でEXTENDED永続コンテキストを利用しています。*4

@Stateful
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
@ConversationScoped
public class ScrumManagerImpl implements ScrumManager, Serializable {
...
    @PersistenceContext(type= PersistenceContextType.EXTENDED)
    protected EntityManager em;

そして、このステートフルBeanをプレゼンテーション層のアクションBeanに対してインジェクションすることで利用しています。

@Model
public class DashboardAction extends AbstractAction implements Serializable {

    private static final long serialVersionUID = 1L;

    //=========================================================================
    // Fields.
    //=========================================================================    

    @Inject
    TaskAction taskAction;

    @Inject
    StoryAction storyAction;
    
    @Inject
    ScrumManager scrumManager;

ここで見逃してはならないポイントは

  • CDIではスコープの異なるBean同士をインジェクションできる。
  • ステートフルBeanを会話スコープで動作させることでスコープ終了時に自動的に解放される

といったことです。こうした動作はCDIEJBを組み合わせることで初めて可能になったことであり、Java EE6で初めてEXTENDED永続コンテキストを活用する道が開かれたと言えるのではないでしょうか

EXTENDED永続コンテキストの伝搬に対する制約

このように、Java EE6でかなり実用的に使えるようになったEXTENDED永続コンテキストですが、現時点では以下の制約に注意する必要があります。

  • @PersistenceContext(type = PersistenceContextType.EXTENDED)の指定ができるのはステートフルBeanのみ。
  • 別々のステートフルBean間では異なるEXTENDED永続コンテキストが作成されて維持されるためコンテキストを共有できない。*5
  • EXTENDED永続コンテキストを生成するステートフルBeanの呼び出しがトランザクションの境界となる必要がある。(すでに別の永続コンテキストがトランザクション中に存在するとエラーとなる。)
  • 一つのトランザクションに関連付けされるEXTENDED永続コンテキストは一つだけ(複数のコンテキストが存在すると例外となる。)

一方で、TRANSACTIONスコープの永続コンテキストは非常に簡単でトランザクションごとに生成され、EJBの種類によらず任意のBeanに対して共通のインスタンスがインジェクションされるため、トランザクション内では簡単に共有できます。この違いを図示すると以下のようになります。


このような制約からScrumToyの例では同一の会話スコープに参加するステートフルBeanを分割せず、ScrumManagerImplという一つのクラスに集約しています。EXTENDED永続コンテキストを利用する場合はこのようにステートフルBeanをファサードとして、会話スコープ単位にある程度大きく分割するのがコツのようです。*6
Seam3では以上の制約を克服するために、EJBコンテナの管理する永続コンテキストを利用する代わりにSeam独自で管理する永続コンテキストを生成して、複数のPojoEJBでなくてもよい)から共有可能にする仕掛けも提供されています。
http://seamframework.org/Seam3/PersistenceModule

*1:JPA2からエンティティのインスタンスを個別に指定して永続コンテキストからclear()するメソッドが追加されています。

*2:この動作はJPAのプロバイダによって異なり、JBossに標準で入っているHibernateの場合は実際の更新の有無によらずデフォルトでは全カラムが更新の対象となります。

*3:Seamを使った場合を除く。ただし、EXTENDED永続コンテキストはステートフルBeanを使う必要がるなどの制約があるため、Seamでは別途Seam管理の永続コンテキストを作成する機能も持っている。

*4:実はインジェクションしているemフィールドは直接は利用していないのですが、直接呼び出さなくても永続コンテキストが自動的にトランザクションと紐づけられて裏で同期がとられます。

*5:あるステートフルBeanから別のステートBeanのインスタンスをルックアップや@EJBにより直接生成する場合のみ例外的に永続コンテキストが共有される。

*6:ここではEXTENDED永続コンテキストの動きを理解するために、全エンティティの更新を一つの会話スコープで行っていますが、本来の意味的には各エンティティの更新タイミングは異なるはずなので、スコープを分けた方がよいでしょう。