2010-10-23

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

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

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

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

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

調査

カテゴリによる検索

Google Data API (プロトコル) では、フィードを要求する際のパラメータとしてカテゴリを指定できる。これまでにも実装の中で使ってきた max-results や start-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 の要素を書いている。h1 や blockquote はそのまま 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
        +-- ...
設定画面

主要素の form は table 内に配置されている。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 の構造の違いを調整した他は、フォントサイズ、色などはそのまま使っている。全体の横幅と要素ごとのマージンとパディングについてはまだ調整していない。

関連リンク

関連記事

twitter より (2010-10-21)

  • 12:00  何だか良くわかんないラインナップだなあ > new MacBook Air。11インチだけなら「世界で一番薄くて軽いMac」でわかりやすかっただろうに。 → http://bit.ly/a7VMie
  • 12:03  あと、バッテリーの駆動時間が MacBook の半分ってどういうことだ? 計測の基準が違うのか? いくら薄くて軽くてもバッテリーが持たないんじゃ持ち歩きにくいだろうに。やっぱ、良くわからん。
  • 12:15  Lion はまだ1年近く先か。Mac App Storeを別にすれば、LaunchPadもフルスクリーンアプリもMission Controlも、ぜんぶ画面の小さなMac用に思えるよ。→ http://www.apple.com/jp/macosx/lion/
Powered by twtr2src.

2010-10-21

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

Blogger Glass (以下、BG)は Blogger で作ったブログの記事を表示するためのアプリだ。その目的は、Blogger の表示用テンプレートを変更することなく、独自の外観でブログ記事を見ることにある。Blogger の提供する(複雑きわまりない構造の)テンプレートに手を入れることなく、記事の見た目を変える。それが BG を作った動機だ。

記事の一覧から内容の表示を作り込み、ブログ閲覧用アプリとしては最小限の機能を備えるようになった。まだ追加したい機能もあるが、このあたりで最初の目的に立ち返り、独自の外観を与えることにしたい。つまり、スタイルを定義するということだ。

テンプレートのリファクタリング

現在、BG には、一覧表示、内容表示、設定という 3 つの画面が定義されている。それぞれ、listview.html、postview.html そして settingsview.html というテンプレートから作られている。まずは、このテンプレートたちをリファクタリングすることから始める。

GAE に組み込まれているのテンプレートシステムの Django では、基本となる共通のテンプレートを定義しておき、画面ごとに差分を定義するという方法を使うことができる。ちょうど、(クラスベースの)オブジェクト指向プログラミング言語における、継承による実装の共有のようなことができるのだ。

以下のように、共通となるテンプレートに {% block foo %} 〜 {% endblock %} で上書き可能なブロックを定義しておく。

(src/base.html より)
[...snip...]
<div id="contents">
{% block content %}
<p>main content area</p>
{% endblock %}
</div>
[...snip...]

個別のテンプレート側では上書きしたいブロックを定義すれば良い。共通テンプレートの内容から、そのブロックだけを置き換えたものが実際のテンプレートになる。

(src/listview.html より)
{% extends 'base.html' %}
[...snip...]
{% block content %}
<ol>
{% for entry in view.content %}
  <li><a href="/post/?id={{ entry.get_post_id }}">{{ entry.title.text }}</a></li>
{% endfor %}
</ol>
{% endblock %}

画面(ビュー)の構造

続いて、画面の構造の見直し。以下がその概要だ。/html/body/section が、個別の画面により変化する領域で、他は(少なくとも構造は)共通になる。

html
+-- head
|   +-- meta[charset]
|   +-- title
|   +-- link[stylesheet]
+-- body
    +-- header
    |   +-- nav
    |   |   #top-menu
    |   |   +-- span.menu-item
    |   |   +-- ...
    |   +-- h1
    +-- section
    |   +-- #view-header
    |   +-- #contents
    |   +-- #view-footer
    +-- footer

個別画面で変化する画面要素と内容の対応を以下に示す。一部、未実装の内容もふくまれている。

画面要素と表示内容
画面 view-header contents view-footer
一覧表示 ページ番号 記事一覧 ページ切り替えリンク
内容表示 記事タイトル 記事本文 ラベル (未実装)
最終更新日時 (未実装)
設定 「Settings」 設定項目 最終更新日時 (未実装)

今日はここまで。実際の CSS の定義は次回に。

関連リンク

関連記事

2010-10-20

GAE アプリ用に PyUnit を使って単体テストを書く

単体テスト

(「アジャイルソフトウェア開発の奥義」p.34)
テストを最初に書くことによって、これまでとは違った視点で物事を見ざるをえなくなるということだ。テストを書こうとすれば、呼び出す側の立場でプログラムを見るようになる。[...snip...] テストを最初に書くことは、ソフトウェアを呼び出しやすい形式に設計することにつながるのだ。

その上、テストを最初に書こうとすれば、プログラムをテスト可能な形式に設計しようと努力するようになる。[...snip...] テストを最初に書くという行為は、ソフトウェアの分離を強いるのだ!

もうひとつの効果は、テストそのものが有益なドキュメントになるということである。[...snip...] この「ドキュメント」はコンパイルも実行も可能であるばかりでなく、常に最新だし、間違いはありえあない。

本体のコードを書く前にテストコードから書き始める、というテスト駆動開発の教義を文字通り実行しないとしても、テストを書くことで違った視点が得られることは確かだ。また、これも良く言われるようにリファクタリングを実行するための後押しにもなる。

そして、何より大事なことは、テストは「動くドキュメント」になるという点だ。

PyUnit

Python には標準添付ライブラリに、単体テストを書き、実行するためのモジュール(unittest)がふくまれている。このモジュールは JUnit の Python 版と言われるだけあって、JUnit を使ったことがあれば、テスト実行の仕組みを理解するのにも、実際にテストを書くのにも苦労しないはずだ。

(単体テストサンプル)
import unittest

# target module 
import foo

class FooTest(unittest.TestCase):
    def test_bar(self):
        self.assert_([...snip...])
    [...snip...]

GAE アプリに適用するには

テスト用のディレクトリを用意する

Python 版の GAE アプリではアプリのソースコードがそのままサーバに送られる。だが、テスト自体のコードをサーバに送るのはムダだ。となれば、アプリのソースとテストはディレクトリを分ける方が良い。

たとえば、こんな風に。

(GAE アプリのディレクトリ構造)
$(APPTOP)/
    +--- src/
    |    +--- app.yaml
    |    +--- main.py
    |    |    ...
    +--- tests/
         +--- run_all_tests.py
         |    ...
モジュールサーチパス

テストをアプリのコードとは別のディレクトリに置く場合、そのままでは import に失敗する。tests ディレクトリにあるテストから src にあるテストの対象となるモジュールを import するには、src をモジュールサーチパスに入れてやらなければならない。

また、GAE のコードであれば、SDK のモジュールをあれこれ import しているはずだから、これもモジュールサーチパスに入れておく必要がある。GData ライブラリのような外部ライブラリを使っているのであればそれも。

今回、モジュールサーチパスを設定するために用意したコードがこれになる。アプリコード用の src に加え、GData ライブラリの atom と gdata、そして GAE の SDK のパスも入れてある。

(tests/pathconf.py)
 1: #!/usr/bin/python2.5
 2: 
 3: import os
 4: import sys
 5: 
 6: current_dir = os.path.dirname(__file__)
 7: extra_path = [
 8:     current_dir + '/../src',
 9:     current_dir + '/../src/atom',
10:     current_dir + '/../src/gdata',
11:     '/usr/local/google_appengine',
12:     '/usr/local/google_appengine/lib/antlr3',
13:     '/usr/local/google_appengine/lib/django',
14:     '/usr/local/google_appengine/lib/fancy_urllib',
15:     '/usr/local/google_appengine/lib/ipaddr',
16:     '/usr/local/google_appengine/lib/webob',
17:     '/usr/local/google_appengine/lib/yaml/lib',
18:     ]
19: sys.path = extra_path + sys.path
20: 
21: if __name__ == '__main__':
22:     print sys.path

したがって、実際のテストは以下のように書く。

(tests/skelton_testcase.py)
 1: #!/usr/bin/python2.5
 2: 
 3: import unittest
 4: 
 5: import pathconf
 6: # target module 
 7: import foo
 8: 
 9: class FooTest(unittest.TestCase):
10:     def test_bar(self):
11:         self.assert_(False)
12: 
13: def suite():
14:     return unittest.TestSuite((
15:             unittest.makeSuite(FooTest, 'test'),
16:             ))
17: 
18: if __name__ == '__main__':
19:     unittest.TextTestRunner().run(suite())
テストできない機能も出てくる

単にモジュールサーチパスを整えただけでは、GAE アプリの実行環境すべてをシミュレートできるわけではない。当然、単体テスト環境では実行できない機能も出てくる。

その制約の中で、いかにテスト対象のコードを GAE の実行環境から独立させるかを考えることが、結果としてコードの独立性を高めることにつながるのだ。

テストを書くだけでも効果がある(かも)

Blogger Glass (以下、BG)にテストを書き始めて(というより、テストの組み込み方をあれこれ試していて)すぐにバグが 1 つ見つかった。BG ではsrc/config.py でアプリの設定を src/config.yaml というファイルから読み込んでいる。その部分のコードは以下のようになっていた。

(src/config.py より)
settings = {}

with open("config.yaml") as f:
    for line in f:
        md = re.search('^([^:]+):\s*([^\s].*)$', line)
        if md:
            settings[md.group(1)] = md.group(2)

これを test ディレクトリに置いたテストケースから import すると、config.yaml が見つからずエラーになる。アプリのコードでは import するコード (最新版では src/util.py) も同じディレクトリにあるためエラーにならない。しかし、それでは config モジュールを利用する側に条件を付けることになってしまう。言葉を換えれば情報隠蔽に失敗している。

このコードの open は、正しくは以下のように書かれるべきだったのだ。

(src/config.py の修正)
with open(os.path.join(os.path.dirname(__file__), "config.yaml")) as f:

これなら、src 以外のディレクトリから import してもエラーにはならない。

テストを書こうとすれば、コードを違った視点で見るようになることも、分離を強いられる(コンポーネントの独立性が高まると言っても良い)ことも間違いないようだ。

参考文献

アジャイルソフトウェア開発の奥義 第2版 オブジェクト指向開発の神髄と匠の技
ロバート・C・マーチン
ソフトバンククリエイティブ ( 2008-07-01 )
ISBN: 9784797347784
おすすめ度:アマゾンおすすめ度

「第4章 テスティング」がテストに関する内容。前半が単体テストを使ったテスト駆動開発について書かれたものになっている。

初めてのPython 第3版
Mark Lutz
オライリージャパン ( 2009-02-26 )
ISBN: 9784873113937
おすすめ度:アマゾンおすすめ度

モジュールサーチパスについては、p.416 〜 421 に記述がある。

関連リンク

関連記事