はじめに
ここ1ヶ月ほど、(とある事情により)JavaEE6 で開発作業をおこなっています。
この中で得られた雑多な知見について、他の開発者の方のためにフィードバックとして簡単にさらしておこうかと思います。の第2回目です。
に続いて、 JPA に関する話題を。
JPA2.0 では不幸にも EntityListner に SessionContext を DI できない
JavaEE6 には、CDI やら JSF やら EJB やら JPA やらのコンテナ管理の Bean が色々あります。これらの Bean は透過的に DI していきたいところですが、いくつかできない箇所があります。
その一例として、EntityListner にはリソースの注入ができません。このリソースの注入は JPA 2.1 として仕様化されていますが、JPA2.0 でなんとかやっている我々にとっては困った状況となります。
エンティティライフサイクルに対するコールバック
まずは出だしとしてエンティティのコールバックから簡単に説明を始めます。
JPA では、エンティティの永続化前や後などでコールバックをアノテーションベースで処理することができます。
例えばエンティティクラスに@PrePersistアノテーションを付与したメソッドを用意しておくと、エンティティのライフサイクルの中で発生するイベントに応じてアノテーションを付与したメソッドが呼び出されます。
この仕組みにより、エンティティを永続化する前処理などをPOJOの単なるメソッドとして定義できます。
以下の例では、エンティティの Persist と Update の前に validate() メソッドをコンテナがコールしてくれるようになります。
@Entity public class Foo { @Id private Long id; private String name; @PrePersist @PreUpdate private void validate() { }
このように、JavaEE6 では、エンティティのライフサイクルに応じてコールバックをアノテーションベースで処理することができるようになっています。
EntityListner でコールバック処理を共通定義
上記はエンティティ単位にコールバックを指定する例でしたが、EntityListner を定義しておくと、コールバックメソッドを別のクラスに切り出して、複数のエンティティから利用できるようになります。
この場合もPOJOに@PrePersistなどのアノテーションを付与したメソッドを用意しておき、エンティティクラスに @EntityListners アノテーションでマーキングしておくだけで、あとはコンテナがうまくやってくれます。
public class ValidateListner { @PrePersist @PreUpdate private void validate() { }
エンティティに@EntityListnersを登録。
@EntityListners({ValidateListner.class}) @Entity public class Foo { }
この仕組みを多数のエンティティに対して統一的に行いたい場合は、共通の abstract class にエンティティリスナを定義しておくことができます。
で、良くあるのが、更新日時と更新者IDを埋め込む処理です。単純に考えて、SessionContext を DI してそこから認証情報の Principal からユーザID取ればよいので、
public class AuditingEntityListener<T> { @Resource private SessionContext sessionContext; @PrePersist public void touchForCreate(Object target) { String userId = sessionContext.getCallerPrincipal().toString(); ・・・ } @PreUpdate public void touchForUpdate(Object target) { ・・・ } }
のように EntityListener でユーザIDを埋め込む処理を書いておいて、
抽象クラス用意して EntityListeners 登録。
@MappedSuperclass @EntityListeners({AuditingEntityListener.class}) public abstract class AbstractAuditable<PK extends Serializable> { }
でも、これうまく行かないんです
JPA 管理のEntityListener にはリソース埋め込みができないようになっています。繰り返しますが JPA 2.1 からはできるようになる予定です。
ではどうするかというと、きれいではないけど ルックアップして取りましょうかとなりますよね。
こんな感じでSessionContext取ってこれます。
InitialContext ic = new InitialContext(); SessionContext sessionContext = (SessionContext) ic.lookup("java:comp/EJBContext"); Principal callerPrincipal = sessionContext.getCallerPrincipal(); return callerPrincipal.getName();
でも、これもうまく行かないんです
いや、状況によってはうまくいくこともあるんです。EJB 呼び出しがローカルコール(@Localアノテーション)の場合にはうまく動きます。
でも、リモートインターフェースでEJBが呼び出されたとたんダメになります。
EJBのリモートインターフェースはクライアントからRMI経由で呼び出され、通常このRMI呼び出しの境目がトランザクション境界となります。
そして、JPA の管理するエンティティは、トランザクション境界で(別レイヤーに)シリアル化されるタイミングで管理状態から分離状態となります。管理状態のエンティティが実際にDBに追加や更新されるタイミングはORM次第ではありますが、トランザクション境界で、メモリ上にためておいた更新内容を更新順序を整えてDBに書き出しにいきます。
@PrePersist や @PreUpdate の処理が実際に呼び出されるのは、データベースへの反映のタイミングであり、トランザクション境界の境目で呼び出されることになります(つまりEJBメソッドを抜けた後)。そのため、トランザクション境界を抜けつつある状態で SessionContext から Principal を正しく取得できない状況が発生します。コンテナの実装にもよるとは思いますが、glassfishではダメでした。
ではどうするかと言うと、AOP + ThreadLocal で無理矢理処理します
具体的にはこちらの記事にあります。
http://blogs.captechconsulting.com/blog/balaji-muthuvarathan/persistence-pattern-using-threadlocal-and-ejb-interceptors
@AroundInvoke でインタセプターを定義して、この中で Principal を取得します。
public class AuditInterceptor { @Resource private SessionContext sessionContext; @AroundInvoke public Object intercept(InvocationContext ctx) throws Exception { try { String userName = sessionContext.getCallerPrincipal().toString(); UserContext.setCurrentUserName(userName); return ctx.proceed(); } finally { // do not !! CurrentContext.getCurrentUserName().set(null); } } }
このインタセプタはejb-jar.xmlなどで定義してEJBメソッドの呼び出し時にAOP適用します。
UserContext は単なる ThreadLocal のホルダーです。
public class UserContext { private static final ThreadLocal<String> currentUserName = new ThreadLocal<>(); public static String getCurrentUserName() { return currentUserName.get(); } public static void setCurrentUserName(String userName) { currentUserName.set(userName); } }
でエンティティリスナからは、UserContext 経由で ThreadLocal に保持しておいたユーザIDを取得。
public class AuditingEntityListener<T> { @PrePersist public void touchForCreate(Object target) { String userName = UserContext.getCurrentUserName(); ・・・ } @PreUpdate public void touchForUpdate(Object target) { ・・・ } }
で、一応うまく動くようになります。
JBoss を使っている場合には TransactionLocal が使える
こちらの記事にあるように、
https://markatta.com/codemonkey/blog/2009/11/02/audit-logging-with-jpa-listeners-on-jboss/
JBoss には TransactionLocal があるので、コンテナの実装依存が気にならなければこちらのほうが吉ですね。
さらに別の解法
こちらのPostにある通り、セッションスコープのCDI管理Beanを準備しておき、EntityListener からLookUp してユーザの情報を取得する方法です。
http://stackoverflow.com/questions/10765508/cdi-injection-in-entitylisteners
まとめ
とまぁ、エンティティリスナにリソースをDIできないだけで、ずいぶんと回り道が必要になりました。このような問題は、JSF 管理のManagedBean に CDI で DI が *昔は* できなかったりと結構いたるところにあります。早いとこ JavaEE7 カモンな状況です。
次回はプレゼン層の話題でも書こうかなぁ