Hatena::ブログ(Diary)

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

2011-08-16

Java EE6環境でJSF2を使う場合はCDIのBeanを管理Beanとして使う方がよい

先週の勉強会で紹介させていただいたjsf-scrumtoys-refactoredでは、JSFの管理Beanを使用する代わりにCDIのBeanを利用しています。この点説明が不十分だったので、ここで簡単に補足させていただきます。

JSFと管理Bean

勉強会の中で、JSFコンポーネントベースのWebアプリケーションフレームワークであると説明させていただきました。(この点については以下のエントリーもご参照ください。Struts1に代わるWebフレームワークの選択 - 達人プログラマーを目指して

f:id:ryoasai:20110816221420p:image:w600

コンポーネントベースのフレームワークの場合、VBSwingといった伝統的な(Webでない)GUIアプリケーションのように、フォームや入力フィールド、ボタンといった画面コンポーネントのツリーが構築されます。そして、画面部品の入力やクリックなどのイベントに従って、管理Beanと呼ばれるPOJOに対してデータのやり取りやメソッドの呼び出しが実行されます。

JSF1.2までのxmlを使った管理Beanの定義

従来のJSF1.2までは、この管理Beanをfaces-config.xmlと呼ばれるxmlファイルにて登録する必要がありました。*1たとえば、今回のSkinActionに相当する管理Beanを登録するためには、以下のような設定が必要でした。

    <managed-bean>
        <managed-bean-class>jsf2.demo.scrum.web.controller.SkinAction</managed-bean-class>
        <managed-bean-name>skinAction</managed-bean-name>
        <managed-bean-scope>session</managed-bean-scope>
        <managed-property>#{skinValuesAction}</managed-property>
    </managed-bean>

つまり、JSFの世界だけで簡単なDIコンテナのようになっておりsetterメソッドを使ってインジェクションが可能でした。

JSF2のアノテーションによる管理Beanの定義

JSF2.0では従来のJSF1.2のxmlを使った定義をそのまま拡張する形で様々なアノテーションが使えるようになっています。上記のような管理Beanはアノテーションを使うと以下のように書けます。

package jsf2.demo.scrum.web.controller;

import java.io.Serializable;
import javax.annotation.PostConstruct;
import javax.faces.bean.ManagedBean;
import javax.faces.bean.SessionScoped;
import javax.faces.bean.ManagedProperty;

//@ManagedBean(name = "skinAction")と同様
@ManagedBean
@SessionScoped
public class SkinAction extends AbstractAction implements Serializable {

    private String selectedSkin;
    @ManagedProperty("#{skinValuesAction}")
    private SkinValuesAction skinValuesAction;
...
}

これで、xmlの登録は不要になったのですがこの場合、以下のイマイチな点があります。

  • DIは型でなく文字列の管理Bean名で行われる
  • @ManagedPropertyによるDIはあくまでもJSFの管理Beanに閉じた世界でのみ可能。(EJBのインジェクションなどは別の方法で行う)

まず、前者の問題はDIがクラスなどの型情報でなくて、EL式で記述した文字列の情報に依存しているため、リファクタリングなどの変更に弱く、また、(IDEによるサポートが不可能ではないとはいえ)一般に間違いの検出が容易ではありません。さらに、xmlで明示的に管理Beanを宣言していないので、インジェクションする相手のソースコードを読んでBean名を調査しないといけないといったこともあります。

後者の問題は、もともとJSF1.2の頃からあった問題ですが、DIできる対象がJSFの管理Bean同士に限られるということですね。通常は管理BeanからEJBなどサービス層のBeanを呼び出すことが多いのですが、その場合にプログラミングのやり方が統一されていませんでした。さらに、EJBそのものをJSFの管理Beanとして直接利用するということもできません。どんなに単純なケースであっても、必ず管理Beanを作成し、そこからEJBを呼び出すといったことが必要になります。

CDIのBeanをJSFの管理Beanとして使うメリット

リファクタリング版のScrumToysではJSFの管理Beanの使用をやめ、代わりにCDIを導入しています。CDIの仕様に従うとwarモジュールのWEB-INFフォルダ中にbaens.xmlが格納されていれば、そのwar全体でCDIが有効になりますが、CDIが有効になっているwarモジュールでは

の条件を満たす任意のクラスは自動的にBeanとしてコンテナに登録されることになっています。*2また、EJBは一般のクラスとは違い特別な扱いを受けますが、やはり、CDIのBeanとして登録されます。

CDIプログラミングモデルの基本は非常に簡単で、このように自動的に登録されたBean同士は@InjectによってDIすることが可能というものです。この時、基本的には型情報をもとにDI対象が決定されます。*3この場合、CDIのBeanの定義は以下のようになります。


package jsf2.demo.scrum.web.controller.skin;

import jsf2.demo.scrum.infra.web.controller.AbstractAction;
import java.io.Serializable;
import javax.annotation.PostConstruct;
import javax.enterprise.context.SessionScoped;
import javax.enterprise.inject.Model;
import javax.inject.Inject;

@SessionScoped @Model
// @SessionScoped @Namedと同義、また@SessionScoped @Named("skinAction")と同義
public class SkinAction extends AbstractAction implements Serializable {    
    private String selectedSkin;

    @Inject
    SkinValuesAction skinValuesAction;

ここで、@SessionScopedのアノテーションのパッケージがJSF2の管理Beanのパッケージとはimport元が違っている点に注意してください。また、DIは文字列ではなく型に基づいて行われていることに注意してください。なお、@Namedで文字列の名前をBeanに与えているのはxhtml画面定義中のEL式から名前で参照するためで、インジェクションのために定義しているのではありません。

このようにCDIのBeanを使うことで

  • インジェクションが型安全になる
  • インジェクション対象がJSFの管理Beanだけでなく、EJBを含めたほぼすべてのBeanになる
  • EJB自体に@Namedを付与することで画面から直接参照できる

といったメリットが得られます。

CDIの更なるメリットと注意点

それに加えて、CDIではJava EE6標準の範囲でフルスタックのWebアプリケーションが簡単に作成できることを確かめてみました。 - 達人プログラマーを目指してでも説明したように、従来のJSFでは利用できなかった、会話スコープを利用することが可能になっています。つまり、@ConversationScopedを利用することで、Beanを丸ごとセッションスコープに格納する代わりに特定の画面遷移ごとに会話スコープを定義することができます。会話スコープは複数同時進行させることもできるため、タブブラウザやマルチウィンドウのアプリケーションでは特に有用です。

しかし、CDIJSFと組み合わせる場合には以下のような点に注意する必要があります。

利用可能なスコープの違い

JSF2で定義されているスコープは

  • @javax.faces.bean.ApplicationScoped
  • @javax.faces.bean.SessionScoped
  • @javax.faces.bean.RequestScoped
  • @javax.faces.bean.ViewScoped
  • @javax.faces.bean.NoneScoped
  • @javax.faces.bean.CustomScoped

があります。一方、CDIで利用可能なスコープは

  • @javax.enterprise.context.ApplicationScoped
  • @javax.enterprise.context.SessionScoped
  • @javax.enterprise.context.RequestScoped
  • @javax.enterprise.context.ConversationScoped
  • @javax.inject.Singleton疑似スコープ
  • @javax.enterprise.context.Depenent疑似スコープ

となっていて、ほとんど共通しているのですが完全には対応していません。(大混乱に陥っているJavaEE 6のアノテーションに関する使い分けについて - 達人プログラマーを目指して)特に、JSF有用なViewScopedは直接は対応するスコープがCDIでは利用できません。このようなViewスコープをCDIでも利用する方法については、Java EE6標準の範囲でフルスタックのWebアプリケーションが簡単に作成できることを確かめてみました。 - 達人プログラマーを目指してで書いた通りです。

JSFコンバーターやバリデーターがCDIのBeanとして利用できない

管理Bean以外にも、コンバーターやバリデーターといったJSF固有のBeanが存在します。これらも従来JSF1.2まではxmlで宣言していましたが、JSF2からはアノテーションで登録できるようになりました。ただし、この場合これらのBeanはJSFの側で生成、管理されてしまい、CDIのコンテナと統合されていないといった制約が現状はあるようです。

したがって、これらのBeanに対して@Injectを使ってEJBや他のBeanを普通にインジェクションすることができません。さらに、コンバーターなどは管理Beanでもないため、@EJBによるインジェクションもできないようです。よって、以下のように面倒でもJNDIルックアップが必要になってしまいます。

@FacesConverter("projectConverter")
public class ProjectConverter implements Converter {

    // @Injectも@EJBも動作しないため、JNDIルックアップが必要
    private ProjectRepository getProjectRepository() {
        Context ctx;
        try {
            ctx = new InitialContext();
            return (ProjectRepository) ctx.lookup("java:module/ProjectRepository");
        } catch (NamingException ex) {
            Logger.getLogger(ProjectConverter.class.getName()).log(Level.SEVERE, null, ex);
            
            throw new RuntimeException(ex);
        }
    }
    
    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.equals("0")) {
            return null;
        }
        try {
            return getProjectRepository().findById(Long.parseLong(value));
        } catch (NumberFormatException e) {
            throw new ConverterException("Invalid value: " + value, e);
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object object) {
        if (object == null) return "";
        
        Project project = (Project) object;
        Long id = project.getId();
        if (id != null) {
            return String.valueOf(id.longValue());
        } else {
            return "0";
        }
    }
}

まとめ

JSF2の仕様はCDIの仕様よりも一足先に決まったという歴史的背景もあり、CDIの仕様を取り込んでうまく融合するという形になっていないため、現状は多少不便なところもあります。しかしながら、JSF2とCDIを組み合わせることでプログラミングモデルを簡易化できるというメリットは無視できないものがあると思います。

GlassfishのようにJava EE6に準拠した環境であれば、あらかじめCDIがサポートされているのですから、そのような環境においてはJSFの管理Beanの使用は避け、CDIのBeanを利用するようにするというのがよいのではないでしょうか。

*1:ただし、ファイルは任意に分割することも可能。

*2:staticな入れ子のクラスすら以上の条件を満たせば管理Beanとして登録されます。

*3:同一型の実装が複数存在する場合のように、型のみでインジェクション対象を判定できなかった場合は限定子と呼ばれるアノテーションを定義して、Bean定義とインジェクションポイントに@Injectとともに限定子アノテーションを指定します。

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を生成する方式ではうまく共有できません。

2010-11-18

Struts1に代わるWebフレームワークの選択

先日書いたいつまでStruts1を使い続けるの? - 達人プログラマーを目指してが想像以上の反響の大きさで驚いています。こんなにたくさんブクマいただいたのはブログ開設以来初めてです。政治的理由でフレームワークが最初から天下り的に与えられてしまい、結果的に要件に合わないフレームワークの使用を強いられて苦労させられた経験のある開発者の方も多いと思います。また、逆にフレームワークを提供したり選択したりする側の立場で、時代遅れのフレームワークを今後どうにかしなくてはならないという問題意識を持たれている方も多いのではないかと思います。(これは、ちょうど多くの会社のパソコンでWindows XPやIE6ではさすがに時代遅れだと認識はしていても、コストなどからなかなか思うようにバージョンアップが進まないというのと似たところもあると思います。*1)Struts1が既に時代遅れなのは知っているけれど、抱えている既存フレームワークの改修や開発者の再教育などのコストを考えると新しいフレームワークへの移行は簡単ではないのですよという声が聞こえてきそうです。

Javaを使ったWebアプリケーション開発ということ自体が、既に時代遅れという意見の方もおられるかもしれませんが、直ちにすべての開発をRubyやRIA技術に移行すればよいかというと、もちろん現実はそんなに簡単ではありません。開発ツールの完成度や既存のライブラリーの流用、開発者のスキルということを考えると、今後も、しばらくはJavaが主流の言語の一つとして利用され続けるというのは間違いないでしょう。(もちろん、Java言語自体も進化していくので、以前のバージョンと比較すると別言語のようになっていく可能性もあると思いますが。)

そこで、ここではJava EE環境で業務アプリケーションを作成するという前提で、Struts1に代わるWebアプリケーションフレームワークとして今後何を選択したらよいのかという点について、現時点で自分なりに考えてみた結果を整理しておきたいと思います。

2種類のMVCフレームワークの分類

一言でMVCフレームワークと言っても、大きく分けると

の2種類に分類できることをご存知でしょうか?前者のアクションベースのカテゴリーに属するフレームワークは、HTTPのリクエスト→レスポンスという基本的なプロトコルを完全に抽象化することなく、リクエストごとに処理をアクションとして記述していく方式になります。Struts1はこの方式の代表的な例ですが、他に似たようなアーキテクチャーのフレームワークとしてStruts2、Spring MVC、Stripesなどがこのカテゴリーに分類できます。一方、後者はちょうどVisual Basic、Delphi、SwingなどのRADツールを使って、画面を作るのと似たような感覚で画面部品を配置することで、簡単に画面を開発できるようにするというタイプのフレームワークです。代表的なものを挙げるとJSF、Wicket、Tapestry、Echo、Clickなど、こちらのカテゴリーにもたくさんのフレームワークが存在します。*2

アクションベースフレームワークの長所

アクションベースフレームワークとしては以下のような長所が考えられます。

  • サーブレットのModel2パターン(PetStore)やStrutsなど伝統的な開発手法で経験者が豊富
  • しくみが単純な傾向があるため、オーバーヘッドが小さく性能が高い
  • Webのアーキテクチャーを隠蔽しないためRESTfulなサイトを構築しやすい
  • 画面レイアウトやデザインに対する柔軟性が高い

一般的には、コンシューマー系のサイトなど、リクエスト数やユーザー数に対するスケーラビリティの確保が最重要で画面デザインも独立させたいようなケースに適合すると考えられます。ただ、このような場合は、そもそもPHPやRubyなどJava以外の言語での開発や、CMSパッケージの適用、SaaSなどの利用も競合することが考えられるので、本当にJavaベースのスクラッチ開発が最適なのかよくよく検討が必要でしょう。

コンポーネントベースフレームワークの長所

一方、コンポーネントベースフレームワークには以下のような長所があります。

  • ボタンや入力フィールド、グリッドなど画面部品を共通化して再利用しやすい
  • HTML、CSS、JavaScriptなどの画面デザインのスキルがないプログラマーでも画面が作成できる
  • コンポーネントやイベントなど自然なオブジェクト指向プログラミングが可能

したがって、社内のVBアプリケーションを単純にWebアプリケーションとして作り変えるような場合は適合することが多いと思います。特に社内システムだと、何百という入力項目に何十というコマンドボタンが付いているようなきわめて複雑な画面を作らなくてはならないことが多くあります。これを直接HTMLのフォームで作成すると非常に大変ですが、コンポーネントベースのフレームワークを使えば画面部品の流用で比較的簡単に作成できると思います。しくみが複雑なため、どうしても性能面では不利になると思うので対外サイトの構築には通常は向かないと思います。なお、このカテゴリーが適するアプリケーションであれば、FlexなどのRIA技術やJavaScript中心のAjaxフレームワーク(GWTなど)も有効なことが多いため、競合するところがあります。

今後注目したいJavaのWebアプリケーションフレームワーク

それで、Struts1に代わる次世代のJavaフレームワークですが、残念ながら今のところこれといった決め手がないというか、非常に選択が悩ましいです。

一応、アクションベースが適合するケースでDIコンテナーとしてSpringを利用する前提なら、まずはSpring MVCを第一選択肢として検討することをお勧めします。Springの一部として現在もアクティブに開発が行われているため、将来性も期待できますし、やはりSpringと相性が抜群によいのは大変に魅力的です。特に、Spring2.5以降で利用できる@MVCではCoCで設定ファイルの記述量も以前と比べて激減していますし、アクションクラスに相当するコントローラーのコード記述量はStruts比で半分から3割程度にも少なくできます。さらに、複雑な会話処理が必要ならWebFlowを組み合わせることができます。現状日本語の情報がほとんどないのと、設計の柔軟性が高い分ある程度(侵略的になり過ぎない範囲で)パターンごとに規約を決めてあげないと初心者のプログラマーには敷居が高いというところが欠点かもしれません。

一方、コンポーネントベースを選択するケースでは、やはりJava EE標準で部品やツールも豊富なため、まずはJSFを中心に検討すべきでしょう。特にJSF2.0では、Seamの特徴を取り入れることで前バージョンの欠点を克服して、かなり実用的なフレームワークになっています。あくまでも現時点における私の主観ですが、まとめると大体以下のような感じになると思います。もちろん、業務要件や開発者の経験によって良し悪しの判断は大きく変わってくるので、あまり鵜呑みにされないように注意してください。

基本
カテゴリー
実績開発者数将来性HTML
非侵略性*3
会話状態*4学習
コスト
開発
生産性
性能単体試験
容易性
CoC*5活用
Struts1アクション×××××
SAStrutsアクション×*6×
Struts2アクション××
Spring MVCアクション*7
JSFコンポーネント*8*9
Wicketコンポーネント×*10×*11×
Tapestryコンポーネント×*12×

フルスタックのフレームワークについて

Spring RooやGrailsなどのフレームワークが利用できるのであれば、最初からビルドやテストの仕組みが提供されているので

ビルドシステム構築スキルの重要性 - 達人プログラマーを目指して

で書いたような問題も軽減されて、工数を削減できるかもしれません。今のところ、こうしたフレームワークでどこまでのことが可能か未調査なのですが、基本的にはCRUD処理を中心とした単純なアプリケーション向けな印象があります。ただし、RooはRealオブジェクト指向ということですし、実は高度なロジックを実現できたりするかもしれません。

Webフレームワークの比較については以下の記事も参考になると思います。

Raible Designs | My Comparing JVM Web Frameworks Presentation from Devoxx 2010

*1:ただし、Windows7がWindowsXPと比べて本当に便利かどうかは疑問の余地もありますが。私が新機能を使いこなしていないだけかもしれませんが。ただし、MSから強制されるバージョンアップとは違い、フレームワークの進歩はOSSなどプログラマーの必要性からボトムアップで起こります。この点はちょっと趣が違うかもしれません。

*2:Seamは純粋なWebフレームワークではありませんが、JSFやWicketと組合せられるということで、このカテゴリーに属すると考えることもできます。

*3:画面を普通のhtmlに近い形で作成できるかどうか。html編集ツールでそのまま編集できたりjspへの変換が楽。独自のタグを多用するとポイントが下がる。non obtrusive htmlの適当な訳。

*4:ウィザードなど一連の処理範囲で状態をサーバー側に保持する機能を持つかどうか。特にブラウザーのタブやウィンドウごとに独立した状態を管理できるかどうか。普通のサーブレットAPIではセッションというログインユーザーごとに1つのグローバルな領域でしか状態が保持できないため、会話状態の管理は困難。

*5:Convention over Configuration。設定より規約ということで規約どおりなら無駄な設定ファイルを省略できるようにすることで無駄な設定を少なくすること。

*6:Struts1依存であることと現時点におけるSeasar2自体の将来性を考慮

*7:WebFlowを併用した場合

*8:faceletsを利用する前提

*9:SeamかCDIを併用した場合

*10:Seamを併用した場合

*11:OOPのスキルが平凡なチームを想定

*12:前バージョンでの実績は別カウントとする

2010-10-26

JSFがピンチといううわさ?!

JSF2.0の仕様を決めているJSR-314のエキスパートグループが、後続のグループを設立することなくいきなり解散してしまったということで、JSFがオラクルによってつぶされるのではないかとか、さまざまな憶測が広がっているようです。

http://in.relation.to/Bloggers/WhenWillJSFHaveANewJSROfficialExpertGroup

http://java.dzone.com/articles/jsf-expert-group-disbanded-no

どちらかというと以前と違ってオラクルはJSFの改善にあまり関心がなく、代わりにSeamやRichFacesを抱えるJBossの方が熱心なようですし。

RIAの技術が成熟すれば、JSFのようなサーバーサイドのコンポーネント技術はもう時代遅れで不要であるということでしょうか?FlexとかJavaFxとか。あるいはWaveMaker、Ext JSのようなAjax系の技術とか。HTML5とか。JSFよりツールの使い勝手がよくて、かつ、業務ロジックとも連携できるなら、JSFのアーキテクチャーにこだわることはないかもしれません。個人的にはJSFが直ちに廃れてしまうということはありえないのではと思っていますが、今後しばらく技術の動向を見守っていく必要がありそうです。

2010-10-22

SeamアプリでGETリクエストとPOSTリクエストとの適切な使い分けに関する注意点

画面遷移定義におけるJSFとSeamの思想の違いについてで述べた様に、Seamでは標準のJSF1.2と比較してGETリクエストのサポートが充実しています。つまり、JSFではGETで単純に画面を開くことしかできないのに対して、Seamの場合<s:button>、<s:link>タグを使うことで、アクションを呼び出したり、会話を開始したり、さまざまな処理が可能となっています。通常のJSFのPOSTバックと比較して、これらのSeam固有のタグ*1を利用したGETリクエストのメリットとして

  • (リンクの場合)右クリックして別タブや別ウィンドウで内容を開ける
  • リンク先のURLがブックマーク可能となる
  • <h:form>タグの外部で自由に使える

などがあります。

ただし、GETリクエストを濫用しないよう注意が必要です。特にGETリクエストはサーバー側の永続的な状態を更新するような操作で使うべきではありません。Seamの場合はステーフルなコンテキストを持つため、時々ちょっと迷うときもあるのですが、基本的にはGETリクエストは冪等な(idempotentな)操作に限定すべきで、少なくともともDBの状態を書き換えるとかショッピングカートのような非永続であってもアプリケーションの動作に重大な影響を与える状態を書き換えるような操作はPOSTで行うべきです。RESTの規約ということもありますが、このような操作をGETで行うとブックマークや検索エンジンのクローラーなどに悪い影響が出ます。Seamの<s:button>、<s:link>タグを使うと意図せずこうした不適切な更新処理を簡単に実行できてしまうため要注意だと思います。(以前、 @DataModel/@DataModelSelectionを使った一覧選択機能の実装はアンチパターンか?で書いたこととちょっと矛盾するようですが、カートの商品の選択とかグリッドの行の直接削除リンクなどはGETで実装すべきでなく、@DataModelSelectionかパラメーター付きアクションメソッドバインディングを使うべきところです。)

あと、<s:button>、<s:link>タグの制約としてAjax4jsfを使ったAjax化ができないということがあります。Ajax4jsfによるAjaxリクエストは基本的にPOSTバック処理となるようです。

http://seamframework.org/Community/AJAXSupportForSlink

*1:JSF2.0では<h:button>、<h:link>タグという標準タグがあり一部Seamの機能が取り入れられている。