Wicket 例外ハンドリング(Exception handling)についてのいろいろ


404エラーのハンドリング方法から派生して、Wicket内での例外ハンドリングの拡張ポイントについて調べてみました。
なかなか拡張ポイントが結構多く、勉強になりました。


目次

この記事の目次になります。
調べた限り以下のような実装が可能でした。
各頁は基本的に内容が独立していますので、必要なところだけ読んで頂ければと思います。


Web.xml を使う

Servletの標準機能に任せる方法です。
Error Pages and Feedback Messages - Apache Wicket - Apache Software Foundationに全部書いてありますが、下記の記述を追加することで、カスタム404ぺージを表示することができます。

  • web.xml
    タグ <error-page> を追加します。

    <filter-mapping>
        <filter-name>WicketFilter</filter-name>
        <url-pattern>/*</url-pattern>
        <dispatcher>REQUEST</dispatcher>
        <dispatcher>ERROR</dispatcher>
    </filter-mapping>
    
    <error-page>
        <error-code>404</error-code>
        <location>/404</location>
    </error-page>
    

  • Application.java
    web.xml で追加した404の URL に対応する Page を mount する実装を追加します。

        // 404
        mountPage("/404", ErrorPage.class);
    
    上記は、404エラーのハンドリング方法 にも書いた内容ですが、 この実装方法だと、「Wicketに登録していないURLに対してのハンドリング」となります。
    アプリケーション内から明示的に404ページに飛ばすには、下記のようにAbortWithHttpErrorCodeException をスローしたり、
        throw new AbortWithHttpErrorCodeException(404, "Festival is not Exist...");
    
    下記のように、RestartResponseException に遷移したいページを指定することで、明示的なページ表示ができます。
        // ここではErrorPage.classが遷移したいページ
        throw new RestartResponseException(ErrorPage.class);
    
    個人的には、Java の Web フレームワークは使っている状況なので、生 Servlet の機能を使用するのはとあまりお勧めできないかと考えます。


ApplicationSettings#set…Page() を使う

Web.xml を使うと同じくError Pages and Feedback Messages - Apache Wicket - Apache Software Foundation に記載のある内容になります。
Application#set...Page() という、末尾が Page()で終了する API を使用することでステータスエラーに対する細かなハンドリングが可能です。
以下末尾がPage()の API について説明します。

ApplicationSettings#setPageExpiredErrorPage()

PageExpiredException に発生した場合、遷移するエラーページを設定します。

ApplicationSettings#setInternalErrorPage()

予期しないエラーが発生した場合、遷移するエラーページを設定します。

ApplicationSettings#setAccessDeniedPage()

AuthorizationExceptionListenerInvocationNotAllowedException が発生した場合(認証エラーが発生した場合)、遷移するエラーページを設定します。

補足

  • AuthenticatedWebApplication を継承した Application クラスを作成している場合、getSignInPageClass でLoginページを指定する必要があります。

  • ページアクセス時に認証が通っていない場合、getSignInPageClass での指定ページ、またはPageに制御がうつってからの権限エラーという状況の場合では、
    AuthorizationExceptionが発生することを許容します。

  • ApplicationSettingsで指定した設定は、DefaultExceptionMapper#mapExpectedExceptions()と、DefaultExceptionMapper#mapUnexpectedExceptionsで、使用されています。
    以下、DefaultExceptionMapper の実装抜粋になります。

DefaultExceptionMapper.java

    /**
     * Maps expected exceptions (i.e. those internally used by Wicket) to their corresponding
     * {@link IRequestHandler}.
     * 
     * @param e
     *          the current exception
     * @param application
     *          the current application object
     * @return the {@link IRequestHandler} for the current exception
     */
    protected IRequestHandler mapExpectedExceptions(Exception e, final Application application)
    {
        if (e instanceof StalePageException)
        {
            // If the page was stale, just re-render it
            // (the url should always be updated by an redirect in that case)
            return new RenderPageRequestHandler(new PageProvider(((StalePageException)e).getPage()));
        }
        else if (e instanceof PageExpiredException)
        {
            return createPageRequestHandler(new PageProvider(Application.get()
                .getApplicationSettings()
                .getPageExpiredErrorPage()));
        }
        else if (e instanceof AuthorizationException ||
            e instanceof ListenerInvocationNotAllowedException)
        {
            return createPageRequestHandler(new PageProvider(Application.get()
                .getApplicationSettings()
                .getAccessDeniedPage()));
        }
        else if (e instanceof ResponseIOException)
        {
            logger.error("Connection lost, give up responding.", e);
            return new EmptyRequestHandler();
        }
        else if (e instanceof PackageResource.PackageResourceBlockedException && application.usesDeploymentConfig())
        {
            logger.debug(e.getMessage(), e);
            return new ErrorCodeRequestHandler(404);
        }
        return null;
    }

    /**
     * Maps unexpected exceptions to their corresponding {@link IRequestHandler}.
     * 
     * @param e
     *          the current exception
     * @param application
     *          the current application object
     * @return the {@link IRequestHandler} for the current exception
     */
    protected IRequestHandler mapUnexpectedExceptions(Exception e, final Application application)
    {
        final ExceptionSettings.UnexpectedExceptionDisplay unexpectedExceptionDisplay = application.getExceptionSettings()
            .getUnexpectedExceptionDisplay();

        logger.error("Unexpected error occurred", e);

        if (ExceptionSettings.SHOW_EXCEPTION_PAGE.equals(unexpectedExceptionDisplay))
        {
            Page currentPage = extractCurrentPage();
            return createPageRequestHandler(new PageProvider(new ExceptionErrorPage(e,
                currentPage)));
        }
        else if (ExceptionSettings.SHOW_INTERNAL_ERROR_PAGE.equals(unexpectedExceptionDisplay))
        {
            return createPageRequestHandler(new PageProvider(
                application.getApplicationSettings().getInternalErrorPage()));
        }

        // IExceptionSettings.SHOW_NO_EXCEPTION_PAGE
        return new ErrorCodeRequestHandler(500);
    }

DefaultExceptionMapperでは、下記のExceptionSettingsに設定を元にした制御も実装されています。


ExceptionSettings#set…() を使う

Applicationクラスのinitメソッドで以下のような記述をすることで、Exception発生時の、ふるまい制御できます。

        getExceptionSettings().setAjaxErrorHandlingStrategy(ExceptionSettings.AjaxErrorStrategy.REDIRECT_TO_ERROR_PAGE);
        getExceptionSettings().setThreadDumpStrategy(ExceptionSettings.ThreadDumpStrategy.THREAD_HOLDING_LOCK);
        getExceptionSettings().setUnexpectedExceptionDisplay(ExceptionSettings.SHOW_NO_EXCEPTION_PAGE);
setメソッドが3種類ありますので、以下説明します。

ExceptionSettings#setAjaxErrorHandlingStrategy()

AjaxリクエストでErrorになった場合の振る舞いをコントロールします。
設定値について説明します。

  • AjaxErrorStrategy.REDIRECT_TO_ERROR_PAGE
    通常のリクエストと同様にApplicationSettings#setInternalErrorPage() で設定した
    エラーページに遷移させます。

  • AjaxErrorStrategy.INVOKE_FAILURE_HANDLER
    Javascript側のcallbackメソッドに制御を任せて、
    AjaxCallListener#onFailureメソッドを呼びだします。

ExceptionSettings#setThreadDumpStrategy()

スレッドダンプの出力方法をコントロールします。

  • ThreadDumpStrategy.NO_THREADS
    出力しない。

  • ThreadDumpStrategy.THREAD_HOLDING_LOCK
    Lockを取得しているThreadStackTraceを出力する

  • ThreadDumpStrategy.ALL_THREADS
    Applicationの全てのThreadStackTraceを出力する

ExceptionSettings#setUnexpectedExceptionDisplay()

予期しない例外の発生時に、どのように振る舞うかを制御します。

  • ExceptionSettings.SHOW_EXCEPTION_PAGE ExceptionErrorPageに遷移します。

  • ExceptionSettings.SHOW_INTERNAL_ERROR_PAGE
    ApplicationSettings#setInternalErrorPage() で指定したページに遷移します。

  • ExceptionSettings.SHOW_NO_EXCEPTION_PAGE
    500エラーを返して、Wicketではなく、Web.xml側で指定した
    エラーページに遷移します。


IExceptionMapper を使う

DefaultExceptionMapperの継承クラスを作成して、エラーハンドリングのロジックを拡張することができます。
以下、Githubexample を作っている人がいたので、そちらへのリンクです。

上記を参考に ExceptionMapper の継承クラスを作成してみました。
継承クラスだけでなく、Application#getExceptionMapperProvider() をオーバーライド、更にExceptionMapper の継承クラス の継承クラスを返すカスタムExceptionMapperProvider を作成する必要があります。
なかなかわかりにくいですが、JAVA8 で作成すると、以下のような実装になります。

  • WicketApplication.java の対象実装の抜粋
        /**
         * the exceptionMapper
         */
        private final AppExceptionMapper exceptionMapper = new AppExceptionMapper();
    
        /**
         * IProvider exceptionMapperProvider
         */
        private final IProvider<IExceptionMapper> exceptionMapperProvider;
    
        public WicketApplication() {
            this.exceptionMapperProvider = () -> exceptionMapper;
        }
    
        /* (non-Javadoc)
         * @see org.apache.wicket.Application#getExceptionMapperProvider()
         */
        @Override
    
        public IProvider<IExceptionMapper> getExceptionMapperProvider() {
            return exceptionMapperProvider;
        }
    

IRequestCycleListener#onException() を使う

以下にサンプルコードの記載があります。
* How to use a different Wicket expiration/error page for popups and other special pages? - Stack Overflow

Applicationクラス内で、IRequestCycleListenerを登録することで、
エラーハンドリング処理を追加することが可能です。
エラーハンドリングが実装可能なAPIが複数ありわかりにくいので、以下説明します。

IExceptionMapperIRequestCycleListener#onException() の挙動の違いについて

以下のコードで検証しました。
少なくとも、ハンドリングが行われるタイミングに違いがあります。

  • WicketApplication.java

    @Log4j2
    public class WicketApplication extends WebApplication {
    
        /**
         * the exceptionMapper
         */
        private final AppExceptionMapper exceptionMapper = new AppExceptionMapper();
    
        /**
         * IProvider exceptionMapperProvider
         */
        private final IProvider<IExceptionMapper> exceptionMapperProvider;
    
        public WicketApplication() {
            this.exceptionMapperProvider = () -> exceptionMapper;
        }
    
        /**
         * @see org.apache.wicket.Application#init()
         */
        @Override
        public void init() {
            super.init();
    
            // Add RequestCycleListener
            getRequestCycleListeners().add(new AbstractRequestCycleListener() {
                @Override
                public IRequestHandler onException(RequestCycle cycle, Exception ex) {
                    // Log
                    log.info("============================================================");
                    log.info("Raise Exception", ex);
                    log.info("============================================================");
                    return super.onException(cycle, ex);
                }
            });
            // Do Somethings..
        }
    
        /* (non-Javadoc)
         * @see org.apache.wicket.Application#getExceptionMapperProvider()
         */
        @Override
    
        public IProvider<IExceptionMapper> getExceptionMapperProvider() {
            return exceptionMapperProvider;
        }
    

  • AppExceptionMapper.java

    import lombok.extern.log4j.Log4j2;
    import org.apache.wicket.DefaultExceptionMapper;
    import org.apache.wicket.request.IRequestHandler;
    
    /**
     * AppExceptionMapper
     *
     * @author Kem
     */
    @Log4j2
    public class AppExceptionMapper extends DefaultExceptionMapper {
    
        @Override
        public IRequestHandler map(Exception e) {
            log.info("---------------------------------------");
            log.info("Raise Exception>>>", e);
            log.info("--------------------");
            return super.map(e);
        }
    
    ExceptionMapper と、RequestCycleListener#onException の双方を実装して、どちらが優先でハンドリングされるのかを確認してみました。
    エラー発生時にログは以下の通り出力されました。
    20:10:11.728 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.WicketApplication - ============================================================
    20:10:11.729 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.WicketApplication - Raise Exception
    java.lang.NullPointerException
    20:10:11.730 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.WicketApplication - ============================================================
    20:10:11.730 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.AppExceptionMapper - ---------------------------------------
    20:10:11.730 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.AppExceptionMapper - Raise Exception>>>
    java.lang.NullPointerException
    20:10:11.731 [dw-51] INFO  xyz.monotalk.festivals4partypeople.web.AppExceptionMapper - --------------------
    

ハンドリングの優先順位について

ハンドリングの順序は、
1. IRequestCycleListener#onException()
2. IExceptionMapper#map()
となります。

どちらを使用すべきか

IRequestCycleListener#onException() はパラメータで、RequestCycleを保持しており、結構何でもやりたい放題できそうです。
それに対して、IExceptionMapper#map()Exceptionのみが引数になるため、発生元のページ情報などはあらかじめ例外に積んでおく必要があります。
IRequestCycleListener#onException() のほうが柔軟な制御ができそうですが、そこで全てを実装すると煩雑な実装になりそうです。
個人的には、共通処理で例外レベルに依りアプリケーション全画面遷移をコントロールしたい場合は、IExceptionMapper#map()
遷移元ページ等、ReuqestCycleの情報で共通で判断したい場合、IRequestCycleListener#onException()にハンドリング処理を実装するのがよいかと思いました。


まとめ

以下まとめます。

Wicket は、例外ハンドリングのための拡張ポイントが多数実装されている。

拡張ポイントが多数実装されているので、正直使い方は迷う(学習コストが高い)。

考えた限り以下のような判断で使うのがよさそう。

  • Web.xml では、Wicketフレームワーク外のエラーページを指定する。これはセーフティーネットとしての設定を行うスタンス。

  • ApplicationSettingsExceptionSettings は主に開発用で、これらを使って開発環境でのエラー調査しやすくする。

  • IExceptionMapper#map() でアプリケーション全体のカスタムエラーハンドリングする。
    これは、認証エラー的なものをハンドリングするイメージです。

  • IRequestCycleListener#onException() では ReuqestCycle の情報が取得できる。画面の状態に依る共通のハンドリング処理を実装する。

大規模なアプリケーションだとどのポイントでも実装が必要なケースが出てきそうに思います。
一箇所にまとまっていて欲しい気もしますが、それだけフレームワークの歴史が長いってことだと理解しました。

以上です。

コメント