• このエントリーをはてなブックマークに追加

ss-1361312987

はじめに

表示条件とそのクエリパラメータが次のような仕様の下で動作するようなアプリケーションを実装することを考えてみます:

* 1 - ソート
** 1-1 - ID でソート
*** 1-1-1 - ID で昇順に表示:  sort=id
*** 1-1-2 - ID で降順に表示:  sort=id&desc=1
** 1-2 - 時刻でソート
*** 1-2-1 - 時刻で昇順に表示: sort=t
*** 1-2-2 - 時刻で降順に表示: sort=t&desc=1
* 2 - 絞り込み(自分の?)
** 2-1 - 自分のものだけ:      owned=1
** 2-2 - 他人のものだけ:      owned=0
** 2-3 - どっちでもいい:      (nothing)
* 3 - 絞り込み(完了)
** 3-1 - 完了したものだけ:    done=1
** 3-2 - 未完了のものだけ:    done=0
** 3-3 - どっちでもいい:      (nothing)
* 4 - 表示数量
** 4-1 {n} 件ずつ表示:        num={n}
* 5 - パジネーション
** 5-1 {p} ページ目を表示:    page={p}
 
----
 
* 5 つのグループ 1 〜 5 はそれぞれの間で独立
* グループ内ではただ 1 つだけ選択される(ただ 1 つ値をもつ)

冒頭の画像は,この表示条件を切り換えるためのリンクなボタン(<a class="btn" href="...">...</a>)群として表してみたサンプルです.

これが次のような状態:

ss-1361292293

のとき,「アクティヴ」なところをそれぞれ拾ってみると,次のようなクエリ文字列:

?sort=t&desc=1&owned=1&done=0&num=50&page=7

に対応させられます.

この表示条件下での「8ページ目」へのリンクは,先のクエリ文字列のうち,パラメータ page のみを変更して,次のようにするのが自然でしょう:

<a href="?sort=t&desc=1&owned=1&done=0&num=50&page=8">8</a>

同様に,「ID 降順で 10 件ずつ表示」な条件下では,次のページでも(ページの値を除いて)同じ条件で表示されるべきでしょうし,「完了したものだけを時刻昇順で 5件ずつ」な条件下で表示件数を 50 件に変更した場合,「完了したものだけを時刻昇順に 50 件ずつ」表示されるべきでしょう.

こんな感じで, クエリ文字列を伴う表示条件下で,あるグループの値を変更するためのリンクには,その他のグループのパラメータが保持される のが自然かと考えています.

(パジネーション以外のグループ値の変更の際には「ページを 1 に戻す」のが自然である,ということだけは特別かも.)

...というようなことを踏まえたリンクを動的にこさえるには,「このパラメータがあるときはこうして,あのパラメータがあるときはああして...」みたいなことをしないといけなくて考えるだけでも非常にメンドウだ!と思っていた時期が私にもありました.

しかし,よくよく考えれば,「変更先のグループのパラメータ」と「現在のクエリ文字列から『変更されるグループ』のパラメータを取り除いたもの」をつなげる というアプローチでより一般的な実装を実現できるんじゃね?

傍から見れば当然のことでしょうが,そんなことに,昨日ようやく気づきました><

例えば,先の「8ページ目へのリンク」の場合,「変更先のグループのパラメータ」が:

page=8

で,これに「現在のクエリ文字列から『変更されるグループ』のパラメータを取り除いたもの」:

sort=t&desc=1&owned=1&done=0&num=50

をつなげることで,目的のリンクを実現できる,という感じです.

Amon2 プラグイン「Web::QueryString」

最近は Amon2 でアプリケーションを書くことが多いので,以上のようなことをそこそこラクに実現できるようなロジックを,Amon2 プラグイン Amon2::Plugin::Web::QueryString として書いてみました.今のところかなり雑な実装ですが:

SYNOPSIS

プラグインを読み込むと:

package MyApp::Web;
use parent 'Amon2::Web';
...
__PACKAGE__->load_plugin('Web::QueryString');
...

コンテキストオブジェクトから,メソッド query_string が生えます:

# in controller
sub foo {
    my $c =shift;
    my $q = $c->query_string();
    ...
}

このメソッドは,Amon2::Plugin::Web::QueryString::QueryString オブジェクトを返します.ネーミングテキトー.

Amon2::Plugin::Web::QueryString::QueryString

Amon2::Plugin::Web::QueryString::QueryString オブジェクトは,クエリ文字列を保持しています.(先の $c->query_string() では,リクエストのクエリ文字列を保持した状態でオブジェクトが返ります.)

で,これのメソッドを何かしら呼び出すことで,変更が加わったクエリ文字列を保持した新しいオブジェクトが返ります.

$q->strip(@args)

保持しているクエリ文字列から @args で指定されたパラメータを取り除きます:

my $q = $c->query_string('?foo=1&bar=2&baz=c');
$q->strip(qw/foo baz/);  # '?bar=2'
$q->replace(%args)

保持しているクエリ文字列のパラメータを,%args の内容で置き換えます:

my $q = $c->query_string('?foo=1&bar=2&baz=c');
$q->replace(foo => 'a');  # '?foo=a&bar=2&baz=c'

後述する URI::Query モジュールからのインスパイアです.

$q->with($char)

クエリ文字列の先頭を,指定した文字で置き換えます.テンプレートの都合とかで & から始めたいときに使うくらいですね:

my $q = $c->query_string('?foo=1&bar=2&baz=c');
$q->with('&');  # '&foo=1&bar=2&baz=c'

URI::Query

プラグインを書く前に CPAN を探したところ,URI::Query を利用すれば実現できるかな,と思いました:

ただ,->strip() した後の状態がオブジェクト自身に保持されてしまうので,自分の考えている用途(後述の「実装サンプル」参照)にはちょっと合わなさげでした.

(後に気づきましたが, ->revert() を使えば元の状態に戻すことはできるようですね.)

% << "..." | perl -l
\ use strict;
\ use warnings;
\ use URI::Query;
\
\ my $q = URI::Query->new( foo => 1, bar => 2, baz => 'c' );
\ print $q;
\
\ $q->strip(qw/foo baz/);
\ print $q;
\
\ $q->replace(foo => 'a', bar => 'b');
\ print $q;
\
\ $q->revert();
\ print $q;
\ ...
bar=2&baz=c&foo=1
bar=2
bar=b&foo=a
bar=2&baz=c&foo=1

そんなことから,このモジュールを使うことはせずに,自分の用途に合いそうなものを自分でこさえることにしました.

実装サンプル

コントローラで,Amon2::Plugin::Web::QueryString::QueryString オブジェクトをビューに渡します:

sub foo {
    my $c = shift;
    return $c->render('foo.tx', {
        q => $c->query_string(),
    });
}

Xslate は Kolon Syntax で冒頭のボタン群を.コントローラから渡されたオブジェクトを,各リンクにおけるクエリ文字列の生成に使用しています:

<div class="cond_switch">
  <div class="btn-toolbar">
    <!-- group 1 -->
    <div class="btn-group">
      <a href="?sort=id<:        $q.strip('sort', 'desc', 'page').with('&') :>" class="btn btn-small">ID <i class="icon-arrow-up"></i></a>
      <a href="?sort=id&desc=1<: $q.strip('sort', 'desc', 'page').with('&') :>" class="btn btn-small">ID <i class="icon-arrow-down"></i></a>
      <a href="?sort=t<:         $q.strip('sort', 'desc', 'page').with('&') :>" class="btn btn-small">時刻 <i class="icon-arrow-up"></i></a>
      <a href="?sort=t&desc=1<:  $q.strip('sort', 'desc', 'page').with('&') :>" class="btn btn-small">時刻 <i class="icon-arrow-down"></i></a>
    </div>
  </div>
  <div class="btn-toolbar">
    <!-- group 2 -->
    <div class="btn-group">
      <a href="?owned=1<: $q.strip('owned', 'page').with('&') :>" class="btn btn-small">自分の</a>
      <a href="?owned=0<: $q.strip('owned', 'page').with('&') :>" class="btn btn-small">他人の</a>
      <a href="<: $q.strip('owned', 'page') || '?' :>" class="btn btn-small">どっちでも</a>
    </div>
    <!-- group 3 -->
    <div class="btn-group">
      <a href="?done=1<: $q.strip('done', 'page').with('&') :>" class="btn btn-small">完了</a>
      <a href="?done=0<: $q.strip('done', 'page').with('&') :>" class="btn btn-small">未完了</a>
      <a href="<: $q.strip('done', 'page') || '?' :>" class="btn btn-small">どっちでも</a>
    </div>
  </div>
  <div class="btn-toolbar">
    <!-- group 4 -->
    <div class="btn-group">
      <a href="?num=5<:  $q.strip('num', 'page').with('&')" class="btn btn-small">5 件ずつ</a>
      <a href="?num=10<: $q.strip('num', 'page').with('&')" class="btn btn-small">10 件ずつ</a>
      <a href="?num=50<: $q.strip('num', 'page').with('&')" class="btn btn-small">50 件ずつ</a>
    </div>
  </div>
  <!-- group 5 -->
  <div class="pagination">
    <ul>
      <li><a href="?page=<: $page - 1 :><: $q.strip('page').with('&') :>">前</a></li>
:for [1 .. 10] -> $p {
      <li><a href="?page=<: $p :><: $q.strip('page').with('&') :>"><: $p :></a></li>
:}
      <li><a href="?page=<: $page + 1 :><: $q.strip('page').with('&') :>">次</a></li>
    </ul>
  </div>
</div>

おわりに

以上,いろいろなクエリ状態に対して「特定のパラメータのみ変更・その他は保持」なリンクのためのクエリ文字列をどうやって生成するのが比較的ラクか,ということと,そんなリンクを Amon2 アプリケーション上で比較的容易に準備するためのプラグイン「Web::QueryString」を書いてみた,というお話でした.