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上や書籍で見つかるのですが、JSFやJPAなど特定の技術要素をターゲットにした説明がほとんどで、画面からデータアクセスまでを組み合わせた実案件で使えそうなよいお手本がなかなか現時点では見つからないようです。
そこで、NetBeansで自動生成可能なJSF2のサンプルアプリケーションである、JsfScrumToysをリファクタリングし、EJB、CDI、JPAと組み合わせるように修正してみました。最初は簡単にできると予想していたのですが、単純なCRUD処理のアプリケーションを作成するだけでも、想定外の試行錯誤がいろいろと必要で、満足な設計に到達するまでに結構時間がかかってしまいました。それでも、最終的には標準技術のみで実際にかなり簡単に書けることがわかりましたのでここで紹介させていただきます。
リファクタリング結果は以下にアップしてあります。例外処理など、実案件への適用に対してはまだまだ考慮が足りていない部分がありますが、Java EE6開発のベースとして使っていただけると思います。
GitHub - ryoasai/jsf-scrumtoys-refactored: A sample web application using Java EE6 stack.
本エントリに関連して以下のまとめもご参照ください。
JavaEE6を使ったアプリケーション開発について - Togetter
オリジナルのScrumToysの問題点
オリジナルのScrumToysアプリケーションは、NetBeansを用いて自動的に生成することができます。
このアプリケーションは以下のようなドメインモデルのエンティティに対して、各エンティティのCRUD処理を実現する簡単なアプリケーションとなっています。
特に特殊なところはなく、単にお互いに入れ子の関係にあるエンティティを管理するアプリケーションになっています。独立した単テーブルの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では、
- earファイルを作成する必要がなくwarファイル中に格納できる。
- インターフェースはオプション
ということがあり、以前の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を使う場合と比べて以下のメリットがあります。
@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で指定されるビュースコープというものがあります。このスコープを使うとセッションや会話スコープにデータを保存しなくても、一つのビューを表示している最中のみデータを保持させることができ便利です。問題なのは、JSFとCDIを組み合わせる上で便利なビュースコープがCDIの標準では定義されていないことです。それで、いろいろやり方を探していたのですが、以下のサイトに方法が書かれていました。
http://www.verborgh.be/articles/2010/01/06/porting-the-viewscoped-jsf-annotation-to-cdi/
この方法に従うと、意外に簡単にCDIで独自のビュースコープを定義することが可能です。まず、以下のようにViewScopedのアノテーションを定義します。
@Inherited @NormalScope @Retention(RUNTIME) @Target({METHOD, FIELD, TYPE}) public @interface ViewScoped { }
次に、以下のようにContextとSystemEventListenerを実装するViewScopedContextを作成し、JSFのビューに関するイベントをハンドリングしてデータをJSFのUIツリー上で正しく管理するロジックを実装します。
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にインジェクションして共有するしくみが存在します。
http://seamframework.org/Seam3/PersistenceModule
ただし、残念ながら現時点では互換性上の問題からか、Glassfish上では動作しませんでした。
まとめ
ライブラリーの助けを借りず、Java EE6の標準機能のみを利用して簡単にWebアプリケーションが作成できることを確かめるため、JsfScrumToysというサンプルアプリケーションをリファクタリングする実験を試みました。
今後、
などの課題が残っていますが、いくつかの注意点を克服すれば、Java EE6標準の範囲内で実際にかなり簡単にWebアプリケーションを開発できることがわかりました。
ただし、このような簡単なアプリケーションですら実際にいくつかの落とし穴があるわけですので、実際のアプリケーションを開発する際にはプログラミングの方式を十分に研究してどの技術をどのように組み合わせるのかを考えることが大切であると思いました。今回はなるべく広範囲の技術の組み合わせを確認してみたのですが、場合によってはJPAだけ使う、JSFだけ使うといったことが適切な場合もあるかもしれません。また、今回はオリジナルの設計を踏襲してステートフルな設計にしましたが、得失を見極めて採用するかどうか検討する必要があります。