Wicket java.lang.RuntimeException: Could not deserialize object from byte[] エラーについて考える


Wicket の Page クラスを変更して、再デプロイすると、
以下、java.lang.RuntimeException が発生することがあります。1
このjava.lang.RuntimeException が発生すると、Innternal Server Error 扱いでハンドリングされてしまい、ユーザビリティがユーザビリティがよくありません。
このエラーをなんとかできないか考えてみました。 2

[1]serialVersionUID を Pageクラスに付与せずに、Pageクラス変更すると、100%の確率で発生します。
これは使用しているVMに依るところもあるかもしれません。

[2]ユーザビリティが良くないとは、発生原因がだいたい特定できているので、それなりのメッセージ通知なりしてあげたいと思っていて、できていないことを「ユーザビリティがよくない」と言っています。

  • 発生エラーの抜粋
    java.lang.RuntimeException: Could not deserialize object from byte[]
        at org.apache.wicket.serialize.java.JavaSerializer.deserialize(JavaSerializer.java:143)
        at xyz.monotalk.festivals4partypeople.web.wicket.protocol.http.Festivals4PartyPeopleWicketApplication$4.deserialize(Festivals4PartyPeopleWicketApplication.java:248)
        at org.apache.wicket.pageStore.AbstractPageStore.deserializePage(AbstractPageStore.java:152)
        at org.apache.wicket.pageStore.AbstractCachingPageStore.getPage(AbstractCachingPageStore.java:67)
        at org.apache.wicket.page.PageStoreManager$SessionEntry.getPage(PageStoreManager.java:211)
        at org.apache.wicket.page.PageStoreManager$PersistentRequestAdapter.getPage(PageStoreManager.java:367)
        at org.apache.wicket.page.AbstractPageManager.getPage(AbstractPageManager.java:82)
        at org.apache.wicket.page.PageManagerDecorator.getPage(PageManagerDecorator.java:50)
        at org.apache.wicket.page.PageAccessSynchronizer$2.getPage(PageAccessSynchronizer.java:246)
        at org.apache.wicket.DefaultMapperContext.getPageInstance(DefaultMapperContext.java:113)
        at org.apache.wicket.core.request.handler.PageProvider.getStoredPage(PageProvider.java:299)
        at org.apache.wicket.core.request.handler.PageProvider.isNewPageInstance(PageProvider.java:211)
        at org.apache.wicket.core.request.mapper.AbstractBookmarkableMapper.checkExpiration(AbstractBookmarkableMapper.java:335)
        at org.apache.wicket.core.request.mapper.AbstractBookmarkableMapper.processHybrid(AbstractBookmarkableMapper.java:258)
        at org.apache.wicket.core.request.mapper.AbstractBookmarkableMapper.mapRequest(AbstractBookmarkableMapper.java:364)
        at org.apache.wicket.request.mapper.CompoundRequestMapper.mapRequest(CompoundRequestMapper.java:147)
        at org.apache.wicket.request.cycle.RequestCycle.resolveRequestHandler(RequestCycle.java:189)
        at org.apache.wicket.request.cycle.RequestCycle.processRequest(RequestCycle.java:219)
        at org.apache.wicket.request.cycle.RequestCycle.processRequestAndDetach(RequestCycle.java:293)
        at org.apache.wicket.protocol.http.WicketFilter.processRequestCycle(WicketFilter.java:261)
        at org.apache.wicket.protocol.http.WicketFilter.processRequest(WicketFilter.java:203)
        at org.apache.wicket.protocol.http.WicketFilter.doFilter(WicketFilter.java:284)
    .......中略
    Caused by: java.io.InvalidClassException: xyz.monotalk.festivals4partypeople.web.ui.pages.festival.search.FestivalsSearchPage; local class incompatible: stream classdesc serialVersionUID = 7766378564606176148, local class serialVersionUID = 7766378564606176111
        at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:621)
        at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1623)
        at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1518)
        at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1774)
        at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1351)
        at java.io.ObjectInputStream.readObject(ObjectInputStream.java:371)
        at org.apache.wicket.serialize.java.JavaSerializer.deserialize(JavaSerializer.java:126)
        ... 59 more
    

前提

  • wicket の version
    7.7.0 です。
<!-- WICKET DEPENDENCIES -->
<dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-core</artifactId>
    <version>7.7.0</version>
</dependency>

参考


エラー発生の原理

DiskDataStore を使用している前提で、
デプロイ前、デプロイ、デプロイ後での状況を記載します。
あくまで私の理解で、もしかすると間違っているかもしれません。

1.デプロイ前

  • ユーザがページにアクセス。ページ情報 が Disk に書き出される

2.デプロイ

  • アプリケーションが停止. 書き出されていたページ情報 は Disk に残る

3.デプロイ後

  • アプリケーションが起動.

  • ユーザーがアプリケーションにアクセス。スティッキーセッション であれば、 jessionid に基づいて同一サーバにアクセス。

  • Wicket が Disk から ページ情報の復旧を試みるが、クラスの互換性がない(serialVersionUIDが変わっているため)、
    java.lang.RuntimeException が発生する。


StackOverFlow の記事を読んだ限りの理解

方針レベルでの案

回避方法 として、というのが挙げられおり、
ユースケースによって、対処方法は変えることを検討したほうがいいようです。

  • jsessionid を 解放して、古い Version のクラスにアクセスできないようにする。

  • セッション内に新しいページインスタンスを作成する (ブラウザのURLにある?0、?1231等のversionパラメータを削除する)

  • サーバ 上のすべてのセッションデータを消去する

具体的な回避実装

具体的な回避実装として以下が挙げられています。

  • BookmarkablePageLink を使用してページ間を移動する。3

  • DelegatingJavaSerializer を作成し、InvalidClassException 発生時は、Nullを返して、Classを再生成する。

[3] BookmarkablePageLink を使用すると、発生確率が下がるので回避策となります。下がる理由は、Link - 萌えキャラですね、わかります の通り、BookmarkablePageLink ではセッションがなくても有効な URL を生成するためです。


DelegatingJavaSerializer を作成する

このDelegatingJavaSerializer を作ったらどうなるかということで、実際に作成してみました。
InvalidClassException の場合、Null を返すだと、インスタンスの再生成により予期しない問題が起こりそうに思いましたので、以下の通りの実装としました。

  • 1. DelegatingJavaSerializer では、InvalidClassException の場合は、 InvalidClassRuntimeException をスロー。

  • 2. DefaultExceptionMapper の 拡張クラスを作成して、InvalidClassRuntimeException の場合は、専用エラーページへ遷移。4

  • 3. 専用エラーページでは、jsessionId の削除と、ページ状態がおかしいので、このページにきた旨の通知する。

[4]追記:RequestCycleListener#onException()でハンドリングしたほうが、RequestCycleへのアクセスがし易いため、このケースの場合融通が効きます。
RequestCycleListener#onException() で実装を行うサンプルを追記しました。

作成したクラスは以下になります。

DelegatingJavaSerializer.java

package xyz.monotalk.festivals4partypeople.web.wicket.serialize.java;

import lombok.extern.log4j.Log4j2;
import org.apache.wicket.serialize.java.JavaSerializer;

import java.io.InvalidClassException;

/**
 * Created by kem
 */
@Log4j2
public class DelegatingJavaSerializer extends JavaSerializer {

    /**
     * Construct..
     *
     * @param applicationKey
     */
    public DelegatingJavaSerializer(String applicationKey) {
        super(applicationKey);
    }

    /**
     * deserialize
     *
     * @param data
     * @return
     */
    public Object deserialize(byte[] data) {
        Object object;
        try {
            object = super.deserialize(data);
        } catch (RuntimeException e) {
            Throwable t = e.getCause();
            if (t instanceof InvalidClassException) {
                log.warn("Throw InvalidClassRuntimeException as InvalidClassException occurred. ", e);
                throw new InvalidClassRuntimeException(e);
            }
            throw e;
        }
        return object;
    }

    /**
     * InvalidClassRuntimeException
     */
    public static class InvalidClassRuntimeException extends RuntimeException {
        public InvalidClassRuntimeException(Throwable t) {
            super(t);
        }
    }
}
  • 補足
    RuntimeException 発生時に原因例外を取得し、原因例外が InvalidClassException の場合は、自前のInvalidClassRuntimeException を返すようにしました。
    ClassNotFoundException も出力される可能性もあるのですが、ClassNotFoundException は、 クラス名を変更した際などに送出される例外です。
    あまり発生確率は高くない気がしたので、InvalidClassException のみ捕捉するようにしました。

ApplicationExceptionMapper.java

DefaultExceptionMapper の拡張クラスになります。

/*
 * Copyright 2016 Kem.
 */
package xyz.monotalk.festivals4partypeople.web.wicket.protocol.http;

import lombok.extern.log4j.Log4j2;
import org.apache.wicket.Application;
import org.apache.wicket.DefaultExceptionMapper;
import org.apache.wicket.core.request.handler.PageProvider;
import org.apache.wicket.request.IRequestHandler;
import xyz.monotalk.festivals4partypeople.web.ui.pages.error.session.session.SessionErrorPage;
import xyz.monotalk.festivals4partypeople.web.wicket.serialize.java.DelegatingJavaSerializer;

/**
 * ApplicationExceptionMapper
 *
 * @author Kem
 */
@Log4j2
public class ApplicationExceptionMapper extends DefaultExceptionMapper {

    @Override
    public IRequestHandler map(Exception e) {
        return super.map(e);
    }

    @Override
    protected IRequestHandler mapExpectedExceptions(Exception e, Application application) {
        if (e instanceof DelegatingJavaSerializer.InvalidClassRuntimeException) {
//            Page page = super.extractCurrentPage();
//            System.out.println("------------------------------------");
//            System.out.println(page);
//            System.out.println("------------------------------------");
//            Url url = ((WebRequest) RequestCycle.get().getRequest()).getUrl();
//            String fullUrl = RequestCycle.get().getUrlRenderer().renderFullUrl(url);
//            System.out.println("------------------------------------");
//            System.out.println(fullUrl);
//            System.out.println("------------------------------------");
//            MapperUtils.getPageComponentInfo(url);
            return super.createPageRequestHandler(new PageProvider(SessionErrorPage.class));
            // return new RenderPageRequestHandler(new PageProvider(SessionErrorPage.class));
        }
        return super.mapExpectedExceptions(e, application);
    }
}

  • 補足1
    super.extractCurrentPage();でアクセスしているページオブジェクトが取得できるので、 ページオブジェクト取得して、特定ページの場合は、そのページインスタンスを改めて生成して返すことができるかと思いました。
    しかし、InvalidClassExceptionが発生するケースで、アクセスしているページオブジェクトは Null になっていました。

  • 補足2
    コメントしている以下のの実装で、アクセスしているURLが取得できます。

    Url url = ((WebRequest) RequestCycle.get().getRequest()).getUrl();
    String fullUrl = RequestCycle.get().getUrlRenderer().renderFullUrl(url);
    
    このURLを元にアクセスしたページを特定して、復旧可能なページであれば復旧処理を行うのも方針としては有りかもしれません。

  • 補足3
    super.createPageRequestHandler と、new RenderPageRequestHandler() でPageRequestHandlerを作成した際の、挙動の違いについてです。

    return super.createPageRequestHandler(new PageProvider(SessionErrorPage.class));
    
    メソッドでページを返すと、アクセスしているURLは変わらずに、Renderingするページだけが変わります。
    return new RenderPageRequestHandler(new PageProvider(SessionErrorPage.class));
    
    メソッドでページを返すと、アクセスしているURLも変わり、Renderingするページも変わります。

RequestCycleListener#onException() でハンドリングする

AbstractRequestCycleListenerの継承クラスを作成し、Application クラスで、add することでも、ハンドリングが可能です。
このケースだと、RequestCycle がパラメータで取得できますので、引数として、exception しかない DefaultExceptionMapper よりも融通のきくハンドリングができそうです。

    // Add RequestCycleListener
    getRequestCycleListeners().add(new AbstractRequestCycleListener() {
        @Override
        public IRequestHandler onException(RequestCycle cycle, Exception ex) {
            // Log
            log.info("============================================================");
            log.info("Raise Exception", ex);
            log.info("============================================================");
            if (ex instanceof DelegatingJavaSerializer.InvalidClassRuntimeException) {
                return new RenderPageRequestHandler(new PageProvider(SessionErrorPage.class));
            }
            return super.onException(cycle, ex);
        }
    });

挙動については、過去にまとめています。以下をご確認ください。
Wicket 例外ハンドリング(Exception handling)についてのいろいろ | Monotalk

SessoinErrorPage.java

package xyz.monotalk.festivals4partypeople.web.ui.pages.error.session.session;

import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.link.ExternalLink;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import xyz.monotalk.festivals4partypeople.web.ui.pages.base.BasePage;

/**
 * SessionErrorPage
 */
public class SessionErrorPage extends BasePage {

    @Override
    protected void initPage(PageParameters parameters) {

        // Add ErrorMessage
        add(new Label("errorMessage", "Invalid Session"));
        // Add Top page Link
        BookmarkablePageLink link = new BookmarkablePageLink<>("topLink", getApplication().getHomePage());
        link.add(new Label("topLinkText", "Top Page").setRenderBodyOnly(true));
        add(link);

        // Add Previous page Link
        ExternalLink previousLink = new ExternalLink("previousLink", "../", "Previous Page");
        add(previousLink);
        // ReplaceSession
        getSession().replaceSession();
    }
}
  • 補足1
    getSession().replaceSession(); で、セッションの破棄、再生成を行なってます。5
    [5]実装みた限りそのような動作となります。実際そうなるか検証ができてませんが。。

  • 補足2
    ログインした認証情報が、jsessionid に紐づいていると、強制ログアウトしていることになるので、そのケースだと上手くいかないです。
    jseesionid ではない値に紐付けして、認証情報管理していれば、上記の実装でうまくいくと思います。


まとめ

シリアライズエラーが原因で、java.lang.RuntimeExceptionが発生した場合の、エラーハンドリングのための仮実装を行いました。
現状でまだ実装足りなそうに思うところだと以下があります。

  • URLにjsessionid付与されている場合は、除去する。
    ブラウザのcookieがoffの場合、jsessionidは、URLに付与されます。
    session の replace 時にそのあたりの考慮をしてくれていれば、現状実装でも問題がないと思います。

  • 元の画面に戻れるようにする。
    これが実現できると、よりユーザービリティはいいかと思いますが、refferを参照する必要があったり、若干危ないかもしれません。

以上です。

コメント