Locked History Actions

wicket/BreadCrumbs

パンくずリストの実現

wicket-extensionsにパンくずリストパネルが用意されているが、これは使い物にならない。 ここでは独自のパンくずリストの実装について考えてみる。

パンくずリストのそもそもの問題

パンくずリストを使って、一つの同じページに到達するのに別々のルートを通った場合の表現を行うのは非常に困難である。 また、経路途中の特定の状態を保持するのも困難である。以下の二つのパンくずリストを考えてみる。

トップ / 顧客一覧 / 顧客A

トップ / 製品一覧 / 製品B / 製品B購入者一覧 / 顧客A

いずれの場合も、現在顧客Aのページを表示しているが、そこにいたるまでの経路はそれぞれ異なる。このパンくずリストのいずれかのエントリをクリックすると、そのページを表示したいとする。

しかしこれは非常に難しい、例えば、今現在表示中の顧客Aのページがブックマーク可能であるとすると、そのブックマークは

http://localhost/customer?id=1234

ではありえない。なぜなら、これでは顧客Aのページということはわかるのだが、その途中の経路情報が一切無いからである。これをあえて表示しようとすれば、

トップ / 顧客A

となるしかない。もし前者の経路を表現しようとするなら、

http://localhost/?path0=top&path1=customerList&path2=customer&id=1234

などとしなければならないだろうし、後者の経路は

http://localhost/?path0=top&path1=products&path2=product&path3=buyers&path4=customer?id=1234

などという情報が必要である(パラメータはかなり適当)。これらの情報があって初めてブックマークされたページのパンくずリストが再現可能になる。

しかし、これを実現するのは極めて煩雑である。ページ遷移のたびに、そのパラメータとして前のページに与えられたパラメータを積み重ねていかなければならない。また、そこまでの必要性があるかどうかは疑問である。

もし、以下のように購入者一覧ページを表示中にその中から顧客Aが選択されたら、

トップ / 製品一覧 / 製品B / 製品B購入者一覧

以下のように顧客Aの「ごく普通のパス」に変更されてしまっても問題は無いと思われる。

トップ / 顧客一覧 / 顧客A

さらに、

トップ / 製品一覧 / 製品B / 製品B購入者一覧

のパンくずリストでは、途中の「製品B」というパスが再現されなければならないが、これもパンくずリストの記述を省略することによって対応すべきであろう。つまり、

トップ / 製品一覧

の状態で製品Bが選択されたら

トップ / 製品一覧 / 製品B

となるが、このページにおいて、製品Bの購入者一覧がクリックされたら、

トップ / 製品一覧 / 製品B購入者一覧

などと省略することにする。

ページの木構造

上に述べたように、ユーザの通った経路をパンくずリストとして逐一記録していくのは困難であるため、途中経路については以下の方針でいくしかない。

  • 各ページは一つの木構造の中の固定的な位置を占める。

つまり、ページの構成は以下のようになり、

A
+- B
|  +- C
|  +- D
+- E
   +- F
      +- G

あるページを表示すると、そこからトップにいたるまでのパスが固定的にパンくずリストとして表示されるということである。また、

  • ただし、途中のページについてはパラメータ付であってはならない(パラメータによって表示内容が変わってはいけない)が、最後のページ(パンくずリストの右端)については、パラメータ付でもよいものとする。

途中のページのパラメータについては(不可能ではないものの)保持するのは非常に煩雑になる。これに対して最後のページ(現在表示中のページ)についてはパラメータ付でもよい。これをブックマークして再生するとこのページを完全に再生できるし、途中のページのリンクもまた完全に再生することができる(パスが固定しており、パラメータが存在しない)。

ブックマーク可能なページの扱い

Wicketでブックマーク可能なページのクラスは、引数無しかあるいはPageParameters引数のあるコンストラクタが存在しなければならず、かつsetResponsePageの呼び出し時にはインスタンス生成を引数としてはならず、クラス(+PageParameters)を引数としてなければならない。

この制限はつらい。なぜなら、前のページでオブジェクトAを取得し、その詳細を表示させるためにそのオブジェクトAを渡すことができないからである。いったんオブジェクトAのID番号等を取得し、それをPageParametersの中に格納し、それと共にsetResponsePageを呼び出さなければならない。

この制限を緩和できないものか?例えば、オブジェクトAをコンストラクタ引数とし次のページを生成してしまっても、そこから逆にPageParametersを取り出せればブックマーク可能なURLが作成できそうなものだが、どうもsetResponsePageの挙動を変更することはできそうもない。このようなページのプログラムは以下のようにしておくとよいかもしれない。

public class HelloPage extends WebPage {
  public static class Parameter {
    private PageParameters params;    
    public Parameter() { params = new PageParameters(); }
    public Parameter(PageParameters params) { this.params = params; };
    public Parameter setId(String value) { params.add("id", value); return this; }
    public String getId() { return params.getString("id"); }
    public PageParameters getParams() { return params; }
  }
  public HelloPage(PageParameters params) {
    Parameter p = new Parameter(params);
    String id = p.getId();
    ....
  }
}
....
  setResponsePage(HelloPage.class, new HelloPage.Parameter().setId("1234").getParams());

パンくずリスト実装の方針

  • すべてのブックマーク可能ページは単一の木構造の中のいずれかの位置に固定的に存在する。
  • ただし、ブックマーク不可能なページはどこに現れてもよい。それらは、ブックマークできないため後からページを再生することもない。
  • ブックマーク可能なページは、setResponsePageにてインスタンスを指定してはいけない。このため、パラメータを伝達するには、それをPageParametersに格納したり取り出したりする小さなクラスがあれば便利である。

実装コード

ブックマーク可能ページアノテーション

すべてのブックマーク可能ページには以下のようなアノテーションを付加する。

import java.lang.annotation.*;

import org.apache.wicket.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})

/**
 * ブックマーク可能ページのアノテーション
 * URLパス、ページタイトル、親ページを指定する。
 * 親ページは省略可能(トップの場合)。
 */
public @interface BookmarkablePage {
  
  /** URLパス */
  String path();
  
  /** タイトル */
  String title();
  
  /** 親ページ */
  Class<? extends Page> parent() default Page.class;
}

これを以下のように用いる。

@BookmarkablePage(path="", title="トップ")
public class TopPage extends MyPage {
}
...
@BookmarkablePage(path="/login", title="ログイン", parent=TopPage.class)
public class LoginPage extends MyPage {
}

このアノテーションは以下の場面で用いられる。

  • wicketにブックマーク可能ページのマウントを行うとき、そのURLパスとページクラスを取得する。
  • メニューページ等を表示するときに、そのタイトルを取得する。
  • パンくずリストを構成するときに、そのタイトルとページクラスを取得する。

それぞれの画面で別々に管理するよりも、各ページに付加された情報を取得した方が便利である。

ロケーション

位置を示すノードを作成する。

import java.io.*;
import java.util.*;

import org.apache.wicket.*;
import org.apache.wicket.markup.html.link.*;

/**
 * 位置を示すノード
 */
public class Location implements Serializable  {

  private static final long serialVersionUID = 1L;
    
  /** 親ロケーション */
  private Location parentLocation;

  /** 日本語タイトル */
  private String pageTitle;
  
  /** ブックマークパス */
  private String bookmarkPath;
  
  /** ページクラス */
  private Class<? extends Page>pageClass;
  
  /** 
   * 親ロケ、ページタイトル、ブックマークパス、ページクラスを指定。
   * これはパラメータクエリ無しでブックマーク可能なページに用いる。
   */
  public Location(Location parentLocation, String pageTitle, 
        String bookmarkPath, Class<? extends Page>pageClass) {
    this.parentLocation = parentLocation;
    this.pageTitle = pageTitle;
    this.bookmarkPath = bookmarkPath;
    this.pageClass = pageClass;
  }
  
  /** 親ロケ、ページタイトルを指定。ブックマーク不可能なページに用いる */
  public Location(Location parentLocation, String pageTitle) {
    this.parentLocation = parentLocation;
    this.pageTitle = pageTitle;
  }
  
  /** 親ロケーションを取得する */
  public Location getParentLocation() {
    return parentLocation;
  }

  /** ページタイトルを取得する */
  public String getPageTitle() {
    return pageTitle;
  }
  
  /** ブックマークパスを取得する */
  public String getBookmarkPath() {
    return bookmarkPath;    
  }
   
  /** ページクラスを取得する */
  public Class<? extends Page>getPageClass() {
    return pageClass;
  }
  
  /**
   * トップからこのLocationにいたるまでの全Locationをリストにして返す。
   * @return
   */
  public java.util.List<Location>locationPath() {
    java.util.List<Location>list = new ArrayList<Location>();
    for (Location loc = this; loc != null; loc = loc.getParentLocation()) {
      list.add(0, loc);
    }
    return list;
  }
  
  /** このLocationのリンクを作成する */
  public AbstractLink createLink(String linkId) {
    if (pageClass == null) return null;
    BookmarkablePageLink<String> link = new BookmarkablePageLink<String>(linkId, pageClass);
    return link;    
  }
}

BookmarkablePageアノテーションからLocationを作成するには、以下のようなユーティリティを使う。

public class SomeClass {
  public static Location getLocationOf(Class<? extends Page>pageClass) {
    return new Object() {
      Set<Class<? extends Page>>checked = new HashSet<Class<? extends Page>>();
      
      Location getOf(Class<? extends Page>pageClass) {
        if (pageClass == Page.class) return null;
        if (checked.contains(pageClass)) {
          throw new InternalError("CYCLIC REFERENCE");
        }
        checked.add(pageClass);
        
        BookmarkablePage anno = pageClass.getAnnotation(BookmarkablePage.class);
        if (anno == null) return null;
        Location parent = null;
        if (anno.parent() != null) {
          parent = getOf(anno.parent());
        }
        return new Location(parent, anno.title(), anno.path(), pageClass);    
      }
    }.getOf(pageClass);

  }
}