Hatena::ブログ(Diary)

達人プログラマーを目指して このページをアンテナに追加 RSSフィード

2011-07-31

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文が発行されることでデータの同期が行われます。

f:id:ryoasai:20110731223530p:image:w600

ですから、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の各メソッドを呼び出すことで、このエンティティの状態がどのように遷移するのかを理解する必要があります。

f:id:ryoasai:20110731225735p:image:w600

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種類があることを次に説明します。

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

両者の動作を図示すると以下のようになります。

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

f:id:ryoasai:20110731235637p:image:w600

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

f:id:ryoasai:20110731235737p:image:w600

両者を比べてみると、直感的には後者の方が状態管理が明らかにシンプルなことがわかります。データベーストランザクションの持続期間によらずにエンティティを編集する会話スコープの期間ずっと永続コンテキストが保持されていれば、先にみたように単にエンティティ自身の状態をビジネスロジック中で操作してやるだけで、透過的にデータアクセスを行うことができます。一方、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永続コンテキストですが、現時点では以下の制約に注意する必要があります。

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

f:id:ryoasai:20110801002545p:image:w600

f:id:ryoasai:20110801002540p:image:w600

このような制約からScrumToyの例では同一の会話スコープに参加するステートフルBeanを分割せず、ScrumManagerImplという一つのクラスに集約しています。EXTENDED永続コンテキストを利用する場合はこのようにステートフルBeanをファサードとして、会話スコープ単位にある程度大きく分割するのがコツのようです。*6

Seam3では以上の制約を克服するために、EJBコンテナの管理する永続コンテキストを利用する代わりにSeam独自で管理する永続コンテキストを生成して、複数のPojoEJBでなくてもよい)から共有可能にする仕掛けも提供されています。

404 Not Found

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

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

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

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

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

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

2011-07-24

Java EE6標準の範囲でフルスタックのWebアプリケーションが簡単に作成できることを確かめてみました。

Java EE6でさらに開発は容易になった?

以前JavaEE標準の進化から最近の業務アプリケーション開発手法の変遷について考える - 達人プログラマーを目指してにてJava EE標準の開発モデルの進化について説明しました。10年前の相当面倒だったJ2EEの開発モデルと比べて、最新のJava EE6では、様々なOSSの良い特徴を取り入れて、簡単にプログラミングできるように大幅に改良されています。また、Glassfish 3.1やJBoss AS7などは起動時間が非常に短縮されており*1、よほど遅いPCでなければわずか数秒で再起動することができます。さらに、Java EEサーバーが重くてテスト不能というイメージはもう過去の話かもしれない - 達人プログラマーを目指してで紹介したように、Java EE6では従来困難であった単体試験の自動化も容易になっています。

個々の技術は優れているのだけれど適切なフルスタックのサンプルがない

いまこそ、Java EEの機能をフルに活用してエンタープライズJavaアプリケーションを楽に開発しましょう。

と、自信を持って言いたいところだったのですが、いざ、Java EE6の機能を組み合わせてアプリケーションを開発しようとしても、実はなかなか良いサンプルというかお手本が見つからないという問題があります。Java EE6のドキュメントは少なくとも英語では様々なチュートリアルがWeb上や書籍で見つかるのですが、JSFJPAなど特定の技術要素をターゲットにした説明がほとんどで、画面からデータアクセスまでを組み合わせた実案件で使えそうなよいお手本がなかなか現時点では見つからないようです。

そこで、NetBeansで自動生成可能なJSF2のサンプルアプリケーションである、JsfScrumToysをリファクタリングし、EJBCDIJPAと組み合わせるように修正してみました。最初は簡単にできると予想していたのですが、単純なCRUD処理のアプリケーションを作成するだけでも、想定外の試行錯誤がいろいろと必要で、満足な設計に到達するまでに結構時間がかかってしまいました。それでも、最終的には標準技術のみで実際にかなり簡単に書けることがわかりましたのでここで紹介させていただきます。

リファクタリング結果は以下にアップしてあります。例外処理など、実案件への適用に対してはまだまだ考慮が足りていない部分がありますが、Java EE6開発のベースとして使っていただけると思います。

no title

本エントリに関連して以下のまとめもご参照ください。

JavaEE6を使ったアプリケーション開発について - Togetter

オリジナルのScrumToysの問題点

オリジナルのScrumToysアプリケーションは、NetBeansを用いて自動的に生成することができます。

f:id:ryoasai:20110724220117p:image:w500

このアプリケーションは以下のようなドメインモデルのエンティティに対して、各エンティティのCRUD処理を実現する簡単なアプリケーションとなっています。

f:id:ryoasai:20110724220941p:image

特に特殊なところはなく、単にお互いに入れ子の関係にあるエンティティを管理するアプリケーションになっています。独立した単テーブルのCRUD処理に比べて関連を適切に処理しなくてはならないところが多少難しいところです。

オリジナルの実装は基本的にJSF2の新機能のデモという位置づけのため、特にデータアクセス層やHTTPセッションの管理などは簡易化できるポイントがたくさん残っています。

データアクセス時のトランザクション管理が面倒

まず、トランザクションの管理はEJBを使わず、以下のように独自にコールバックパターンを使って実装されています。

    @PersistenceUnit
    private EntityManagerFactory emf;
    @Resource
    private UserTransaction userTransaction;

    protected final <T> T doInTransaction(PersistenceAction<T> action) throws ManagerException {
        EntityManager em = emf.createEntityManager();
        try {
            userTransaction.begin();
            T result = action.execute(em);
            userTransaction.commit();
            return result;
        } catch (Exception e) {
            try {
                userTransaction.rollback();
            } catch (Exception ex) {
                Logger.getLogger(AbstractManager.class.getName()).log(Level.SEVERE, null, ex);
            }
            throw new ManagerException(e);
        } finally {
            em.close();
        }

    }

そして、個別のManagerクラスで以下のようにデータアクセスを実行します。

    public String remove() {
        final Project project = projects.getRowData();
        if (project != null) {
            try {
                doInTransaction(new PersistenceActionWithoutResult() {

                    public void execute(EntityManager em) {
                        if (em.contains(project)) {
                            em.remove(project);
                        } else {
                            em.remove(em.merge(project));
                        }
                    }
                });
                getProjectList().remove(project);
            } catch (Exception e) {
                getLogger(getClass()).log(Level.SEVERE, "Error on try to remove Project: " + getCurrentProject(), e);
                addMessage("Error on try to remove Project", FacesMessage.SEVERITY_ERROR);
                return null;
            }
        }
        init();
        // Using implicity navigation, this request come from /projects/show.xhtml and directs to /project/show.xhtml
        // could be null instead
        return "show";
    }

コールバックパターンを使ってある程度処理を共通化しているものの、それでも相当の鋳型コード(boilerplate code)の記述が必要になっています。Java EE5まではEJBを利用するのがそれなりに面倒だった(earファイルを使う必要があるなど)のですが、EJBを使わない限り標準の範囲内ではコンテナ管理の永続コンテキストが適切に利用できないため*2、このような冗長なコードはやむを得ないところがありました。

セッションの肥大化

オリジナルのScrumToysではほとんどのBeanをSessionスコープに保持しています。

@ManagedBean(name = "sprintManager")
@SessionScoped
public class SprintManager extends AbstractManager implements Serializable {

    private static final long serialVersionUID = 1L;
    private Sprint currentSprint;
    private DataModel<Sprint> sprints;
    private List<Sprint> sprintList;
    @ManagedProperty("#{projectManager}")
    private ProjectManager projectManager;
    private Project currentProject;
...
}

DataTableなどJSFの多くのコンポーネントは画面表示中データがメモリ上に保持されていることを前提としていることもあり、これも仕方がないところがあります。ただし、上記の例を見てもわかるように検索結果もすべてメモリ上に保持して、ログアウトまでクリアされない状態になってしまっています。検索するデータ量や同時ログインユーザー数が増えればこれは性能上の問題となる可能性が高いですし、特にクラスタ環境では肥大したセッションレプリケーションは性能上大きなオーバーヘッドになってしまいます。

それから、意外に知られていない事実ですが、上記のコードはスレッドセーフではありません。SessionScopeやApplicationScopeの管理Beanは並行アクセスに対してデフォルトでは保護されないからです。これも、サンプルアプリケーションでは問題にならなくても、実際の案件適用する際には問題です。

手動の状態同期

ScrumToyのアプリケーションのほとんどはCRUD処理なので本来業務ロジックはほとんどないのですが、メモリ上に保持しているエンティティの状態を手動で同期するコードがかなりの分量記述されています。

    public String save() {
        if (currentSprint != null) {
            try {
                Sprint merged = doInTransaction(new PersistenceAction<Sprint>() {

                    public Sprint execute(EntityManager em) {
                        if (currentSprint.isNew()) {
                            em.persist(currentSprint);
                        } else if (!em.contains(currentSprint)) {
                            return em.merge(currentSprint);
                        }
                        return currentSprint;
                    }
                });
                if (!currentSprint.equals(merged)) {
                    setCurrentSprint(merged);
                    int idx = sprintList.indexOf(currentSprint);
                    if (idx != -1) {
                        sprintList.set(idx, merged);
                    }
                }
                getProjectManager().getCurrentProject().addSprint(merged);
                if (!sprintList.contains(merged)) {
                    sprintList.add(merged);
                }
            } catch (Exception e) {
                getLogger(getClass()).log(Level.SEVERE, "Error on try to save Sprint: " + currentSprint, e);
                addMessage("Error on try to save Sprint", FacesMessage.SEVERITY_ERROR);
                return null;
            }
        }
        return "show";
    }

メモリ上にエンティティの状態を保持しているため、どこかで更新処理を行った場合に正しく状態を同期してやらないと不整合になってしまうわけです。これは一般にこのようなステートフルのアプリケーションを設計する際のもっとも難しいポイントになるのですが、本来同じIDのエンティティであっても複数のインスタンスが存在した場合、お互いの状態を同期してやる必要があります。手動でこのような同期を毎回行うのは面倒ですしバグの原因になります。

EJBの導入によるトランザクション管理の簡易化

改良版では、EJB3.1を組み合わせることで、まず、最初の問題であるトランザクション管理が面倒な点を解消しています。EJB3.1では、

ということがあり、以前のEJB3.0の頃と比較して導入の敷居は実際にかなり下がっています。

まず、通常よく行われているGenericDaoパターンのように以下のような親クラスを定義しておきます。

public abstract class JpaRepository<K extends Serializable, E extends PersistentEntity<K>> implements Repository<K, E> {

    private Class<E> entityClass;
   
    @PersistenceContext
    protected EntityManager em;

    public JpaRepository(Class<E> entityClass) {
        this.entityClass = entityClass;
    }

    @Override
    public E findById(K id) {
        return em.find(entityClass, id);
    }

    @Override
    @SuppressWarnings("unchecked")
    public List<E> findByNamedQuery(String queryName) {
        return (List<E>) em.createNamedQuery(queryName).getResultList();
    }

    @Override
    public E persist(E entity) {
        if (entity.isNew()) {
            em.persist(entity);
            return entity;
        } else {
            return entity;
        }
    }

    @Override
    public void remove(K id) {
        E managed = findById(id);
        em.remove(managed);
    }
        
    @Override
    public void remove(E entity) {
        remove(entity.getId());
    }
}

そして、これを継承した各エンティティ用のレポジトリクラスをステートレスEJBとして以下のように作成するようにしました。

@Stateless
public class ProjectRepository extends JpaRepository<Long, Project> {

    public ProjectRepository() {
        super(Project.class);
    }    
    
    public long countOtherProjectsWithName(Project project, String newName) {
        
        Query query = em.createNamedQuery(project.isNew() ? "project.new.countByName" : "project.countByName");
        
        query.setParameter("name", newName);
        if (!project.isNew()) {
            query.setParameter("currentProject", project);
        }

        return (Long) query.getSingleResult();
    }
}

通常のGenericDaoパターンと同様に、正しくインターフェースを定義して実装させても良いのですが、ここではEJB3.1のノーインターフェースビューの機能を使い、インターフェース定義を省略しています。

JSFの管理BeanをCDIの管理Beanに修正

オリジナルではJSFの管理Beanとして定義されていたのですが、これをCDIの管理Beanとして定義しなおしました。修正点としては、

import javax.faces.bean.ManagedBean;
import javax.faces.bean.SessionScoped;
...
@ManagedBean(name = "skinManager")
@SessionScoped
public class SkinManager extends AbstractManager implements Serializable {

のような宣言を、以下のように修正します。

import javax.enterprise.context.SessionScoped;
import javax.inject.Named;
...
@Named
@SessionScoped
public class SkinManager extends AbstractManager implements Serializable {

ここで、単純名が同じなので間違えやすいのですが、@javax.faces.bean.SessionScopedを@javax.enterprise.context.SessionScopedに修正しています。(大混乱に陥っているJavaEE 6のアノテーションに関する使い分けについて - 達人プログラマーを目指して

CDIを利用することで、JSFの管理Beanを使う場合と比べて以下のメリットがあります。

  • 会話スコープが使える(特定の画面遷移の間のみメモリに保持する)
  • EJBを含めて他の任意のBeanを@Injectでインジェクションできるようになる。
  • インジェクション対象が文字列ベースでなくて型ベースなので間違いにくい。
  • EJBそのものを管理BeanとしてJSFの画面から利用できるようになる。

@SessionScopedを@ConversationScopedに修正

@SessionScopedのBeanはログイン中ずっと状態が保持されますが、@ConversationScopedのBeanはプログラム中で会話の開始と終了を指示することでメモリの状態を効率的に管理することができます。実際、以下のようなベースクラスを作成し、Conversationを@Injectによりインジェクションすることで、会話の開始と終了をコントロールするようにしてみました。

public abstract class BaseCrudAction<K extends Serializable, E extends PersistentEntity<K>> extends AbstractAction implements Serializable {

...
    
    @Inject
    protected Conversation conversation;

    // 会話をbiginしている状態中のみ複数リクエストにまたがって状態が保持される。
    public void beginConversation() {
        if (conversation.isTransient()) {
            conversationNested = false;
            conversation.begin();
        } else {
            conversationNested = true;
        }
    }

    // 会話をbiginしていない、あるいはendを呼び出した後は各リクエスト終了後に解放される。
    public void endConversation() {
        if (!isConversationNested() && !conversation.isTransient()) {
            conversation.end();
        }
        
        this.currentEntity = null;
    }

...

独自の@ViewScopedを作成して検索結果を保持させる

この時点で問題となったことがあります。会話スコープによりメモリの解放を管理できるようになったのですが、会話をいつbeginし、endすればよいのかということを適切に設計する必要があるという点です。もちろん、最初から会話をbeginすることで、もともとのSessionスコープの時と同様の動作をさせることができるのですが、それでは、セッションの肥大化というもともとの問題が解決されません。

常考えられるあるべき設計としては、

  • 一覧表示は会話の外で行う。
  • 特定の行を画面で選択して入力フォーム画面を表示させるタイミングで会話を開始する。
  • 更新、作成、キャンセルなどの処理実行時に会話を終了する。
  • 行削除は会話を開始させずに実行する。

という方法です。

しかし、この方法をそのまま利用するのではうまくいかないことがわかりました。なぜなら、JSFのDataTableは表示時とポストバック時でデータが保持されていることが前提のためです。一覧表示を会話の外で行った場合、ポストバック時にデータが消えてしまうためテーブル中の行選択が正しく動作しません。

一覧データを長期間保持したくないのに、少なくとも多くのJSFコンポーネントは同一画面へのポストバック時にデータが残っていることを期待しています。

JSFの場合@javax.faces.bean.ViewScopedで指定されるビュースコープというものがあります。このスコープを使うとセッションや会話スコープにデータを保存しなくても、一つのビューを表示している最中のみデータを保持させることができ便利です。問題なのは、JSFCDIを組み合わせる上で便利なビュースコープがCDIの標準では定義されていないことです。それで、いろいろやり方を探していたのですが、以下のサイトに方法が書かれていました。

404 Not Found

この方法に従うと、意外に簡単にCDIで独自のビュースコープを定義することが可能です。まず、以下のようにViewScopedのアノテーションを定義します。

@Inherited
@NormalScope
@Retention(RUNTIME)
@Target({METHOD, FIELD, TYPE})
public @interface ViewScoped {
}

次に、以下のようにContextとSystemEventListenerを実装するViewScopedContextを作成し、JSFのビューに関するイベントをハンドリングしてデータをJSFUIツリー上で正しく管理するロジックを実装します。

public class ViewScopedContext implements Context, SystemEventListener {
...
}

そして、以下のようなCDIのExtentionを作成して上記のクラスを登録します。

public class ViewScopedExtension implements Extension {

    public void addScope(@Observes final BeforeBeanDiscovery event) {
        event.addScope(ViewScoped.class, true, true);
    }

    public void registerContext(@Observes final AfterBeanDiscovery event) {
        event.addContext(new ViewScopedContext());
    }
}

最後に、以上のExtentionをMETA-INF/services配下のjavax.enterprise.inject.spi.Extensionという名前のファイル中で登録します。

以上の拡張を行うことで、検索結果のリストをビュースコープに保持できるようになります。例えば、以下のようなCDIの生成メソッドを記述することで、検索結果のリストをビュースコープに保持できます。

    @Produces @Named @ViewScoped
    public List<Project> getProjects() {
        return projectRepository.findByNamedQuery("project.getAll");
    }    

したがって、JSFのテーブルで以下のようにして正しく表示することができます。

<h:dataTable value="#{projects}" var="project"
             rendered="#{not empty projects}"
             title="#{i18n['project.show.table.title']}"
             summary="#{i18n['project.show.table.title']}"
             border="0"
             headerClass="datatableHeader"
             rowClasses="datatableRow,datatableRow2"
             columnClasses="dataTableFirstColumn"
             styleClass="datatable"
             id="dtProjects">

    <h:column>
        <f:facet name="header">#{i18n['project.show.table.header.name']}</f:facet>
        #{project.name}
    </h:column>
    <h:column>
        <f:facet name="header">#{i18n['project.show.table.header.startDate']}</f:facet>
        <h:outputText value="#{project.startDate}">
            <f:convertDateTime pattern="#{i18n['project.show.table.header.startDate.pattern']}" />
        </h:outputText>
    </h:column>

....

ステートフルセッションBeanを利用してエンティティの一意性を自動的に保障させる

最後に、状態の同期の問題について考えます。JPAでは永続コンテキストがあり、これが持続している期間中は自動的にエンティティの一意性を保証してくれるようになっています。(Identity Mapパターン、O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある - 達人プログラマーを目指して

したがって、基本的な発想として、CDIの会話スコープが継続している最中ずっとJPAの永続コンテキストを持続させることで、エンティティの状態管理とキャッシュJPAに任せてしまうことができればアプリケーション側の状態管理が大幅に簡単になるはずです。

複数のDBトランザクションをまたがって長期間JPAの永続コンテキストを持続させるには、

  • ステートフルセッションBeanを使う
  • ステートフルセッションBeanを会話スコープに保持する
  • @PersistenceContext(type= PersistenceContextType.EXTENDED)というアノテーションを使ってEntityManagerをインジェクションする
  • EJBデフォルトトランザクション属性をTransactionAttributeType.NOT_SUPPORTEDにして勝手にDBに更新が行われないようにする。
  • 本当にDBに対して書き換えを行いたいメソッドに対してTransactionAttributeType.REQUIREDを付ける。

といった規則に従う必要があります。

実際に試してみて分かったのですが、この方式で最も問題となるのはEXTENDED指定された永続コンテキストの伝搬の制約です。理想的には同一の会話スコープ内であれば、複数のBeanから単一の永続コンテキストインスタンスにアクセスしたいのですが、現状のJava EEの仕様では単一のステートフルBeanからステートレスBeanに対してのみ正しく伝搬されるようです。特に、複数のステートフルBeanに分割した場合、同一の永続コンテキストを簡単に共有させることができません。*3

とりあえず、今回はこのアプリケーションの会話で管理するすべての状態をScrumManagerImplという一つのステートフルBeanに集約することで対応することにしました。


@Stateful
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
@ConversationScoped
public class ScrumManagerImpl implements ScrumManager, Serializable {

    private static final long serialVersionUID = 1L;

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

    private Project currentProject;
    private Sprint currentSprint;
    private Story currentStory;
    private Task currentTask;

    @Inject
    ProjectRepository projectRepository;

    @Inject
    SprintRepository sprintRepository;

    @Inject
    StoryRepository storyRepository;

    @Inject
    TaskRepository taskRepository;
    
    @Inject
    @SuppressWarnings("NonConstantLogger")
    transient Logger logger;
    
    @PersistenceContext(type= PersistenceContextType.EXTENDED)
    protected EntityManager em;
    
    //=========================================================================
    // Bean lifecycle callbacks
    //=========================================================================
    
    @PostConstruct
    public void construct() {
        logger.log(Level.INFO, "new intance of {0} in conversation", getClass().getName());
    }

    @PreDestroy
    public void destroy() {
        logger.log(Level.INFO, "destroy intance of {0} in conversation", getClass().getName());
    }
   
    //=========================================================================
    // Project management
    //=========================================================================
        
    @Produces @Current @Named    
    @Override
    public Project getCurrentProject() {
        return currentProject;
    }

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void setCurrentProject(Project currentProject) {
        this.currentProject = projectRepository.toManaged(currentProject);
        
        currentSprint = null;
        currentStory = null;
        currentTask = null;
    }

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

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

    //=========================================================================
    // Sprint management
    //=========================================================================
    
    @Produces @Current @Named    
    @Override
    public Sprint getCurrentSprint() {
        return currentSprint;
    }

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void setCurrentSprint(Sprint currentSprint) {
        this.currentSprint = sprintRepository.toManaged(currentSprint);
        
        currentStory = null;
        currentTask = null;
    }

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

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void removeSprint(Sprint sprint) {
        assertThatEntityIsNotNull(currentProject);      

        currentProject.removeSprint(sprint);
    }

    //=========================================================================
    // Story management
    //=========================================================================
    
    @Produces @Current @Named    
    @Override
    public Story getCurrentStory() {
        return currentStory;
    }

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void setCurrentStory(Story currentStory) {
        this.currentStory = storyRepository.toManaged(currentStory);

        currentTask = null;
    }    

    @TransactionAttribute(TransactionAttributeType.REQUIRED)    
    @Override
    public void saveCurrentStory() {
        assertThatEntityIsNotNull(currentSprint);
        assertThatEntityIsNotNull(currentStory);
       if (!currentStory.isNew()) return;
       
        currentSprint.addStory(currentStory);
    }

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void removeStory(Story story) {
        assertThatEntityIsNotNull(currentSprint);

        currentSprint.removeStory(story);
    }

    //=========================================================================
    // Task Management
    //=========================================================================
    
    @Produces @Current @Named    
    @Override
    public Task getCurrentTask() {
        return currentTask;
    }

    @TransactionAttribute(TransactionAttributeType.REQUIRED)    
    @Override
    public void saveCurrentTask() {
        assertThatEntityIsNotNull(currentStory);
        assertThatEntityIsNotNull(currentTask);
       if (!currentTask.isNew()) return;
       
        currentStory.addTask(currentTask);
    }

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

        currentStory.removeTask(task);
    }
    
    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    @Override
    public void setCurrentTask(Task currentTask) {
        this.currentTask = taskRepository.toManaged(currentTask);
    }    
}

なお、Seam3ではこの問題に対処するため、EXTENDED指定された永続コンテキストを利用する代わりに、アプリケーション管理の永続コンテキストを会話スコープに生成して、任意のBeanにインジェクションして共有するしくみが存在します。

404 Not Found

ただし、残念ながら現時点では互換性上の問題からか、Glassfish上では動作しませんでした。

まとめ

ライブラリーの助けを借りず、Java EE6の標準機能のみを利用して簡単にWebアプリケーションが作成できることを確かめるため、JsfScrumToysというサンプルアプリケーションリファクタリングする実験を試みました。

  • EJBトランザクション管理を導入する。
  • CDIの会話スコープを導入する。
  • CDIのビュースコープを拡張して検索結果を保持できるようにする。
  • ステートフルBeanを使って状態管理を簡易化する。

今後、

などの課題が残っていますが、いくつかの注意点を克服すれば、Java EE6標準の範囲内で実際にかなり簡単にWebアプリケーションを開発できることがわかりました。

ただし、このような簡単なアプリケーションですら実際にいくつかの落とし穴があるわけですので、実際のアプリケーションを開発する際にはプログラミングの方式を十分に研究してどの技術をどのように組み合わせるのかを考えることが大切であると思いました。今回はなるべく広範囲の技術の組み合わせを確認してみたのですが、場合によってはJPAだけ使う、JSFだけ使うといったことが適切な場合もあるかもしれません。また、今回はオリジナルの設計を踏襲してステートフルな設計にしましたが、得失を見極めて採用するかどうか検討する必要があります。

*1OSGiといったモジュール化のテクノロジーを採用が高速化に大きく寄与しているらしい。

*2サーブレットなどに@PersistenceContextで直接永続コンテキストをインジェクションするのはスレッドセーフではない。

*3JPAの仕様ではあるステートフルBeanが自分で別のステートフルBeanをJNDIや@EJBによるインジェクションで明示的に生成した場合に限り、ステートフルBean間で永続コンテキストを共有できるようですが、CDIのようにコンテナが各Beanを生成する方式ではうまく共有できません。

2011-05-06

JBoss AS5.1上でJPAを完全にSpring駆動で実行させる方法

JPAプロバイダーを起動するしくみがJavaEE5対応のアプリケーションサーバに組み込まれているため、JPAの仕様に従って、META-DATA/persistence.xmlファイルが格納されていると、アプリケーションサーバーによって読み込まれて処理されます。読み込まれたJPAの永続ユニットはローカルのJNDIコンテキストから参照できます。

しかしながら、

  • persistence.xmlの動作は環境によってかなり差異がある(パスの解決方法など)
  • 開発時は軽量なTomcatのようなサーバーで開発したい

などの理由により、アプリケーションサーバーにJPAを処理させずに、Springの軽量コンテナーに処理させるという方法も時として便利です。このような場合、アプリケーションサーバーにpersistence.xmlを認識させないための、てっとり早い方法としては、persistence.xmlをpersistece-spring.xmlなどにリネームし、これをSpringに読み込ませるという方法があります。

	
	<bean id="emf"
		class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
		<property name="persistenceUnitName" value="sampleDB" />
		
		<property name="persistenceXmlLocation" value="classpath*:META-INF/spring-persistence.xml" />
		<property name="dataSource" ref="dataSource" />
		<property name="jpaVendorAdapter">
			<bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
				<property name="database" value="MYSQL" />
				<property name="databasePlatform" value="org.hibernate.dialect.MySQL5Dialect" />
			</bean>
		</property>
		<property name="jpaDialect">
			<bean class="org.springframework.orm.jpa.vendor.HibernateJpaDialect" />
		</property>
	</bean>

しかし、この方法ではJBoss AS5*1上で正しくJPAを動作させることができないようです。実際、以下の例外となりアプリケーションが起動しません。

java.lang.IllegalArgumentException: Can't find a persistence unit named 'null' in AbstractVFSDeploymentContext@2123658583{vfszip:/D:/development/tools/JBoss/jboss-5.1.0.GA/server/default/deploy/sample.war/}
	at org.jboss.jpa.resolvers.BasePersistenceUnitDependencyResolver.resolvePersistenceUnitSupplier(BasePersistenceUnitDependencyResolver.java:107)
	at org.jboss.web.tomcat.service.TomcatInjectionContainer.resolvePersistenceUnitSupplier(TomcatInjectionContainer.java:685)
	at org.jboss.injection.PersistenceUnitHandler.addPUDependency(PersistenceUnitHandler.java:130)

Springのアプリケーションでは部分的にJavaEEの標準アノテーションが利用できるようになっており、例えば、EJBでないPojoのDAOクラスの中で@PersisteceContextアノテーションを利用することができます。しかし、なぜか、このアノテーションJBossに認識されてしまうという問題があるようです。(本来であれば、EJBサーブレットなど特定のJavaEEコンポーネント上でしか認識してほしくないのですが。)今の場合、persistence.xmlを別名にリネームしているため永続ユニットが初期化されていないとJBossに認識されているため、@PersistenceContextアノテーションの読み込みがされた時点でチェックされて上記のエラーとなるようです。この問題について、しばらく悩んでいたのですが、以下に解決策のヒントが書かれていました。

[JBSEAM-3587] Spring example on AS 5 throwns a non-jta-data-source error while deploying - JBoss Issue Tracker

結局、対処としては、web.xmlの<web-app>タグにmetadata-complete="true"をつけてやればよいようです。

<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
xmlns="http://java.sun.com/xml/ns/javaee" 
xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" 
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" 
id="WebApp_ID" version="2.5" metadata-complete="true">

	<display-name>Archetype Created Web Application</display-name>

	<context-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>
			classpath:appCommonContext.xml
			/WEB-INF/applicationContext.xml
		</param-value>
	</context-param>

	<listener>
		<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
	</listener>
...
</web-app>

この属性により、JBossxmlファイルによって設定が完結していると認識し、勝手にPojo内のアノテーションを探しに行く動作が抑制されるようです。さらに嬉しいことに、この属性により、起動時間も多少軽くなるようです。

ちなみに、以前からJBossとSpringの相性はよくなく、VFSから設定ファイルを読み込めず、Spring2.5まではSpring MVCがそのままでは起動すらしないという状態でした。SpringとJBossの連携については以下のプロジェクトも知られています。

Snowdrop - Cloud native Spring Boot applications

Spring3ではかなり相性の問題は解決されているようですが、古いバージョンを利用する場合には以上に紹介した連携モジュールの仕掛けが役に立つかもしれません。

*1JBoss AS6は今のところ未確認

2011-05-01

O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある

一般的な業務アプリケーションではデータを永続化するために、RDBMS(関係データベース管理システム)を利用します。RDBMSでは大量のデータを効率的に検索したり、集約してレポートを作ったりすることが得意ですし、一般的に業務システムで求められるトランザクションのACID特性*1を満たすことも容易です。また、適切にテーブル設計の正規化を行うことにより、運用面においてデータの管理コストを下げることもできます。最近ではスケーラビリティの問題などもあり、RDBMS以外のデータベースについても注目されるようになってきていますが、今後も業務アプリケーションの主流としてRDBMSは使われていくだろうと思われます。

従って、Javaなどのオブジェクト指向言語で開発を行い、DDDのようなオブジェクト指向の設計技法を利用する場合に必ず考えなくてはならない問題は、オブジェクト指向と関係モデルとのインピーダンスダンスミスマッチにいかに対処するかという問題を考えてなくてはなりません。

DDDにおいてRDBMSとのインピーダンスミスマッチをどう扱うべきか? - Togetter

インピーダンスミスマッチング - 都元ダイスケ IT-PRESS

RDBMSの関係モデルとオブジェクトモデルは、お互いに水と油のような関係のところもあり、一見似ているところもあるのですが、うまく混じり合わないところがあり、また、どちらか一方のモデルに適合させてもうまくいかない傾向があります。しかし、逆にアプリ層とデータ層を完全に分離するためにはマッピングのために相当のコストがかかります。

もちろん、ソフトウェアの世界で銀の弾丸というものはないのですが、最近は以前と違い、JPAに代表されるような本格的なO/Rマッピングのしくみが標準化されており、安価に利用することができます。こうしたしくみを正しく理解して使いこなすことで、両者のインピーダンスミスマッチを軽減させることができます。

ここでは、そもそもO/Rマッピングフレームワークとな何なのか、また、それによってどのようにインピーダンスミスマッチが緩和できるのかについて、簡単にまとめてみたいと思います。

O/Rマッピングフレームワークの分類

実は、O/Rマッピングという用語はかなり幅広い意味で利用されており、それが何をするものなのかを正しく理解していないと混乱が起きる場合があります。大別すると以下の通り分類できます。

  • 部分O/Rマッピング
    • SQL中心のマッピング(SQLを抽象化しない。S2Dao、MyBatis、Spring JdbcTemplateなど)
    • 静的O/Rのマッピング(SQLを抽象化するが状態の同期は手動。S2JDBC、ActiveRecord、Force.comなど)
  • 完全O/Rマッピング(状態の同期を含めて完全にRDBMSを抽象化しようとする。HibernateJPAなど)

部分か完全かの違いは後から説明するようにUnit Of Workパターンを実装して状態の更新に対する動的なインピーダンスダンスミスマッチの解決を行うかどうかの違いで分けています。もちろん、これらの分類のうち、どれが優れていて、どれが劣っているということは絶対的には決まりません。それぞれについては、以下のような特徴があるため、プロジェクトの目的によって使い分ける(あるいは併用する)ということが大切です。

分類長所短所向いているシステム
SQL中心の
マッピング
レガシースキーマにも
柔軟に対処できる。RDBMSの
パワーをフルに活用できる。
単純なケースでは似たような
SQLを大量に作成することによる
生産性や保守性低下。
オブジェクト指向設計が
困難となることがある。
大量データのバッチ処理、
帳票作成処理など。
静的
O/Rマッピング
単純なCRUD処理では生産性が高い。複雑な業務ロジック
データ構造に対処することが困難。
一般に大量データ処理は苦手。
マスターデータ管理など、
ロジックが単純な
オンラインアプリケーション
完全
O/Rマッピング
エンティティに振る舞いを持たせる
ことが可能になる。継承
ポリモーフィズムを直接活用できる。
キャッシュによる性能向上。
学習コストが非常に高い。
使い方次第では性能面の問題に直面しやすい。
大量データ処理はSQL方式を併用すべき。
複雑な業務ドメイン
DDDで開発する場合。
データベーススキーマを
クラスの構造に合わせて、
自由にリファクタリングできる場合。

インピーダンスミスマッチには静的と動的の2種類の側面がある

さて、話をインピーダンスミスマッチの問題に戻しましょう。RDBMSとオブジェクト指向言語とのインピーダンスミスマッチの問題については、

  • テーブル構造とクラス構造との静的なミスマッチ
  • オブジェクトとテーブルとの更新頻度の違いによる動的なミスマッチ

の2種類が存在します。前者の静的なミスマッチの問題は、オブジェクトモデルと関係モデルとの間での継承関係の有無の違いや、非正規化による粒度の違いのことを言います。一方、後者は多くの場合見過ごされがちな問題なのですが、オブジェクトの状態変更のタイミングとデータベースのUPDATEの頻度の違いについて言及しています。

そして、先ほどのO/Rマッピングフレームワークの分類で言えば

  • SQL中心のマッピング→ミスマッチはほとんど解決しない
  • 静的O/Rマッピング→静的なミスマッチのみ解決する
  • 完全O/Rマッピング→静的、動的なミスマッチを完全に解決する

という関係があります。従って、単にインピーダンスミスマッチと言った場合、それがどの部分を指して言っているのかという点を勘違いしないように注意する必要があります。

静的なインピーダンスミスマッチを解決する

通常、多くの人がRDBMSとオブジェクト指向とのインピーダンスミスマッチと聞いてイメージするのは静的なインピーダンスミスマッチの問題なのではないでしょうか。単純なマッピングでは

といったマッピングを行うことができます。実際、SQL中心のフレームワークやSIer製や内製の自称O/Rマッピングフレームワーク*2ではこのような単純なマッピングを前提としていることがほとんどでしょう。一見このマッピングで問題が無いように見えてしまうかもしれませんが、詳細を考えると

  • データベースのテーブルには継承関係が定義できない(継承関係のミスマッチ)
  • オブジェクトなどを考えると粒度が異なる(粒度のミスマッチ)
  • 関係モデルでは本質的に双方向の関連しか定義できない(関連の方向性ミスマッチ)

といったオブジェクト指向設計との相違点がいろいろと出てきます。一つの解決策はデータベース設計を中心で考え、このミスマッチをアプリケーションの設計で制約として受け入れてしまうことです。もちろん、単純なCRUD処理やデータの加工が中心となるアプリケーションではこれで問題とはなりません。*3しかし、ドメインの複雑なビジネスロジックが関与する(実際に多くの業務アプリケーションの場合はそうなのですが)場合、オブジェクト指向プログラミングのメリットを活用できないと、ロジックの重複といった保守性や拡張性の問題を引き起こします。そうしたことが問題となる場合にはO/Rマッピングによる静的なインピーダンスミスマッチの解決策を取り入れることが有用になります。

継承関係のミスマッチ

継承関係のミスマッチに対しては、現在では具体的な解決策がパターンとして知られており、有名なPofEAAでも解説されています。特に継承関係のマッピングのパターンの解説としてはスコットアンブラーの記事が知られています。

Mapping Objects to Relational Databases: O/R Mapping In Detail

この場合、3種類のマッピング方法が知られています。

継承パターン説明使うべき時JPAでの継承タイプ
Single Table Inheritance継承階層を1テーブルに格納する。商品種別ごとの階層など大量サブクラスが存在する場合。継承がフィールドの違いでなく、振る舞いの違いを中心とする場合に特に有利。JOINやUNIONを利用せず、多態的に親クラスをまとめて検索できるため、一般には性能的に有利。テーブルモデルの観点からは正規化されておらず、NOT NULL制約のないカラムが必要。SINGLE_TABLE
Class Table Inheritance抽象クラスも含めて各クラスを別々のテーブルに対応して格納する。正規化された無駄のないテーブル設計が可能で、テーブルモデル自体としては拡張性や保守性が高い。必ずJOINが必要となるため一般には性能面でのコストがかかる。JOINED
Concrete Table Inheritance具象クラスのみ別々のテーブルに格納する。具象クラスを別々のテーブルに格納できるためわかりやすいが、多態的な検索や関連の処理はコストがかかる。TABLE_PER_CLASS*4

JPAを実装した本格的なO/Rマッピングフレームワークであれば、これらの継承マッピングをサポートしているため、本当にエンティティ間の継承が必要な場合にインピーダンスミスマッチを和らげてくれます。

粒度のミスマッチ

オブジェクト指向設計を行う場合には、一般に大量のフィールドを持ったクラスを作成することは責務の観点から望ましくありませんが、データベースのテーブルを設計する場合には性能面も考慮して、1対1の関係にあるデータはあえて一つのテーブルにまとめてしまうことが望ましい場合が少なくありません。たとえば、顧客に対して送付先住所、課金先住所などの情報を別々のテーブルで持つ代わりに、一つのテーブルにまとめて保持させるということは実際よくやります。また、DDDのようなオブジェクト指向設計であれば、Stringやintといった基本的な値だけでなく、Date、Moneyなど特定の値と振る舞いをカプセル化した値オブジェクトを抽出して利用する場合があります。このように、テーブルとクラスとの間で望ましい粒度の違いがあるため、インピーダンスミスマッチの一つの原因となります。

この点についてもJPAでは@Embeddableを用いて値の埋め込みという手法である程度対処することができるようになっています。詳しくは以下を参照してください。

JPAの@Embeddableの使い道 - 達人プログラマーを目指して

なお、DDDでいうところのエンティティ、値オブジェクトJPAの@Entity、@Embeddableとでは意味が微妙にずれているところがあり、単純な対応付けができない部分があります。この点については別の機会に私の考えをまとめてみたいと思います。

関連の方向性のミスマッチ

RDB上のテーブル間の関連は外部キーと主キーとのカラムとのマッピングで行うことが基本です。したがって、本来関連にはどちらのテーブルからどちらのテーブルという方向性という概念がありません。一方、クラス間の関連の場合、関連先のオブジェクトの参照をもう一方が保持するかどうかで双方向の関連にも一方向の関連にもなり得ます。そして、関連の方向性は依存関係に関与してくるため、オブジェクト指向の設計では欠かすことのできない重要な要素となります。

高度なO/Rマッピングフレームワークでは、通常、一方向や双方向の関連を定義することができますが、方向性のないテーブルの関連にマッピングする際のミスマッチを吸収する工夫がされています。たとえば、Owner(飼い主)とDog(犬)というエンティティが一対多で関連づいていたとして

    @Entity
    public Owner {
        @Id
        private Long id;
        
        @OneToMany(mappedBy = "owner")
        private List<Dog> dogs;

        ...
    }

    @Entity
    public Dog {
        @Id
        private Long id; 

        @ManyToOne
        private Owner owner;
        ...
    }

のような双方向の関連を定義することができます。この場合、飼い主の犬との関連はクラス上は、飼い主→犬のリスト、犬→飼い主というお互いに独立した二つの方向の関連で定義されています。しかし、テーブル上はDOG表の外部キー列でOWNER表の主キーを参照することでマッピングします。もし、犬の所有者を変更するなどがあった場合に関連の紐付けを変更する必要があるのですが、その場合にオブジェクト上は2箇所の関連を書き換える必要がありますが、更新すべきテーブル上のカラムの値は一箇所です。オブジェクト上の変更を単純にテーブルの更新にマッピングしてしまうと、無意味に2回の更新をかけることになってしまうため、JPAでは@ManyToOne側の関連のみが更新に関与するという動作を行うことにより、この二重更新を防ぐ工夫がされています。*5

完全O/Rマッピングは動的なインピーダンスミスマッチも解決する

本格的なO/Rマッピングフレームワークではこのように継承、埋め込みオブジェクト、関連などのデータ構造の違いに起因する静的なインピーダンスミスマッチを吸収してくれますし、インピーダンスミスマッチといえばこのような静的なものを思い浮かべる人が実際多いと思います。実際、多くの内製フレームワークがサポートしている範囲はほとんどの場合せいぜいこの範囲のサポートに留まっています。しかし、JPAをサポートするような完全O/Rマッピングフレームワークでは、エンティティの状態更新とテーブルの更新頻度の違いというインピーダンスミスマッチの動的な側面にも光を当てています。

単純なCRUD処理が中心のアプリケーションであれば、データの更新はテーブルの行単位で実行されるため、特にこの動的なインピーダンスミスマッチが問題になることはありません。しかし、DDDが対象とするような本格的なドメインモデルにおいては、エンティティや集約そのものが状態をカプセル化する*6だけでなく、状態を部分的に更新したり、関連先の複数オブジェクトにまたがって更新するような複雑な振る舞いを保持すると考えます。このような場合には

  • エンティティの状態が更新されたかどうかによらず行全体を毎回UPDATEするのは非効率
  • エンティティの状態を部分的に更新するだけなので行全体のロックをトランザクションの最初から取得するのは無駄
  • 複数のエンティティの更新順序によってデッドロックの危険もある
  • 同一IDの複数のエンティティのインスタンスがメモリ中で存在した場合、更新漏れや上書きが起こるおそれがある
  • 関連先のエンティティを必ず参照するとは限らないため毎回ロードしてしまうのは無駄

といったような問題が出てきます。つまり、更新頻度がオブジェクトとテーブルとで異なるということが本質的です。これは動的なインピーダンスミスマッチの問題と呼ばれていますが、RDBMS上で本格的なオブジェクト指向のドメインモデル設計を行う場合には無視のできない問題となります。これらの問題に対処するためには

などのパターンが知られています。JPAでもこれらパターンが実装されています。実際、JPAのEntityManagerは伝統的なDAOと違って、UPDATEやINSERTなどのタイミングをアプリケーションプログラムが明示的に制御することは通常しません。普通にエンティティオブジェクトの状態を更新するとUPDATE文や行ロックの取得は裏で非同期に処理されます。そのため、動的なインピーダンスミスマッチの問題が軽減されます。

まとめ

O/Rマッピングといっても、テーブルの行を一つのクラスのインスタンスに単純に詰め替えるような単純なものから、JPAのように完全O/Rマッピングを実装したものまでさまざまなものがあります。O/Rマッピングの種類により、どのレベルまでインピーダンスミスマッチが吸収されるかについての違いがあります。

  • 静的なインピーダンスミスマッチ
    • 継承関係の有無
    • 粒度の違い
    • 関連の方向性の違い
  • 動的なインピーダンスミスマッチ
    • 更新頻度の違い
    • 関連先データ取得タイミングの違い

もちろん、インピーダンスミスマッチを吸収するためには複雑な仕組みが必要となるため、必ずしも常に高度なO/Rマッピングが望ましいということではなく、要件の性質に応じて適切な手段を使い分けることが必要です。しかし、少なくともDDDが目指すような本格的なオブジェクト指向設計のドメインモデルを実装に取り入れる際にはこれらのインピーダンスミスマッチに対処してくれるフレームワークをインフラストラクチャとして利用し、正しく使いこなすことが重要になってきます。

*1:Atomicity(原子性)、Consistency(一貫性)、Isolation(独立性)、Durability(永続性)

*2:未だに内製にこだわっている組織もあるかもしれませんが、少なくともO/Rマッピングのような汎用的なコンポーネントOSSなどの広く使われている製品を流用するのが今では常識ではないでしょうか。

*3:今では、そのような単純なアプリケーションJavaでわざわざカスタム開発すること自体が疑問視されるべきですが。

*4:この方式への対応は必須でなくJPAプロバイダーによる

*5:mappedBy属性のある@OneToMany側をinverseサイド、外部キーの更新に関与する側をownerサイドと呼ぶ

*6:より望ましい設計としては一意性にかかわらない状態を値オブジェクトとして抽出して委譲させる

*7:多くの設定では作業単位はDBトランザクションと一致するが、作業単位が複数のDBトランザクションを含むように構成することもできる。

*8JPAでは作業単位(永続コンテキスト)内で状態の更新が記録されているエンティティのインスタンスをManagedエンティティと呼びます。

2010-11-08

JPAの@Embeddableの使い道

JPAには@Embeddableというアノテーションがありますが、このマッピング機能をうまく活用しているチームはどれくらいあるのでしょうか?私が今まで適用してきた使い方は結局以下の2通りの使い方のいずれかに集約できると思います。

1.属性の多い巨大なテーブルに対するエンティティを入れ子に構造化されたクラスとして扱う

これはちょうどCOBOLにおいて巨大なレコードをばらばらの独立項目として扱うのではなく、値の塊ごとに集団項目として一まとまりの変数としてまとめて考えるという発想に近い考え方です。たとえば、COBOLでは以下のように従業員レコードを固まりで分割して定義できます。

       DATA                    DIVISION.
       WORKING-STORAGE         SECTION.
       01  EMPLOYEE.
           05  EMP-NO          PIC 9(7).
           05  EMP-NAME
             10  FIRST-NAME    PIC X(15).
             10  LAST-NAME     PIC X(15).
           05  JOIN-DATE.
             10  YEAR          PIC 9999.
             10  MONTH         PIC 99.
             10  DAY           PIC 99.

これはC言語などでも入れ子の構造体という形でよく使われるテクニックだと思います。たとえば従業員エンティティにおいて、住所や入社日といった複数のカラムからなる集合を別クラスに抽出し、@Embeddedと@Embeddableでマッピングすることができます。

@Entity
public class Employee {
  @Id
  private String empNo;

  @Embedded
  private Name name;

  @Embedded
  private YearDate joinDate;  

  ...
}

@Embeddable
public class Name {
  private String firstName;
  private String lastName;
...
}

// COBOLとの対応をわかりやすくする例のため
// 本来的には日付の表現としていまいちかもしれない。
@Embeddable 
public class YearDate {
  private int year;
  private int month;
  private int day;
...
}

業務システムでよく使うRDBのレコードは理由はともあれ正規化を十分に行っておらず*1、多数のカラムを含むことが多いため、これをフラットなエンティティクラスに直接マッピングすると、フィールドの数が多くなり過ぎてしまうことがよくあります。この考え方で@Embeddableを使う場合、単にレコード、構造体の入れ子ですから、抽出されたクラスは読み書き可能な(ミュータブルな)クラスとして設計することが普通かと思います。

2.ValueObjectパターンの実装手段として利用する

ここではValueObjectパターンは、SunのペットショップやJ2EEパターン―明暗を分ける設計の戦略(初版)で間違って広まったDTOとしての意味ではなく、エンタープライズ アプリケーションアーキテクチャパターン (Object Oriented SELECTION)で説明されているように、それ自身IDを持たない抽象的な型(ADT)としての値クラスのことを意味するものとします。つまりValueObjectとは、JDKに最初から含まれるintなどの基本型、String、BigDecimal、Date、列挙型などのクラスの性質をユーザー定義型として自然に拡張した概念であり、典型的な例は「金額」「契約期間」「取引日」などをあらわすクラスを意味すると考えます。C、C++などの言語ではクラスや構造体をポインターではなく値として渡すことで簡単にインスタンスの値のコピーが作成可能ですが(C#などでは構造体という概念で限定的にサポートされる)、Javaの場合オブジェクトはすべて参照型なので、このような値オブジェクトのセマンティクスを保つためには参照の共有によるエリアシング問題*2を防ぐために、オブジェクトを不変(イミュータブル)にするということが一般的です。ファウラーのエンタープライズ アプリケーションアーキテクチャパターン (Object Oriented SELECTION)ではEmbedded Valueというパターンが紹介されており、このパターンに従えば、@EmbeddableをValueObjectの実装に利用するのは少なくとも理論上は理にかなった使い方であると考えられます。ValueObjectは単なる入れ子の構造というよりも値と共にロジック(Side Effect Free Function)を一緒にカプセル化したいような場合に特に有用です。

実際のところはどうか

私の経験から、前者の方法は特に問題なく使えることが多いと思います。特に(このようなケースでJPAを使うことはあまりお勧めしませんが)レガシースキーマからボトムアップでエンティティを作る場合、@Embedableを用いてクラスのサイズを適当に分割することは有用な場合が多いです。また、エントリー系のシステムでも、最近のフレームワークではEL式などでピリオドを使って入れ子の階層をたどることは容易ですから、必ずしもフラットなエンティティクラスにマッピングする必要はありません。

一方、2のValueObjectとして使う方法ですが、最初の想定とは異なり、実際はうまく使いこなせないことが多いです。最大の理由は@EmbedableがJPAでマップされたエンティティの抽象データ型とはならないという仕様上の制約があるからです。したがって、たとえば、JPQLで検索するような場合、検索パラメーターに取引日などのValueObjectを使って直接、同値や前後比較などを記述することができないのです。結局のところ、値オブジェクトとして抽象化しているのに、中身のStringやDate型のフィールドを取り出して処理しないといけないなど、ところどころでいまいちな実装が必要になっていまいます。(実はJPAの実装によってばらつきがあり、HibernateではValueObjectを直接検索条件のパラメーターとして使っても動いてしまう場合もあるのですが、これはJPAプロバイダー間で可搬性がありません。JPQLの検索パラメーターにValueObjectを使えないことはJPA2の仕様で明確化されています。仕様書の4.12参照。)そのほか、protected以上のデフォルトコンストラクタを定義しないといけないため、厳密にイミュータブルにできなかったり、@IdClassを使って主キークラスを使う場合、ValueObjectをエンティティの主キーの型として使えないなどの制約もあります。(@EmbeddedIdを使うことは可)どうしてこういう制約があるのかわかりませんが、以前のバージョンのEJB-QLとの互換性なども関係しているのでしょうか?

したがって、独自の日付型など検索条件に普通に入れたいようなごく基本的なValueObjectの実装手段についてはJPAの@Embeddableを利用するのではなく、HibernateであればUserTypeの機能を利用する方がベターかもしれません。この方法であれば独自の型をStringやDateとほぼ同格の存在として扱えます。この方法の欠点はHibernate固有のアノテーションが必要になることと、

  1. UserTypeの実装クラスを作成
  2. package-info.javaなどで@TypeDefを使ってユーザータイプとして登録
  3. オブジェクトのフィールドに@Typeをつける

という面倒な手順が必要になることですが、どうしてもValueObjectパターンを使いたい場合には有用な手段だと思います。なお、この方法でValueObjectをマッピングするときにはValueObjectのクラスにJPAの@Embeddableをつけると動作がおかしくなるようですので注意してください。

*1:性能上の理由などで正規化を崩す以外に、ビューが正しく利用されておらず、C/Sで画面表示したり、帳票出力したりするためにテーブルが外部スキーマに近い形態として設計されていることが多い。

*2:変更可能なValueObjectのインスタンスが複数のオブジェクトから共有されていると、一方の値を変更したときに意図せず別のエンティティの値まで連動して変更されてしまう。