JSF2.0のエラーハンドリング

JSF2.0のエラーハンドリングについて調べてみたのでまとめる。

ここで言う『エラーハンドリング』とは、Struts1.xの<globa-exceptions>や、org.apache.struts.action.ExceptionHandlerを継承してユーザが作成するカスタム例外ハンドラを想定しており、Struts1.xと同じようなことがJSF2.0でも可能か確かめることが目的だ。

今回は、以下のようなバッキングBeanからランタイム例外が投げられた場合に、スタックトレースが表示されないようにしたい。

@Model
public class BookController {

    @EJB private BookService service;

    @Inject private Book book;

    public String createBook() {
        service.persist(book);    // ここから投げられるランタイム例外に対応したい
        return "nextpage.xhtml";
    }
}

何もエラーハンドリングしないと、以下のような画面が表示される。スタックトレースにはアプリケーションの内部構造を示す情報も含まれており、セキュリティという観点でもあまり好ましくない*1

f:id:n_agetsuma:20130211125210p:plain


JSF2.0においてエラーハンドリングは大きくわけて2つの方法がある。

1. web.xmlに<error-page>を設定する

Servletに含まれる機能で、Struts1.xを使っている方にもおなじみだと思うが、web.xmlに例外クラス名やHTTPレスポンスコードを定義し、対応する遷移先を設定することが可能だ。例えば、なんでも良いので例外が投げられたらエラーページに遷移したい場合は以下のように設定する。

<error-page>
    <exception-type>java.lang.Exception</exception-type>
    <location>/error.xhtml</location>
</error-page>

このやり方はシンプルで良いが、例えば遷移先画面に何かしら動的なメッセージを表示したい場合に困る。またJSF(mojjara2.1)の場合、バッキングBeanのアクションメソッドからスローされた例外はjavax.faces.el.EvaluationExceptionでラップされているため、アプリケーション例外に応じた遷移先を設定するのが難しい。*2
例えば、以下のような設定は、意図したように動かない。

<error-page>
    <!-- 
         DataAccessExceptionがバッキングBeanから投げられても、
         EvaluationExceptionでラップされるので、error.xhtmlには遷移しない。
     -->
    <exception-type>sample.DataAccessException</exception-type>
    <location>/error.xhtml</location>
</error-page>

このようなケースに対応するためには、次に紹介するJSF2.0のカスタム例外ハンドラを使う。


2. カスタム例外ハンドラを作成する

カスタム例外ハンドラはJSF2.0から導入された機能で、FacesMessagesの追加、画面遷移の指定、ロギングなど、柔軟なエラーハンドリングを実現している。前述したアプリケーション例外sample.DataAccessExceptionのハンドリングをカスタム例外ハンドラで行いたい。

カスタム例外ハンドラは、javax.faces.context.ExceptionHandlerWrapperクラスを継承して作る。

public class DataAccessExceptionHandler extends ExceptionHandlerWrapper {
  private ExceptionHandler wrapped;
  
  public DataAccessExceptionHandler (ExceptionHandler wrapped) {
    this.wrapped = wrapped;
  }
	
  @Override
  public void handle() {
    for (Iterator<ExceptionQueuedEvent> it = 
     getUnhandledExceptionQueuedEvents().iterator(); it.hasNext() == true;) {

      ExceptionQueuedEventContext eventContext = it.next().getContext();
			
      // 1. ハンドリング対象のアプリケーション例外を取得
      Throwable th = getRootCause(eventContext.getException()).getCause();

      if (th instanceof DataAccessException) {

        FacesContext facesContext = eventContext.getContext();
				
        // メッセージを追加する
        facesContext.addMessage(null, 
          new FacesMessage(FacesMessage.SEVERITY_ERROR,
                                 "システム障害", "システム障害が発生しました。"));
				
        // 2. リダイレクトしてもFacesMessageが消えないように設定
        facesContext.getExternalContext().getFlash().setKeepMessages(true);

        try {
          // エラー画面に画面遷移させる
          String contextPath = facesContext.getExternalContext().getRequestContextPath();
          facesContext.getExternalContext().redirect(contextPath + "/error.xhtml");	
        } catch (IOException e) {
          System.out.println("error.xhtmlがありません");
        } finally {
          // 3. 未ハンドリングキューから削除する
          it.remove();
        }
      }
    }
    wrapped.handle();
  }
	
  @Override
  public ExceptionHandler getWrapped() {
    return wrapped;
  }
}

例外ハンドリング処理はhandle()メソッドに実装する。今回の例ではfaceletのxhtml上で>h:messeages>でメッセージを表示できるようにすることと、遷移先のエラー画面をリダイレクト指定した。そのなかでも3つのポイントがある。

1. ハンドリング対象の例外クラスはラップされている

// 1. ハンドリング対象のアプリケーション例外を取得
Throwable th = getRootCause(eventContext.getException()).getCause();

上記のようなコードで例外を取得している理由は、バッキングBeanから投げられた例外がこのカスタム例外ハンドラに至るまでに、様々な例外でラップされて到達するためである。
手元のJBossAS7.1.1で試した場合は、FacesException → (getCause) → FacesException → (getCause) → EvaluationException → (getCause) → DataAccessExceptionのように、アプリケーション例外が2重のFacesExceptionと、さらにEvaluationExceptionでラップされた状態で取得された。上位クラスでJSF(mojjara)が実装しているExceptionHandlerWrapper#getRootCause()でまずFacesExceptionの部分を取り除き、さらにgetCause()することでアプリケーション例外を抽出している。

2. Flash.setKeepMessages(true) を設定する

// 2. リダイレクトしてもFacesMessageが消えないように設定
facesContext.getExternalContext().getFlash().setKeepMessages(true);

この設定を忘れると、リダイレクトによりリクエストスコープに格納されたFacesMessagesが消えてしまい、メッセージが表示できない。setKeepMessages(true)により、リダイレクトの間はメッセージを保持してくれるようになる。

3. ハンドリングした後は、未ハンドリングキューから削除する

// 3. 未ハンドリングキューから削除する
it.remove();

拡張forループを使わずにループをまわしていたのは、この処理が必要であったため。ハンドリングが終わった例外はgetUnhandledExceptionQueuedEvents()から取得できる未ハンドル例外キューから削除する必要がある。この設定を忘れると、例えば複数のカスタム例外ハンドラを設定した場合に、他の例外ハンドラで処理が終わっているのにも関わらず、getUnhandledExceptionQueueEvents()で取得できてしまう。運が悪いと、重複したロギングや、思いもしない画面遷移の不具合を盛り込むことになるだろう。

カスタム例外ハンドラを作成したら、合わせてカスタム例外ハンドラファクトリを作成する。
作成したファクトリをfaces-config.xmlに登録したら完成だ。

/**
  * カスタム例外ハンドラファクトリの実装
  */
public class DataAccessExceptionHandlerFactory extends ExceptionHandlerFactory {

  private ExceptionHandlerFactory parent;

  public DataAccessExceptionHandlerFactory(ExceptionHandlerFactory parent) {
    this.parent = parent;
  }
	
  @Override
  public ExceptionHandler getExceptionHandler() {
    ExceptionHandler handler = new DataAccessExceptionHandler(parent.getExceptionHandler());
    return handler;
  }
}

faces-config.xmlには以下のようにファクトリを登録する。

<factory>
    <exception-handler-factory>handler.DataAccessExceptionHandlerFactory</exception-handler-factory>
</factory>

参考 JBoss Seem Solder だと簡単にできる

SpringMVCなどもっと簡単に例外ハンドリングできるフレームワークと比較すると、前述したJSFカスタム例外ハンドラは非常に面倒に感じる。さらにプログラム中でinstanceOfで例外クラスごとに条件分岐するのもあんまりいけてない。こんなときにはJBoss Seemに含まれるSolderを使うと、アノテーションベースで例外ハンドラが作成できるらしい(まだ実際に動かしてはいない)。

@HandlesExceptions
public class CustomHandlers {
    void handle(@Handles CaughtException<Throwable> evt)  {
        // 例外ハンドリング処理
    }
}

こういう便利そうな仕様がもっとJava EEに盛り込まれると嬉しい。

*1:参考 : 安全なウェブサイトの作り方 1-(iii) エラーメッセージをそのままブラウザに表示しない

*2:参考 GlassFishのフォーラム JSF: user exception thrown in action won't show web.xml error page