目次へ戻る

Javaウェブフレームワーク「Wicket」の使い方

RSS Readerを作る その2 - フィードの取得と表示

矢野 勉

このエントリーを含むはてなブックマーク

目次

ROMEでフィードを取得するクラスを作る

「RSSReaderを作る その1」にてURLの入力まではできましたね。これから先はフィードを取得しないとどうしようもない事柄ばかりですので、まずはWicketから離れて、RSSフィードを取得しましょう。

フィードの取得にはjava.netで開発されているライブラリ「ROME」を使用します。RSSの数バージョンとATOMをサポートする上に、どのプロトコルだろうと同じインターフェースから使えるように工夫されたライブラリです。

ROMEは一応「Rss and atOM utilitiEs for java」ということにされていますが、もちろんコジツケでしょうね(^^) このライブラリの目指すところは ROMEのページにさりげなくかかれています。「..ending syndication feed confusion by supporting all of 'em. (RSSフィードの混乱を、全てをサポートすることで終わらせる)」。その目的のため、ROMEがサポートするフィード・プロトコルは「RSS 0.90, RSS 0.91 Netscape, RSS 0.91 Userland, RSS 0.92, RSS 0.93, RSS 0.94, RSS 1.0, RSS 2.0, Atom 0.3, Atom 1.0」と大量です。ほぼ全てといっていいでしょう。そしてそれら全てを共通のJava Interfaceを介してアクセスできるのです。

まずはROMEをダウンロードしましょう。ROMEのjarだけでなく、いくつかの依存ライブラリとROME用の拡張ライブラリが必要となります。

ちょっと多いですが、今回は利便性を考えてROME Fetcherという拡張ライブラリも使います。ROMEの本来の依存jarはjdomだけです。ROME FetcherはROMEのHTTP接続方式を選べるようにするためのライブラリで、ROME本体だけですとURLConnectionを介した接続しかできません。ROME Fetcherを使うとCommons HttpClientなどのライブラリを使って接続できるようになります。

ROME(+ROME Fetcher)の使い方は非常に簡単です(例ではHttpClientのFetcherを使っています)。

FeedFetcher fetcher = new HttpClientFeedFetcher();
SyndFeed feed = fetcher.retrieveFeed( new URL( url));

これだけです。import文は省略しているものの、わずか二行ですから簡単でしょう。これで指定したURLからSyndFeedオブジェクトを作成します。相手側の配信プロトコルかが何かに関わらず、すべて「SyndFeed」というインターフェースを介して操作できます。

フィードからエンティティ一覧を取得するのも簡単です。

List<SyndEntry> entries = feed.getEntries();

これだけです。getEntries()メソッドを呼べば、SyndEntryオブジェクトのListが返されるわけです。これもまたプロトコルに関係なくSyndEntryインターフェースで全てのエントリ情報を扱います。ちなみに上記の例ではList<SyndEntry>と型パラメータを使っていますが、ROME自体はまだJava 5のgenericsに対応していません。ですので上記のコードはワーニングを出力しますが、getEntries()List<SyndEntry>を返すことは間違いないので、ここは型パラメータの力を使うことを優先しました。

ちなみにROMEがSyndEntryという一つのインターフェースで配信プロトコルを網羅しようとしているからといって、元のプロトコルにない情報はさすがに取得できませんのでご注意下さい。

ROMEはもうこれだけで十分使えるくらいシンプルなライブラリです。ROMEに含まれるJavaDocでSyndEntryインターフェースのgetterメソッドをざっと見れば、どのような情報を取得できるかは一目瞭然でしょう。とりあえずは下記のプロパティさえ押さえておけば十分でしょう

getLink()
そのエントリをブラウザで表示する際のURLです。Permalinkですね。
getTitle()
もちろん、エントリのタイトルです。
getUpdatedDate()
エントリの更新日(投稿日とはちょっと違いますよ。更新した日です)
getPublishedDate()
こちらが投稿日です。
getDescription()
エントリの要約です。といってもこれは文字列ではなくSyndContentというインターフェース型のオブジェクトを返します。実際の文字列情報はさらに「getValue()」を呼ぶことで取得できます。getDescription().getValue()で要約文字列を取得できるわけです。

フィードを取得するためのクラスを作る

さて今回のRSSReaderサンプルにROMEの機能を組み込みましょう。今回のサンプルではFeedManagerというクラスを作ってフィードの取得を一元管理します。イメージとしては次のような感じです。

FeedManager manager = なんらかの方法でFeedManagerを作成
manager.addFeedUrl(url);    //マネージャに対象URLをすべて登録
manager.addFeedUrl(url2);
manager.addFeedUrl(url3);
Set<SyndFeed> feedSet = manager.getFeedSet();
for( SyndFeed feed : feedSet) {
    List<SyndEntry> entries = feed.getEntries();
    for( SyndEntry entry : entries) {
        //エントリに対するなんらかの処理
    }
}

こんなことが出来るようにしましょう。

まずFeedManagerはフィードのurl文字列を内部に保持できる必要があります。addFeedUrl()で追加されるURL文字列を保持するためです。また取得済みSyndFeedも保持する必要があるでしょう。毎回URLからSyndFeedを作る意味はあまりありませんから、SyndFeedを取得できたらURLの方は破棄し、代わりにSyndFeedを保持するのがいいでしょうね。

おまけとして、フィードを取りに行ったらネットワーク障害とかで取得できなかった場合、すべてのフィードが表示できないのではなく、取得できたフィードだけ表示するようにしておきましょう。フィード取得に失敗したら例外を投げるのではなくエラーになったURLを保持して後で確認できるようにしておきます。

もちろんFeedManagerは内部にROMEのFeedFetcherオブジェクトを保持しておく必要があります。

これらの機能を実装したのが、下記のコードです。

package rssreader.feed;

import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.fetcher.FeedFetcher;
import com.sun.syndication.fetcher.FetcherException;
import com.sun.syndication.fetcher.impl.HttpClientFeedFetcher;
import com.sun.syndication.io.FeedException;
import java.io.IOException;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.LinkedHashSet;
import java.util.Set;
import org.apache.log4j.Logger;

/**
 *
 * @author ben
 */
public class FeedManager implements Serializable {
    Set<SyndFeed> feedSet = new LinkedHashSet<SyndFeed>();
    Set<String> errorSet = new LinkedHashSet<String>(0);
    Set<String> urlSet = new LinkedHashSet<String>();
    boolean updated = false;
    
    transient FeedFetcher fetcher;
    
    /** Creates a new instance of FeedManager */
    public FeedManager() {
        super();
    }
    
    public synchronized Set<SyndFeed> getFeedSet() {
        if( this.updated) {
            this.errorSet = new LinkedHashSet<String>(0);
            Logger log = Logger.getLogger( this.getClass());
            for( String url : urlSet) {
                try {
                    if( url != null && url.startsWith("http://")) {
                        SyndFeed feed = getFeedFetcher().retrieveFeed( new URL( url));
                        this.feedSet.add( feed);
                    }
                } catch (MalformedURLException ex) {
                    log.error( "Can not retrieve from a feed.", ex);
                    errorSet.add( url);
                } catch (FeedException ex) {
                    log.error( "Can not retrieve from a feed.", ex);
                    errorSet.add( url);
                } catch (IOException ex) {
                    log.error( "Can not retrieve from a feed.", ex);
                    errorSet.add( url);
                } catch (FetcherException ex) {
                    log.error( "Can not retrieve from a feed.", ex);
                    errorSet.add( url);
                }
            }
            
            this.urlSet = new LinkedHashSet<String>();
            this.updated = false;
        }
        
        return new LinkedHashSet<SyndFeed>( feedSet);
    }
    
    public synchronized Set<String> getErrorFeedList() {
        return new LinkedHashSet<String>( this.errorSet);
    }
    
    public synchronized FeedManager addFeedUrl( String url) {
        urlSet.add( url);
        this.updated = true;
        return this;
    }

    public synchronized void clearAllFeeds() {
        this.feedSet = new LinkedHashSet<SyndFeed>();
        this.urlSet = new LinkedHashSet<String>();
    }
    
    synchronized FeedFetcher getFeedFetcher() {
        if( fetcher == null) {
            fetcher = new HttpClientFeedFetcher();
        }
        return fetcher;
    }
}

フィードのURLも、エラーとなったURLも、取得済みのSyndFeedオブジェクトも、同じものを複数持つ必要性はありませんので、全てSetオブジェクトに格納します。また、FeedManagerはセッションに格納していつでも使えるようにできるのが理想的なので、Serializableインターフェースをimplementsしておきます。

FeedFetcherオブジェクトの宣言にtransientが付いているのは、FeedFetcherまでセッションに格納したくないからです。もし無ければまた作ればいいだけのものなのですから、後生大事にセッションにまで保管する必要もありません。transientを指定することで、セッションがディスクへ退避される時にはfetcherフィールドは保管されなくなります。

その代わりにfetcherフィールドを直接使うことはできなくなります。フィールドを取り出しても予告もなくnullになっている可能性があるからです。代わりにgetFeedFetcher()メソッドを用意して、そのなかで「もしnullだったら新しく生成する」という処理を行っています。

また、FeedManagerは複数のリクエストから同時にアクセスされる可能性があります。つまりマルチスレッドでも使えるように考えておかなければいけないでしょう。addFeedUrl()は内部フィールドであるurlSetに要素を追加しますが、(いまはまだ実装してませんが)要素を取り出す処理とバッティングしてしまったら問題です。

getFeedFetcher()もfetcherフィールドのチェックとfetcherフィールドの更新を一ヶ所で行っているので、一応考慮の必要がありそうです。

このため、FeedManagerクラスではクラス内の保護すべきフィールド(今回は全て)にアクセスするメソッドは全てsynchronized修飾子をつけて同期化します。synchronized修飾子の付いたメソッドはどれであれ、一度に一つのスレッドしかアクセスできません。同期のし方としては単純でロックによって保護される範囲が広すぎるきらいがありますが、サンプルプログラムとしては十分でしょう。

処理の流れをもう少し追いかけてみましょう。FeedManagerを使う時は、

という流れでSyndFeedオブジェクトのSetを取得できるのでしたね。これを順番に見ていきます。

まずはaddFeedUrl()です。このメソッドは単純にフィールドurlSetに要素を追加します。ただ一点だけ、フィールドupdateをtrueに更新しています。このフラグの用途はgetFeedSet()メソッドを見れば分かりますのでここでは保留としましょう。とりあえずはupdateフィールドはurlSetに新しい要素が追加されたことを表していると覚えておいてください。

ちなみにaddFeedUrl()メソッドは戻り値としてFeedManagerオブジェクトそのものを返します。単純に自身を返すわけですね。これは、下記のように一行にまとめてURLを追加できるようにするためです。

manager.addFeedUrl("http://hoge.com").addFeedUrl("http://sample.co.jp").addFeedUrl("http://javelindev.jp");

もちろん、一行毎に一つの要素を追加する通常のやり方でも使えますが、こちらのやり方が好みの方も多いので、自分自身(this)を返却するようにしています。

続いてgetFeedSet()を見てみましょう。冒頭でいきなりupdateフラグをチェックしています。フィードURLにアクセスしてSyndFeedオブジェクトを作る処理はネットワークアクセスを含むため非常に重い処理です。ですので既にフィードを取得済みでかつ新しいURLも追加されていない場合は、フィードの取得処理を再度行わずに内部にキャッシュしているSyndFeedを返すようになっているのです。

また、FeedFetcherを使う際には前述のgetFeedFetcher()メソッドを必ず呼び出していることに注目してください。fetcherフィールドはtransientのため、直接使用すると予期しないタイミングでnullになっている可能性があります。getFeedFetcher()を毎回呼び出すことで確実にFeedFetcherオブジェクトを取得しているのです。

フィードからSyndFeedを生成する処理ではいくつかの例外が発生し得ますが、ここでは単純にログを出力してエラーとなったURLをerrorSetというエラーURL保管用のSetに格納しているだけです。例外は再スローしないので、ここで消滅します。つまり例外が発生したフィードは無視し、次のフィードの処理に移るわけです。

すべてのフィードを処理し終えたら、フィードURLはもはや必要ありませんので、新しいSetオブジェクト(空のSet)で置き換えます。最後にupdateフラグをfalseにしてネットワークへの再アクセスを抑止し、SyndFeedの詰まったSetを返却します。

Set返却の際には

return new LinkedHashSet<SyndFeed>( feedSet);

のように、内部フィールドであるfeedSetを直接返却するのではなく、同じ要素を持った新しいSetを作成してそちらを返却するようにしています。これは「防御的コピー」と呼ばれるやり方で、オブジェクトの状態を外部から保護するためのイディオム(常套句)です。内部フィールドを外部に返してしまうとそれを介してフィールドの内容を書き換えられてしまうので、フィールド値そのものではなく同じ内容の新しいオブジェクトを返すのです。コピーによる負荷が発生しますが、オブジェクトの内部情報を公開することを避けることの方がはるかに重要です。

あと二つのメソッドが残っています。getErrorFeedList()メソッドが残っています。これはgetFeedSet()メソッド内でエラーになったURLを補完しているerrorSetを返却するメソッドです。外部から参照できるようにpublicメソッドとしています。ここでもerrorSetの防御的コピーを行っている点に注目してください。errorSetもFeedManagerクラスの内部情報ですから、そのままでは公開してはいけません。また、errorSetの複写などの処理中にスレッドが切り替わる可能性がありますので、このメソッドもsynchronizedとしています。

最後の一つは、登録されている全URL情報と取得済みフィード情報をクリアするためのメソッドです。メソッドclearAllFeeds()は内部フィールドfeedSeturlSetを新しい空のSetオブジェクトで置き換えることで、全てのURLとフィードを破棄します(情報はそのうちガーベージ・コレクタが破棄してくれるでしょう)。

これでFeedManagerクラスはとりあえず完成です。このクラスを使えば、簡単にRSSフィードからエントリを取得できます。早速やってみましょう。いよいよ、Wicketのプログラムを修正していきます。

WicketのListViewを使ってエントリを表示する

まずはアプリケーションに期待する動作を整理しておきましょう。

  1. 私たちは既にURLを入力するフィールドを持っています。ここに入力したURLがFeedManagerに登録されます。
  2. 登録したフィードは自動的に画面上に「タイトル」「要約」「日付」を伴って表示されます。
  3. URL入力フォームを使っていくつでもフィードを登録できます。
  4. すべてのエントリは公開日順にソートして表示されます。フィードURLが登録されるたびにソートされ直します。
  5. 「クリア」ボタンを用意します。これを押すとすべてのフィードが破棄され、画面にも表示されなくなります。

ちょっと大変そうでしょうか? いいえ! WicketのListViewコンポーネントで簡単に実現できます。

ListViewコンポーネントは商品リストのような、同じように表示されるけども表示内容だけが異なるものをつづけてリスト上に表示するためのコンポーネントです。同一エレメントを縦に並べて表示するためのものです(横に並べて表示するにはRepeatというWicket-Extentionコンポーネントが便利です)。今回ならば、エントリの「タイトル」「要約」「日付」をひとかたまりとして、取得した全エントリをずらっとリスト上に表示するわけです。

より具体的にいうと、ListViewはHTMLの<ul>dlのようなリストタグを表現しており、ulタグに囲まれた要素を繰り返し表示するためのものです。つまりはliタグそのものですね。

論より証拠。エントリを表示するためのHTMLタグを見てみましょう。

    <dl wicket:id="entryList">
        <dt>●<a href="http://test.com" wicket:id="entryLink">これはテストです。</a> <span wicket:id="publishDate">2006/8/30</span></dt>
        <dd><span wicket:id="entryDesc">記事のdescriptionです。</span></dd>
    </dl>

単純なdlタグですね。ただしもちろんwicket:idが付けられています。dlタグ全体はentryListという名前です。リストの各項目は、タイトル部分がエントリへのリンクになっていて、公開日付とともに表示されます。項目の説明文部分(ddタグの部分)にはエントリの要略が表示されます。この部分はEntry Descriptionという意味でentryDescというwicket:idを付けました。

もちろんwicket:idが付いている以外は単純なHTMLですから、ブラウザで表示してもきちんと表示されます。このHTMLにWicketコンポーネントを埋め込んでいきます。

/**
 * create an area to display RSS entries.
 */
void createListView() {
    final IModel entryListModel = new LoadableDetachableModel() {
        protected Object load() {
            return getEntryList();
        }
    };
    
    final ListView listView = new ListView( "entryList", entryListModel) {
        protected void populateItem(ListItem item) {
            final SyndEntry entry = (SyndEntry)item.getModelObject();
            item.add( new ExternalLink( "entryLink", entry.getLink(), entry.getTitle()));
            Date updateDate = entry.getUpdatedDate();
            if( updateDate == null) updateDate = entry.getPublishedDate();
            item.add( new Label( "publishDate", new SimpleDateFormat( "yyyy-MM-dd").format( updateDate)));
            item.add( new Label( "entryDesc", entry.getDescription().getValue()));
        }
    };
    add( listView);
}  

このプログラムの要になる部分です。Wicketのセッション管理とモデルとの関係や、ListViewオブジェクトとモデルの関係などなかなか面白い要素を備えている部分ですので、注目して見ていきましょう。

まずは最初のentryListModelという変数の宣言部分です。entryListModelはリストが表示する要素、つまりは各行を表すモデルの「集合体」です。集合体というと難しいですね。プログラム的に言うと、このモデルのgetObject()を呼び出すと、リストが表示すべき全項目を含んだListオブジェクトを返すようなモデルです。ListViewコンポーネントはそのモデルとして「getObject()を呼んだらListが帰ってくるようなモデル」を要求します。

「だったらListをそのまま渡せばいいじゃない」と思いますよね。もちろんListそのものをListViewに渡すことも可能です。でも、ここでWicketの重要な側面に触れておかないといけません。

Wicketはオブジェクト指向のウェブ・フレームワークです。ページも全てオブジェクトです。そしてオブジェクトには「状態」があります。不変オブジェクトでない限り、オブジェクトの状態は変化しますし、変化は保持されないといけません。Wicketはページの状態を保持するため、WebPageオブジェクトの全てのフィールド情報をHttpSessionに格納するのです。WebPageにはさまざまなWicket Componentが張り付いていて、各Componentがモデルを保持しています。それらすべてがセッションに格納されます。

もちろん全てのフィールドを単純にセッションに格納すると、莫大なメモリが消費されてしまう可能性があります。WicketのComponent自体は対したサイズではありませんが、Componentの保持するモデルはもしかしたらかなりの量のオブジェクトへのリンクを保持しているかもしれません。

モデルをセッションに保持すると莫大なメモリを消費する可能性があるということです。ですからWicketはモデルをなるべくセッションに保持しないようにします。そのための機構がIDetachableインターフェースです。

IDetachableインターフェースを実装したすべてのモデルは、レスポンスを返した時に「デタッチ(detach)」されます。detachで何をするかはモデル次第ですが、少なくともリクエスト・レスポンスのサイクルの最後に必ずモデルの「detach()」が呼ばれるのです。

たとえばリクエストが来るたびにDBにアクセスして情報を取得する必要があるなら、モデル内に情報を保持しておく必要は全くありません。毎回モデルを生成すればいいのですから。なら必要になった時に初めてDBにアクセスして、detach()ですべての情報を破棄すればいいのです。

Wicketにはそのような用途のためのクラスとしてLoadableDetachableModelが用意されています。このクラスではモデルのgetObject()が呼ばれたタイミングでload()というメソッドが自動的に呼び出されます。load()はモデル・オブジェクトを返すシンプルなメソッドで、プログラマがオーバーライドして定義します。load()が呼ばれたときにDBにアクセスしてモデル・オブジェクトを返せばよいのです。LoadableDetachableModelはそのオブジェクトをモデル内に一時的に保持し、以後のgetObject()ではこれを返すようになります。そしてデタッチのとき(レスポンスを返す時)にこの一時的な情報は破棄されます(モデル・オブジェクトがnullになります)。表示のみを行いたい場合、モデルはReadOnlyで問題なく、モデルの値もページを生成するときのみ存在すれば構いません。LoadableDetachableModelはそのようなリードオンリーでDetachableなモデルの代表例です(実際、LoadableDetachableModelはAbstractReadOnlyModelのサブクラスです)。値を保持する必要のないモデルが必要になった時は使用を検討してみてください。

前述のコードでは、entryListModelという変数に無名内部クラス記法を使ってLoadableDetachableModelのサブクラスのインスタンスを代入しています。このサブクラスはもちろんload()をオーバーライドしていて、その中ではgetEntryList()というメソッドでSyndEntryオブジェクトのListを返却しています。以後これがモデル・オブジェクトになるわけです。

破棄については気にする必要がありません。前述の通りLoadableDetachableModelはモデルのデタッチのタイミングでモデル・オブジェクトを自動的に破棄するからです。

ここではgetEntryListの詳細には触れないで先に進みましょう。モデルを用意した後、次にlistViewという変数を初期化しています。これがHTMLのliタグの繰り返しを表現するListViewオブジェクトです。モデルとして「getObject()を呼んだらListを返すモデル」を必要とします。これは先ほど用意したentryListModelを使います。

ListViewコンポーネントはモデル・オブジェクトであるListの一件一件に対してprotected void populateItem(ListItem item)というメソッドを呼び出します。リストの全要素について必ずpopulateItem()が呼び出されるわけです。ですからこのメソッドをオーバーライドして、liタグに対応するコンポーネントを生成してやればよいのです。

populateItemの引数ListItemオブジェクトこそが、一つ一つのliタグを表すコンポーネントです。populateItemが呼ばれたる毎にListItemのモデルにはListViewに渡されたリストの各要素がセットされています。ListItemgetModelObject()を呼べばその要素自体を取得できます。

populateItemの中身をもう少し細かく見てみましょう。

    final SyndEntry entry = (SyndEntry)item.getModelObject();
    item.add( new ExternalLink( "entryLink", entry.getLink(), entry.getTitle()));
    Date updateDate = entry.getUpdatedDate();
    if( updateDate == null) updateDate = entry.getPublishedDate();
    item.add( new Label( "publishDate", new SimpleDateFormat( "yyyy-MM-dd").format( updateDate)));
    item.add( new Label( "entryDesc", entry.getDescription().getValue()));

先ほど書いたように、ListItemこそがliタグを表すコンポーネントですので、このListItemliタグに付けたwicket:idに対応するコンポーネントをaddしてやれば、それが画面に反映されるという寸法です。ページやフォームとなんら変わるところはありません。ページの変わりに各行を表すListItemを使うだけの話です。

ListViewの引数にはSyndEntryのListを返すモデルを渡していますから、ListItemのモデル・オブジェクトはSyndEntryです。SyndEntryには各ブログエントリの詳細情報が詰まっていますから、あとはその情報を適切なコンポーネントとしてaddしてやればいいのです。

リンクを表すコンポーネントはExternalLinkです。サイト外部へのリンクを動的に生成する時に使います。リンクURLと、リンクとして表示される文字列を組み合わせたコンポーネントです。更新日付は単純な文字列ですので、おなじみのLabelで表します。SyndEntrypublishDateプロパティを整形してセットしてやります。エントリの要約にもLabelが適当でしょう。要約もSyndEntrydescriptionプロパティから簡単に取得できます。

見ての通り、各Wicketコンポーネントを生成して、ListItemにaddしているだけです。シンプルですね。これですべてのエントリを画面に表示することができるのです。

load()メソッドの中身で使ったgetEntryList()についてはまだ説明していませんが、それは「リストを表示する」というポイントとは関係のないところです。Wicketでリストを表示するには、このとおり、ListViewを(無名内部クラスなどで)オーバーライドしてpopulateItem()を実装してやるだけでいいのです。リスト表示項目が少ないときにはLoadableDetachableModelなんて使う必要もありません。そもそもモデルもいりません。ListViewのコンストラクタにはListをそのまま受け付けるものもありますので、そちらを使えば実に簡単にリストを表示できます。

残ったgetEntryList()も説明しておきましょう。この処理はWicketにおけるHttpSessionの扱いのサンプルになっていますので、その視点で説明しましょう。

getEntryList()は、先に作っておいたFeedManagerを使って登録済みの全フィードからエントリを取得して、SyndEntryを含んだListオブジェクトとして返します。リターンの前に、エントリの登録日の新しいもの順にソートしてから返すという処理も行っています。FeedManagerオブジェクトを取得して、getFeedSet()メソッドで取得可能な全フィードを取得し、さらに各フィードの全エントリを取得します(重複を省くためにSet内に格納します)。あとはJavaのComparatorを使って日付順にソートしているだけです。

ソートロジックでnullチェックを頻繁に行っているので長めですが、やっていることは非常にシンプルです。

/**
 * get entries which are retrieved from feed URLs registered to FeedManager.
 * @return List<SyndEntry>
 */
protected List<SyndEntry> getEntryList() {
    final Set<SyndEntry> allEntrySet = new HashSet<SyndEntry>();
    final FeedManager feedManager = getFeedManager();

    Set<SyndFeed> feedSet = null;
    feedSet = feedManager.getFeedSet();
    for( SyndFeed feed : feedSet) {
        List<SyndEntry> entries = feed.getEntries();
        for( SyndEntry entry : entries) {
            allEntrySet.add( entry);
        }
    }
    
    final List<SyndEntry> entryList = new LinkedList<SyndEntry>( allEntrySet);
    
    Collections.sort( entryList, new Comparator<SyndEntry>() {
        public int compare(SyndEntry o1, SyndEntry o2) {
            if( o1 == o2) return 0;

            Date o1PublishDate = o1.getPublishedDate();
            Date o2PublishDate = o2.getPublishedDate();
            
            if( o1PublishDate == null && o2PublishDate == null) return 0;
            if( o1PublishDate == null && o2PublishDate != null) return 1;
            if( o1PublishDate != null && o2PublishDate == null) return -1;
            
            return -(o1PublishDate.compareTo( o2PublishDate));
        }
    });
    
    return entryList;
}

ポイントはロジックではなく、FeedManagerを取得するgetFeedManagerメソッドです。実装を見てみましょう。

/**
 * get an instance of FeedManager which binded to user's session.
 * If no instance exists, new one will be created.
 * @return FeedManager
 */
synchronized FeedManager getFeedManager() {
    final AppSession session = (AppSession)this.getSession();
    FeedManager feedManager = session.getFeedManager();
    if( feedManager == null) {
        feedManager = new FeedManager();
        session.setFeedManager( feedManager);
    }
    return feedManager;
}

AppSessionという見慣れないクラスが出てきますが、これがセッション・オブジェクトだということは想像が付くでしょう。FeedManagerは頻繁に使うオブジェクトですので、セッション内に保持してセッションが続く限りは同じものを使い回すようにしています。

ここでWicketにおけるHttpSessionの取り扱いについて知る必要が出てきます。Wicketは型チェックを使えるところでは可能な限り型チェックを使うように作られています。ですからページ内の情報もサーブレットでおなじみのgetAttribute()ではなく、ページ・オブジェクトのプロパティという形で表現します。getAttribute()Object型を返すため、常にキャストが必要になるからです。セッションも同じです。WicketではHttpSessionのsetAttribute()/getAttribute()を使うのではなく、独自のセッション・クラスを用意し、そこにsetter/getterを用意することで、型安全性を維持するのです。

AppSessionというクラスがその独自クラスです。AppSessionにはFeedManagerを返すgetFeedManager()メソッドを定義しており、これでキャストすることなくFeedManagerオブジェクトを取得することができるわけです。

このようにセッションに格納する値にはそれぞれsetter/getterを用意するのがWicketの流儀です。これによりプログラムの型安全性を確保すると同時に、「独自セッションクラスのプロパティを見れば、セッションにどのような値が格納され得るのかが一目で分かる」という利点をも得られるのです。

Wicketでセッション・オブジェクトを使う方法は簡単です。WicketComponentクラスにはgetSession()というメソッドがありますので、ページやフォームではいつでもこのメソッドで簡単にセッションを取得できます。しかしWicketのセッションは先ほど書いたように、あなたが定義した独自クラスであることが流儀ですから、getSessionメソッドに「このアプリケーションでの独自セッション・クラスはこれだよ」と教えておく必要があります。

その設定をアプリケーションの初期化の時の行っておくのです。既にアプリケーションの初期化のためにWebApplicationクラスのinit()メソッドを使っていたことを思い出してください。このWebApplicationクラスにはsessionFactoryというプロパティがあり、ここにISessionFactory型のオブジェクトをセットしておけば、getSession()を呼んだ時には指定したセッション・ファクトリを使ってセッション・オブジェクトが作られるのです。

init()メソッドに下記のコードを追加するだけです。

    setSessionFactory( new ISessionFactory() {
        public Session newSession() {
            return new AppSession( Application.this);
        }
    });

ISessionFactorynewSession()メソッドを実装し、そのアプリケーション独自のセッションオブジェクトを返すようにしてください。ここでは独自クラスAppSessionオブジェクトを生成しています。

AppSessionクラスは下記のようにWicketのWebSessionクラスを継承してsetter/getterを追加しただけのシンプルなクラスです。

package rssreader;

import java.io.Serializable;
import rssreader.feed.FeedManager;
import wicket.protocol.http.WebApplication;
import wicket.protocol.http.WebSession;

public class AppSession extends WebSession implements Serializable {
    FeedManager feedManager;
    
    public AppSession( WebApplication application) {
        super( application);
    }

    public FeedManager getFeedManager() {
        return feedManager;
    }

    public void setFeedManager(FeedManager feedManager) {
        this.feedManager = feedManager;
    }
}

このようにセッションについてもオブジェクトとして扱い、セッションに格納する値もsetter/getterを介してアクセスすることで型安全性を確保するのがWicketのやり方です。残念ながらgetSession()メソッドはWicketのSession型を返すように作られているので、getSession()の戻り値をAppSessionにキャストする必要はありますが、その後はgetFeedManager()という分かりやすいメソッドを使ってセッション値を取得できますし、セットする時にもsetFeedManager()という明確なメソッドを使うことができます。毎回キャストをする必要もないのです。

さらに「getSession()」の戻り値をキャストしなければいけないという欠点も時期Wicketでは解消される予定です。J2SE 5.0からオーバーライドの規約が変更になり、戻り値の型がオーバーライド元の戻り値のサブクラスであれば、オーバーライドしていると見なすようになりました。そのため下記のような独自のgetSession()メソッドを作ってWicketのgetSession()をオーバーライドできるようになる予定です。

@Override
public AppSession getSession() {
    return (AppSession)super.getSession();
}

現在Wicket 2.0と呼ばれているバージョンはJ2SE 5.0が必須環境となるため、上記のオーバーライドが行えるようになり、欠点が解消されるわけです。

まとめると、フィード・エントリの表示は下記のような流れで行われます。

  1. ListViewを用意して、そのモデルとしてLoadableDetachableModel(の無名サブクラス)を渡します。
  2. LoadableDetachableModelはページオブジェクト作成時にgetEntryList()メソッドを使って登録されている全フィードのエントリを日付順にソートしてモデルオブジェクトとして保持します。
  3. ListViewはそのモデルオブジェクトの一つ一つに対してpopulateItem()メソッドを実行します。そのときエントリ1件がpopulateItemの引数ListItemのモデル・オブジェクトとして格納されます。
  4. あなたはpopulateItem()をオーバーライドして、ListItemのモデル・オブジェクトを取り出し、エントリ1件分の情報を取得します。
  5. エントリの各情報をListItemにaddします。
  6. Wicketはページ生成時にListViewの描画を行い、そこでpopulateItem()が呼ばれ、すべてのエントリが画面に表示されます。ListItemが<li>一つ分の情報になります。
  7. Wicketはレスポンス送信時にすべてのIDetachableなモデルのdetach()を呼び出します。これによりLoadableDetachableModelが保持しているエントリ情報は破棄され、HttpSessionには軽量な空のLoadableDetachableModelだけが残ります。

このように、セッションフルなWicketはデタッチという機構を使ってリードオンリーな情報をリクエスト・レスポンスのサイクル毎に破棄することでセッションを軽量に保ちます。モデルが持つ情報をセッションに残す場合はdetach()で何も行わなければ良いわけですが、本来保持すべきはユーザーの入力値のような回復不能なデータのみのはずです。それ以外の値はLoadableDetachableModelを使って毎回ロードするなどしましょう。Wicketが自動的にロードした値を破棄してくれます。

さあ、ここまで来れば実際にフィードURLの登録を受け付けてListViewで表示できそうです。まずは作ったListViewをページに貼り付けるようにしましょう。作成したcreateListView()メソッドをページのコンストラクタで呼ぶようにしましょう。

    public ListPage() {
        super();
        
        add( new Label("test", new AbstractReadOnlyModel() {
            public Object getObject(Component component) {
                return new SimpleDateFormat("yyyy年MM月dd日").format( new Date());
            }
        }));
        
        createUrlForm();
        createListView();    //<--- この行を追加。
    }

これでエントリのリストが表示できるようになりました。あとはユーザーの入力したURLを受け取って、FeedManagerのaddFeedUrl()メソッドに渡してやるだけです。

前回入力フォームを作った際に、既にユーザーの入力値を画面に表示するようにしたことを覚えているでしょうか。あの値を画面に表示する代わりにFeedManagerに渡せばいいのです。

urlForm.add( new Button("submitBtn") {
    @Override
    protected void onSubmit() {
        Component urlField = this.getParent().get( "urlTextField");
        String newUrlStr = urlField.getModelObjectAsString();
        info( "入力値は: " + newUrlStr);
    }
});

createUrlForm()メソッド内の上記の部分です。上記コードは受け取った値をinfo()メソッドを使ってFeedback Panelに表示しています。今回は代わりにFeedManagerに登録します。

urlForm.add( new Button("submitBtn") {
    @Override
    protected void onSubmit() {
        Component urlField = this.getParent().get( "urlTextField");
        String newUrlStr = urlField.getModelObjectAsString();
       // info( "inputed value is: " + newUrlStr);
       //下の二行に変更した。
        FeedManager manager = getFeedManager();
        manager.addFeedUrl( newUrlStr);
    }
});

info()を呼んでいる行をコメント化して二行追加しました。getFeedManager()でFeedManagerを取得し、単純にaddFeedUrl()を呼んでいるだけです。これでもう動きます。実際に動かしてみましょう。

URLを入力して、登録すると....

URL入力

エントリが一覧表示されます。

URL入力

これでRSSリーダーのサンプルとして最も重要な機能を実装できましたね。どんどんフィードURLを入力してみてください。エントリの登録日順に次々と更新されます。

ここまでのまとめ

ここでもう一度流れを追っておきましょう。Wicketの入力受付とページ描画の流れを再確認します。

  1. ユーザーの入力したURLがWicketのValidatorでチェックされる。
  2. Validatorのチェックをくぐりぬけると、入力値はモデル内に格納されます。
  3. フォームもしくはボタンのonSubmit()メソッドが呼び出されます。
  4. 受け取った文字列はもはや検証済みですので、すぐにFeedManager#addFeedUrl()を使って登録します。
  5. onSubmit()が完了したときに次のページが指定されていない場合、Wicketは元のページを再作成しようとします。
  6. ListViewに設定されたLoadableDetachableModelのgetObject()が呼ばれます。LoadableDetachableModelはload()メソッドを使ってモデル・オブジェクトを取得します。このときにgetEntryList()が呼ばれます。
  7. getEntryList()はFeedManagerから全エントリを取得しようとします。addFeedUrl()が呼ばれているためFeedManagerは自身のエントリリストを再更新し、最新のエントリリストを返します。
  8. ListViewがそのエントリリストを表示します。
  9. セッション内のエントリリストはLoadableDetachableModelによって破棄されます。

ListViewはListを返すモデルを渡してやれば、List内の各項目ごとにpopulateItem()を呼んでくれる便利なコンポーネントです。プログラマはListItemに各行の表示コンポーネントを追加していくことで、簡単にHTMLの<li>要素を生成することが出来ます。

複雑な処理は上記のようにモデルのgetObject()に隠蔽できます。WicketはgetObject()が何をしようと気にしないことを思い出してください。上記のようにURLアクセスしてフィードからエントリを取得してリストを作って...といった作業を行おうと、ただシンプルにgetObject()の返したListを処理するだけです。

しかし、このままでは画面がエントリで一杯になってしまいます。次に並べる機能をこのサンプルに追加して完成としましょう。

フィードのクリア

さてここまで来たみなさんはWicketの入力値サブミットとページ再構成のやり方は十分すぎるほど分かっているはずです。エントリのクリアは簡単です。

  1. クリアボタンをクリック
  2. onSubmit()でFeedManagerのclearAllFeeds()メソッドを呼ぶ(覚えてますか? FeedManagerの全フィードを削除するメソッドです)

これだけです。あとは何もしなければWicketはページを再生成しますから、そこでフィードの再取得が行われます。もちろんフィードは空ですから、画面もクリアされるという寸法です。

まずはHTMLにボタンを追加しましょう。URL入力欄を下記のように変更します。

    <h1>フィードURL登録</h1>
    <form method="POST" wicket:id="urlForm">
      新規URL: <input type="text" name="urlTextField" value="" size="100" width="100" wicket:id="urlTextField" />
              <input type="submit" value="登録" name="submit" wicket:id="submitBtn" />
      <br />
      チェック:<input type="checkbox" wicket:id="check1"><input type="checkbox" wicket:id="check2" />
      <p>フィードのクリア<input type="submit" value="クリア" name="clear" wicket:id="clearBtn" /></p>
    </form>

最後の行に「フィードのクリア」というsubmitボタンを追加しました。wicket:idは「clearBtn」です。さて、このコンポーネントをJava側から設定しましょう。createUrlForm()メソッド内に下記のように新しいボタンを追加する処理を書きます。

urlForm.add(new Button("clearBtn") {
    @Override
    protected void onSubmit() {
        FeedManager manager = getFeedManager();
        manager.clearAllFeeds();
    }
}.setDefaultFormProcessing(false));

ボタン「clearBtn」のonSubmit()にて単純にFeedManager#clearAllFeeds()を呼び出しているだけですね。これで画面をクリアできます。試してみてください。URL登録に比べると簡単ですね。でもこの短いコードにいままで説明したほとんどのことが含まれています。

さらにこの短いコードに一つ新しい要素も含まれています。Buttonの無名サブクラスを生成するコードの末尾に.setDefaultFormProcessing(false)と書かれています。これはボタンのプロパティの一つで、このプロパティがfalseにセットされると、submit時にValidatorによるチェックが働かなくなります。

フォームの削除時は入力値などありませんので、ここでfalseにセットしているわけです。

あと注目すべきポイントは、上記コードはsetDefaultFormProcessing()の返り値をadd()していることです。setDefaultFormProcessing()メソッドは返り値として自分自身を返すので、このようなコードを書くことが出来るわけです。

フィード一覧のダウンロード

エントリを削除することが出来るようになったものの、このままでは削除したフィードは再度URLを入力しない限り見ることができません。せめて一度登録したフィードの一覧をダウンロードできるようにしておきましょう。

ここではWicketの「Resource」というコンポーネントを使います。

基本的な流れは下記のようになります。

  1. ユーザーが「ダウンロード」ボタンを押す
  2. FeedManagerに登録されているフィードの情報を使ってカンマ・改行区切りの文字列を作り、WicketのResource化する。
  3. リクエストの処理先をページからリソースに変更する。
  4. Wicketのリソースが返却される

実際に見てみましょう。

WicketでHTMLページ以外のものを返却する場合、Pageクラスでは処理できません。ですからリクエストを別のところに任せてしまいます。これを「RequestTarget」といいます。Page自体もRequestTargetの一つですが、RequestTargetを途中で差し替えてやると任意のレスポンスを返すことが出来ます。

特に文字列やファイルをダウンロードさせるような場合には「ResourceStreamRequestTarget」を使います。ResourceStreamというクラスをコンストラクタに渡すことで、ストリームの内容をレスポンスとして返すことのできるRequestTargetです。特にResourceStreamRequestTargetにはconfigureというメソッドが用意されており、そこでレスポンス・ヘッダを細かく設定することが出来るので、ダウンロードファイルとしてデータを転送したりする場合に便利です。

ResourceStreamRequestTargetを使うには、まずは返却したい情報を現す「ResourceStream」を作らなくてはいけません。ResourceStreamとはWicketのIResourceStreamを実装した任意のクラスですが、Wicketには再利用できる便利なベースクラスがいくつか用意されています。ファイルをストリームとして渡せるFileResourceStreamや、別URLの情報をストリーム化するUrlResourceStreamなどです。

今回のような文字列をダウンロードファイルとして転送する用途なら、StringBufferResourceStreamがいいでしょう。これはStringBufferのようにプログラム上で文字列を組み立てて作成するResourceStreamです。

早速作ってみましょう。ダウンロードボタンが押された時に呼ばれるメソッドを一つ作って、そのなかで全てやってしまいます。メソッド名は「downloadFeedList()」としましょう。

    protected void downloadFeedList() throws RetrieveException {
        final String charset = getResponse().getCharacterEncoding();
        final StringBufferResourceStream resource = new StringBufferResourceStream("text/plain");
        resource.setCharset( Charset.forName( charset));

        final Set<SyndFeed> feedSet = getFeedManager().getFeedSet();
        final String separator = System.getProperty( "line.separator");
        
        for( SyndFeed feed : feedSet) {
            String url = feed.getUri();
            String name = feed.getTitle();           
            resource.append( name + "\t" + url + separator);
        }
    }

最初のgetResponse().getCharacterEncoding()は、レスポンスの文字コードを取得しています。前回レスポンスの文字コードをApplicationの初期化で行えることを説明しましたが覚えているでしょうか。これは、その設定値を取得しています。

そしてStringBufferResourceStreamを生成しています。引数はこのリソースが返却されるときに使われるContent-Type値です。「image/jpeg」とか「text/html」とかですね。今回は単純な文字列を返却したいのですが、ダウンロードファイルとして扱うので「application/octet-stream」を指定しています。次の行ではResourceStreamの文字セットをレスポンスと同じものに設定しています。

あとはStringBufferで文字列を組み立てるのと同じ要領です。まずはFeedManagerからすべてのフィードを取得し、さらに改行コードをシステムプロパティから取得します。あとはすべてのフィードの名前とURLをタブ区切りで、末尾に改行コードを付けていっています。

これでResourceStreamは作成完了です。連結した文字列情報がそのままレスポンスの値になります。

このResourceStreamを使ってResourceStreamRequestTargetを作るのですが、特に細かい制御が必要なければ、ResourceStreamRequestTargetのコンストラクタにResourceStreamを渡すだけで終わりです。

new ResourceStreamRequestTarget(resource);

ResourceStreamRequestTargetクラスはResourceStreamの長さなどをちゃんと計算して、HTTPヘッダを生成してレスポンスを組み立ててくれるので、これだけで十分なことが多いのです。現在のRequestTargetをResourceStreamRequestTargetに差し替えるのは次のようにします。

getRequestCycle().setRequestTarget( new ResourceStreamRequestTarget(resource));

RequestTargetはページのgetRequestCycle()で取得できるRequestCycleオブジェクトのsetRequestTargetを呼ぶことでいつでも差し替えられるのです。簡単ですね。

しかし今回はもう少し細かい制御をしなければいけません。文字列はファイルとしてダウンロードできるようにしなければいけません。いまのままですと、文字列はそのままブラウザに表示されてしまうでしょう。

さらに、実はStringBufferResourceStreamには一つ国際化関連のバグがあり、その対処も必要です。リソースの長さを取得してHTTPヘッダのlengthを設定する際に、文字列の「バイト長」ではなく「文字列長(つまりString#length()の結果)」を返してしまうのです。英語圏であれば問題ありませんが、2バイト文字が混ざっている場合には文字列長とバイト長は一致しないので問題になります。

これらの処理を行うためにconfigureをオーバーライドします。

getRequestCycle().setRequestTarget( new ResourceStreamRequestTarget(resource) {
    @Override
    protected void configure(Response response, IResourceStream resourceStream) {
        super.configure( response, resourceStream);
        
        final IStringResourceStream stream = (IStringResourceStream)resourceStream;
        final WebResponse webResponse = (WebResponse)response;
        webResponse.setAttachmentHeader( "feedlist.out");
        try {
            webResponse.setContentLength(stream.asString().getBytes(charset).length);
        } catch (UnsupportedEncodingException ex) {
            error("No such encoding: " + charset);
        }
    }
});

まず最初にsuper.configure()を呼ぶことでデフォルトのヘッダ設定を行わせることを忘れないようにしましょう。そのあとにヘッダ定義を書き換えます。

まずはリソースを添付ファイルとして扱うようにします。そのためにはWicketのWebResponseクラスにあるsetAttachmentHeader()メソッドを使ってファイル名を設定してやります。これでリソースはダウンロード・ファイルであることがブラウザに分かります。

WebResponseはconfigureの引数として渡ってきます。ただしResponseという汎用的な値として渡ってくるので、WebResponse型にキャストする必要があります。

長さの設定もWebResponseを使います。WebResponseのsetContentLength()メソッドを使うとレスポンス全体の長さをバイト長で設定できます。configureメソッドには引数でResourceStreamも渡ってきますので、このストリームのバイト長を得られればいいわけです。

今回のResourceStreamがStringBufferResourceStreamであることははっきりしていますので、その機能をつかいましょう。StringBufferResourceStreamはIStringResourceStreamインターフェースを実装しているので、asString()というメソッドを持っています。これでストリーム全体を文字列化し、さらにレスポンスの文字コードで何バイトになるかを算出してセットしてやるわけです。

この処理をdownloadFeedList()メソッドに追加してやれば、ダウンロード処理は完成です。全体は下記のようになります。

protected void downloadFeedList() throws RetrieveException {
    final String charset = getResponse().getCharacterEncoding();
    final StringBufferResourceStream resource = new StringBufferResourceStream("application/octet-stream");
    resource.setCharset( Charset.forName( charset));

    final Set<SyndFeed> feedSet = getFeedManager().getFeedSet();
    final String separator = System.getProperty( "line.separator");
    
    for( SyndFeed feed : feedSet) {
        String url = feed.getUri();
        String name = feed.getTitle();           
        resource.append( name + "\t" + url + separator);
    }
    
    getRequestCycle().setRequestTarget( new ResourceStreamRequestTarget(resource) {
        @Override
        protected void configure(Response response, IResourceStream resourceStream) {
            super.configure( response, resourceStream);
            
            final IStringResourceStream stream = (IStringResourceStream)resourceStream;
            final WebResponse webResponse = (WebResponse)response;
            webResponse.setAttachmentHeader( "feedlist.out");
            try {
                webResponse.setContentLength(stream.asString().getBytes(charset).length);
            } catch (UnsupportedEncodingException ex) {
                error("No such encoding: " + charset);
            }
        }
    });
}

これでダウンロード・ボタンの処理を行うメソッド完成です。このメソッドを呼んでやれば、レスポンスとしてファイルが返却されます。ではダウンロードボタンを作りましょう。

「フィードURL登録」フォームの下に下記HTMLを追加します。

<hr />

<h1>フィード一覧ダウンロード</h1>
<form method="POST" wicket:id="downloadForm">
  <input type="submit" value="ダウンロード" name="donwload" wicket:id="downloadBtn" />
</form> 

新しいフォームを追加しました。wicket:idは「downloadForm」です。このフォーム用のFormオブジェクトを作ってやればいいわけです。フォーム上にはsubmitボタンがひとつだけあります。wicket:idは「downloadBtn」です。

もう何をすればいいかはお分かりですね? 簡単ですので結果を書きましょう。下記のメソッドを作成します。

/**
 * create a form with a download button.
 */
void createDownloadForm() {
    final Form downloadForm = new Form("downloadForm");
    downloadForm.add( new Button( "downloadBtn") {
        @Override
        protected void onSubmit() {
            try {
                downloadFeedList();
            } catch (RetrieveException ex) {
                ex.printStackTrace();
                error( "Download Error: can not download feed list.");
            }
        }
    });
    add( downloadForm);        
}

そしてページのコンストラクタから呼んでやりましょう。

public ListPage() {
    super();
    
    add( new Label("test", new AbstractReadOnlyModel() {
        public Object getObject(Component component) {
            return new SimpleDateFormat("yyyy年MM月dd日").format( new Date());
        }
    }));
    
    createUrlForm();
    createListView();
    createDownloadForm();   // <-- この行追加。
}

これで完成です。実行してみましょう。

URL入力

ダウンロードボタンを押すと「feedlist.out」というファイルがダウンロードできたことが分かると思います。これでダウンロード機能完成です。

ここではWicketがページ以外のリソースを扱う時には「RequestTarget」を使うことを説明しました。Page自身もRequestTargetの一つですが、ファイルであったり別のURLであったりする「ResourceStreamRequestTarget」を使えば、事実上ストリームとして扱えるものならなんでも転送できるわけです。今回は動的に文字列を生成して、それをファイルとして転送する方法を説明しました。

ファイルのアップロード

このサンプルに足りない機能はひとつになりました。先ほどフィード一覧をダウンロードできるようにしましたが、これをアップロードできなければ何の意味もありません。ファイルをアップロードする機能を追加します。

ファイルのアップロードはウェブ・アプリケーションでもかなりの頻度で使う機能ですから、サンプルとして役に立つと思います。

やりたいことは次の通りです。

  1. HTMLのアップロードボタンでアップロードされたデータを取得する。
  2. データを一行ずつ呼んで、タブで区切る。二つ目のデータがフィードのURLであるはず。
  3. 二つ目のデータをFeedManagerに登録する。
  4. あとは何もしなければページが再描画されてエントリ一覧が表示される。

Wicketにはアップロードされたデータを取り扱うための「FileUploadField」というクラスが存在します。これを使えば上記の処理は1メソッドでかけます。

まずはHTMLにアップロード用フォームを追加しましょう。ダウンロード用フォームの下に下記HTMLを追加してください。

<hr />

<h1>フィード一覧アップロード</h1>
<form method="POST" wicket:id="uploadForm">
  アップロードファイル: <input type="file" value="" wicket:id="uploadField" /><input type="submit" value="送信" name="upload" wicket:id="uploadBtn" />
</form>

wicket:idが「uploadForm」であるフォーム上に、ファイルアップロード用のHTMLフィールドとサブミット・ボタンがあります。この三つをJava側で生成してやれば良いわけです。

Javaのコードを見てみましょう。

void createUploadForm() {
    final Form uploadForm = new Form("uploadForm");
    uploadForm.setMultiPart(true);
    
    final FileUploadField uploadFld = new FileUploadField("uploadField");
    uploadForm.add( uploadFld);
    
    uploadForm.add( new Button("uploadBtn") {
        @Override
        protected void onSubmit() {
            try {
                FileUpload upload = uploadFld.getFileUpload();
                if( upload != null) {
                    String encoding = getResponse().getCharacterEncoding();
                    InputStream input = upload.getInputStream();
                    try {
                        List<String> lines = IOUtils.readLines(input, encoding);
                        for( String line : lines) {
                            if( line != null && line.length() > 0) {
                                String[] keyValuePair = line.split("\\t");
                                if( keyValuePair.length >= 2 && keyValuePair[1] != null){
                                    FeedManager manager = getFeedManager();
                                    manager.addFeedUrl( keyValuePair[1]);
                                }
                            }
                        }
                    } finally {
                        IOUtils.closeQuietly(input);
                    }
                }
            } catch (IOException ex) {
                ex.printStackTrace();
                uploadForm.error( "フィードファイルを処理できませんでした。");
            }
        }
    });
    add( uploadForm);
}

ちょっと長めですが慌てる必要はありません。ゆっくりみていきましょう。まずは下記の部分です。

    final Form uploadForm = new Form("uploadForm");
    uploadForm.setMultiPart(true);
    
    final FileUploadField uploadFld = new FileUploadField("uploadField");
    uploadForm.add( uploadFld);
    
    uploadForm.add( new Button("uploadBtn") {
        @Override
        protected void onSubmit() {

新しいコンポーネントも出てきますが、何をしているかは分かると思います。「uploadForm」というFormクラスのオブジェクトを作り、そこにFileUploadField」という新しいコンポーネントと、サブミット・ボタン用の<|Buttonをaddしているわけです。ButtonはいつものようにonSubmit()をオーバーライドしています。

フォームのsetMultiPart()をtrueにセットしていますが、これはファイルアップロード時の定石のようなものなのでそのまま覚えてしまいましょう。ファイルを別データとして送るための設定です。

FileUploadFieldはHTMLの「type="file"」と指定した<input>要素を表すコンポーネントです。このコンポーネントがファイル・アップロードの要です。続きのコードを見てみましょう。onSubmit()の実装です。

    uploadForm.add( new Button("uploadBtn") {
        @Override
        protected void onSubmit() {
            try {
                FileUpload upload = uploadFld.getFileUpload();
                if( upload != null) {
                    String encoding = getResponse().getCharacterEncoding();
                    InputStream input = upload.getInputStream();
                    try {
                        List<String> lines = IOUtils.readLines(input, encoding);
                        for( String line : lines) {
                            if( line != null && line.length() > 0) {
                                String[] keyValuePair = line.split("\\t");
                                if( keyValuePair.length >= 2 && keyValuePair[1] != null){
                                    FeedManager manager = getFeedManager();
                                    manager.addFeedUrl( keyValuePair[1]);
                                }
                            }
                        }
                    } finally {
                        IOUtils.closeQuietly(input);
                    }
                }
            } catch (IOException ex) {
                ex.printStackTrace();
                uploadForm.error( "フィードファイルを処理できませんでした。");
            }
        }
    });
    add( uploadForm);  //フォームをページにaddするのを忘れないように。

FileUploadFieldコンポーネントにはgetFileUpload()というメソッドがあります。これはFileUploadというクラスのオブジェクトを返すのですが、このクラスにアップロードされたファイルの情報が詰まっています。ファイルのクライアント側での名前やバイト長、コンテント・タイプなどを取得できますが、今回はgetInputStream()メソッドでアップロードされたファイルのデータへのストリームを取得しています。

ストリームさえ得られれば、Javaで普通にファイルを開いたことと代わりありませんから処理は簡単です。まずはレスポンスのキャラクター・エンコーディングを取得しておき、アップロードされたファイルのエンコーディングとして使うことにします。ファイルを一行ずつ読むコードを書くのは簡単ですが、面倒なのでここではJakarta Commons IOのIOUtilsクラスを使っています。プロジェクトのjarにcommons-io.jarを加えてください。

IOUtils.readLines()を使うとファイルの中身を行単位で分割してListに格納してくれます。読み込みエンコーディングも必ず指定しましょう。Listになってくれれば拡張forループで処理できるので処理が簡単ですね。readLines()にはインプットとしてInputStreamを渡しますが、ここにFileUploadオブジェクトのgetInputStream()の結果を渡すわけです。ファイルはfinallyブロックで確実に閉じてください(ファイルのクローズもIOUtils.closeQuietly()を使うと便利です)。

あとは地味な処理です。行をタブで分割し(思い出してください。各行はタブ区切りで二つ目にフィードのURLが入っています)、要素が二つ以上で二つ目が空でないことを確認後、その二つ目の要素(これはフィードのURLのはずです)をFeedManagerにaddFeedUrl()するのです。

リストの全行を処理し終わるとonSubmit()の処理も終わります。Wicketはページを再生成してレスポンスを作ろうとしますが、その時にはFeedManagerにURLが追加されていますから、エントリの一覧が画面に表示される、というわけです。

あとはこのメソッドをページのコンストラクタから呼んでやりましょう。

public ListPage() {
    super();
    
    add( new Label("test", new AbstractReadOnlyModel() {
        public Object getObject(Component component) {
            return new SimpleDateFormat("yyyy年MM月dd日").format( new Date());
        }
    }));
    
    createUrlForm();
    createListView();
    createDownloadForm();
    createUploadForm();   // <-- この行追加。
}

これでRSSリーダーのサンプルは完成です。完成したプログラムをサーバにディプロイしていますので、こちらのリンクを辿って触ってみてください。URLを追加したり、ダウンロードしたり、そのファイルをアップロードしてみたりして、ちゃんと動いていることを確認してみてください。

まとめ

今回のサンプルにはWicketのさまざまな要素が盛り込まれるように工夫しています。その分少々覚えきるのが大変ですが、どれもウェブ・アプリケーションでは頻繁に使う機能ですから役に立つはずです。

フィードの管理にROMEを使いました。ROMEはRSS4Jのメンテナンスがされなくなって以来、JavaにおいてRSS処理の事実上の標準といっても良い地位にいるかと思います。実際便利なライブラリなので紹介したい気持ちもあり、あえてRSSリーダーをサンプルとした面もあります。

「その2」においてはROMEによるフィードの取り扱いに始まり、Wicketのリスト表示コンポーネントListViewの使い方を説明しました。ListViewはリストさえあればなんでも<li>要素として表示可能な便利なコンポーネントで、Wicketでは多用します。ListViewに渡したモデルはListを返す必要があること、ListItemが<li>に対応すること、各要素ごとにpopulateItem()メソッドがコールバックされることを説明しました。

エントリのクリアでは、submitボタンクリック時の検証処理を敢えて停止させるためのプロパティを紹介しました。キャンセル・ボタンなどでは必須となる機能ですので覚えておきましょう。

ファイルのダウンロードでは、Wicketにおけるページ以外のリソースの返却方法を紹介しました。ResourceStreamRequestTargetを使えば、事実上どんなものでも返却できます。その際にはconfigure()メソッドをオーバーライドすることでレスポンス・ヘッダを操作できます。

ファイルのアップロードではFileUploadFieldオブジェクトのプロパティFileUploadを介して、アップロードされたデータにアクセスできることを示しました。単体ファイルのアップロードならごく短いコードでデータを取得できます。今回は紹介しませんでしたが、FileUploadFieldクラスにはアップロード・データをFileオブジェクトとして書き出すメソッドもあります。複雑な処理を行う場合には便利でしょう。

逆に紹介できていないことに「画面遷移」があります。これはウェブ・アプリケーションでは必須の機能ではありますが今回には盛り込めませんでした。Wicketにおける画面遷移はページ・オブジェクトの扱い方でフォワードになったりリダイレクトになったりするので、なかなか興味深い要素をいろいろ含んでいます。また「Nice URL」と呼ばれる機能で、あるページを特定のURLに「マウント」するようなこともできます。機会があればチュートリアルとして紹介したいと思います。

ここまで読んだあなたは、すでにWicketで相当コードを組めるようになっていると思います。是非一度試してみてください。もし問題が見つかったら? この記事や 私のブログにトラックバックを打つと、ブログで取り上げることになると思います(必ず取り上げるとはお約束できませんが....) 気軽にどうぞ。

トラックバックURL:

http://www.javelindev.jp/wicket/Trackback?article=2
注意:トラックバック元にこのページのURLが含まれていない限り受け付けられません。

トラックバック一覧:

すみませんテストです。 http://www.javelindev.jp/wicket/doc/tutorial02
2007年05月30日
今日から初日記。 ちょっと、最近javaを久しぶりにかじり始めた。 会社の同僚から、最近はwicketってのがあるらしい、とのこと。 早速グーグルで検索、お勉強サイトを見つけた。 Javaウェブフレームワーク「Wicket」の使い方(http://www.javelindev.jp/wicket/index.html)
2009年02月07日
前回のエントリで脆くも砕け散ってしまったお勉強テーマである、 俺が所有する、現在アクティブな4つのブログのRSSフィードを、 Stax+Wicketで纏めようと言う試みがようやく形になって...
2009年05月06日