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>
参考
-
subject:”Bes practice for deployment to avoid ClassNotFoundException”
java.io.InvalidClassException
に関するものではないですが、Class名変更した際に、ClassNotFoundException
が発生する話 -
Wicket: Best practice for deployment to avoid ClassNotFoundException - Stack Overflow
こちらも上記と同じです。 -
難解なSerializableという仕様について俺が知っていること、というか俺の理解 - 都元ダイスケ IT-PRESS
Serializable の動作について -
SerializableとserialVersionUID - 都元ダイスケ IT-PRESS
Serializable の動作についての続き -
Javaとシリアライズと互換性 - CLOVER Java 6 以降でのシリアライズについて
エラー発生の原理
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 url = ((WebRequest) RequestCycle.get().getRequest()).getUrl(); String fullUrl = RequestCycle.get().getUrlRenderer().renderFullUrl(url);
-
補足3
super.createPageRequestHandler
と、new RenderPageRequestHandler()
でPageRequestHandlerを作成した際の、挙動の違いについてです。
メソッドでページを返すと、アクセスしているURLは変わらずに、Renderingするページだけが変わります。return super.createPageRequestHandler(new PageProvider(SessionErrorPage.class));
メソッドでページを返すと、アクセスしているURLも変わり、Renderingするページも変わります。return new RenderPageRequestHandler(new PageProvider(SessionErrorPage.class));
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を参照する必要があったり、若干危ないかもしれません。
以上です。
コメント