読者です 読者をやめる 読者になる 読者になる

UZABASE Tech Blog

株式会社ユーザベースの技術チームブログです。 主に週次の持ち回りLTやセミナー・イベント情報について書きます。

Apache Wicketのイベントシステム徹底活用(実用サンプルもあるよ)

SPEEDAの開発してます矢野です。社外から参画してます。SPEEDAを書いてないときは、最近はClojureを書いています。

SPEEDAではApache Wicketを使っています。バージョンも順調に挙げられているので、Wicket 5から導入されたイベントシステムも、最近はかなり活用しています。イベントシステムについてはドキュメントも少なく活用方法がよくわからない、という話もあるようなので、活用方法を紹介します。

まずは手始めに

Wicketのあらゆるコンポーネント(つまり、Componentのサブクラス)は、イベントの送信者と受信者になることができます。 イベントの送信には、Component#send を使います。

public final <T> void send(IEventSink sink,
                           Broadcast type,
                           T payload)

どのコンポーネントに対してsend()を呼び出すかは、実は重要ではありません。重要なのは、引数のsinkです。イベントは、このsinkに対して送信されます。 そしてpayloadが、送信されるオブジェクトです。「イベント」と呼んで良いかと思いますが、実際にはなんでも、どんなオブジェクトでも送信できます。

そして、イベントを受け取ったsinkを起点にして、typeで指定した方法で、イベントがコンポーネントツリーに伝播します。

  • Broadcast.BREADTH 幅優先
  • Broadcast.DEPTH 深さ優先
  • Broadcast.BUBBLE 遡り
  • Broadcast.EXACT 特定sink限定

詳しい解説はBroadcastクラスのJavadocに書かれているのですが、英語が苦手でも、ちゃんと数値でどの順序にイベントが伝播するか書かれているので、わかりやすいと思います。

Broadcast

一番多く使うのは、コンポーネントをsinkとしてイベントを送ると、自分自身と、そのすべての子コンポーネントに、幅優先でイベントを伝播させるBREADTHでしょう。というか、ほとんどこれしか使わないように思います。特別に深さ優先が必要だったり(DEPTH)、そもそも伝播して欲しくなかったり(EXACT)するシーンだけ変えるくらい。

さて、イベントが伝播する途中にいるコンポーネントはすべて、伝播していくイベントを補足できます。そのためには、Componentの onEvent をオーバーライドします。

public void onEvent(IEvent<?> event)

IEvent#getPayload()を使って、sendに渡したpayloadオブジェクトを取得できます。あとは、このpayloadが、処理したいオブジェクトかどうかチェックして、そうであれば処理すればいいし、そうでなければ単に捨てればいいのです。いずれにせよ、イベントは次のコンポーネントに伝播します。

問題

ところが、このonEventが実にダサい。payloadオブジェクトの種類によって地道にチェックするしかないので、結局、次のようなコードになってしまいます。

@Override
public void onEvent(IEvent<?> event) {
    Object payload = event.getPayload();
    if (payload instanceof MyEvent) {
        MyEvent myEvent = ((MyEvent) payload);
        // なにかmyEventを使ったすごいCoolな処理
    }
}

instanceOfでクラスチェックして、キャストして…ダサさのオンパレードで大変です。でもWicketのデフォルトのイベント補足システムは、このonEventしかありません!

ところで、イベントの送信側も受信側も自分で書いたプログラムであれば、他からイベントが来ることもないし、どのイベントpayloadかも把握できているわけですから、チェックせずにすぐにキャストしても良さそうに思えます。この戦術はうまくいきません。WicketのイベントシステムはWicketフレームワーク内部でも使われていて、各コンポーネントは、フレームワークが送信するイベントを受け取っています。それらと区別するためにも、必ずpayloadの種類をチェックする必要があります。

ならば書けばよいのだ

さすがにいろんなところに、こんなinstanceofとか書いてられません。 幸い、Wicketにはイベント補足システムを拡張する仕組みがあります。IEventDispatcherというインタフェースがその役目を担います。このインタフェースには、次のようなメソッドが一つだけ定義されています。

void dispatchEvent(Object sink,
                   IEvent<?> event,
                   Component component)

次のように、IEventDispatcherを実装したインスタンスをApplicationのFrameworkSettingsに追加することで、イベントがコンポーネントに届いた時に、どのようにして、どんなメソッドを呼び出すのかを細く制御できます。

getFrameworkSettings().add(new MyGreatEventDispatcher());

SPEEDAでは、この機能を利用して、次のようなイベント補足メソッドをサポートしています。

  • アノテーションを指定することで、どんな名前のメソッドであれイベント補足メソッドになれる
  • イベント補足メソッドは一つのクラスにいくつでも書くことができる
  • イベント補足メソッドはprivateメソッドでも良い
  • イベントディスパッチャは、イベント補足メソッドの引数の型を見て、送信されたpayloadの型が引数と合致するときに、そのメソッドを呼び出す
  • イベントディスパッチャは、どのクラスがどのようなイベント補足メソッドを持っているか、記憶する

この独自のIEventDispatcher実装によって、SPEEDAでは、次のようにイベント補足メソッドを気軽に設定できています。

@EventHandler
public void onConditionChanged(CriteriaChanged event) {
    AjaxRequestTarget target = event.getTarget();
    target.appendJavaScript("showOverlay()");
}

@EventHandlerアノテーションをつけることで、イベント補足メソッドを作ることができます。上記のメソッドは、イベントpayloadがCriteriaChangedだったときに呼び出されます。もう、ダサいinstanceofやキャストとはおさらばです。そのようなチェックはイベントディスパッチャが肩代わりして、すでに解決済みキャスト済みのオブジェクトが引数に渡されるのです。

配布してます

このイベントディスパッチャは、私が個人で作って使って公開していたものを、少々改造して使い回しているもので、Apache Licenseでもともと公開されていました。今回、新たにgistに公開したので、欲しい方はご自由に活用ください。ファイルは二つだけです。

コンポーネントの疎結合とクラス定義のコツ

Wicketはもともと、HTTPの上でありながら、仮想的なオブジェクト指向環境を作り出すことで、デスクトップアプリケーションのようにウェブアプリケーションを開発できるのがウリです。独特のコンポーネントのパッケージ技術により、コンポーネントはCSSやJavaScriptとともに「ひとまとまりとして」配布できました。

ただ、コンポーネントに発生する事象(イベント)をコンポーネントユーザに伝える手段にかけていて、習慣的には、onSelected といった、頭にonをつけたabstractメソッドを定義し、ユーザにオーバーライドしてもらうことで、ユーザに制御を移していました。いわば、ここだけは他のクラスと密結合しているのです。

Wicket 5によってイベントシステムが導入されて以降、実は、コンポーネントは「クラス」「HTML」「CSS」「Javascript」に加え「イベントクラス」とパッケージ化して配布することができるようになりました!例えばダイアログを閉じた時、ユーザが項目を選んだ時などなど、そのための(オーバーライド可能)メソッドを書く代わりに、専用のpayloadを送信すればいいのです。何をしたらどのようなpayloadが送信されるかをクラスの冒頭のJavadocコメント部に書いておけば、コンポーネントは外部と独立して存在することができます。つまり、疎結合です。コンポーネント利用側は、イベントハンドラを書くことで、コンポーネントの更新を受け取ることができます。

このような関係を実現するためには、ちょっとしたコツがあります。ノウハウあるいは定石のようなものです。

コンポーネントは外部からsinkを指定できるべき

コンポーネント内部で何か起こりイベントpayloadを送信するとき、どのsinkに送信するかが問題になります。これは、コンポーネント利用側が指定できるのが理想的です。コンストラクタ引数としてsinkを受け取れるのがよいと思いますが、setterメソッドでもよいでしょう。

sinkが指定されてない場合はページをsinkとする

sinkを指定しないのもユーザの自由です。しかしsend()の呼び出しを行うにはsinkが必要です。この場合、コンポーネントの所属するページ(getPage()で取得できます)に対して、Broadcast.BREADTHでpayloadを送信するのが良いでしょう。こうすれば、ページ上の全コンポーネントに対してpayloadが伝播します。 もちろん、sinkを指定すれば伝播の範囲が狭まるので、ページ全体に伝播させるよりパフォーマンス的に有利です。どちらを選ぶかはユーザに選ばせるべきです。

コンポーネントがどのようなpayloadをいつ送信するかはドキュメント化すべき

payloadの種類と送信タイミングは、各メソッドに書くより、クラスのJavadocコメント部にまとめておく方が、見やすいですし更新も楽です。

これらのプラクティスに沿えば、コンポーネントを疎結合にすることができます。onClickのようなメソッドを定義するのではなく、イベントpayloadの送信を行うことの利点は、受け取る側は「どこでも受け取れる」ということです。たとえばページ上の、4段くらい重なったところにあるパネルにあるボタンを押すと、ページのすぐ上に乗っているコンポーネントが合わせて変化する、という場合、onXXXXといったabstractメソッドでは、どうやってページのすぐ上にあるコンポーネントまで伝えるかわかりません。イベントなら簡単です。ページにイベントハンドラとなるメソッドを書けば済むのですから。

ただし、このようにイベントハンドラを多用すると、ある操作をした時にどこのメソッドが呼び出されるのかわかりにくくなる欠点があります。その面でも、この記事で紹介したAnnotationEventDispatcherを使うのが有利です。引数となるクラスは各イベント独自のものですから、あとは、IDEでそのイベントを使っているメソッドを探せばいいのです。

Wicketのイベントシステムは日本語の情報がほぼないので、もしかしたら活用してない人が多いかもしれません。しかし、Wicket 5以上を使っているならば、この仕組みを使わない手はありません。活用してください。