ラベル 1. GAEをプログラムする の投稿を表示しています。 すべての投稿を表示
ラベル 1. GAEをプログラムする の投稿を表示しています。 すべての投稿を表示

2010-12-03

Google App Engine SDK がアップデート (1.3.8 → 1.4.0)

2010-12-02 付けで Python 版 Google App Engine SDK がバージョンアップしている。iMac で作業をしていると、Google アプリのアップデータが起動して、GoogleAppEngineLauncher.app が更新されたと告げられた。そのままアップデートを実行。Java 版のことはわからないが、公式サイトからダウンロード可能な SDK のバージョンは Python 版と同じ 1.4.0 になっている。

で、何が変わったかを調べようと公式ドキュメントのページから Release Note を開いた。以下に、ざっくりと超直訳してみる。誤解、曲解、いろいろふくまれている可能性がある。訳の品質に期待しすぎないように。

  • Always On 機能はアプリが 3 つのインスタンスを常時実行状態のまま保持することのできる機能だ(課金対象)。アプリの遅延を著しく削減することができる。
  • 開発者は Warmup リクエストを有効にできるようになった。アプリの app.yaml 中でハンドラを指定すると、App Engine はアプリの新しいインスタンスがユーザからのリクエストを受け付け始める前に、その初期化のために Warmup Request を送ろうとする。これにより、エンドユーザがアプリの初期化にともない感じる遅延を削減できる。
  • Channle API がすべてのユーザで利用できるようになった。
  • Task Queue が公式にリリースされ、もはや実験的な機能ではなくなった。'labs' を使った API のインポートパスは非推奨となった。Task Queue で使うストレージはアプリ全般のストレージ割り当て量として計量され、課金の対象となる。
  • Task Queue と Cron リクエストのデッドラインが 10 分に引き上げられた。それらのリクエスト中であっても、データストアと API のデッドラインは以前から変更されていない。
  • Task Queue に対して、開発者は queue.yaml でタスクの retry_parameters を指定できる。
  • 課金を有効にしているアプリでは Task Queue API で待ち行列(queue)を 100 個まで使える。
  • データストアに対して種別、名前空間および実体のプロパティを問い合わせるメタデータクエリが使えるようになった。
  • URLFetch ではレスポンスのサイズとして 32 MB まで許されるようになった。リクエストのサイズは引き続き 1MB までとなっている。
  • イメージ API に対するリクエストとレスポンスのサイズが 32BM に増やされた。Memcach のバッチ操作の合計サイズが 32 MB に増やされた。Memcache の個別のオブジェクトに対する 1MB の制限は引き続き適用される。
  • 送信メールへの添付のサイズが 1MB から 10MB に増やされた。受信メールのサイズ制限は引き続き 10MB となっている。
  • データストア上のバッチ方式による get/put/delete の操作に対する大きさと数量の制限は取り除かれた。個別の実体は引き続き 1MB に制限されているが、データストア全体に対するデッドラインに余裕があれば、バッチ方式で望むだけ多くの実体を同時に get/pub/delete 処理することができる。
  • クエリー結果をもとに反復する場合、データストアサービスは非同期に結果を先読みするようになった。これにより遅延を 10 - 15 % 削減できる場合がある。
  • 管理コンソールの Blacklist ページは拒絶された訪問者の上位の一覧を表示する。
  • 画像のサムネイルの自動生成サービスは 1600px までの任意の切り取りサイズをサポートする。
  • 管理コンソールに表示される全般的なインスタンス遅延の平均値は、インスタンスごとの QPS に応じた平均値になった。
  • アプリのあるバージョンをアップロードした開発者は appcfg.py download_app コマンドを使ってそのバージョンのコードをダウンロードできる。この機能はアプリごとに管理コンソールの Permissions タブで無効にできる。一度無効にすると、この機能を再度有効にすることはできない。
  • 独自ドメインで Google Appes を使っているユーザに対して、カスタム管理コンソールのページが機能していなかった問題を修正。
  • Python 実行環境では、リクエストハンドラが DeadlineExceededError を起こした場合は、インスタンスは強制終了された後に再開される。これは、Django を使っているときに SystemErrors が周期的に起きる問題と関連して修正されるべきだ。
    http://code.google.com/p/googleappengine/issues/detail?id=772
  • webapp.template とピュア Django を混在させたときに起きる Django のバージョンの不一致を避けるため、Django の初期化を appengine_config.py に移動できるようになった。
    http://code.google.com/p/googleappengine/issues/detail?id=1758
  • SSL 上の OpenId の問題を修正。
    http://code.google.com/p/googleappengine/issues/detail?id=3393
  • dev_appserver で login/logout のためのコードが Python 2.6 で動かない問題を修正。
    http://code.google.com/p/googleappengine/issues/detail?id=3566
  • dev_appserver で get_serving_url が透明で長さの足りない(cropped) PNG に対して機能しない問題を修正。
    http://code.google.com/p/googleappengine/issues/detail?id=3887
  • DatastoreFileSub の問題を修正。
    http://code.google.com/p/googleappengine/issues/detail?id=3895

Blogger Glass に関係する変更としては、Task Queue が公式に GAE のサービスの一部としてリリースされたことぐらいのようだ。API が変わったとは書いていないから、今までのコードはそのまま動くってことだろう。とりあえず、taskqueue の import から labs のネームスペースを削除しておいた。appspot に配備ずみ。

関連リンク

関連記事

2010-11-17

タスクキューを使う - 内部リンクを置き換える #3 (Blogger Glass)

内部リンクの置き換えをデータストアを使って単純に実装すると、「一覧画面」表示リクエストの処理に時間がかかるようになってしまい(データストアへの書き込みが発生するときのみ)、GAE の管理コンソール(の Current Load)に赤文字が出るようになってしまった(→「CPU 使い過ぎ? - GAE 管理コンソール上の警告」参照)。

今回は、GAE のタスクキュー API を使って「一覧画面」リクエストの処理時間を短くすることに挑戦してみた。

タスクキュー API の使い方

GAE でタスクキュー API を利用して何らかの処理を行う際には以下の 3 つが必要になる。

  1. タスクを登録するキューの設定
  2. タスクを処理するハンドラの定義
  3. タスクを登録するコードの追加

以下で、これらを順番に今回の実装を例に説明する。ただし、公式ドキュメントにもあるように、タスクキュー API はまだ experimental 扱いなので、この先、仕様変更の可能性は高い。ここに書かれている内容もいつまでも正しいとは限らない。

タスクを登録するキューの設定

GAE のタスクキュー API で使用するキューには、名前(name)、処理速度(rate)、実行量(bucket_size) を設定することができる。詳細については「Python Task Queue Configuration」を参照。設定は queue.yaml という名前の YAML ファイルに記述する。1 つの GAE アプリが持つことのできるキューは 10 までとなっている。

タスクキューにはデフォルトキューが用意されており、これを(デフォルトの設定のままで)使う分には特に設定しなくて良い(つまり、queue.yaml を作らなくても良い)。以下にデフォルトキューの(デフォルトの)設定を示す。

Default Queue Settings
項目設定値
name default
rate 5/s
bucket_size 5

簡単に言うと、デフォルトキューでは 1 秒間に 5 つのタスクを処理するようになっている。

今回の実装では、デフォルトキューをデフォルトのまま使っている。このため queue.yaml は作っていない。

タスクを処理するハンドラの定義

タスクを処理するコードは、通常のリクエストハンドラと同様に定義する(google.appengine.ext.webapp.RequestHandler を継承)。タスク登録時に指定しない限り、タスクは HTTP の POST メソッドで処理される。よって、タスクのハンドラでは最低限 post() 関数を定義することになる。

また、他のリクエストハンドラと同様に app.yaml にも登録する。以下は今回の実装で追加した定義だ。login: admin はリクエスト実行にアプリ管理者の権限を要求する設定。こうすることで、ユーザがタスクハンドラを直接実行することを防ぐことができる(タスクハンドラを呼び出すタスクキューは管理者権限を持っている)。

- url: /store_pol
  script: store_pol.py
  login: admin

今回、実装したタスクハンドラは以下の通り。リクエストパラメータとして permalink と Blog ID を受け取り、さらにひもづけられた Post ID を memcache から取り出して、データストアに保存している。また、データストアへの書き込みはトランザクションとして実行するようになっている。これはタスクが並列で処理されるからだ。

model.bind_post_id_with_permalink() 関数は以前に示したものから変わっていない。

# store_pol.py: process tasks to store a pair of links to the data store.

from google.appengine.api import memcache
from google.appengine.ext import db
from google.appengine.ext import webapp
from google.appengine.ext.webapp.util import run_wsgi_app

import model
import util

class StorePolHandler(webapp.RequestHandler):
    def post(self):
        permalink = self.request.get('permalink')
        blog_id = self.request.get('blog_id')
        post_id = util.get_post_id_from_memcache(permalink, blog_id)
        if post_id:
            def txn():
                model.bind_post_id_with_permalink(permalink, blog_id, post_id)
            db.run_in_transaction(txn)

def main():
    application = webapp.WSGIApplication([('/store_pol', StorePolHandler)],
                                         debug=True)
    run_wsgi_app(application)


if __name__ == '__main__':
    main()
タスクを登録するコードの追加

タスクを登録するには google.appengine.api.labs.taskqueue.add() 関数を使う(デフォルトキューの場合)。今回の実装では、permalink と Post ID の「ひもづけ情報」を登録する関数にこれを組み込んでいる(src/util.py 185 〜 186行目)。

def register_pair_of_links(entry):
    """
    Register a binding of a permalink and a post_id.
    The registration won't change, since the binding must be permanent.
    """
    permalink = entry.get_html_link().href
    blog_id = entry.get_blog_id()
    post_id = entry.get_post_id()
    # save the pair into the memcache, if it hasn't been stored.
    if memcache.get(permalink) is None:
        memcache.add(permalink, (blog_id, post_id))
    # issue a task, if the pair hasn't been stored yet in the data store.
    if model.get_post_id(permalink, blog_id) is None:
        taskqueue.add(url='/store_pol',
                      params={'permalink': permalink, 'blog_id': blog_id})

引数 url がタスクを処理するハンドラの URL 、params はハンドラに渡されるパラメータだ。また、URL は通常のリクエスト URL と同様、app.yaml に定義したタスクハンドラの URL と一致していなければならない。

結果はどう?

上記のバージョンを GAE に配備して動かしてみたところ、「一覧画面」の表示にかかる時間は確かに改善された。管理コンソールの赤文字も消えた。実装を(少し)複雑にしただけの効果はあったようだ。

結局、GAE で実用的なアプリを作るには、memcache - taskqueue - datastore の三段構えは必須なのだ。だからこそ、標準で用意されているのだろう(taskqueue はまだ experimental となっているけど)。

参考文献

Programming Google App Engine
Dan Sanderson
Oreilly & Associates Inc ( 2009-11-15 )
ISBN: 9780596522728

関連リンク

関連記事

2010-11-16

CPU 使い過ぎ? - GAE 管理コンソール上の警告 (Blogger Glass)

管理コンソールに赤文字が!

前回(→「内部リンクを置き換える #2」)の実装を配備した後、管理コンソールを見に行くと、Dasshboard の Current Load のところに以下のスクショのように赤文字が出ていた。

GAE Admin Console
Dashboard の Current Load
「Avg. CUP (API)」が赤文字で表示されている。 黄色三角上にマウスカーソルを持っていくと「This URI uses a high amount of CPU and may soon exceed its quota.」という警告が出てくる。
Logs
Current Load で赤字になった URI をクリックすると Logs が開く。 cpu_ms と api_cpu_ms を使い過ぎているということらしい。

「/」というリクエスト URI の処理に時間がかかりすぎているようだ。この「/」は、「一覧」画面を表示するためのもので、つまりは「内部リンクを置き換える」機能の実装で変更を加えた部分でもある。「一覧」を作るために取得したフィードから、permalink と Post ID を取り出し、そのひもづけ情報をデータストアに書き出すという処理が付け加えられている。この処理に時間がかかっているようだ。

Quota Details で確認すると……

管理コンソールの「Quota Details」を開きリソースごとの「Daily Quota」を確認すると、どの項目も 0% のまま。「Rate」もすべて Okay となっている。リソースの割り当てを使い切りそうということではない。

困ったときはググってみる

何度かググってみて、Google グループの Google-App-Engine-Japan で関連しそうな情報を見つけた(→「ダッシュボードの「Current Load」の「Avg CPU (API)」が赤文字になる」)。これによれば、この赤文字はさほど気にする必要はないようだ。ただ、平均リクエスト処理に 1 秒以上かかるアプリには同時リクエスト数が 30 までという上限が科せられる、という点は覚えておいた方が良いかも。

GAE ではアプリへのリクエストがしばらく途絶えるとアプリのインスタンスが消される。その後にリクエストが届くと、アプリインスタンスを再生成することになり、このときは(インスタンスが存在するときにくらべると)処理時間がかかる。ログを見ていると、このアプリインスタンス再生成をともなったリクエスト処理のときにも警告が出ている。気付いていなかっただけで、単発の警告はこれまでも出ていたってことだ。これだけでどうにかなるなら、とっくになっている。

設計変更が必要か?

Current Load で赤文字が出るのは、「一覧」画面のリクエストで permalink と Post ID のひもづけ情報をデータストアに保存したときだけだ。一度保存してしまえば 2 回目以降のリクエストでは出なくなる。つまり、赤文字の警告が出るような状況は頻繁には起こらない。

仮に、「設定」画面等に「ひもづけ情報の保存」のためのボタンを設け、ユーザが明示的に指示するようにしたとしても、そのリクエストの処理ではやはり赤文字が出ることになるだろう。

データストアの呼び出しを減らすのは効果があるだろうか? 今の設計/実装では、「一覧」画面のリクエストでは一回につき最大 25 回のデータストアの書き込みが起きる(ページごとの記事数が 25 なので)。データモデルが以下のようになっているから(src/model.py より)、記事の数だけデータストアに書き込むことになる。

class PairOfLinks(db.Model):
    """
    PairOfLinks binds a permalink to a post_id of a blog specified
    with a blog_id.
    Each entity of PairOfLinks must be created specifying a permalink
    as its key.
    """
    blog_id = db.StringProperty()
    post_id = db.StringProperty()

これを例えば一ヶ月単位でまとめて書き込むことにすれば、1 度のリクエストでせいぜい 2 回の書き込みで済む。書き込むデータの総量は変わらないものの、API 呼び出しの回数が 25 回から 2 回に減れば効果はあるかもしれない。その分、前処理やら後処理が複雑になるけれど。

保存先をデータストアから memcache に変えるのは有効だろうか? 処理時間は確実に短くなるはず。その代わりに「ひもづけ情報」が揮発性になってしまう。

そこで登場するのが Task Queue だ。

Programming Google App Engine」、13. Task Queues and Scheduled Tasks より
Enqueueing a task is fast, about three times faster than writing to the datestore. [...snip...] For example, an app can write a value to the memcache, then enqueue a task to persist that value to the datastore. This saves time during the user request, [...snip...]

ここに挙げられている例がぴったり当てはまる。ユーザが「一覧」と「記事」画面を行ったり来たりしている間は memcache が有効だし、セッションが終了または長く中断する間に Task で「ひもづけ情報」がデータストアに書き出されるなら、次回のセッションにはデータストアから情報が取り出せるはず。

memcache、Task、データストア、という三段構えで対応するように実装してみようか。そのためには、Task と Queue について調べないと。

追記@2010-11-30

GAE の Task と Queue の使い方および、それを使った Blogger Glass の変更については以下の記事を参照のこと。

参考文献

Programming Google App Engine
Dan Sanderson
Oreilly & Associates Inc ( 2009-11-15 )
ISBN: 9780596522728

関連リンク

関連記事

2010-11-15

内部リンクを置き換える #2 (Blogger Glass)

#1 のデザイン(設計)にしたがい、最小限の機能を実装できた。

実装

permalink と Post ID をひもづける

ひもづけ情報としてデータストアに保存するのは、(1) permalink、(2) Blog ID、(3) Post ID の 3 つ。このうち、(1) をデータ実体へのキーに使う。というのも、この情報を利用するときには permalink に一致する Post ID を引き出すことになるからだ。permalink をキーにしておけばデータストアに問い合わせるクエリを組み立てる必要がない。

以下が、「ひもづけ情報」を保存するデータ実体の定義と、それを利用するための関数だ(src/model.py)。

class PairOfLinks(db.Model):
    """
    PairOfLinks binds a permalink to a post_id of a blog specified
    with a blog_id.
    Each entity of PairOfLinks must be created specifying a permalink
    as its key.
    """
    blog_id = db.StringProperty()
    post_id = db.StringProperty()

def get_post_id(permalink):
    post_id = None
    pol = db.get(db.Key.from_path('PairOfLinks', permalink))
    if pol:
        post_id = pol.post_id
    return post_id

def bind_post_id_with_permalink(permalink, blog_id, post_id):
    pol = db.get(db.Key.from_path('PairOfLinks', permalink))
    if not pol:                 # new bind
        pol = PairOfLinks(key_name=permalink)
        pol.blog_id = blog_id
        pol.post_id = post_id
        pol.put()

get_post_id 関数は permalink にひもづけた Post ID を取り出すためのもの。記事のテキストを置換する際に用いることを想定している。permalink に対してひもづけられた Post ID がないときには None を返す。

bind_post_id_with_permalink 関数は permalink と Post ID のひもづけを保存するもの。「一覧」系の画面を作る際に用いることを想定している。指定された permalink に対して、すでにひもづけが存在した場合には何もしない。これは、permalink と Post ID の組み合わせが変わることはない、という前提に立つもの。Blogger の場合、記事をポストした後にこの 2 つを変更する手段がないから、この前提が成り立つ。

ひもづけを生成する部分のコードは以下のようになる(src/listview.py)。

        feed = util.get_posts(q)
        if feed:
            [...snip...]
            for entry in feed.entry:
                util.regist_pair_of_links(entry)

実際に、ひもづけ情報を保存する regist_pair_of_links 関数は util モジュールで定義している。上述の bind_post_id_with_permalink 関数を呼び出すだけのものだ。

def regist_pair_of_links(entry):
    model.bind_post_id_with_permalink(entry.get_html_link().href,
                                      entry.get_blog_id(),
                                      entry.get_post_id())

permalink へのリンクを置き換える

記事テキスト内のリンクの置換は以下の関数で行う(src/util.py)。

def replace_permalinks(original):
    pat = re.compile('<a[^>]*href=\"([^"]*)\"[^>]*>')
    new_text = original
    pos = 0
    while pos < len(new_text):
        m = pat.search(new_text, pos)
        if not m:
            break
        permalink = m.group(1)
        logging.info("Permlink: %s" % permalink)
        post_id = model.get_post_id(permalink)
        if post_id:
            postlink = "/post/?id=%s" % post_id
            new_text = new_text.replace(permalink, postlink)
            pos = m.end() - len(permalink) + len(postlink) + 1
        else:
            pos = m.end() + 1
    return new_text

正規表現を使い href 属性を持った a 要素を探し、href 属性の値を取り出す。その値を permalink としてひもづけられた Post ID が記録されていれば、それを Blogger Glass の「記事」画面へのリクエスト URL に置き換える。この処理はテキスト中から a 要素が見つからなくなるまで行う。

テキスト中を(正規表現で)探す際には、開始位置を pos で指定しているが、置換を実行した場合は次の開始位置がずれるため 185 行目で調整している。

これを呼び出す部分が src/postview.py 中にある。

    def fill_view_attrs(self, post_id):
        [...snip...]
        entry = client.get_one_post(settings.get('blog_id'), post_id)
        if entry:
            [...snip...]
            self.view.content = util.replace_permalinks(entry.content.text)

これで完成か?

#1 でも書いたように、データストアに保存したひもづけ情報を「いつ消すか」という問題が残っている。経過時間で切るなら保存した実体の中に記録した日時を保管しなければならない。加えて、「消す」作業を開始するトリガーをどうするかという問題もある。「設定」画面でユーザに明示的に消させるか。その場合、リンクの置き換えが機能するにはログインが前提となる。それでも構わないかな。

関連リンク

関連記事

2010-11-13

ソースコードの色付け - google-code-prettify を使う

古いブックマークを整理していて google-code-prettify を紹介するマイコミジャーナルの記事を見つけた(→「【ハウツー】ハイライトもGoogle流 - "google-code-prettify"でソースコードに色付けを」)。

ブログの記事に埋め込むコードの色付けをしたくて調べていたときに見つけたものだったのだろう。ブックマークしただけで放置して(忘れて)しまっていたのは、外部ファイル(JavaScript と CSS)を使うその仕組みが Blogger で使うには不向きだったから。

しかし今は、GAE を外部ファイルの置き場所として使う方法を知っている(→「外部ファイルの置き場所としての Google App Engine」)。さらに Blogger Glass は、まさにこういうモノを組み込むためにあると言って良い。Blogger Glass に組み込み、さらに Blogger のテンプレートにも組み込んでみた。Blogger のテンプレートではスタイルシートと同様 Style Repository に google-code-prettify を置いた。

使い方

マイコミジャーナルの記事にも簡単な使い方が書かれている。公式なドキュメントとしては README が用意されている。

一番単純な使い方。pre 要素のクラスとして prettyprint を指定する。

class ListViewHandler(webapp.RequestHandler):
    def __init__(self):
        self.app = info.App()
        self.view = info.ListView()

    def get(self):
        util.save_url(self.request, self.response)
        util.fill_app_attrs(self.app, self.request.uri)
        label = self.request.get("label", default_value=None)
        page = int(self.request.get("page", default_value="1"))
        if page > 0:
            self.fill_view_attrs(label, page)
            self.response.out.write(util.render_template(self.app, self.view))
        else:
            self.view = info.MetaInfoView()
            self.fill_metainfo(label)
            self.response.out.write(util.render_template(self.app, self.view))

主要な言語は自動的に認識してくれるが、クラスとして明示的に指定することもできる。クラス属性に prettyprint の後に lang-* という形式で追加する。「*」の部分の書き方は README の「How do I specify which language my code is in?」を参照のこと。Python なら py、Ruby なら rb、つまりはソースファイルの拡張子だと思って良いようだ。

class ListViewHandler(webapp.RequestHandler):
    def __init__(self):
        self.app = info.App()
        self.view = info.ListView()

    def get(self):
        util.save_url(self.request, self.response)
        util.fill_app_attrs(self.app, self.request.uri)
        label = self.request.get("label", default_value=None)
        page = int(self.request.get("page", default_value="1"))
        if page > 0:
            self.fill_view_attrs(label, page)
            self.response.out.write(util.render_template(self.app, self.view))
        else:
            self.view = info.MetaInfoView()
            self.fill_metainfo(label)
            self.response.out.write(util.render_template(self.app, self.view))

行番号を表示させることもできる(5行ごと)。これは linenums を追加する。行番号を 1 以外から開始させるには linenums:15 のように番号を指定すれば良い。

class ListViewHandler(webapp.RequestHandler):
    def __init__(self):
        self.app = info.App()
        self.view = info.ListView()

    def get(self):
        util.save_url(self.request, self.response)
        util.fill_app_attrs(self.app, self.request.uri)
        label = self.request.get("label", default_value=None)
        page = int(self.request.get("page", default_value="1"))
        if page > 0:
            self.fill_view_attrs(label, page)
            self.response.out.write(util.render_template(self.app, self.view))
        else:
            self.view = info.MetaInfoView()
            self.fill_metainfo(label)
            self.response.out.write(util.render_template(self.app, self.view))

既存のスタイルと組み合わせることも可能。以下の例は、このブログでこれまで使ってきた code クラスと prettyprint クラスを同時に指定したものだ。ちなみに、複数のクラスを適用する場合はクラス名を空白で区切って並べれば良い。↓の例では class="code prettyprint" と書いている。

class ListViewHandler(webapp.RequestHandler):
    def __init__(self):
        self.app = info.App()
        self.view = info.ListView()

    def get(self):
        util.save_url(self.request, self.response)
        util.fill_app_attrs(self.app, self.request.uri)
        label = self.request.get("label", default_value=None)
        page = int(self.request.get("page", default_value="1"))
        if page > 0:
            self.fill_view_attrs(label, page)
            self.response.out.write(util.render_template(self.app, self.view))
        else:
            self.view = info.MetaInfoView()
            self.fill_metainfo(label)
            self.response.out.write(util.render_template(self.app, self.view))

スタイルの調整

prettyprint クラスのスタイルは、配布パッケージにふくまれている prettyprint.css で定義されている。ただ、主にトークンの種類による色分けの定義のみで、フォントサイズやフォントの種類については定義されていない。つまり、ブログのスタイルを引き継ぐことになる。

prettyprint.css を組み込む位置に注意すれば、ブログの CSS 中で prettyprint クラスのスタイルを追加(あるいは上書き)することができる。

実際、上の例では以下のようなスタイルをブログ側のスタイルで定義している(Blogger Glass の場合)。

pre.prettyprint {
    margin: 0 2em;
    padding: 5px 1em 5px 1em;
    border: #cccccc 1px dotted; /* silver */
    font-size: 10pt;
    font-family: "Monaco", monospace;
    overflow: auto;
    background-color: #ffffff;
}

これは従来の pre.code のスタイル定義と背景色以外が同一のものだ。

関連リンク

関連記事

2010-11-09

内部リンクを置き換える #1 (Blogger Glass)

Blogger Glass (以下、BG)を作ったのは「Blogger で作ったブログを Blogger とは独立した表示システムで見る」ためだ。そもそもそんなことを考えるようになったのは、Blogger のブログ表示用テンプレート(とスタイル)の構造が複雑だったから。御仕着せのスタイルはどれもしっくりこない。かといって自前でスタイルを定義しようとすればテンプレートの複雑さが立ちはだかる。

それでも複雑なテンプレートと格闘し、どうにか自前のスタイルで見られるようになり、iOS デバイスにも対応した。が、そこで力尽きた。同じことを繰り返す気力が失せた。けれど、この先きっと、デバイスは増えるだろうし、スタイルにも飽きるに違いない。いずれ、また、どうにかしたくなるときが来る。

やがて BG に至る「アプリのかけら」を作り始めたときには、ただのプログラミングの練習のつもりでしかなかった。Ruby で書いてみてPython に書き直し、せっかくだから GAE に載せた

記事の内容が表示できラベルで検索もできる。iPhone でアクセスすれば、iPhone アプリっぽく見えるように外観も整えた。ここまで来れば、BG だけで自分のブログを読むことができる。実際、最近は LOG+REPO の記事を探したり読んだりするのに BG だけで済んでいる。

しかし、まだ 1 つ、機能が足りない。BG だけで(つまり、blogspot にアクセスすることなく)ブログを読むためには、どうしてもあと 1 つ欲しい機能がある。それは、ブログの記事本文に置かれた内部リンク(同じブログの他の記事へのリンク)の貼り替えだ。

(ラベル検索の結果をふくむ)一覧表示画面から記事の内容を表示させることはできる。しかし、その記事本文に張られた内部リンクは blogspot のエントリ(すなわち permalink)を指したままだ。記事表示画面から関連記事を開けば blogspot に行ってしまう。BG だけで読み続けることができないのだ。

そう、最後に残る(基本)機能は、記事中の内部リンクの置き換えになる。

デザイン (意匠と設計)

この機能は外観には影響を与えない。記事中の一部のリンク先が変わるだけだから。よって意匠的には付け加えるものも、変更になるものもない。

一方、仕組みの設計としてはいくつか(これまでにない)考慮点がある。

記事の permalink と (BG が記事表示に利用する) ポスト ID については、どちらも Blogger から取得できるフィードにふくまれており、GData Python ライブラリを使って簡単に取り出すことができる。ポスト ID は一覧表示画面でフィードから取り出し記事表示画面へのリクエスト URL の作成に使っているし、permalink も記事表示画面中で記事タイトル部分にリンクとして貼り付けてある(つまり BG の記事表示画面で記事タイトルをたどると blogspot の同じ記事が開く)。記事本文から permalink に張られたリンク要素を抽出し、href 属性の値を /post/?id=1234567890 のようなもので置き換える。これも正規表現を使うなどすれば難しいことではない。

ここまでは問題ない。問題なのはここから。

問題は大きく 2 つある。すなわち、(a) フィードをいつ、どうやって取得するか。(b) permalink とポスト ID の組み合わせを、どこにどのように(そしていつまで)保持するか。この 2 つだ。

簡単なのは (b) の方。こちらは主に選択の問題。GAE ではデータの保存先は memcache かデータストアの 2 種類だ。どちらを使うかは容量と保存期間で選ぶことになる。あるいは、両者を組み合わせるか。いつまで保持するかについては、ユーザに管理させる(設定画面にクリアボタンを付ける等)か、アプリ側で適当な時期に消すか。データ量としては大した量でもないから前者もアリだが、アプリ(の管理者)にとっては後者の方が良い。

(a) が難しい理由はユーザ体験に関わるから。ブログの全記事を毎回取得するという方法は設計と実装が単純になるが、取得に時間がかかる可能性がある。今の LOG+REPO 程度の記事数(300足らず)なら体感するほどの遅れはないかもしれないが、対話型システムにとって大量のデータを同期的にやったり取ったりするのはできるだけ避けたいもの。それに、iPhone で 3G 回線を使っているときのことを考えれば転送量は少ない方が良い。

今、考えている方法としては、(a1) 他の目的で取得したフィードにふくまれている分だけを記録していく方法、(a2) ユーザに明示的に全取得を指示してもらう方法、の 2 つ。(a1) では一覧表示画面や記事表示画面を作る際に取得するフィードから、そこにある分だけ permalink とポスト ID の組み合わせを拾い出して記録しておき、リンクの置き換えに際しては記録にある分だけを対象とするというものだ。(a2) は設定画面等で記事データの取得ボタンを付けるというようなもの。

(a1) の場合、データの取得は、これまでも内部リンクの置き換え機能の有無に関係なく行っていることで、ユーザ体験として変わるところはない。一度も取得していない記事へのリンクは置き換えられないことになる。とはいえ、記事の内容とは違い、permalink とポスト ID の対応は通常変わることはないから、BG で一度でも表示していれば(一覧表示でも、検索結果表示でも良い)情報が記録されることになり、実用上はこれで十分と言えるかも。

まずは、(a1) 方式で作ってみよう。この場合、むしろ「いつ消すか?」の方が難しいか。「適当な時期」っていつかな。単純に経過時間で切るか。あるいは、参照頻度を加味するか。まずは「消さない」ように作るか(というより「消す」コードを書かないって表現すべきだな)。

追記@2010-12-08

「内部リンク置き換え」機能の実装については、以下の後続記事を参照。

関連リンク

関連記事

2010-11-08

「Labels」ボタンの実装 (BloggerGlass)

「Labels」ボタンは、Blogger Glass の iPhone 専用画面のうち、記事表示画面の下部に付いているボタンだ。その名の通り、記事に付けられたラベルの一覧を表示させるためのボタンとして置いてある。これまでは、対応する機能を実装しておらず、ただの飾りでしかなかった。今回はそれを実装した。

ラベルの取得

ブログ記事のデータは GData Python ライブラリを使って読み込んでいる。ラベルのデータも同ライブラリが処理してくれている。

記事画面の場合

記事画面(を表示するリクエストハンドラ)で扱うデータは gdata.blogger.data.BlogPost だ。記事に付けたラベルは、このオブジェクトの category 属性として保持されている(atom.data.Category オブジェクトのリスト)。atom.data.Category オブジェクトの term 属性がラベルの文字列になる。

記事画面(のハンドラ)ではこれまでもラベルを上記の方法で取り出している。これまでは画面表示用に使うだけだったが、今回から「ラベル一覧画面」用に別途保存することになる。util.save_lables() については後述。

該当する部分のコードは以下のようになる。

(src/postview.py より)
        entry = client.get_one_post(settings.get('blog_id'), post_id)
        if entry:
            [...snip...]
            labels = []
            for label in entry.category:
                labels.append(label.term)
            labels.sort()
            self.view.labels = labels
            util.save_labels(labels, self.request, self.response)

ちなみに、Python のリストのソートメソッドは自分自身を返さないので注意が必要。たとえば上記のコードで、ソートした labels を代入するつもりで self.view.labels = labels.sort() とすると、None が代入されることになる。今回もふくめて何度も痛い目に会ってきたので、忘れないようにここに書き留めておく(それでもまた忘れるんだろうな)。

一覧画面の場合

一覧画面および検索結果画面(を表示するリクエストハンドラ)で扱うデータは gdata.blogger.BlogPostFeed になっている。このオブジェクトは entry 属性として記事データ(gdata.blogger.data.BlogPost)のリストを保持している。したがって、ここから個々の記事に付けられたラベルをすべて集めるには以下のようなコードが必要だ。

(src/util.py より)
def collect_labels(entries):
    """
    'entries' must be a list of gdata.blogger.BlogPost.
    """
    labels = []
    for entry in entries:
        for label in entry.category:
            if label.term not in labels:
                labels.append(label.term)
    labels.sort()
    return labels

これを呼び出すコードはこうなる。

(src/main.py より)
        q = gdata.blogger.client.Query(start_index=start_index,
                                       max_results=info.Pager.PAGESIZE)
        feed = util.get_posts(q)
        if feed:
            [...snip...]
            # collect labels for each post, then save them.
            labels = util.collect_labels(feed.entry)
            util.save_labels(labels, self.request, self.response)

ラベルの保存と読み込み

ラベルの保存には GAE が提供する memcache サービスを使う。データストアとは異なり揮発性のストレージだが、その分高速に動作するとのこと。前回の記事(「Back」ボタンの実装)にも書いたように、ラベル情報の保存先とその形態にはいくつか方法が考えられるが、とりあえずはセッション固有データとして memcache に保存する方式にしてみた。

ラベルの保存と読み取りは以下の 2 つの関数で行う。

(src/util.py より)
def save_labels(labels, request, response):
    sess = session.Session(request, response)
    if memcache.get('labels', namespace=sess.id) is None:
        memcache.add('labels', labels, namespace=sess.id)
    else:
        memcache.set('labels', labels, namespace=sess.id)

def load_labels(request, response):
    sess = session.Session(request, response)
    return memcache.get('labels', namespace=sess.id)

memcache へのデータの書き込みには、keynamespace という 2 つの情報を指定できる。ここでは key として 'labels' という文字列を、namespace としてセッション ID を指定している。セッション ID を使うことで、セッション固有の情報として保存と読み取りが可能になる。

「Labels」ボタン用のリクエスト

以下が今回追加したリクエスト形式になる。iPhone 用の「記事画面」のボタンだけでなく、「アプリメニュー」にも「Labels」項目を追加しておいた。当初は「記事画面」の時だけラベル情報を保存するつもりだったが、「一覧」と「検索結果」でも表示した分の記事に付いたものを集めて保存することにしてみた。これにより「一覧」系の画面から呼び出しても(それなりに)意味のある機能になったため、アプリメニューにも付け加えた次第。

すべての記事に付けられたすべてのラベルを集めて表示することも考えたが、そのためには全記事データの取得が必要でまとまった時間が必要になる。むしろ別機能として実現すべきだと考え、今回は表示した分の記事に付いたものだけを集める、という機能にした。

リクエスト形式 機能
/lables/ 直前の画面で表示された記事に付いていたラベルの一覧を表示する。

このリクエストを処理するハンドラを新たに追加してある。

その他の変更点

以下のコミットを参照。

関連リンク

関連記事

2010-11-07

「Back」ボタンの実装 - セッション管理 (Blogger Glass)

iPhone アプリらしい外観には「Back」ボタンが必要だ。Safari の「戻る」ボタンを使ったのではアプリらしくない。そして「Back」ボタンを実装するためには画面の履歴を記録しておくための仕組みが必要だ。

履歴を記録する仕組みは大きく 2 つの部分に分かれる。履歴そのものを(文字列のリストとして)記録しておくための部分と、そのリストをクライアント(ブラウザ)ごとに持つための部分だ。前者の実装は「リングバッファを作る」で説明した。また、後者を実現するためのセッション管理の肝となる部分も「Cookie の使い方」で説明ずみだ。

今回は、これまでに実装した「かけら」を Blogger Glass に組み込みセッション管理を実現するとともに、セッションデータとして履歴を保持させることで、「Back」ボタンを実現する。

セッション管理

session モジュールに定義した Session クラスは Cookie を用いたセッション管理を実現する。また、セッション固有のデータを data 属性として保持している。このセッション固有データは GAE のデータストアサービスで保存される。

セッションの作り方(ID の生成、ブラウザとの Cookie の授受)は「Cookie の使い方」に書いたコードをほぼそのまま流用している。

セッション管理のほとんどは Session オブジェクトの初期化時に完了している。この初期化時には、リクエストハンドラから RequestResponse のオブジェクトがわたってくることを想定しており、ブラウザとの Cookie の授受も、この 2 つのオブジェクトを通して行っている。

セッション管理の利用者(リクエストハンドラ)側では、Session オブジェクトを初期化し、セッション固有データとしての data 属性を読み書きするだけで良い。

セッション固有のデータ

現在のところ、セッション固有データとして保持するのはリクエスト履歴のみで、これは URL (文字列)のリストになるため、データストア用のモデルは以下のようになる。

(src/model.py より)
class SessionData(db.Model):
    history = db.StringListProperty()

セッション管理をリクエストハンドラに組み込む

Session クラスを使ってセッション管理(とセッション固有データの保存)には以下のようなコードを書く。この関数自体は util モジュールで定義している。

(src/util.py より)
def save_url(request, response):
    history = RequestHistory()
    sess = session.Session(request, response)
    history.import_history(sess.data.history)
    logging.info("Hisotry: %s" % history)
    history.push(request.uri)
    sess.data.history = history.export_history()
    sess.data.put()

リクエストハンドラの get メソッド等で以下のような関数を呼び出す。

back リクエストの追加

セッション固有データとして保存された履歴をたどって、前の画面に戻る動作を実現するのは back リクエストを受けるハンドラになる。

back リクエストの基本的な処理は、(セッション固有データから)リクエスト履歴を 2 つ読み取り、2 つ目のリクエスト URL にリダイレクトする、というものだ。履歴の 1 つ目は back リクエストを送ってきた画面(つまり「Back」ボタンが配置されている画面)であり、戻るのはそのさらに 1 つ前(履歴の 2 つ目)のリクエスト URL になる。ただし、画面によってはユーザ体験として戻る意味のない画面もあり(例: メニュー画面)、そこはスキップし、さらに前の画面に戻るようにしてある。また、戻るべき履歴が空の場合は、いつもトップ画面に戻る。

セッション固有データからの履歴の読み取りは util モジュールで定義した以下の関数で行う。

(src/util.py より)
def load_url(request, response):
    sess = session.Session(request, response)
    history = RequestHistory()
    history.import_history(sess.data.history)
    from_url = history.pop()
    back_url = history.pop()
    if not back_url:
        back_url = '/'
    sess.data.history = history.export_history()
    sess.data.put()
    return (from_url, back_url)

その他の変更点

以下のコミットを参照。

次の一手

「Back」ボタンが完成したので、「iPhone アプリらしく #2 - 実装 (Blogger Glass)」で(画面には配置しておきながら)未実装のままにしておいた部品のうち、残っているのは「Labels」となる。これは、「ポスト画面」で表示中の記事に付けられたラベル一覧を表示する画面に移るためのものだ。

これを実現するには、「Back」ボタンと同様にセッション固有のデータに保存しても良いし(保存先は memcache で十分か)、ブログ ID とポスト ID をキーとしてグローバルデータとして保存するのでも良い。とりあえず、セッション固有データとして保存する方式で作ってみようか。

関連リンク

関連記事

2010-11-01

リングバッファを作る - リクエスト URL をためておく仕組み (BloggerGlass)

「Back」ボタンを実装するためには、戻るべき画面を記録しておかなければならない。現状の画面遷移だけなら 1 つ前の画面を覚えておけば十分なはずだが、将来のことも考えて、画面遷移の履歴を保持するための仕組みを作ってみることにした。

履歴保存用クラス

実体はリクエスト URL (文字列)を保存しておくためのリスト(Ruby で言うなら配列)に過ぎない。ただし、「前の画面に戻る」ための履歴だから、最後に登録した URL を最初に取り出すことになる。つまり、LIFO (Last In First Out)と呼ばれるデータ構造になる。別の呼び方にスタックというのもある。

スタックなら、操作のためのインタフェースは以下のようになる。

スタックの操作
名称 機能
is_empty 空かどうかを返す
push データを 1 つ追加する
pop 最後に追加したデータを取り出す(取り出しされたデータはスタックから削除する)
peek 最後に追加したデータを見る(取り出さない)

最低限必要なのは push と pop で、他はあると便利なものだ。

また、この履歴保存用クラスは、スタックであると同時にリングバッファでもある。これは平たく言えば、バッファが一杯になったとき、古いデータが新しいデータで上書きされていくデータ構造だ。

リングバッファにしたかったのには理由がある。直前の画面に戻るための履歴なのだから、バッファが一杯になったからと言って履歴が保存できなくなるのは困る。一方で、古い履歴よりも新しい履歴の方が重要だ。そういう意味で、新しいデータを常に保存することができる(その代わり古いデータは消えていく)リングバッファは最適だと言える。

export_historyimport_history は、履歴をセッションデータとしてデータストアに収めるときに使用する(ことになるはず)。

単体テスト

RequestHistory クラスは、以下のような単体テストを書きながら、そしてテストしながら実装した。50 行足らずの短いプログラムだと言うのに意外に難しかった。何度「これで良い(はず)!」と思ってからテストに失敗したことか。こういう一般的なプログラムを書くときは、本当にテストが役に立つ。

(tests/util_test.py より)
import unittest

import pathconf
# target module 
import util

class RequestHistoryTest(unittest.TestCase):
    def setUp(self):
        self.history = util.RequestHistory()

    def test_is_empty(self):
        """empty?(EMPTY) == True"""
        self.assert_(self.history.is_empty())

    def test_peek(self):
        """peek(push(EMPTY, A)) == A,
        then pop() also returns A
        """
        self.history.push('/1')
        self.assertEqual(self.history.peek(), '/1')
        self.assertEqual(self.history.pop(), '/1')

    def test_pop_against_empty(self):
        """pop(EMPTY) == None"""
        self.assertEqual(self.history.pop(), None)

    def test_push_1_pop_1(self):
        """pop(push(EMPTY, A)) == A"""
        self.history.push('/')
        self.assertEqual(self.history.pop(), '/')

    def test_push_2_pop_2(self):
        """pop(push(push(EMPTY, A), B)) == B
        pop(pop(push(push(EMPTY, A), B))) == A
        """
        self.history.push('/')
        self.history.push('/foo')
        self.assertEqual(self.history.pop(), '/foo')
        self.assertEqual(self.history.pop(), '/')

    def test_push_10_pop_10(self):
        """H = push(push(...(push(EMPTY, 0), 1), ...), 9)
        then, pop(pop(...(pop(H))...)) = 0
        """
        for i in range(10):
            self.history.push("/%d" % i)
        for i in reverse_range(10):
            self.assertEqual(self.history.pop(), ("/%d" % i))

    def test_push_11_pop_10(self):
        """H = push(push(...(push(EMPTY, 0), 1), ...), 10)
        then, pop(pop(...(pop(H))...)) = 1
        """
        for i in range(11):
            self.history.push("/%d" % i)
        r = reverse_range(11)
        r.pop()
        for i in r:
            self.assertEqual(self.history.pop(), ("/%d" % i))

    def test_export_history(self):
        for i in range(10):
            self.history.push("/%d" % i)
        h = self.history.export_history()
        for i in range(10):
            self.assertEqual(h[i], ("/%d" % i))

    def test_import_history(self):
        h = []
        for i in range(10):
            h.append("/%d" % i)
        self.history.import_history(h)
        r = range(10)

    def test_pickle(self):
        """Make sure that it can be pickled in and out."""
        for i in range(10):
            self.history.push("/%d" % i)
        pickled = pickle.dumps(self.history)
        history = pickle.loads(pickled)

        for i in reverse_range(10):
            self.assertEqual(history.pop(), ("/%d" % i))

def reverse_range(n):
    r = range(n)
    r.reverse()
    return r

def suite():
    return unittest.TestSuite((
            unittest.makeSuite(RequestHistoryTest, 'test'),
            ))

if __name__ == '__main__':
    unittest.TextTestRunner().run(suite())

関連リンク

関連記事

2010-10-31

Cookie の使い方 - GAE におけるセッションの保持

前回(「iPhone アプリらしく #2」)にも書いたように、Blogger Glass は「画面遷移だけの(とても古臭い)ウェブアプリ」だ。これを iPhone アプリらしく見せるためには、ぜひとも「Back」ボタンが必要だ。というのも、iPhone アプリでは、ある画面からボタンやら何やらを押すことで子画面を開き、そこで作業が完了すると親画面に戻る、という操作が良く実装されている。このユーザ体験を実現することは、iPhone アプリとしてごく標準的なことなのだ。

しかし、ウェブアプリでこれ(一つ前に開いていた画面に戻る)をやろうとすると、セッションの保持(と管理)という壁にぶつかる。ステートレスな HTTP 上に作られるウェブアプリの宿命だ。

Blogger Glass では、ここまでセッション管理にまつわることを避けてこれたが、それもそろそろ限界。このあたりで、ちゃんとセッション周りを実装することにしよう。

実現方法

ウェブアプリにおけるセッションの概念とは、たとえるなら病院(医者)とそこに治療に来る患者の間にあるものだ。初診時に治療セッションが始まり、完治によって終了する。

患者は病気なりケガなりの治療で数回、病院を訪れることになる(セッションの継続)。その度に、病院(医者)の側が患者のことをすっかり忘れてしまっては治療は成立しない。病院(医者)は個々の患者について、病気(やケガ)の状態と治療の経過を記録しておかなければならない。それが患者ごとに用意されるカルテと呼ばれる記録だ。

一方、カルテに書かれた記録を有効に利用するためには、患者一人一人を識別できるようにする必要がある。大抵は(カルテに書かれた)患者の名前で間に合うが、中には同姓同名の患者もいるから常に確実な方法とは言えない。そんなわけで患者に一意の番号を割り当て、それでカルテと患者をひもづける。でも、患者にとったら何桁にもなる番号を覚えるのは大変なので、病院はこの番号を記録したカード、すなわち診察券を用意して、初診時に患者にわたす。

病院がウェブアプリで患者がブラウザ、カルテはウェブアプリが記録するセッション情報で、カルテと患者をひもづける番号がセッション ID という対応関係になる。

残るは診察券に対応するモノ(ブラウザ側にセッション ID をわたすための仕組み)だが、これには 3 つの方法がある。すなわち、(1) URL に埋め込む方式、(2) HTML のフォームに隠し要素として埋め込む方式、(3) cookie を使ってわたす方式、の 3 つだ。

練習も兼ねて、今回は (3) の方式でセッションを実現してみる。

サンプルプログラム

以下のサンプルプログラムでは、セッション情報(整数値 1 つ)は GAE の提供するストレージサービスの 1 つである memcache を利用している。このサービスはもう 1 つのデータストアとは違い、「揮発性」のストレージだ。容量も(データストアに比べればかなり)小さい。その代わり、ずっと高速に動作するらしい。頻繁にアクセスする少量のデータは、memcache に置く方が良い。

このプログラムを GAE アプリのリクエストハンドラにしてアクセスすると、「Back」と「Forward」という 2 つのリンクを持つページが開く。Forward をたどると memcache 中のデータ(カウンタ)がインクリメントされ、Back をたどればデクリメントされる。ただ、それだけのプログラムだが、内部的にはセッション管理がなされており、memcache に保持される値はクライアントごとに用意される。実際に複数のブラウザで開けばそのことがわかる。

memcache に保存するカウンタとは別に、データストアにも 1 つ値を保存している。これはセッション管理そのものとは関係ない。この値は、セッションを作るために必須の ID (先の病院と患者のたとえで言うなら、カルテと患者をひもづける番号だ) を作るための「種」になっている。

セッション管理の仕組み自体は単純で、リクエストを受けたら(ハンドラの get メソッドが呼ばれたら)、ブラウザが送ってきた cookie からセッション ID を取り出す。次にセッション ID をキーとして memcache からカウンタの値を取り出す。

ブラウザが cookie を送ってこなかった、あるいは(このプログラム用の)セッション ID がふくまれていないときは、新しくセッション ID を生成しレスポンスヘッダに入れる。

「Back」ボタンを実装するには

Blogger Glass (の iPhone 用画面)に「Back」ボタンを実装するには、画面を開くときにリクエスト URL をセッション情報として保存すれば良い。「Back」ボタンには専用のリクエスト URL を用意しておく(たとえば /back)。そのリクエストハンドラでは、セッション情報から保存されたリクエスト URL を取り出し、そこにリダイレクトする。

まだ、コードを書いていないから確信はないけれど、だいたいこんな感じで動きそうだ。

関連リンク

関連記事

2010-10-30

iPhone アプリらしく #2 - 実装 (Blogger Glass)

Blogger Glass: List view

前回(「iPhone アプリらしく #1」)、画面のデザイン(おもに意匠)を示してから、途中寄り道をしていたりもしたが、ようやく iPhone アプリらしい画面が動くようになった(一部、未実装の機能あり)。

Blogger Glass は(今のところ) Ajax はもちろん、JavaScript すら使っていない、純粋に画面遷移だけによるとても古臭いウェブアプリだが、それでも画面を整えてやることでグッと iPhone アプリらしく見えてくる。

実装方針

デバイスによって画面テンプレートとスタイルの切り替える

iPhone アプリらしい画面を Mac 用のものと共通にしようとするのは骨が折れるばかりだとわかった。同じ iOS デバイスの iPad にしたところで、画面の大きさがここまで違うと同じ画面を使い回すのはユーザ体験を低下させかねない。iPad のアプリの多くが「HD」と銘打って(iPhone と兼用ではなく) iPad 専用となっているのもうなずける。思い切って、画面を作るためのテンプレートとスタイルシートは iPhone 専用のものを作ることにした。

Blogger Glass を作り始めたときから、画面の構造(テンプレート)は画面ごとに用意していた。画面が増えてきたときに共通部分をくくり出すことにしたが、取り出せたのは、head 要素の他は画面の上部と下部に表示させることにした一部の情報用の領域のみ(図の App Header と App Footer)。

iPhone 用の画面を作っているときに気付いたが、この共通情報は、ブログなどのウェブサイトの構造を引きずったものだ。ウェブアプリには向かない、むしろ不要と言って良いものだった。そんなわけで、iPhone 用の画面構造(テンプレート)では、App (Header|Footer) をばっさり削った。以下に示す通り、iPhone 用にも base.html は存在するものの、共通化されているのはほぼ head のみになっている。

(src/templates の構造)
templates/
+-- base.html
+-- iphone/
|   +-- base.html
|   +-- list.html
|   +-- menu.html
|   +-- post.html
|   +-- search.html
|   +-- settings.html
+-- list.html
+-- menu.html
+-- post.html
+-- search.html
+-- settings.html

画面の構造が変われば、スタイルシートも変わる。idclass を駆使し、複雑なセレクタ指定を使えば、すべての画面に対して 1 つのスタイルシートファイルで対応することもできる。が、それは巨大なファイルになるし、複雑さを軽減させようとテンプレートを分けたのにスタイルシートで複雑さを増やしていたのでは片手落ちと言うものだろう。だから、スタイルシートも画面ごとに分けることにした。

ただし、共通部分の多い画面(一覧画面と検索結果画面等)ではスタイルシートファイルを @import することで、同じスタイルの定義が複数の場所に散らばることの避けた。もっとも、こういう方針を厳守しているかと問われると、実は心もとなかったりする。CSS って書き方(文法)に自由度がありすぎて、統一した様式で書くのって難しいんだよ。

(src/stylesheets の構造)
stylesheets/
+-- default.css
+-- ipad.css
+-- iphone/
|   +-- common.css
|   +-- list.css
|   +-- menu.css (iphone/list.css を import; 一部独自スタイルで上書き)
|   +-- post.css
|   +-- search.css (iphone/list.css を import)
|   +-- settings.css
+-- mac.css (default.css を import)

実装

デバイスに応じて画面の構造(テンプレート)とスタイルを切り替える仕組みが、新たに追加した device モジュールだ。

device モジュール

このモジュールでは、クライアントに想定してる各種デバイスを表すクラスといくつかのユーティリティ関数を定義している。

リクエストハンドラからは、ユーティリティの 1 つ、ファクトリ関数を呼び出してリクエストデバイスを表現するオブジェクトを取得する。ファクトリ関数を以下に示す。

(src/device.py より)
def get_device(user_agent, view):
    if user_agent.find('iPad') > -1:
        dev = iPad(view)
    elif user_agent.find('iPhone') > -1 or user_agent.find('iPod') > -1:
        dev = iPhone(view)
    else:
        dev = Mac(view)
    return dev

これを呼び出すリクエストハンドラのコードは以下のようになる。

(src/main.py より)
        user_agent = self.request.headers['User-Agent']
        view = self.viewinfo.type
        self.viewinfo.device = device.get_device(user_agent, view)

ViewInfo オブジェクトに格納された device オブジェクトは、画面を定義しているテンプレートの切り替えと、画面テンプレートの中でスタイルを定義している CSS ファイルの参照に使用される。

(src/util.py より)
def render_template(handler):
    view = handler.viewinfo.type
    path = handler.viewinfo.device.template
    return template.render(path, {
            'app': handler.appinfo,
            'view': handler.viewinfo,
            })
(src/templates/iphone/base.html より)
<!DOCTYPE HTML>
<html lang='{{ app.lang }}'>
<head>
  [...snip...]
  {% for css in view.device.stylesheets %}
  <link type='text/css' rel='stylesheet' href="{{ css }}">
  {% endfor %}
</head>

各種デバイスを表すクラスを示す。

(src/device.py より)
class Device(object):
    def __init__(self, device, view):
        self.is_ios_device = False
        self.stylesheets = None
        self.template = None
        self.template = get_template(device, view)
        self.stylesheets = get_stylesheets(device, view)

class iPhone(Device):
    def __init__(self, view):
        Device.__init__(self, 'iphone', view)
        self.is_ios_device = True

is_ios_device 属性は、専用テンプレートを使っている iPhone では不要だ。他のデバイス(Mac) とテンプレートを共有している iPad でまだ使っているため残してある。実際に、テンプレートとスタイルシートの切り替えを実現しているのは、基底クラス(device.Device) の初期化中に呼び出されている以下の 2 つの関数だ。

(src/device.py より)
def get_template(device, view):
    if device == 'iphone':
        name = ("templates/%s/%s.html" % (device, view))
    else:
        name = ("templates/%s.html" % view)
    return os.path.join(os.path.dirname(__file__), name)

def get_stylesheets(device, view):
    style_dir = 'stylesheets'
    if device == 'iphone':
        common_style = ("/%s/%s/common.css" % (style_dir, device))
        view_style = ("/%s/%s/%s.css" % (style_dir, device, view))
        return [common_style, view_style]
    else:
        return [("/%s/%s.css" % (style_dir, device))]

これを見ればわかるように、切り替えといっても大袈裟なものではなく、デバイス名と画面のタイプをそのままディレクトリの構造とファイル名にしているだけ。

この部分を実装し始めた当初は、テンプレートもスタイルシートもパス名を自由に設定できるような作りにしていた。一言でいえば、デバイス名と画面タイプをキーとする二重のディクショナリだ。で、その二重ディクショナリにセットする値(つまりファイル名)を書いていて、何かひどく間違ったことをやっている気になった。

確かに、ひとつひとつファイル名を設定できた方が自由度が上がる。極端な場合を想像すれば、アプリ外部の URL を(テンプレートはともかくスタイルシートなら)使うことだってできる。しかし、それは過剰な(そして何より不要な)自由度ではないだろうか? そう思えてきた。

このとき頭の中にひとつの言葉が浮かんできた。それは "Convention over Configuration"、そう Rails の基本理念の 1 つだ(→ 「誰が Redmine を起動したのか?」の「Convention over Configuration (設定よりも規約)」を参照)。

デバイス名と画面タイプ(の名前)でファイルが特定できるなら、それをそのままファイルシステムにマップすれば良い。iPhone 用の一覧(list)画面なら iphone/list.html と決めれば良い。誰でもすぐに思い付く自然な規約(convention)だろう。むしろ直感的と言って良いほど。

(プログラマにとって)大事なことを思い出せたおかげで、コードはグッとシンプルなものになった。中途半端に(デバイス名による)条件分岐が残っているのは、今回採用したテンプレートとスタイルシートを画面ごとに分割する方式に則っているのが iPhone の場合だけだから。ま、そのうち、直すとしよう(iPad 用の画面を作るときかな)

jQTouch

今回の更新から、「iPhoneアプリケーション開発ガイド」で紹介されている jQTouch というライブラリを使っている。といっても、使っているのは jQTouch の配布にふくまれているボタン等の背景画像のみだ(iPhone 用の画面で使用)。

やはり外部で配布されているものなので、画像を src/images ディレクトリにコピーするような乱暴なことはやらず、配布物の構造そのままに src/lib に置くことにした (GData ライブラリと同様に bgithub のリポジトリには入れていない)。このため、iPhone 用のスタイルシートからは /lib/jqtouch/themes/jqt/img/button.png のような URL でアクセスしている。

スクリーンショット

この iPhone 対応版はすでに appspot に配備ずみ。ボタンの機能等で未実装部分が一部、残っているが、主要機能は iPhone/iPod touch で動かすことができる。以下は、実際に GAE アプリとして動かした様子を iPod touch (Retina ディスプレイ搭載) で撮ったスクリーンショットだ。

iPod touch によるスクリーンショット
一覧画面
画面上部左にはメニュー画面を開くためのボタンを配置。 画面下部は一覧のページ切り替えのためのボタンを配置。 また、ページ切り替えのボタンはページに応じて有効、無効が変化する。 無効の場合は、ボタンの文字がグレーで表示される。
メニュー画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 メニューの項目はログインの状態によって変化する。
設定画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 「Save Settings」等、HTML フォームのボタンについてはスタイル(文字の大きさ等)を検討中。
記事画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 画面下部左にはラベル一覧画面を開くボタンを配置(機能は未実装)。
検索結果画面
画面上部左にはメニューを開く前の画面に戻るたけのボタンを配置(機能は未実装)。 その他は一覧画面と同様(下部のページ切り替えボタン等)。

今後の展開

もちろん、未実装の部分(機能しないボタンとか)を実装しなければならない。「Back」ボタンなどは簡単に見えて、実装するには戻り先となる前の画面の情報を覚えておかなければならないため、それほど単純なものではない。GAE のデータストアサービスを使うか、あるいは(まだ使い方を調べていないけど) memcache サービスの方が良いのか。「Labels」(ラベル一覧画面を開く)も、ラベルの一覧をどこに保持しておくかで同様の課題がある。ま、見かけほど簡単じゃないと思ったから後回しにしているわけだけどね。

実用的になってくると毎日使うことになり、使えば使うほど「あら」も「ぼろ」も見えてくる。見えれば、「あら」は埋めたくなるし、「ぼろ」は繕いたくなる。やることはまだまだあるってことだ。

iPhone に専用画面で対応したんだから iPad にも同じようにしたい。記事の中身を読むという点では iPad の方が読みやすいんだから。

あと、iPhone 用としてはオフラインで記事を読めるようにしたい。ネットにつながらなかったり、つながっても遅かったりすると、やはりオフライン機能が欲しくなる。HTML5 のオフラインアプリケーションキャッシュ(「iPhoneアプリケーション開発ガイド」6 章で解説されている)を使えば可能かもしれない。しかし、そこまで考えるなら、もういっそのこと Objective-C で書き直すべきかもしれない。GData クライアントは Objective-C 版 (gdata-objectivec-client) もあるしな。

参考文献

関連リンク

関連記事

2010-10-28

リクエストの表現 (あるいは URI) の設計 - Blogger Glass

Blogger Glass では、リクエスト(特定の機能を実行するための URL)を表現するために「クエリ変数」を用いている。具体的には、個別の記事を見るためのリクエストは以下のようになる。

http://bloggerglass.appspot.com/post/?id=1234567890

Blogger Glass にとって(Blogger にとっても)、記事は最小のリソースだが、記事 ID はそれを特定するために必要十分な情報だ。言い換えると、/post/ リクエストに対する付加情報(引数と言っても良い)には記事の ID 以外にはない。このことを踏まえると、/post/?id=1234567890 という表現の中の ?id= の部分は余計なものだとわかる。これは以下のように書けばすっきりした表現になる。

http://bloggerglass.appspot.com/post/1234567890

同じことは他のリクエストに対してもあてはまるだろうか?

2 つの形式

(「RESTful Webサービス」p.126)
パス変数は階層をトラバースしているように見えるし、クエリ変数はアルゴリズムに引数を渡しているように見える。「検索」にはアルゴリズムのような響きがある。

以下では、「クエリ変数」を使ったリクエストの表現を「アルゴリズム形式」、パスにマップした表現を「パス形式」と呼ぶことにする。

プログラミング的に見れば、必要な情報をウェブアプリに渡すという意味で 2 つの形式は等価だと言える。ただし、アルゴリズム形式は、伝統的に CGI で使われてきたこともあり、パス形式より簡単に扱える(後述)。

ヒトが読むことを想定するなら、アルゴリズム形式よりもパス形式の方が見やすい。先の個別記事を見るためのリクエストの例のように、パスの階層の意味が明確ならなおさらだ。

とはいえ、ウェブサービスを作っているならともかく、ウェブアプリではトップの URL を別にして、リクエストを表現する URL はユーザの目に触れることを意図しない。であれば、どちらを使ったところで「ユーザ体験」に差が出るとは思えない。

試してみる

なにはともあれ、試してみよう。

Blogger Glass 全リクエスト形式 (省略形をのぞく)
リクエスト 機能
/?page=<number> number で指定したページの一覧を表示する。
/post/?id=<id> id で指定した記事の内容を表示する。
/search/?label=<string> ラベル string で記事を絞り込んむ。結果が複数ページにわたる場合は、最初のページ(ページ番号 0)を開く。
/search/?label=<string>&page=<number> ラベル string で記事を絞り込んだ結果のうち、number で指定したページを開く。
/settings/ 設定変更のための画面を開く。

これらアルゴリズム形式のリクエストをパス形式で表現すると以下のようになる(付加情報のない /settings/ を除く)。

アルゴリズム形式とパス形式の対応
アルゴリズム形式 パス形式
/?page=<number> /<number>
/post/?id=<id> /post/<id>
/search/?label=<string> /search/<label name>
/search/?label=<string>&page=<number> /search/<label name>/<number>

以下では、個別記事を見るためのリクエストをパス形式にするための実装上の変更点を見ていく。

追記@2010-10-31

以下の記述はほぼ無用のものだった。GAE の webapp フレームワークでは、リクエスト URL とリクエストハンドラを結びつける URL マッピングという機能を提供している。これにより、上述のパス形式で表現されたリクエスト URL の中から、その一部をパラメータとして切り出すことが簡単にできる。正規表現のグルーピングを使って切り出す部分を指定すると、get メソッド等の引数となってわたってくる。

具体的にはこうなる。

(src/postview.py より)
class PostViewHandler(webapp.RequestHandler):
    [...snip...]
    def get(self, post_id):
        [...snip...]

def main():
    application = webapp.WSGIApplication([('/post/(\d+)', PostViewHandler),
                                          ],
                                         debug=True)

Bloggger Glass のコードには、GAE の便利な機能を知らないために書いてしまっている無駄なコードが他にもまだあるんだろうな。

app.yaml への記述
(src/app.yaml より)
- url: /post/|/post/.*/?
  script: postview.py

省略形の /post//post/1234567890/ のように末尾の余分なスラッシュに対応するために、URL パターンは少し複雑になっている。

リクエストハンドラの変更

変更点は主に 2 ヶ所。1 つは URL から付加情報を取り出す部分、もう 1 つは URL に対するハンドラの対応づけだ。

付加情報の取り出しは util モジュールに関数として置く。

(src/util.py より)
def get_params(uri, request_type):
    """This function assumes that a request uri must be written in
    one of the following forms:
    - 'http://<host>/<param_1>/<param_2>/.../<param_n>/'
    - 'http://<host>/<request_type>/<param_1>/<param_2>/.../<param_n>/'
    """
    uri_parts = uri.split('/')[3:]
    if len(uri_parts) < 1 or uri_parts[0] == '':
        params = None
    elif uri_parts[0] == request_type:
        params = uri_parts[1:]
    else:
        params = uri_parts
    return params

呼び出す側を以下に示す。

(src/postview.py より)
        post_id = util.get_params(self.request.uri, 'post')[0]

ハンドラの対応づけ部分はこうなる。

(src/postview.py より)
def main():
    application = webapp.WSGIApplication([('/post/', PostViewHandler),
                                          ('/post/.*/?', PostViewHandler),
                                          ],
                                         debug=True)

app.yaml 同様の指定になっている。2 つに分けているのは見易さのためだ。

参考文献

RESTful Webサービス
Leonard Richardson, Sam Ruby
オライリー・ジャパン ( 2007-12-21 )
ISBN: 9784873113531
おすすめ度:アマゾンおすすめ度

関連リンク

関連記事

2010-10-26

iPhone アプリらしく #1 - 画面デザイン (Blogger Glass)

ウェブアプリを iPhone アプリらしく仕上げるためには、画面のデザイン(とくに意匠)も iPhone 用のものが必要になる。単にスタイルシートの切り替えだけでは限界がある。さらに言えば、スタイルシートも共通部分と画面に特有のものとに分けるべきだ。

以下は、Blogger Glass の主要画面のデザインを iPhone 用に作り直したものだ。

Blogger Glass: iPhone 用画面デザイン
画面 外観 説明
一覧画面 記事の一覧をリストとして表示する。各項目をタップすることで個別記事の内容画面に移る。左上のボタンでメニューを表示する。
内容画面 ブログの個別記事の内容を表示する。左上のボタンでリスト表示に戻る。左↓のボタンで、記事に付けられたラベル一覧を表示する(→ ラベル検索画面)。
メニュー アプリメニューを表示する。「Back」でメニューを開く前の画面に戻る。
ラベル検索画面 記事に付けられたラベルをリストで表示する。各項目をタップすることで、ラベル検索を行う。下部にはラベルによる絞り込みの状況を表示する。メニューと同様、「Back」で 1 つ前の画面に戻る。

画面のデザインが変われば機能にも手直しが入る。

たとえば、iPhone の画面の狭さから、これまではアプリ画面上部に共通で表示させていたメニューは、別画面として独立させることにした。また、内容画面では一覧表示に戻るためのボタン(リンク)を用意する。メニューやラベル検索画面にも「Back」ボタンを用意して元の画面に戻れるようにする。

この「戻る」を実現するためには、元の画面(をリクエストする URL)をプログラム的に記憶しておく仕組みが必要になる。

変更するのはスタイルシートとテンプレートだけで済みそうにないな。

関連記事

2010-10-25

iOS デバイスに対応する (基礎編) - Blogger Glass

Blogger の提供するスタイルにあれこれ変更を加え始めたのは何より iPhone (と iPad) で見やすくしたかったからだ。それが高じてブログの記事を表示する GAE アプリを作るまでに至った。ある程度の実用性を備えた今こそ、iOS デバイスの対応を始めるときだ。

iOS デバイスへの対応と言っても、これまでのように単にスタイルを最適化する程度で終わりたくない。ブログコンテンツを表示するためのアプリと呼べるものに仕上げたいのだ。最終的には Blogger Glass を iOS デバイス用のウェブアプリと呼ぶにふさわしいモノにするつもり。何をどこまで作り込めばそう呼べるかはまだわからない。そもそも BG 自体まだ機能的に不足しているし、実装ずみの機能も洗練されているとは言えないしな。課題は(見えないものもふくめて)多いが、まずは一歩を踏み出そう。そうすれば次に進むべき方向も見えてくるから。

今回は基礎編。まずは以前の成果(スタイル定義)を流用して、現在、Blogger (の LOG+REPO) を iOS デバイスで開いたときと同等の外観を実現する。それには主に以下の 2 つの作業が必要だ。

  • Viewport の設定
  • デバイスごとの CSS ファイルの用意

それでは順番に見ていこう。

viewport の設定

(「iPhoneアプリケーション開発ガイド―HTML+CSS+JavaScript による開発手法」p.17)
特に指定がない場合、iPhone 版の Safari ではページの横幅が 980 ピクセルであるとみなされます(図2-3)。多くの場合はこの設定でも問題はありませんが、iPhone の小さな画面に特化したコンテンツを作成するためには横幅を明示する必要があります。

さて、この横幅の指定は meta タグによって「viewport」を設定することで行う。viewport の設定(の meta タグ)は(iOS デバイスの Safari)以外のブラウザでは無視されるということだ(→「iPhoneアプリケーション開発ガイド」p.18)が、そこはウェブアプリなのだからリクエストデバイスに応じて追加するようにしたい。

それが以下の部分だ。app オブジェクトの is_ios_device 属性が True のときだけ viewport 設定のmeta タグを追加している。

(src/base.html より)
<title>{{ app.title }}</title>
{% if app.is_ios_device %}
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1, maximum-scale=1">
{% endif %}
<link type='text/css' rel='stylesheet' href="{{ app.stylesheet }}">

app オブジェクトは、各リクエストハンドラからわたってくる info.AppInfo クラスのインスタンスだ。このオブジェクトに情報を詰めているのが、util.fill_appinfo 関数で、リクエストデバイスの識別を行う util.get_device 関数を利用してデバイスごとの情報を生成している。それにはデバイス専用の CSS ファイルもふくまれている。

(src/util.py より)
def fill_appinfo(appinfo, request_url, user_agent):
    [...snip...]
    device = get_device(user_agent)
    if device == 'iphone':
        appinfo.is_ios_device = True
        appinfo.stylesheet = '/stylesheets/iphone.css'
    elif device == 'ipad':
        appinfo.is_ios_device = True
        appinfo.stylesheet = '/stylesheets/ipad.css'
    else:
        appinfo.is_ios_device = False
        appinfo.stylesheet = settings.get('stylesheet')

デバイスの識別はユーザエージェント文字列中のデバイス名によるもので、3 つのタイプを返す(iphone、ipad、そして mac)。iOS デバイス以外は mac 扱いにしている。

(src/util.py より)
def get_device(user_agent):
    if user_agent.find('iPad') > -1:
        device = 'ipad'
    elif user_agent.find('iPhone') > -1 or user_agent.find('iPod') > -1:
        device = 'iphone'
    else:
        device = 'mac'
    return device

デバイスごとの CSS ファイルの用意

デフォルトスタイルの時と同様に、Style Repository で LOG+REPO 用に提供している各デバイス用のスタイルをほぼそのまま流用した。

iPhone 用では実験的に少し色を変更してあるが、これはすぐに変更することになる(後述)。

Blogger Glass が生成する HTML は HTML5 に準拠することを目指している。headersection、そして footer のような区画分けのための要素を使っているのも、そのためだ。今回、iPad 用のスタイルを用意していて、現在の iPad 上の Safari (OS: 3.2.2 (7B500)) ではこれら区画分けの要素に単独で指定したスタイルが効かないことがわかった。具体的には以下のような定義が無効になる。

(区画分け要素のスタイル)
header {
    background-color: #eeffcc;
}

同じ定義が iPhone および iPod touch (どちらも OS 4.1 (8B117)) では有効になる。iPad の OS がアップデートされれば解決されるだろう。

ま、わたし自身が十分に HTML5 のスペックを読み込んでいないため、BG の HTML5 準拠も手探りだからな。BG が生成する HTML の構造もまだまだ変わる。スタイルもそれに合わせて変えなくてはならない。今のスタイルも暫定版でしかない。

もっと iPhone らしく

さて、以上が iOS デバイス対応の基礎編だ。ここから先は、デバイス対応というよりも、むしろ BG の iOS デバイス専用ウェブアプリ化になる。その手始めとして、今、実験中のスタイル定義によるスクショを示す。iPod touch による表示だ。

これは「iPhoneアプリケーション開発ガイド」の p.19 〜 23 の書かれている手法を適用したものだ。少しリストと見出しのスタイルをいじるだけで、ずいぶん iPhone アプリらしくなる。

ゴールを、iPhone でも使えるモノから iPhone で専ら使うモノに変えると、機能のデザインも(とくにユーザ体験に関して)いろいろと変わってくる。たとえば、ラベル検索による絞り込みも、Mac (上の Safari)で使うことが前提なら、詳細検索画面を作って複数のラベルを指定させて、なんていう拡張を考える。けれど iPhone に特化するなら、まずラベルを 1 つ選択して検索し、その結果表示画面でさらにもう 1 つラベルを選択して(2 つのラベルによる複合検索で)絞り込む、というような使い方を考える。

iPhone では一度に表示できる情報が限られているし、モバイル機器であるためユーザとの対話は短いほど望ましい(ユーザは椅子に座ってじっくりと iPhone の画面を眺めているとは限らない)。そういった機器の特性はユーザ体験のデザインに大きく影響する。ウェブアプリとして実現されていたとしても、iPhone 用を標榜するなら、iPhone アプリらしくあらねばならない。単にスタイルを変えるだけで済む問題ではないってことだ。

参考文献

関連リンク

関連記事

2010-10-24

ページ切り替え機構の切り替え - Blogger Glass

今回はちょっと実験。2 種類のページ切り替えの仕組みを用意し、どちらが使いやすいかを確かめる実験だ。

「ページ切り替え」と言うのは、Blogger Glass の一覧表示系の画面の下部に出てくるもののこと。リストする項目が多いときには複数のページに分割する。そのページを切り替えるための仕組みだ。

現状では、Google の検索結果に出てくるモノ(Goooooooooogle! のようになるアレだ)を真似ている。これをもっとシンプルな「Next」「Prev」ボタンのものと比較してみようと思ったのだ。

2 つの方式の比較

実験というからには、2 つを比較できなければ意味がない。旧来の方式と新しい方式を両方使えるようにしておいて、実際に使ってみて試すわけだ。

幸い、一覧表示系の画面(機能)は「一覧画面」と「検索結果画面」の 2 種類がある。このうち、「一覧画面」のみを新方式にして、「検索結果画面」の方は従来のままにしておく。そうすれば、どちらの方式も試すことができる。

まあ、配備ずみの正式版(appspot で稼動しているもの)と開発中のローカル版(SDK で動かしているもの)で比較するという手もあるんだがね。

リファクタリング

「ページ切り替え機構」を簡単に取り替えられるようにするため、src/info.py、さらに src/main.pysrc/searchview.py にも手を入れる。

具体的には、「ページ切り替え機構」そのものを ViewInfo のインスタンスで抱え、表示対象のページ番号や全ページ数は「ページ切り替え機構」の属性に押し込める。リクエストハンドラは「ページ切り替え機構」を選び、ViewInfo のインスタンスにセットすることになる。

「ページ切り替え機構」は、util.Pager クラスを基底として派生させる。旧方式は util.GlassPager と名付けた。

(src/util.py より)
class Pager(object):
    PAGESIZE = 25
    def __init__(self, page_num, total_posts):
        self.current = page_num
        self.total_pages = 0
        if total_posts > 0:
            self.total_pages = (total_posts - 1) // Pager.PAGESIZE + 1
        else:
            self.total_pages = 1

class GlassPager(Pager):
    class Page(object):
        def __init__(self, number, current=False):
            self.number = number
            self.current = current

    def __init__(self, page_num, total_posts):
        Pager.__init__(self, page_num, total_posts)
        self.pages = []
        for p in range(self.total_pages):
            self.pages.append(GlassPager.Page(p, p == page_num))

src/main.pyMainHandler で「ページ切り替え機構」をセットする付近のコードを示す。

(src/main.py)
            total_posts = int(feed.total_results.text)
            self.viewinfo.pager = util.PrevNextPager(page, total_posts)
            self.viewinfo.start_index = start_index

結果として、ページ切り替えに関する情報の生成と詰め直しを追い出したことになり、少しすっきりした。

シンプルなページ切り替え

ごく単純な「Prev」「Next」の 2 つのボタン(リンク)のある方式で、最初と最後へのショートカットを持っている。つまり、こんな感じになる。

[Top] [Prev] <current page #> [Next] [Last]

また、現在のページが最初のときは「Top」と「Prev」は表示せず、最後のときは同様に「Next」と「Last」を表示しない。

これを実現するための util.Pager の派生クラスは以下のようになる。

(src/util.py より)
class PrevNextPager(Pager):
    def __init__(self, page_num, total_posts):
        Pager.__init__(self, page_num, total_posts)

        if self.total_pages > 0:
            self.last = self.total_pages - 1

        if self.current != 0:
            self.is_top = False
            self.prev = self.current - 1
        else:
            self.is_top = True
            self.prev = 0

        if self.current != self.last:
            self.is_last = False
            self.next = self.current + 1
        else:
            self.is_last = True
            self.next = self.last

で、これを表示するテンプレートはこう。

(src/listview.html より)
{% block view_footer %}
<nav>
  <div class="pager">
    {% if not view.pager.is_top %}
    <span class="l-button"><a href="/?page=0">Top</a></span>
    <span class="l-button"><a href="/?page={{ view.pager.prev }}">Prev</a></span>
    {% endif %}
    <span id="pager-current">{{ view.pager.current }}</span>
    {% if not view.pager.is_last %}
    <span class="r-button"><a href="/?page={{ view.pager.next }}">Next</a></span>
    <span class="r-button"><a href="/?page={{ view.pager.last }}">Last</a></span>
    {% endif %}
  </div>
</nav>
{% endblock %}

info.Menu クラスと同様に、util.Pager クラス(とそのサブクラス)は info モジュールに移すべきかな。

「Goooooooooogle!」方式と「前後」方式

実際に使ってみたところ、「Goooooooooogle!」方式にはとくにメリットがないと感じた。この方式では途中のページにダイレクトに飛べるわけだが、そこに何がリストされているかがわかっていない限りほとんど意味がない。リストを順にたどって見るようなときには「前後」方式の方が切り替えやすい(マウスを動かさなくても良いから)。まとめて何ページも先を開くなら「Goooooooooogle!」方式の方が便利だが、実用上そういう場面が思い浮かばない。

特定の時期に書かれた記事を探すなら大きく前後に移動できた方が便利だが、そういう便利さを実現したいなら月や週で検索できる画面(ビュー)を用意した方が良い。

iPhone のような画面の狭いデバイスの場合も「前後」方式が適している。特定のページを直接開くというユーザ体験を実現するなら、それ専用の画面(ビュー)を作るべきだ。

「一覧」や「検索」といった機能を実行した結果が複数のページに分かれる場合、シンプルな前後方式の方が汎用性が高い(画面のサイズを選ばないから)。そもそも(大量の)複数ページに分かれているという点でユーザ体験としては減点だろう。ユーザが意識して「全部を順番に見たい」というのでない限り、適切に絞り込む手段を提供すべき。そして、適切に絞り込めるなら(ページ数にしてせいぜい 2 〜 3)「前後」方式が(ユーザに余分なことを考えさせないという意味で)直感的だ。

関連リンク

関連記事

2010-10-23

Blogger Glass、ラベルで検索したい

一覧から記事を表示できるようにはなった。しかし、一覧だけから特定の記事を探すのはシンドい。Google の存在に慣れた身にとって「探す == 検索」であって欲しい。Blogger Glass にも「検索」機能が必要だ。

Blogger Data API では、データのいわゆる全文検索には対応していない(→ Blogger API Reference Guide)。可能なのはカテゴリを指定してフィードの内容を絞り込むことだけ。ここでいうカテゴリとは Blogger (の投稿画面など)ではラベルと呼ばれているもの。

たとえラベルによる検索だけだったとしても、何もないよりはマシだ。適切にラベル付けがされていれば効果的な絞り込みができるし、適切でない場合も多少の助けにはなる。

そんなわけで、今回はラベル検索を実装してみる。

調査

カテゴリによる検索

Google Data API (プロトコル) では、フィードを要求する際のパラメータとしてカテゴリを指定できる。これまでにも実装の中で使ってきた max-resultsstart-index などと同様に URL に埋め込む形で利用する。

これはそのまま GData ライブラリでも利用できる。具体的にはこうなる。

(カテゴリによる絞り込み)
        client = gdata.blogger.client.BloggerClient()
        q = gdata.blogger.client.Query(categories=[label.encode('utf-8')])
        feed = client.get_posts(blog_id, query=q)

この例では label にラベル文字列が入っている。複数のラベルで絞り込む場合(AND 結合)は gdata.blogger.client.Query 生成の際に categories に複数のラベルを詰めれば良い。OR 結合で指定する場合はラベルを "|" でつないた文字列を詰める。たとえば、'imac' と 'macbook' のどちらか一方をラベルに持つ記事を探すのであれば categories=['imac|macbook'] をわたす。

デザイン(意匠と設計)

検索結果画面

一般に検索結果は複数の記事になるから、その表示も一覧表示が適している。欲を言えば Google の検索結果のように記事の抜粋も付けたいところだが、現段階ではそこまでは望まない。よって、画面そのものは「一覧画面」と同様になる。また、結果が多数の場合は複数のページに分かれることになるから、これも「一覧画面」と同様にページ切り替えの仕組みも必要だ。唯一の違いは、「検索結果画面」にはどんな検索語(ラベル)で検索したかを表示すべきだという点だ。これは「view-header」の部分に表示する。

リクエスト形式

リクエスト形式として /search/ を追加する。以下に、これをふくめた定義ずみ全リクエスト形式を示す。

Blogger Glass 全リクエスト形式
リクエスト 機能
/ page=0 を指定した場合と同様。
/?page=<number> number で指定したページの一覧を表示する。
/post/ 最新の記事の内容を表示する。
/post/?id=<id> id で指定した記事の内容を表示する。
/search/?label=<string> ラベル string で記事を絞り込んむ。結果が複数ページにわたる場合は、最初のページ(ページ番号 0)を開く。
/search/?label=<string>&page=<number> ラベル string で記事を絞り込んだ結果のうち、number で指定したページを開く。
/settings/ 設定変更のための画面を開く。

実装

新規に追加したのは、SearchViewHandler (後述)を定義する src/searchview.py と「検索結果画面」の src/searchview.html だ。

src/app.yaml には新しいリクエスト形式 (/search/) を追加。src/info.py には「検索結果画面」に表示する情報を詰めるためのクラス(SearchViewInfo)を追加。また、「一覧画面」と「検索結果画面」で共通になるページ切り替えの仕組みは src/util.py に移動させた。src/postview.html への変更はラベルに検索リクエストへのリンクを張るためのものだ。

SearchViewHandler

結果として、ほぼ「一覧画面」の MainHandler と同じコードになった。違いはクエリ(gdata.blogger.client.Query)生成時にラベルを指定している程度だ。fill_viewinfo() をリファクタリングしてくくり出すことを考えても良いかも。

この実装における注意点は、1 つは GData Python ライブラリのバグ(後述)で、もう 1 つは、クエリにラベル文字列を追加する際にエンコードを指定する、という点だ。つまり、label がラベル文字列だったとして、このままクエリにわたすのではなく、label.encode('utf-8') でわたさなければならない、ということ。さもないと、GData ライブラリの奥で以下のようなエラーが出る。

UnicodeEncodeError: 'ascii' codec can't encode characters in position 6-13: ordinal not in range(128)

そう言えば、以前同じような現象に遭遇したことがあった(→ Ruby で書いたフィルタを Python で書き直す #2)。文字列のエンコードの問題には、こっちが忘れた頃に出くわすなあ。

GData Python ライブラリのバグ?

実は、今回の実装の過程で、GData Python ライブラリのバグだと思われるものにぶつかった。それは上述のようにクエリにカテゴリを指定しても絞り込みが行われないというものだ。原因は gdata/client.py 中にあった。

(gdata/client.py のオリジナル; 827 行付近)
 759: class Query(object):
[...]   [...snip...]
 824:   def modify_request(self, http_request):
 825:     _add_query_param('q', self.text_query, http_request)
 826:     if self.categories:
 827:       http_request.uri.query['categories'] = ','.join(self.categories)

この 827 行目は正しくはこうなるようだ。

(gdata/client.py 修正版; 827 行目)
 827:       http_request.uri.query['category'] = ','.join(self.categories)

要は http_request.uri.query に詰められたキーと値が最終的に Google Data API にわたるが、このキー名が間違っているのだ。複数形の "categories" ではなく単数形の "category" がプロトコルとして正しいクエリ文字列だ。

ま、次のバージョンでは直ってくるだろう。

追記 (@2010-10-24)

このバグはすでに問題として報告されていて、かつすでに修正されているようだ。といっても修正されたのは最近(この 10 月に入ってから)のようだけど。

  • Issue 315 (gdata-python-client; Project Hosting on Google Code)

今後の展望

Blogger Glass はブログを「見る」ことに特化したアプリだ。その意味では「検索」機能は重要な機能だと言える。今回実装した「検索」機能は、とくにそのユーザ体験において必要最低限のものにも到達していない。なにしろ、個々の記事を開いたときにしか検索機能を使うことができないのだ、ユーザにとって使いやすいものではない。せめて、ラベルの一覧表示は欲しい。できれば一覧の他にタグクラウドのような表示も欲しい。また、ラベルを複数指定できればさらに実用性が高まるだろう。GData ライブラリ(と API)ではカテゴリを複数指定できる。これに関して足りないのは単純にアプリ側の作り込みなのだ。

一歩前進すれば、より遠くが見えるようになる。ゴールのように見えていた場所は、単なる中継地点でしかなかったことがわかる。

とはいえ、記事の一覧と個々の記事の内容が独自のスタイルで表示できるようになったことで、Blogger Glass は実用性のあるアプリになった。今回、指定できるラベルが 1 つに限定されているとは言え、ラベルによる絞り込み(検索)ができるようになり、さらに実用性は高まったと言える。もう記事の投稿の時以外にオリジナルの Blogger の方を開く必要はないかも。

さっき数えたら、Blogger Glass に関係する記事が 28 本になっていた。そろそろ全体の「まとめ記事」を書いて整理した方が良いな。前ばっかり見ていると、いつまでたっても近付かないゴールに(なにしろゴールが動くからな)やる気を削がれかねない。来し方を振り返り積み重ねたものを実感することで、また一歩を踏み出すモチベーションになる。

関連リンク

関連記事

2010-10-22

Blogger Glass でスタイルを定義する #2

前回からの続き。今回は、実際に CSS によるスタイルを定義する。スタイルのベースになるのは、Style Repository で LOG+REPO 用に提供しているものだ。

画面(ビュー)の構造: 詳細編

共通部分 (src/base.html) の構造は前回示した通り。今回は、画面ごとに変化する領域の構造を示す。

個別画面の構造は、ソース上、以下のファイルで定義されている。

ちなみに、前回は書かなかったが、以下に示す HTML の構造図では、主に CSS のセレクタに記述する書式で HTML の要素を書いている。h1blockquote はそのまま HTML の要素名で、pre.code のように . (ピリオド) に続くのはクラス名だ。また # (シャープ) に続くのは ID を表している。ただし、div 要素については、他と混同する恐れがない限り .pager#contents のように要素名を省略して書くことにする。

一覧表示画面

テンプレート (src/listview.html) を見てもわかる通り、この画面の特徴となる構成要素は、ブログ記事のタイトル一覧(内容表示画面へのリンク付き)とページ切り替え用のリンクだ。

#view-header
|   +-- h2
|   +-- p
#contents
|   +-- ol
|       +-- li
|       +-- ...
#view-footer
    +-- nav
        +-- .pager
            +-- span
            +-- span.current-page
            +-- span.other-page
            |   +-- a
            +-- span
内容表示画面

内容表示画面では、#contents 領域にブログの記事がそのまま流し込まれる。よって、この画面の HTML の構造は src/postview.html と記事の部分の構造を融合したものになる。以下の構造図では、典型的な記事の構造を挿入してある。

#view-header
|   +-- h3
|       +-- a
#contents
|   +-- h4
|   +-- p
|   +-- blockquote
|   +-- h4
|   +-- h5
|   +-- .right
|   |   +-- img
|   +-- p
|   +-- h5
|   +-- p
|   +-- table
|   |   +-- caption
|   |   +-- thead
|   |   +-- tbody
|   +-- h4
|   +-- pre.code
|   +-- pre.terminal
|   +-- ul
|   +-- ...
#view-footer
    +-- #labels
        +-- span.label
        +-- ...
設定画面

主要素の formtable 内に配置されている。table は 第 1 行 (theadに収められている) と 2 行目以降の第 1 列が見出しになる標準的な構造だ。form の要素 (input) は2 行目以降の第 2 列に置かれている。

#view-header
|   +-- h3
#contents
|   +-- form [action='/settings/', method='post']
|   |   +-- table
|   |       +-- caption
|   |       +-- thead
|   |       |   +-- tr
|   |       |   |   +-- th
|   |       |   |   +-- th
|   |       +-- tbody
|   |       |   +-- tr
|   |       |   |   +-- th
|   |       |   |   +-- td
|   |       |   |       +-- input [name='blog_id']
|   +-- form [action='/settings/', method='post']
|       +-- form-buttons
|           +-- input [type='hidden', name='delete', value='yes']
|           +-- input [type='submit', value='Delete Settings']
#view-footer
    +-- (text)
        +-- span.timestamp

デフォルトスタイル

いずれ、デバイスによるスタイルの切り替えを実装する(BG 内に作り込むか、または Style Repository で提供する)が、まずは Mac (または PC) 用のものをデフォルトスタイルとして BG 内に用意することにした。また、画面ごとに専用のスタイルを用意するかどうかは検討中で、デフォルトスタイルは 3 つの画面すべてに共通のものにしている。

スタイルの定義は Style Repository で LOG+REPO 用に提供しているものをほぼすべて流用している。HTML の構造の違いを調整した他は、フォントサイズ、色などはそのまま使っている。全体の横幅と要素ごとのマージンとパディングについてはまだ調整していない。

関連リンク

関連記事