ドラクエのプレー時間がついに150時間を突破して妻の視線が痛いmikioです。今回は、かんたんCMS「Tokyo Promenade」にスクリプト言語Luaを組み込んでカスタマイズする方法について述べます。

なぜスクリプト言語処理系を組み込むのか

Tokyo Promenade(TP)はCで書かれていて軽量で高速に実行できるCMSです。PerlやRubyなどのスクリプト言語で書かれたCMSはそのソース自体を編集して改良するのが容易ですが、Cの場合は再コンパイル作業が必要だし下手に手を出すとメモリ破壊などの致命的なバグを入れてしまう可能性が比較的高いので、ソース自体を編集してカスタマイズを行うのは現実的ではありません。

そこで、TPではプレゼンテーション層の機能をできるだけテンプレート側に委譲させるとともに、さらに装飾の多くはCSSを編集するだけで変更できるように配慮しています。テンプレートにはJavaScriptを埋め込むこともできますから、入力パラメータに対して決定的な(つまり入力パラメータとTPのデータベースだけに依存してそれ以外の外部要因に依存しない)情報を表現するのであれば、そのカスタマイズ性は現状で十分だと言えます。

HTMLとCSSとJavaScriptをいじればほとんどのカスタマイズはできるのに、なぜサーバ側でのスクリプティングが必要なのでしょうか。主にJavaScriptの制限に起因する2つの理由があります。ひとつは、クライアント側のJavaScriptだけでは計算結果の保存をクッキーに頼ることになるので、サイズの大きいデータを保存したり、ユーザ間で共有したりするのが難しいということです。もう一つは、クライアント側のJavaScriptだけでは外部のサイト(クロスドメイン)のデータを通信で引っ張ってくることができないということです。

最も単純な例としてアクセスカウンタが挙げられます。これはページが閲覧される度に数値をカウントアップするだけの機能ですが、その値を全ユーザで共有する必要があるので、クライアント側の努力だけで実装するのは困難です。ページが閲覧される度に起動されるプロシージャをサーバ側に仕掛けることができればこの問題は簡単に解決します。

実用的な例だと、記事内にあるアンカーテキストのリンク先で書籍のISBNが書かれている場合に、その書籍のタイトルや表紙画像をAmazonから取得して表示することが考えられます。このように、リソース識別子に関連付けられたメタデータを外部サイトから取得して記事に埋め込んで表示したいというニーズは各種考えられますが、セキュリティ上の理由でクロスドメインの通信を禁じられているクライアント側の努力だけではそれに答えることはできません。

アクセシビリティ重要

Amazonの書籍情報を表示したいだけならば、Amazonが提供する埋め込み用のHTMLスニペットを記事に張り付ければいいと人は言うかもしれません。しかし、それはTPの思想からは外れます。TPの記事の素データはWiki記法で文書の論理構造を記述したものです。デフォルトでたまたまHTMLに変換して表示することができるようになっているだけで、HTMLは表現方法の一形態にすぎません。LaTeXやDocBookやroffを介してプレゼンテーションを行うUAへの対応を今後追加するかもしれませんし、そのような対応を容易にしておくことがTPのアクセシビリティのポリシーなのです。その観点に立つと、Wiki記法にHTMLを混ぜてしまったらHTMLとしてしか表現できないようになってしまうので、許容できません。

URIの普遍性という観点も忘れてはなりません。書籍紹介をする際にAmazonのURLを記事に埋め込むことがよくありますが、Wiki記法のレベルでそれをやるのは望ましくありません。その代わりに、ISBNなどの、より普遍性の高いものをリンク先のURIとして記述して、表示時にAmazonのURLに変換する方がアクセシビリティ(アベイラビリティ)が高いと言えます。仮にAmazonが潰れたり買収されたとしてもISBNは不変なので、表示スクリプトを改修してbk1や紀伊國屋にある同等の書籍紹介ページへ誘導することができるからです。

TPは記事の作者の思考を直列化するための機構です。したがって、言及の意図が「Amazonの書籍紹介ページ」に向けられているのであればAmazonのURLを使うべきですが、そうでなく「書籍の内容」に向けられているのであれば、ISBNなどを使うべきです。アクセシビリティを担保するためにまず第一に重視すべきはURIであり、alt属性やその他のメタデータはその補助にすぎないというのが私の考えです(もちろん、そのURIを解釈できない処理系のためにalt属性などを付随させるのは有用であり、その努力は尊敬されるべきものです)。

実際に組み込んでみる

さて、前置きはほどほどにして、インストール手順に進みましょう。TPにLua拡張を組み込むには、ビルド時に以下のような指定を行います。もちろん、その前提としてLuaをインストールしてあることが必要です。

$ ./configure --enable-lua
$ make
$ sudo make install

そして、テンプレートファイル promenade.tmpl の冒頭にある設定に以下の行を加えます(最新バージョンのデフォルトテンプレートではその行が最初からあります)。

[% CONF scrext "hogehoge.lua" \%]

こうすると、TPの起動時に hogehoge.lua というファイルが読み込まれて、それに記述された関数が各種のトリガに応じて呼び出されるようになります。トリガと関数の仕様は以下のようになっています。該当の関数が実装されていない場合は単に無視されます。

  • _begin() : DBを読み込む前に呼ばれます。引数なし。戻り値はテンプレートのbeginmsgという変数に格納されます。
  • _end() : DBを閉じた後に呼ばれます。引数なし。戻り値はテンプレートのendmsgという変数に格納されます。
  • _procart(wiki) : 各記事の表示データを作る直前に、そのWiki記法データを表示用に加工するために用います。引数は元のwiki記法文字列で、戻り値は加工後のwiki記法文字列です。
  • _procpage(html) : ページ全体のHTMLを表示する直前に、HTMLを表示用に加工するために用います。引数は元のHTML文字列で、戻り値は加工後のHTML文字列です。

また、以下の機能が組み込まれています。

  • _params : CGIスクリプトの呼び出しパラメータがテーブルとして保存されているグローバル変数。
  • _user : ユーザの認証情報(nameとpassとinfo)がテーブルとして保存されているグローバル変数。
  • _strstr(src, pat, repl) : 文字列の単純な検索と置換を行う組み込み関数。srcは対象文字列で、patはマッチングパターンで、replは置換文字列。replが省略された場合は一致の検査のみを行う。
  • _regex(src, pat, repl) : 正規表現による検索と置換を行う組み込み関数。srcは対象文字列で、patはマッチングパターンで、replは置換文字列。replが省略された場合は一致の検査のみを行う。

Luaの標準ライブラリにも文字列検索や文字列置換の機能はあるので_strstrと_regexは不要とも言えますが、標準のものはびっくりするほど使いにくいので、_strstrと_regexは重宝することになると思います。ちなみにそれらはTCのLuaバインディングにもおまけで組み込まれています。

サンプル

ネタでいくつか実装してみましょう。ログインユーザに挨拶文を出すためのスクリプトは以下のようになります。

function _begin()
   local name = "World!!"
   if _user then
      name = _user.name
   end
   return "Hello, " .. name .. "!!"
end

各記事をラムちゃん風にするためのスクリプトは以下のようになります。

function _procart(wiki)
   wiki = _strstr(wiki, "ります。", "るっちゃ。")
   wiki = _strstr(wiki, "です。", "っちゃ。")
   wiki = _strstr(wiki, "だ。", "だっちゃ。")
   return wiki
end

不適切な語を含む記事のタイトルにラベルをつけるスクリプトは以下のようになります。

function _procart(wiki)
   wiki = _strstr(wiki, "Tokyo", "Osaka")
   if _regex(wiki, "*sex") or _regex(wiki, "*fuck") then
      wiki = _regex(wiki, "^#!", "#! [sexual] ")
   end
   return wiki
end

簡易的なアクセスカウンタは以下のようになります(排他制御をしていないので本気利用はできませんが…)。上記3例はJavaScriptでもできますが、この例はLuaならではと言えるでしょう。

tmpfile = "/tmp/promscrcount.txt"

function _begin()
   local file = io.open(tmpfile, "r+")
   local count = 0
   if file then
      count = file:read()
      file:seek("set", 0)
   else
      file = io.open(tmpfile, "w")
   end
   if not count then
      count = 0
   end
   count = count + 1
   file:write(string.format("%d\n\n", count))
   file:close()
   return "The access count is " .. count .. "."
end

まとめ

Tokyo PromenadeにLuaを組み込む方法とその思想的背景について説明しました。クライアント側で完結するカスタマイズはJavaScriptでやった方が簡単だしサーバの負荷も軽くなるのでよいのですが、それがサーバ側の能力が必要な場合に組み込みのLuaで補うというのは合理的な選択だと思います。そして、言及するリソースの種類に応じたプレゼンテーションを人間が書き分けるのではなく、記事にはURIのみを記述して表示時にスクリプトが適切なプレゼンテーションを生成するということの意義についても伝われば幸いです。

TPそれ自体をスクリプト言語で書けばいいじゃんとか、サーバ側のスクリプト言語もJavaScriptでいいじゃんとか、いろいろご意見があると思います。CとLuaの組み合わせが絶対最強だと主張するつもりはありません。でも、自分で使ってみた限りでは、そこそこイケてるんじゃないかと思っています。

新生児が家に来たおかげで生活が一変して激太りしたmikioです。さて、一部のWebマニアには好評をいただいているTokyo Promenadeですが、今回はその追加機能について語ります。

サイト移転

Tokyoシリーズの配布サイトを新規設置したホームページに移しました。そもそも、Tokyo Promenadeはそこで使うためのCMSとして作ったのです。以後、Tokyo CabinetTokyo TyrantTokyo DystopiaTokyo Promenade、そしてまだ見ぬTokyo製品のパッケージとドキュメントはそこで配布します。

TPの最新版を使ったブログもそこで書いていく所存です。使い勝手はそちらか、以前設置したデモサイトで確認していただけます。

Atomフィード

最近人気のあるブラウザはRSSAtomの購読機能をデフォルトでサポートしています。また、Google ReaderLivedoor Readerなどのフィード購読サービスを使って効率的にブラウジングを行う習慣も一般化してきています。そういった背景もあって、イマドキのCMSではRSSなりAtomなりのフィード機能を実装していないとユーザにそっぽ向かれてしまうようになってしまいました。個人的にはXHTMLに標準語彙のメタデータを埋め込めばRSSやAtomなど不要なはずだと思っているのですが、世の流れには逆らえません。

ということで、TPの最新版ではAtomフィード機能が実装されています。TPのAtomフィードには以下の5種類のビューがあり、それぞれ対応するHTMLページにあるオートディスカバリから取得することができます。

  • 作成日時順一覧: サイト全体の記事を作成された日時の降順で提示
  • 最終更新日時順一覧: サイト全体の記事を最後に更新された日時の降順で提示
  • 最終コメント日時順一覧: サイト全体の記事を最後にコメントされた日時の降順で提示
  • 記事毎の本文とコメント一覧: 個々の記事に着目して本文とコメントを各エントリとして提示
  • 検索結果: 検索ページの任意の結果の最新情報を提示

デフォルトでは各フィードは降順(新しい順)で提示されますが、ソート順のパラメータ(order)の値に「_」を接頭させると昇順(古い順)にできるという裏技もあります。例えば、「order=cdate」だと作成日時降順なので、「order=_cdate」にすると作成日時昇順になります。

なお、Atomの各エントリおける時間情報のメタデータは、更新日時(updated)と作成日時(published)しかありません。一方で、TPでは、記事の最終更新時刻と最終コメント時刻は別々の属性として管理しているので、そのままだと最終コメント日時順一覧をうまく表現できません。Atom Threading Extensions(RFC 4685)という語彙が標準化されているのでそれを使うことも試みましたが、それに対応したユーザエージェントがそれほど普及していないので現時点では断念しました。仕方ないので、最終コメント日時順の場合に限って、各記事の最終コメント日時を最終更新時刻(updated)として配信するようにしています。結果として、最終コメント日時順のフィードを常用すると2ちゃんねる的なフローティングが発生するフィードになります。

更新プラグイン

サイトが更新された際にはブログ検索エンジンやフィード購読サービスに対して更新通知(ping)を打ってクローラを呼び寄せるというのがブログ等のCMSでは一般的です。しかし、pingを打つURLやプロトコルは対象のサービスによってまちまちなので、具体的な指示を設定ファイルだけで行うことは現実的ではありません。そこで、TPでは、各記事が更新された際に、その記事の情報を渡して任意のコマンドが呼べるようにしています。そのコマンドとしてシェルスクリプトを指定して、その中でpingを打つなり差分をバックアップするなりの任意の処理を行うことができます。具体的な仕様は以下にになります。

  • 設定ファイルのupdatecmdという変数でコマンド名(パス)を指定する。
  • 記事が更新された際にそのコマンドが呼び出され、以下の7つの引数が渡される。
    1. 操作種別 : new/update/comment/removeのいずれか
    2. 記事ID : 対象記事のID番号
    3. 更新後データ : 更新後の記事のWikiデータを保存したキャッシュファイルのパス
    4. 更新前データ : 更新前の記事のWikiデータを保存したキャッシュファイルのパス
    5. タイムスタンプ : 更新時刻のマイクロ秒
    6. ユーザ名 : 更新操作を行ったユーザの名前
    7. スクリプトのURL : 実行されているCGIスクリプトの絶対URL
  • コマンドの終了コードが真(0)なら何もせず、偽(0以外)ならエラーメッセージを出力する。

更新プラグインのサンプルとして、すべての記事の更新履歴をパッチ(diffデータ)として保存してトレーサビリティを確保するための「promupdiff」というスクリプトが同梱されています。また、GoogleとYahooのクローラに更新通知を送るための「promupping」というスクリプトもあります。なお、promuppingの方は、今話題の「Pubsubhubub」の公開ハブにも更新通知を送っています。この仕組みが普及すれば、CMS側としてはいろんなサービスにpingを打つプラグインを量産しなくて済むようになって嬉しいし、購読サービス側としてはクローラの実装が簡単になってかつリアルタイムで情報が取得できて嬉しいことになりそうなので、うまいこと流行ってほしいところです。

object要素

従来から、「@」の後ろにURLを書く記法によって、記事の中に画像を埋め込むができました。しかし、埋め込みたいのは画像だけではなく、動画や音楽を埋め込みたいこともあるでしょう。ということで、「@!」の後ろにURLを書く記法を追加しました。そうすると、img要素の代わりにobject要素が使われ、src属性の代わりにdata属性が使われます。width属性やheight属性を「|」で区切って指定できることは同様です。その後ろにさらに「|」で区切ったフィールドを追加すると、param要素を子要素として持たせることができます。例えば、以下のような変換ルールになります。

@! http://example.com/foo.mov|400|300
 ↓
<object data="http://example.com/foo.mov" width="400" height="300"
  type="video/quicktime"></object>
@! http://example.com/foo.class|400|300|nick|mikio|sex|male
 ↓
<object data="http://example.com/foo.class" width="400" height="300">
<param name="nick" value="mikio">
<param name="sex" value="male">
</object>

この方法はかなり汎用性が高く、FlashやJavaアプレットを埋め込むこともできますし、MIDIを埋め込んでBGMを鳴らすこともできますし、Google Mapsも埋め込むことができます。

提示されたobject要素をどうレンダリングするかはユーザエージェントに任されることになっており、ブラウザ毎のサポート状況の違いが激しいことになっていますが、画像以外のデータを埋め込むためのstrict標準に準拠した方法はobject要素しかないのが現状です。しかし、近い未来を想定したアクセシビリティを考えると、あくまで「任意の形式のデータを埋め込む」という意味のWiki記法を定義して、それに応じてobject要素を提示するという方針が最善だと思います。applet要素、embed要素、iframe要素など、個々のデータ形式に応じて要素を使い分けないと表示できないブラウザもまだまだ多くあります(例えば上記のGoogle Map埋め込みはIEだと表示できない)。しかし、個々のブラウザへの最適化はonloadイベントを拾ってJavaScriptでDOMに干渉することによって可能なので、その方針で適宜サポート範囲を広げることで、「現在のアクセシビリティ」と「保守性」すなわち想定される近未来のアクセシビリティの両立を図る所存です。

画像配置の拡充

また、「@」記法も「@!」記法も、「@|」や「@!|」などとして「|」を後置させることによって画像などを段組みさせることができるようになりました。デモとして、段組みなどの凝った画像配置をしたページをご覧ください。「|」が1つだと2段組み、2つ並べると3段組み、3つ並べると4段組み、4つ並べると5段組みになります。例えば、TCのロゴを3段組みで表示したい場合には以下のようにします。

@|| http://1978th.net/tokyocabinet/logo.png
@|| http://1978th.net/tokyocabinet/logo.png
@|| http://1978th.net/tokyocabinet/logo.png

なお、段組みはスタイルシート(CSS)のみで実現しているので、テキストブラウザなどのCSS非対応ブラウザで見る場合には段組みでなく上下に並べられて表示されることになります。

検索機能の強化

今年の花火大会をどこに行くか決める時に、去年の花火大会について書いた記事を読み返したくなるようなユースケースを想定してみましょう。そのような場合は記事の作成日時を指定して検索できると便利です。従来から、記事名、著者名、タグ、本文のそれぞれの全文検索とそれらを統合した全文検索機能をサポートしているTPですが、最新バージョンからは日付を指定した検索ができるようになりました。例えば、検索対象を「creatin date」にして検索条件を「2008」にすると、2008年中に執筆した記事の一覧を得ることができます。なお、「2008-07」とすると2008年7月中という条件になり、「2008-07-29」とすると2008年7月29日中という条件になります。

また、検索ページのファーストビューで、全記事の名前の一覧「recent articles」と、記事を執筆日時毎にまとめた検索条件の一覧「archives」を表示するようにしました。前者は最近どんな記事を書いたか手軽に確認するのに便利ですし、後者は上述の日付検索を手軽に行うのに便利です。

サイドバー

ページの右端にサイトの更新情報やナビゲーションを表示する「サイドバー」を実装しました。これを実装するかどうかは正直かなり悩みました。というのも、シンプルさを何よりも大切にするTPのコンセプトにおいて、必ずしも日常的に使うわけではない機能群を常にファーストビュー(最初に目に入る画面)に表示するというサイドバーの存在は違和感があるからです。サイドバーには更新日時や新着記事の一覧や新着コメントの一覧などが表示されるわけですが、同じ情報はタイムラインや検索ページにあるので、サイドバーは冗長な存在とも言えます。そして、そこに張られているリンクを実際にクリックすることはほとんどありません。このブログの読者の皆さんにおいても、右端にあるサイドバーのリンクをクリックしたことのある人の割合はかなり低いのではないでしょうか。

このように、存在に必然性がなく冗長で画面(視線移動)の無駄とも言えるサイドバーですが、にもかかわらずなぜサポートしたかと言うと、TP以外のCMSに慣れたユーザ(=読者)の暗黙的期待に答えるためです。また、検索系やナビゲーション系の機能の価値は利用頻度だけで量るべきではなく、執筆した情報にいつでも到達できるという「安心」を提供できるという利点も考慮しました。ただし、ある程度規模が大きくて更新が頻繁なサイトでないとサイドバーは心理的意味すら持たないので、デフォルトでは無効になっています。設定ファイルで有効にした場合にのみ、サイドバーが表示されます。

なお、このブログ(WordPress)に見られるように、検索のところで述べた「archives」に相当するコラムをサイドバーに表示する実装も多くありますが、TPではそれは回避しました。「recent articles」に乗らないくらい古い記事を日付指定してまで読むというのは、頻度もとても低いですし、上述の花火大会の例のようにかなり能動的な欲求から起こる行動なので、「search」リンクの1クリックを要求されても苦にはならないでしょう。また、「archives」のコラムは過去の情報の増大とともに占有領域を増すことになって見栄えがよくない(安定しない)という理由もあります。さらに、全記事のレコードを走査しないと日付毎の集計ができないので、その演算を全ページでやるとなると応答時間に影響する可能性が否定できないという理由もあります(キャッシュすれば問題ないのですが、それもあんまりやりたくない)。

その他の小技

ここまで述べた以外にも細かい改善をしています。iPhoneで見た場合に画面を効率的に使うようにしているとか、印刷した場合だけ不要なナビゲーションを非表示にするとか、ブラウザの言語設定を見てヘルプ記事を出しわけたりとか、HTMLのmeta要素をリッチにしたりとかです。ブラウザのキャッシュを効率的に利用できるようにHTTPのCache-Control設定も工夫しました。

まとめ

Tokyo Promenadeの付加機能について説明しました。どれも自分でしばらく運用してみて欲しくなった機能ですので、類似のユースケースの人には嬉しい機能だと思います。まだまだ改良の余地はありそうですが、凝り出すと止まらないのがUIの世界です。いくら高機能なCMSを使ったところで内容がショボいのでは意味がないので、ここらで機能追加は自重して、本来の研究開発と情報発信をする仕事に戻りたいと思います。

先日、待望の長女が誕生したmikioです。あまりにかわいいから育児ブログでもつけようという魂胆ではありませんが、今回は自作のCMSであるTokyo Promenadeについて語ります。

tp-help-screen

Tokyo Promenadeとは

以前の記事で、Tokyo Cabinet(TC)を使ったCMSを作ることを予告しましたが、Tokyo Promenade(TP)がまさにそれです。TCのテーブルデータベースを使って記事を管理する軽量なコンテンツ管理システム(CMS)の実装です。例によってC言語のみで記述され、libc以外の全実装が "made by mikio" な製品です。

読み方は「東京プロムナード」です。プロムナードとは散歩道のことですが、東京メトロの広告に出てくる宮崎あおい的なキャラが写真付きブログを書いちゃうようなユースケースをイメージして名づけました。まあ実装はそんな洒落た感じとはほど遠いですし、実際に私が使う際には小賢しい技術ネタを書きなぐることになるでしょうけども…。

まずはデモサイトにアクセスしてみてください。ヘルプに基本的な使い方は書いてあります。デモサイトなので、SandBoxの記事を追加したり編集したり削除したりしていただいて構いません。

掲示板なの? Wikiなの?

この質問はFAQでありつつ、回答に困ってしまうものの代表です。TCのようなスキーマレスのデータベースを使っている場合、掲示板(またはブログ)とWikiのデータ構造は全く同じであるとみなすことができます。双方とも、各記事は内部IDで識別され、名前(タイトル)やタイムスタンプや著者名などの属性を持つレコードとして記事を記録し、その属性によって表示対象と並び順を定義したビューを構成できて、ユーザからの入力をもとにビューの決定と記事の操作を行うシステムです(まあそのレベルで抽象化したらほぼ全てのDBアプリはそういうアーキテクチャに収まるとも言えますが)。

掲示板とWikiの主な違いは、トップページで提示するビューが「時系列リスト」なのか、人間の編集者がおすすめする「フロントページ」なのかということです。TPのデフォルト設定では掲示板として時系列リストを提示する掲示板仕様になっていますが、設定ファイルで任意の記事をフロントページを指定するとWiki仕様になります。なお、作成日順の時系列リストを提示すると普通の掲示板として使えますが、更新日順の時系列リストを提示すると「2ちゃんねる」のようなフローティングスレッド掲示板風に使うこともできます。

ということで、質問への回答としては「掲示板とWikiはビューの違いに過ぎないので、あなたがどう使うかでどちらであるかが決まるのです」ということになります。個人的には名前とか分類とかはどうでもよくって、「HTMLとかHTTPとか難しいことなんて知らなくても誰でも簡単にお洒落なWebサイトが作れる」というCMSの醍醐味が味わえればそれでいいかなと。

TPの素敵ポイント1:シンプルでロジカルなUI

私はw3mというテキストブラウザが結構好きで、実はTPのユーザインターフェイスはw3mで最も見やすいように調整されています。しかし市場のブラウザシェアを考えればIEやFirefoxやSafariでそこそこ綺麗に表示できないと話になりませんので、CSSを駆使してそれらでもできるだけ読みやすくなるように努力しています。華美な装飾を施すのではなく、各記事の著者がWiki記法で記述した論理性をいかに直感的に読者に伝えるかということを追求しています。

テキストブラウザで読みやすくするためには論理構造のみをHTMLに記述するという選択が自然になされるため、結果的にロジック(HTML)とスタイル(CSS)の分離が徹底されることになります。また、ロジックの部分をできるだけ論理的に記述してもらうために、Wiki記法では論理的に必要だと思われる表現のみを厳選しています。そういった仕様策定のヒントにしたのは、研究者が論文などを書くときによく用いられるLaTeXというシステムです。LaTeXの文書でよく使う見出しやリストや図表といった構造のみがWiki記法で選択できるようにしています。CSSによるスタイルも、LaTeXっぽい見栄えになるように調整しています。

TPの素敵ポイント2:アクセシビリティ

ロジックとスタイルを分離した結果として、いわゆるフルブラウザはもちろん、テキストブラウザでも、視覚障害者用の音声ブラウザでも、モバイル端末でも、プリンタでも、それぞれの環境なりに最善を尽くした表現ができるようになっていると思います。このように様々な環境および様々な境遇の人に対して情報の授受ができる程度を指してアクセシビリティと言うことがありますが、世に数多あるCMSの中でTPはかなりマシな方だという自負があります。

ただ、デフォルトの状態では多様な環境で読みやすいようにしている分、個々の環境に最適化できているわけではありません。最適化の要求があるユースケースでは、出力用テンプレートをカスタマイズすることで対処できるし、またそうすべきだと考えています。音声ブラウザを使っていない私が音声ブラウザに最適な表現を定義するのは無理ですし、iPhoneを持っていない私がiPhoneに最適化された表現を定義するのも辛いものがありますので、個々のユースケースへの最適化はユーザの皆さんに任せるのが現実的です。出力用テンプレートはTCのテンプレート直列化機能をそのまま使って実装されています。テンプレートファイルはHTMLの中に [% ... %] という形式のディレクティブを記述した単純な構造なので、プログラマでなくても簡単にHTMLレベルの構造を変更することができます。

TP以外の多くのCMSでは記事にHTMLを直接記述できる機能があります。そうすると備え付けのWiki記法では不可能な表現方法を自由に使うことができるからです。しかし、それをやってしまうと論理構造がめちゃくちゃになってしまうし、validなHTMLを書けるユーザの割合は著しく低いので、TPでは禁止しています。そのおかげで、TPが出力するHTMLはXHTML 1.0に完全準拠することが保証されます。したがって、JavaScriptやXSLTで加工するのも容易ですし、スクレイピングをして外部アプリケーションを作るのも容易です。

基本事項ではありますが、コンテンツを表示する際には以下の点に心がけるようにしています。

  • ブックマークの名前として識別しやすいtitle要素をつける。
    • 複数の記事をリスト表示する際にはサイト名と機能名をつなげた文字列
    • 記事単体を表示する際にはサイト名と記事名をつなげた文字列
  • 各ページで最も強い見出しをh1要素にする。そしてh1要素は必ず0個か1個にして複数は置かない。
    • 複数の記事をリスト表示する際にはサイト名をh1にし、各記事の名前はh2、見出し1以降はh3以降
    • 単体の記事を表示する際には記事の名前をh1にし、見出し1以降はh2以降
  • 見出しには各記事のIDおよび見出しの階層を反映させたid属性を付与してリンクされやすくする。
  • データベース内の日付はカレンダー時間で保持するが、表示時にはローカル時間に変換する。

各記事のタイトルや見出しは特定のHTML要素に単純に変換させるわけではなく、ビューに基づく相対的な順位で要素が決められるというあたりが私なりのこだわりです。とはいえCSSでスタイルを割り当てる際の利便性を考えて、記事毎の絶対的な見出し順位もきちんとclass属性として指示しています。

TPの素敵ポイント3:快適な画像挿入

テキストブラウザな人は画像とかあんまり興味ないでしょうが、世の一般的なブログなどではほぼ全ての記事に写真やイラストを載せるのがトレンドだというのも否めません。私が育児ブログをつけるとしてもおそらくそうすることでしょう。そのようなユースケースでは、画像をいかに美しく挿入できるかがCMSとしての良し悪しを左右することになります。

画像はさすがにWiki記法では記述できない(Base64で貼り付けるなどの方法は不可能ではないが現実的ではない)ので、ファイルアップロード機能を実装しました。また、アップロードした画像に任意の名前(デフォルトはローカルでのファイル名)がつけられますが、タイムスタンプで識別されるので同じ名前のファイルが複数個あっても問題ありません。

画像を記事に挿入する際には、「@」の後ろにURLを書くだけです。「http://」で始まるURLでWeb上にある任意の画像データを参照できるのはもちろん、「upfile:」で始まる内部識別子でTP内にアップロードした画像を参照することもできます。URLでなく内部識別子を使った方がサイトを移設した際のリンク切れを回避できるので有利です。

画像を挿入するような記事の著者はおそらくタブブラウザを使っているでしょう。記事を執筆すべく編集画面を開きながら、画像が挿入したくなった段階で別タブでアップロードファイルの管理ページを開き、検索機能やサムネイルを駆使して対象の画像を特定し、そこに張られた内部識別子を右クリックでコピーしてクリップボードに入れます。それから編集画面に戻って任意の位置にペーストすれば作業完了です。原始的なようですが、JavaScriptでURLを挿入するタイプの挙動だと元の編集画面が勝手にスクロールしてしまってイラつくことが多いので、敢えてこの素朴な方法を推奨しています。

挿入した画像の表示位置も簡単に制御できます。「@ hoge」と書けばその位置に画像がそのまま表示されますが、「@< hoge」と書けば左寄せ、「@> hoge」と書けば右寄せで表示されます。左寄せなら本文は右に回りこみ、右寄せならば左に回り込むようになります。結果として、雑誌の記事のような見栄えで写真を紹介できるようになります。

TPの素敵ポイント4:Wikipedia大好き

著者には既知だが読者に未知であるかもしれない一般的な概念を記事の中でわざわざ説明するのはだるいものです。一般的な概念であればだいたいのことはWikipediaで説明されているので、該当の記事にリンクを張れば十分です。ということで、リンク先に「wpja:」で始まる識別子を書くと、自動的にWikipedia日本語版のその名前の記事へのURLに直す機能があります。「wpen:」はWikipedia英語版へのURLになります。いわゆるInterWikiですね。小ネタのような機能ですがかなり時間の節約になります。

実装の苦労

TCのテーブルDBとテンプレート直列化機能とオブジェクトシステムがめちゃくちゃ強力なので、Wiki記法だのユーザ管理機能だのファイルアップロード機能だのを満載している割には、TPの全ソースコードは3000行未満に収まっていて非常にコンパクトです。なので、実装について語ることはあまりありません。ご興味のある方はパッケージ内の promenade.c を読めばだいたいの流れが理解できると思います。

敢えて苦労した点を挙げるなら、拡張子とMIMEタイプの対応表を手でハードコーディングしたりとか、時刻表現は10進数とW3CDTFとRFC1123形式を全てサポートしたりとか、クッキーを暗号化するためにRC4を実装したりとか、ファイルアップロードのmultipart/form-data形式のパーザを自分で書いたりとか、1バイトたりともメモリリークしないようにvalgrindでほぼ全てのコードを調べたりとか、全画面がXHTML 1.0でvalidかどうか確かめるためにxmllintとhtmllintで出力を調べたりとかがありました。それより何より、一番面倒だったのはWiki記法を解析してHTMLに直すコンバータを書くことでした。得にリストがネストする構造が厄介で、途中でやめようかと何度も思ったものです。

とはいえ、全ての課題はクリアされ、現時点で私の欲しい機能が全て実装されているCMSが完成しました。スクリプト言語の処理系やデータベースサーバをインストールせずに使えるし、貧弱なマシンでも軽快に動くし、それでいてテンプレートをいじって簡単にカスタマイズできるし、シンプルで飽きの来ないデフォルトのデザインも付いてくるし、我ながら結構イイ感じです。

インストールしてみましょう

ここまで読んでみてTPを使いたくなってくれた人も少数ながらいるかと思いますので、インストール方法について超要約で説明します。WebサーバとTCが予めインストールされていることを前提とします。

# TPをダウンロードする
wget http://tokyocabinet.sf.net/promenadepkg/tokyopromenade-0.9.1.tar.gz

# TPをインストールする
tar zxvf tokyopromenade-0.9.1.tar.gz
cd tokyopromenade-0.9.1
./configure
make
sudo make install

# CGIスクリプトなどを置くベースディレクトリを作る
mkdir /home/mikio/public_html/cms
cd /home/mikio/public_html/cms

# TPの各種ファイルをベースディレクトリにコピーする。
cp /usr/local/libexec/promenade.cgi .
cp /usr/local/share/tokyopromenade/promenade.* .
cp /usr/local/share/tokyopromenade/passwd.txt .

# データベースファイルとアップロードファイル用ディレクトリを作る
prommgr create promenade.tct
mkdir upload

# CGIスクリプトがデータベースやディレクトリを更新できるように適宜設定する
chmod 666 promenade.tct*
chmod 777 upload

# 気が向いたら、ヘルプファイルをインポートする
prommgr import promenade.tct /usr/local/share/tokyopromenade/misc/help-ja.tpw

あとは、設置したCGIスクリプト「promenade.cgi」にWebブラウザでアクセスすれば使い始めることができます。デフォルトで管理者ユーザのアカウントが名前「admin」およびパスワード「nimda」として定義されていますので、それでログインして記事を書いたりユーザを作ったりファイルをアップロードしたりしてみてください。

promenate.tmpl」というファイルがテンプレートファイルになります。これをいじることで、出力されるHTMLのほぼ全てをカスタマイズすることができます。デフォルトでは掲示板スタイルの時系列表示がトップページに設定されていますが、テンプレートの冒頭にある「[% CONF ... %]」のディレクティブをいじってフロントページを設定するとWikiとして使うことができます。

まとめ

Tokyo Promenadeの概要について述べました。設定によって掲示板(ブログ)風にもWiki風にも使えるシンプルなCMSです。C言語だけでも、DBMだけでも、GNOMEなんたらやApacheなんたらを使わなくても、そこそこ実用的なシステムを作れることが伝われば幸いです。

シンプルっていいですよね。複雑なシステムってだいたいすぐに嫌気が差してしまいますし、特定のユーザのユースケースに特化した機能を操作の選択肢として全員に提示するのは、全体のユーザビリティを下げることにつながります。大多数のユーザが必要最低限の機能を迷わず使えるという大前提を確保した上で、慣れたユーザはその学習曲線に応じて個々のユースケースに最適化された使い方ができるようにするのが理想です。その理想に照らすとTPはちょっとシンプルさが行き過ぎた感もありますが、TPが想定するユーザ層である「ワープロでなくテキストエディタを使う人達」にとってはこれくらいがバランスポイントだと思っています。

TPの今後ですが、RSS配信機能やメールによる更新機能をつけたりといった今のトレンドに合った機能追加を行う予定です。あと、そもそもの開発の動機として英語ブログを立ち上げてTokyoシリーズについて語りまくるというのがあるので、近日中にやりたいと思っています。レンタルサーバとドメイン取得で最もコスト(手間含め)が低いオススメの方法があれば教えてください >識者。

まだピクミン2をクリアしてないのでケジメ的に新作ゲームを買えないmikioです。今回は、Tokyo Cabinetを使って激烈簡単に特定サイトの専用の検索機能を設置する方法について説明します。クローリングから検索までを10分くらいの作業で可能にします。

wgettsv

特定サイトの検索エンジン

Web全体の検索機能を作るのは、途方もない技術力と設備を持っているGoogleやMicrosoftなどのビッグプレーヤでないと難しいのが現実です。でも、自分が気に入っているいくつかのサイトを対象とした検索エンジンを作るのであれば個人だってできます。また、インターネットから手が届かないイントラネットのコンテンツの検索機能は自分達で手がけないと構築できません。

ということで、企業用の検索システムが数多く売られていますし、LuceneやGroongaやHyper Estraierなどのオープンソース製品も世に多数存在します。それらに比べてTCによる検索システムが性能的あるいは機能的な観点で優れていると主張するつもりはありませんが、構築が簡単だという点はこの記事で伝わればいいかなと思っています。

実際にやってみる

はい、今から10分です。まずはこちらのコマンドをダウンロードして、wgettsv という名前で保存してください。それを実行します。例として、intra.example.com の全てのHTMLコンテンツを最大10000ファイルだけ収集してきてそれをTSVファイルに保存してみます。URLをあなたのオフィスのものに置き換えて動かしてみてください(もちろん自己責任でですが)。

$ ruby wgettsv -allow 'http://intra\.example\.com/' \
  -max 10000 'http://intra.example.com/' > intra.tsv

「-allow」オプションは、再帰的にコンテンツを取得する際に許容する飛び先のURLを指定します。example.comのイントラネットである intra.example.com ドメインのコンテンツのみを取得するようにしています。「-deny」オプションで禁止パターンを指定することもできます。「-max」オプションは最大取得数を指定します。最後の引数のURLはクロールのエントリポイントです。これは複数指定することもできます。

サイトの規模や回線の速度によりますが、小組織なら5分もあればクロールが終わると思います。ブログなどを再帰的に巡回させるとキリがないので、そういう場合は「-deny」で不要なクローリングを除外してください。クロールしてきたデータは文字コードをUTF-8に正規化した上で body や title や mtime などの属性を抽出し、それらを表現したTSVデータとして標準出力に流します。今回はそれを intra.tsv というファイルにリダイレクトして保存しています。このTSVファイルはTCのテーブルDBのインポートコマンドが期待する形式になっていますので、すぐにもインデックス作成を開始できます。

$ tctmgr importtsv tctsearch.tct intra.tsv
$ tctmgr setindex -it qgram tctsearch.tct title
$ tctmgr setindex -it qgram tctsearch.tct body

TSVファイルからインポートして tctsearch.tct というインデックスを作成して、titleコラムとbodyコラムにインデックスを張っています。5分でクロールしたくらいのレコード数であれば、この作業は数秒で終わると思います。

検索UIを設置します。Tokyo Cabinetのソースパッケージに example というディレクトリがあり、その中に検索UIのサンプルが含まれています。makeしてから、tctsearch.cgi と tctsearch.tmpl をCGIが実行できるディレクトリにコピーしてください。また、インデックスも同じディレクトリに移動してください。

$ cd tokyocabinet-1.4.29/example
$ make
$ cp tctsearch.cgi tctsearch.tmpl ~mikio/public_html
$ mv ~/mikio/tctsearch.tct ~mikio/public_html

あとは、設置した tctsearch.cgi にWebブラウザでアクセスするだけです。ここまで10分以内でできますよね? UIをカスタマイズしたい場合は tctsearch.tmpl を適当に修正してください。インデックスを定期的に更新したい場合は、クロールとインデクシングの手順をシェルスクリプトに書いてcrondに実行させればよいでしょう。

Perl/Ruby/Javaで検索UIを作る

上記で設置したデモ用の検索用CGIスクリプトは、以前の記事で述べたTCのテンプレート直列化機能を用いてC言語で実装したものです。100行に満たないCのコードでここまで表現できるのは我ながら素敵だなと思うわけですが、しかしCプログラマの数は各種スクリプト言語のプログラマの数に比べてかなり少ないのが現実です。Cプログラマでも、UI層をCで書くなんて常軌を逸していると感じるかもしれません。私の趣味はさておき、CGIスクリプトと言えばスクリプト言語の出番ですよね。ということで、PerlとRubyとJavaでサンプルを実装してみました。いずれもロジック部分は50行以下で済んでいます。

tctsearch

Perlで書くとロジック部分は以下のようになります(完全なソースコードはこちら)。ロジック部分と表示部分を完全に分けて記述するのがCGIスクリプトを美しく書くコツです。この例では検索結果の主キーのリストをforeachで回して各々の文書情報をわざわざ@docsという配列に入れて、あとは@docsをforeachで回して表示するようにしています。metasearchとkwicというメソッドが謎な感じですが、それらについては後述します。

# CGI処理オブジェクトを作り、パラメータを受け取る
my $cgi = CGI->new();
my $scriptname = $cgi->script_name();
my $expr = $cgi->param("expr");

# 検索語が入力されていれば検索を行う
my @docs;
if(defined($expr) && length($expr) > 0){

  # データベースを開く
  my $tdb = TokyoCabinet::TDB->new();
  $tdb->open(DBPATH);

  # クエリオブジェクトを作る
  my $tqry = TokyoCabinet::TDBQRY->new($tdb);
  $tqry->addcond("title", $tqry->QCFTSEX, $expr);
  $tqry->setorder("title", $tqry->QOSTRASC);
  $tqry->setlimit(30, 0);
  my $bqry = TokyoCabinet::TDBQRY->new($tdb);
  $bqry->addcond("body", $bqry->QCFTSEX, $expr);
  $bqry->setorder("title", $bqry->QOSTRASC);
  $bqry->setlimit(30, 0);

  # 検索する
  my $res = $tqry->metasearch([ $bqry ], $tqry->MSUNION);

  # 該当文書の情報を取り出して表示用の配列に入れる
  foreach my $pkey (@$res){
    last if(@docs >= 30);
    my $cols = $tdb->get($pkey);
    if(defined($cols)){
      my $kwic = $bqry->kwic($cols, undef, 20, $bqry->KWMUCTRL);
      $cols->{kwic} = $kwic;
      push(@docs, $cols);
    }
  }

  # データベースを閉じる
  $tdb->close();

}

Rubyで書くとロジック部分は以下のようになります(完全なソースコードはこちら)。Perlと全く同じ流れです。

# CGI処理オブジェクトを作り、パラメータを受け取る
cgi = CGI::new
scriptname = cgi.script_name
expr = cgi.params["expr"][0]

# 検索語が入力されていれば検索を行う
docs = []
if expr && expr.length > 0

  # データベースを開く
  tdb = TDB::new
  tdb.open(DBPATH)

  # クエリオブジェクトを作る
  tqry = TDBQRY::new(tdb)
  tqry.addcond("title", TDBQRY::QCFTSEX, expr)
  tqry.setorder("title", TDBQRY::QOSTRASC)
  tqry.setlimit(30, 0)
  bqry = TDBQRY::new(tdb)
  bqry.addcond("body", TDBQRY::QCFTSEX, expr)
  bqry.setorder("title", TDBQRY::QOSTRASC)
  bqry.setlimit(30, 0)

  # 検索する
  res = tqry.metasearch([ bqry ], TDBQRY::MSUNION)

  # 該当文書の情報を取り出して表示用の配列に入れる
  res.each do |pkey|
    break if(docs.length >= 30)
    cols = tdb.get(pkey)
    if cols
      kwic = bqry.kwic(cols, nil, 20, TDBQRY::KWMUCTRL)
      cols["kwic"] = kwic
      docs.push(cols)
    end
  end

  # データベースを閉じる
  tdb.close()

end

Java(JSP)で書くとロジック部分は以下のようになります(完全なソースコードはこちら)。PerlやRubyと全く同じ流れです。

// サーブレットリクエストからパラメータを受け取る
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
String expr = request.getParameter("expr");

// 検索語が入力されていれば検索を行う
List docs = new ArrayList();
if(expr != null && expr.length() > 0){

  // データベースを開く
  TDB tdb = new TDB();
  tdb.open(dbpath, TDB.OREADER);

  // クエリオブジェクトを作る
  TDBQRY tqry = new TDBQRY(tdb);
  tqry.addcond("title", TDBQRY.QCFTSEX, expr);
  tqry.setorder("title", TDBQRY.QOSTRASC);
  tqry.setlimit(30, 0);
  TDBQRY bqry = new TDBQRY(tdb);
  bqry.addcond("body", TDBQRY.QCFTSEX, expr);
  bqry.setorder("title", TDBQRY.QOSTRASC);
  bqry.setlimit(30, 0);
  TDBQRY[] oqrys = { bqry };

  // 検索する
  List res = tqry.metasearch(oqrys, TDBQRY.MSUNION);

  // 該当文書の情報を取り出して表示用の配列に入れる
  Iterator resit = res.iterator();
  while(docs.size() < 30 && resit.hasNext()){
    byte[] pkey = (byte[])resit.next();
    Map cols = tdb.get(new String(pkey, "UTF-8"));
    if(cols != null){
      String[] kwic = bqry.kwic(cols, null, 20, TDBQRY.KWMUCTRL);
      cols.put("kwic", kwic);
      docs.add(cols);
    }
  }

  // データベースを閉じる
  tdb.close();

}

3言語ともほとんど同じ構造になるあたり、tokyocabinet.idlの威力を感じていただけたのではないかと思います。各種言語の良さを100%引き出すインターフェイスにはなっていませんが、どれかひとつでの書き方を知っていれば他の言語でもサクっと仕上げて定時に帰れるというのが素敵ポイントです。

メタ検索

Webコンテンツの全文検索においては、「タイトルもしくは本文のいずれかに検索文字列を含む」という条件を指定するのが普通です。言い換えれば、「検索文字列をタイトルに含む」文書群と「検索文字列を本文に含む」文書群の和集合を提示するということです。TCのテーブルデータベースでは、ひとつのクエリオブジェクトに複数の条件をaddcondメソッドで追加していくことで論理積(AND条件:積集合)を表現することができますが、その方法では論理和(OR条件:和集合)を表現することはできません。それだとWeb検索の典型的なユースケースであるタイトルと本文を対象とした検索では困ったことになります。

そこで、複数のクエリオブジェクトを使って積集合、和集合、差集合を表現するためのメタ検索という機能が提供されています。例えば、qry1.metasearch([qry2,qry3], TDBQRY.MSISECT); とすると、qry1とqry2とqry3の検索結果の積集合(つまり全てクエリの結果に含まれる主キーの集合)を問い合わせることができます。また、qry1.metasearch([qry2,qry3], TDBQRY.MSUNION); とすると、qry1とqry2とqry3の検索結果の和集合(どれかのクエリの結果に含まれる主キーの集合)を問い合わせることができます。qry1.metasearch([qry2,qry3], TDBQRY.MSDIFF); とすると、qry1と「qry2とqry3の和集合」の差集合(つまりqry1に含まれるがqry2にもqry3にも含まれない主キーの集合)を問い合わせることができます。

単純な検索エンジンとしての効率を求めるならば、タイトルと本文を連結した文字列を単一のコラムとして格納しておいて、それを対象とした検索をすればいいでしょう。ただ、そうしてしまうと「タイトルのみに含む」という条件や「本文のみに含む」という条件を指定することができなくなってしまうので、痒いところに手が届かなくなってしまいます。効率を求めるか機能性を求めるかはユースケースに応じて決定すべきなわけですが、後者を選択する場合にはメタ検索機能を重宝することになるでしょう。

メタ検索機能の嬉しい副作用として、複数のデータベースにまたがった検索ができるということが挙げられます。メールを格納したデータベースと接続したクエリオブジェクトとチャットログを格納したデータベースと接続したクエリオブジェクトに対して論理和のメタ検索を行えば、メールとチャットログの統合検索が可能となります。メーリングリスト毎に別個のデータベースを作っておいて、それらをチェックボックスで選択して統合検索するようなUIも簡単に作ることができます。

スケーラビリティの観点からも、メタ検索機能は重要です。いわゆるリアルタイム検索を想定した場合、更新の負荷を下げることが重要となりますが、そのためにはデータベースを細かく分割することが必要となります。すなわち、主キーにハッシュ関数などを適用して格納先のデータベースを決定する、いわゆる水平分散(sharding)という手法です。大規模な文書群を対象とした検索機能を構築する場合にも水平分散は重要となるでしょう。分割数を上げるほどに検索の負荷は上がりますが、更新の負荷はその分だけ下げることができます。よって、分割数を調整することで検索負荷と更新負荷のバランスポイントに近づくことができるわけです。単体のホスト内でもデータベースを分けて水平分散を行い(必要であればマルチスレッドで並列処理を行い)、ホスト間でも水平分散してさらなる性能向上を図るという二段階の視点がスケーラビリティ向上には重要です。ホスト間の分散についてはTCの領域ではありませんのでアプリ層で頑張ってくれというスタンスですが。

KWIC

検索に多数の文書がヒットした場合、文書内のどこに検索語が含まれるかと、その検索語がどんな文脈で使われているかを提示することで、実際にどの文書が欲しいものなのかを判断する手助けをするのが普通です。KWIC(KeyWord In Context)とかスニペットとか呼ばれるような、本文から検索語の周辺部分を抜き出した文字列を提示するということです。Googleとかでも検索結果に太字の検索語とともに周辺文が提示されますよね。

このKWICですが、アプリ側で実装しようとすると意外に大変なのです。材料として検索語のリストと本文の文字列があればよいのですが、処理内容は著しく複雑です。正規表現でやろうとすると周辺文を領域を指定するのが厄介ですし、何より普通の正規表現では検索語をきちんと表現できないのです。というのも、全文検索においては正規表現よりも強烈に文字列の正規化をしているからです。例えば、TCでは検索対象の文字列に以下のような正規化を施しています。

  • 全角英数字を半角英数字にする。
  • 半角カタカナを全角カタカナにする。
  • ウムラウトやセディーユなどのアクセント記号を外す。
  • 英字やギリシャ文字やスラブ文字の大文字を小文字にする。
  • タブや改行やその他の空白文字および制御文字を通常の空白文字にする。

これらの正規化をするだけなら各種のライブラリを使えばよく、正規化した後なら一般的な正規表現の実装で検索語に該当する部分を特定することができるでしょう。しかし、ハイライトさせる文字列は正規化する前のものでないといけないため、正規化した後で正規表現をかけるという方法は通用しないのです。例えば「I’m Mikio Hirabayashi」を「I’m <strong>Mikio</strong> Hirabayashi」としたいとしましょう。正規化して「i’m mikio hirabayashi」にしてから正規表現をかける方法だと、「mikio」の「M」が大文字だったという情報が失われてしまうのでダメです。正規表現のi(ignore-case)オプションを使えばいけそうですが、アクセント記号などを含んでいる場合にはそれもダメです。したがって、インデックス内の文字列の正規化方法を知っている動作主体が、その知識を使って部分文字列の検出および周辺文の抽出を一括で行うことが必要となります。

ということで、KWICを出力するのはインデクサのライブラリの仕事にするのが現実的です。データストレージや情報検索を担当するライブラリとしては異色の機能ですが、仕方ないのです。クエリオブジェクトにkwicメソッドが実装されています。第1引数に文書データのハッシュ、第2引数に本文を格納したコラムの名前(undef/nil/nullの場合はクエリに指定した最初の条件のコラムを自動選択)、第3引数に周辺文の文字数、第4引数にオプションをとります。オプションには、KWMUTAB(検索語をタブでマークアップ)、KWMUCTRL(検索語を0×02と0×03でマークアップ)、KWMUBRCT(検索語を [ ] でマークアップ)、KWNOOVER(周辺文のオーバーラップを禁止)、KWLEAD(文書の先頭を強制的に選択)のビット論理和を指定することができます。いわゆる普通のKWICが欲しい場合は、qry.kwic(cols, nil, 20, TDBQRY.KWMUCTRL) で得た文字列内の制御文字を出力用のマークアップに置換するのが定石でしょう。

まとめ

任意のWebコンテンツを簡易クローラで収集してインデクシングする方法と、そうしてできたインデックスを対象とした検索用ユーザインターフェイスを各種言語バインディングを用いて簡単に実装する方法について説明しました。付属のUIを設置するだけなら10分くらいでできそうだと思っていただければ幸いです。

検索UIの実装ってなかなか楽しいですよね。メタ検索やKWIC生成などの面倒な部分はTCが担当してくれるので、適切なロジックを実現するための業務分析と使いやすいインターフェイスを実現するための情報設計に工数を割くことができます。実装言語が選択できるというところも素敵ポイントかなと思っている今日この頃です。ぜひお試しあれ。

相対性理論のボーカルが頭から離れないmikioです。熱いわっふるの声に応えて今回はTokyo Cabinetのテーブルデータベースにおける検索機能の実装について語ってみたいと思います。とても長いのですが、最後まで読んだあかつきには、自分でも全文検索エンジンを作れると思っていただければ嬉しいです。

デモ

モチベーションをあげていただくために、100行のソースコードで検索UIのデモを作ってみました。Java 6の日本語文書を対象としているので、「stringbuffer」とか「コンパイル」とか「倍精度浮動小数」とかそれっぽい用語で検索してみてください。

インデックスがちゃんとできていれば、たった100行で某検索エンジン風味の検索機能をあなたのデータを対象にして動かすことができます。ソースコードはこちらテンプレートはこちら)です。

でも、今回はUIの話ではないのです。ものすごく地味に、全文検索システムの核となる転置インデックスの設計と実装について説明します。最初に設計から入り、インデックスを構築するアルゴリズムとそれに対する検索を行うアルゴリズムを擬似コードで解説します。

概要設計

以前の記事でも述べましたが、テーブルデータベースは、ハッシュデータベースとして主キーとレコード本体のデータを関連付けて管理します。各レコードのデータは、名前と値からなるコラムの集合として記録され、ハッシュ(連想配列)と同様のインターフェイスで操作されます。

コラムインデックスを張れるというのがテーブルデータベースの最大の特徴です。全てのレコードにおける特定の名前のコラムを対象としたインデックスを管理するのです。新規に追加したレコードが該当のコラムを持っている場合や、既存のレコードの該当のコラムが更新された場合には、コラムインデックスも自動的に更新されなければなりません。コラムインデックスはB+木の外部データベースとして実現されます。処理の流れに基づいて整理すると、レコードの追加と削除については以下のデータベース操作が必要となります。

  • レコードを挿入する(tctdbputkeep)
    1. レコード本体をメインのデータベースに挿入する(tchdbput)
    2. コラムの値をキー、レコード本体の主キーを値にしたデータをインデックスに挿入する(tcbdbput)
  • レコードを削除する(tctdbout)
    1. レコード本体をメインのデータベースから取得する(tchdbget)
    2. コラムの値をキーとして、インデックスからデータを削除する(tcbdbout)
    3. レコード本体をメインのデータベースから削除する(tchdbout)
  • レコードを更新する(tctdbput)
    1. レコード本体をメインのデータベースから取得する(tchdbget)
    2. 既存のコラムと新しいコラムを比較して異なる場合、上記のインデックス削除操作(tcbdbout)とインデックス挿入操作(tcbdbput)を行う。
    3. レコード本体をメインのデータベースに上書きで挿入する(tchdbput)

削除操作の際には既存のレコードを使ってインデックスを元に戻すということと、更新操作の際には削除を行ってから挿入を行うというところがポイントです。MySQLの各種ストレージエンジンでも、CouchDBのストレージエンジンでも、たいていのインデックス付きストレージは上記の枠組みを踏襲しているはずです。

インデックスの型による実装の分岐

文字列型や数値型といった、比較によって探索範囲を絞り込むような操作はツリー系のインデックスで高速化することができるので、実装としてB+木データベースをそのまま使えばよいでしょう。コラムの値をキーにして、該当レコードをメインデータベースから取り出すための主キーを値として関連付けてB+木に入れるのです。コラムの値は複数のレコードで重複する可能性があるので、B+木の実装は重複キーを許容するタイプのものでなければいけません。文字列型と数値型は比較方法の違いでしかないので、B+木の比較関数を文字列用と数値用で切り替える以外はほとんど同じ実装を使いまわせます。

一方で、今回のトピックである転置インデックスは、文字列型や数値型とは一線を画します。B+木を使うところは一緒なのですが、検索キーの作り方が全く違うのです。コラムの値をさらに分解して、転置インテックス用のトークンを抽出し、各トークンをキーにして、該当レコードをメインデータベースから取り出すための主キーを値として関連付けてB+木に入れるのです。

とはいえ、インデックスの型にかかわらず、各インデックスに対して「主キーとコラムの値をパラメータとして渡してインデックスを更新する」という部分は共通です。すなわち、以下のような共通インターフェイスを介して個々の実装に分岐すれば、その上層は綺麗に実装できそうです。

// インデックスにコラムの値を追加する
bool tctdb_index_insert_column(TCTDB *tdb, INDEX *idx,
  const char *pkey, const char *value);

// インデックスからコラムの値を削除する
bool tctdb_index_remove_column(TCTDB *tdb, INDEX *idx,
  const char *pkey, const char *value);

ここまでの設計を注意深く行っておけば、それより下の層はコーディングテクニックのみの世界になります。すなわち、決まった仕様をアルゴリズムに落とし込んで、そのバグのない実装をそこそこ最適化しながら書きまくるという職人芸の世界です。それでは、トークン転置インデックスおよびq-gram転置インデックスの2つの実装を見ていきましょう。

トークン転置インデックスの正直な実装

トークン転置インデックスは簡単です。コラムの値を空白とカンマで区切った文字列をトークン(検索キー)にしてB+木に入れまくるだけです。擬似コードで書くとこんな感じでしょうか。

-- トークンの区切り文字
separators = " ,"

-- インデックスにコラムの値を挿入する
index_insert_column(tdb, idx, pkey, value)
  -- トークンを分割
  tokens = split(value, separators)
  -- 各トークンをDB上のposting listに追加
  foreach token in tokens do
    idx.db.add(token, pkey)
  end
end

-- インデックスからコラムの値を削除する
index_remove_column(tdb, idx, pkey, value)
  -- トークンを分割
  tokens = split(value, separators)
  -- 各トークンのDB上のposting listを更新
  foreach token in tokens do
    -- 既存のposting listを取得
    old_pkeys = idx.db.get(token)
    -- 新しいposting listを生成
    new_pkeys = []
    -- 既存のposting listで、該当レコード以外の主キーを移行
    foreach old_pkey in old_pkeys do
      if old_pkey != pkey then
        new_pkeys.add(old_pkey)
      end
    end
    -- 新しいposting listでDBを上書き
    idx.db.replace(token, new_pkeys)
  end
end

削除処理で、既存のposting listを取得してから該当レコードの主キーのみを削って書き戻すというところがポイントです。アルゴリズム的にダサいことこの上ないこの削除処理ですが、現実的にはこのような実装が最善です。posting listを予め整列させておいて計算量が O(logN) である二分探索を摘要するという手も考えられますが、それでも削除分を詰める処理はO(N)ですし、そもそもDBに書き戻すデータ量は変わらないので全く意味がありません。上位層でデータベースやインデックスを分割するのが計算量削減には有効なのですが、それはこのモジュールの実装には関係ないことですので、このままのアルゴリズムを最適化して実装すればよいでしょう。

q-gram転置インデックスの正直な実装

q-gram転置インデックスはちょっと複雑になります。コラムの値における一定の文字数の連続した部分文字列を、1文字ずつずらしながら、全ての組み合わせを抽出したものをトークン(検索キー)とします。例えば、「abcdef」をtri-gramで処理すると「abc」「bcd」「cde」「def」「ef」「f」が抽出されます。「ef」と「f」は3文字に満たないのですが、抽出しておかないと後で検索できないので無理やり仲間に入れてあげます。トークンに対応づけて格納するposting listには、そのトークンが何番目に現れたかというオフセット情報も加える必要があります。3文字以上の検索語で検索された場合に、トークン同士が並んでいるかの隣接判定を行うためです。ということで、擬似コードはこんな感じになります。

-- q-gramの単位文字数
qgunit = 3

-- インデックスにコラムの値を挿入する
index_insert_column(tdb, idx, pkey, value)
  -- トークンを分割
  tokens = qgram_split(value, qgunit)
  -- 各トークンをDB上のposting listに追加
  offset = 0
  foreach token in tokens do
    idx.db.add(token, [pkey, offset])
    offset += 1
  end
end

-- インデックスからコラムの値を削除する
index_remove_column(tdb, idx, pkey, value)
  -- トークンを分割
  tokens = qgram_split(value, qgunit)
  -- 各トークンのDB上のposting listを更新
  foreach token in tokens do
    -- 既存のposting listを取得
    old_pairs = convert_to_pairs(idx.db.get(token))
    -- 新しいposting listを生成
    new_pairs = []
    -- 既存のposting listで、該当レコード以外の主キーのペアを移行
    foreach old_pair in old_pairs do
      if old_pair[0] != pkey then
        new_pairs.add(old_pair)
      end
    end
    -- 新しいのposting listでDBを上書き
    idx.db.replace(token, new_pairs)
  end
end

追加処理では主キーとオフセットのペアをDBに入れています。なので、削除処理では、DBから取得したposting listをペアのリストに変換した上で扱うことになります(実際にはポインタをゴニョゴニョすればいいのでわざわざ変換はしませんが)。qgram_splitをどう実装するかも結構深い課題なのですが、文字エンコーディングをUTF-8やUTF-16/32に正規化してゴニョゴニョすればよいでしょう。TCではUTF-8の入力を前提としつつUTF-16に変換してから各種の正規化を施しています。

キャッシュの活用

ここまでの説明は、「正直な実装」についてです。アルゴリズムとしては最善を尽くしていますが、これをそのまま実装すると、ディスクがガリガリ音立てまくってちょっと悲しい気持ちになるだけです。トークンを検出する度にDBの読み書きを行っているのではI/Oの負荷が高すぎるので、キャッシュを活用して性能を改善を図るのが普通です。そのためには、擬似コードにおける idx.db に対する操作を全てキャッシュに対して行うようにすればよいのです。そしてキャッシュがメモリ容量を圧迫した時にデータベースにフラッシュ(flush=吐き出す)するようにします。TCではTCMAP(オンメモリマップ)を使ってキャッシュを実現しています。

キャッシュをフラッシュするにあたっては、キャッシュ内のレコードをインデックスのB+木の比較関数と同じ順序でソートしてから、その順番でインデックスに書き込みます。B+木はランダムアクセスよりシーケンシャルアクセスの方が圧倒的に高速だからです。それを踏まえると、キャッシュをフラッシュする戦略には2種類の解が導かれます。キャッシュ内の全レコードを一度にフラッシュする方法と、LRUなどで判定したヒット率の低いレコードのみをフラッシュする方法です。前者の戦略だとB+木のアクセスが完全にシーケンシャルになるので、スループットを最大化することができます。その反面、ソートや書き出しのために関連するリソースを長い時間ロックすることになり、レイテンシのばらつきが大きくなってしまいます。後者の戦略は全く逆の特性で、ロックの粒度が低いのでレイテンシは低く抑えられますが、B+木のアクセスパターンが悪化するので、スループットは低くなります。

冒頭のデモで用いたJava6 SDKの日本語文書約1万ファイルのインデックスを私の開発環境で作成した場合、全体を一気にフラッシュする戦略だと21秒で完了しましたが、フラッシュに最大1.78秒のロックがかかりました。ロック時間はそのままレイテンシに直結していまいます。一方でLRUの1%をちょびちょびフラッシュする戦略だと、最大0.05秒のロックで済んだ代償として、処理時間は49秒に伸びてしまいました。どちらの戦略が望ましいかはユースケースによるので、実装としてどちらを採用するかは悩ましいところです。ベンチマーク野郎達に気に入ってもらうには前者だし、SIのシーンの多くで要求されるのは後者でしょう。悩んだ末、とりあえずTCのテーブルデータベースはリアルタイム的な使い方を重視しているので、レイテンシ最低化のために後者をデフォルトにしました。ただし、インデックスを張らずにDBを作ってから後でインデックスを張る場合にのみ、一気にフラッシュする戦略で動作するようになっています。バルクでインデクシングしてリードオンリーで検索させるようなユースケースではこの方式が便利でしょう。

トークン転置インデックスを使った検索

テーブルデータベースに対してクエリが発行された際には、クエリオプティマイザによって、クエリに含まれる条件に合ったインデックスが探されて、適切なものがあればそれが使われます。トークン転置インデックスはSTRAND演算子(空白区切りトークンの完全一致AND検索)とSTROR演算子(空白区切りトークンの完全一致OR検索)の際に利用されます。いずれにせよ、クエリの式から抽出したトークンをもとにキャッシュとデータベースからposting listを取得し、それに含まれる主キーのリストを返します。STRANDの実装を擬似コードで示すと以下のようになります。

index_search_strand(tdb, idx, tokens)
  -- 最初のトークンだけ特別扱い
  res = {}
  token = tokens.shift()
  -- posting list内の主キーを結果のプールに入れる
  pkeys = idx.db.get(token)
  foreach pkey in pkeys do
    res.set(pkey)
  end
  -- 残りのトークンはAND条件で処理
  foreach token in tokens do
    nres = {}
    -- 既に取り出した集合にある主キーのみを選ぶ
    pkeys = idx.db.get(token)
    foreach pkey in pkeys do
      if res.exists(pkey) then
        nres.set(pkey)
      end
    end
    -- 結果の置き換え
    res = nres
  end
  res
end

とても簡単ですね。検索エンジンというと何だか凄い技術のように思っている人も多いかと思いますが、分かち書き方式(タグ検索方式)であれば上記のような短いコードで実装できてしまうものなのです。もちろん大規模なインデックスを扱うならばそれなりの工夫は必要になりますが、q-gram方式に比べるとインデックスも小さいし実装も単純だし、天国です。

q-gram転置インデックスを使った検索

一方で、q-gram転置インデックスは、地獄です。文字数と同じ数だけトークンが作られ、しかもposting listにオフセット情報も含めるので、インデックスのサイズが莫大になります。そして、主キーとオフセットを使ってトークンの隣接判定を行う必要があるため、実装もかなり複雑になってしまいます。q-gram転置インデックスはFTSPH演算子(フレーズ検索)とFTSAND演算子(空白区切りトークンの中間一致AND検索)とFTSOR演算子(空白区切りトークンの中間一致OR検索)とFTSEX演算子(Web検索エンジン風検索クエリ)の際に利用されます。ここではそれらに共通で呼び出される、トークンの隣接判定を伴う文字列中間一致について擬似コードを示します。

index_search_fts(tdb, idx, tokens)
  -- 隣接判定をするための全オカレンス情報の配列
  ocr = []
  -- 全トークンのposting listをオカレンス配列に入れる
  for i = 0, tokens.length do
    pairs = idx.db.get(token)
    foreach pair in pairs do
      -- そのオカレンスのトークン番号をペアに加える
      pair.push(i)
      ocr.push(pair)
    end
  end
  -- オカレンス配列をソート。主キーが第1キーでオフセットが第2キー
  sort_occrrence(ocr)
  -- 結果の主キーのセット
  res = {}
  -- オカレンス配列の全要素をスキャン
  i = 0
  while i < ocr.length do
    -- 要素を取り出す。主キーとオフセットとトークン番号のトリオ
    trio = ocr[i]
    i += 1
    -- トークン番号が0のものに着目
    if trio[2] == 0 then
      id = trio[0]
      off = trio[1]
      seq = 1
      -- 後続のオカレンスを調べる
      for j = i, ocr.length do
        ntrio = ocrs[j]
        -- 主キーが違っているかオフセットが進みすぎていたら抜ける
        if ntrio[0] != id or ntrio[1] > off + qgunit then
          break
        end
        -- 主キーが同じでトークン番号とオフセットが整合していた場合には
        if ntrio[2] == seq and ntrio[2] == off + qgunit then
          -- 現在のオフセットとトークン番号を進める
          off = ntrio[2]
          seq += 1
        end
      end
      -- 現在のオフセットがクエリの長さまで到達していたら結果セットに加える
      if off == trio[2] + tokens.length * qgunit then
        res.set(id)
        -- 次の主キーまで飛ばす
        while i < ocrs.length and ocrs[i][0] == id do
          i += 1
        end
      end
    end
  end
end

上記の処理に渡すトークンリストは、クエリの文字列からq-gramのq文字数毎にずらして切り出したものを用います。例えば、tri-gramで「abcdef」の場合は、「abc」と「def」になります。じゃあq文字の倍数でなかった場合はどうするかと言うと、最後のトークンだけ前に端数の文字数分を前にずらします。例えば「abcde」の場合、「abc」と「cde」を用います。その場合はposting listから取り出したオフセットもオカレンス配列に入れる際にずらしておくことで、隣接判定の処理で余計なことをせずに済ませます。

クエリの文字列がq文字以下だった場合には、クエリの文字列に前方一致する全てのトークンを調べる必要があります。その際には隣接判定は不要ですから、タグ検索の処理とほぼ同じようにposting listをそのまま結果セットに入れるような処理になります。

カウンティングソート

隣接判定の計算量を削減するためにオカレンス配列をソートしておくことが重要なわけですが、ソートするのってどうなのよと思いませんか。馬鹿正直過ぎます。ところで、オカレンス配列に求められる条件は次のものです。

  • 主キーが同じオカレンス情報が連続している
  • 主キーが同じ中ではオフセット毎にソートされている

もちろん全体をソートすればこの条件は達成されますが、冷静に考えてみると、全体を一気にソートしなくても達成できます。まず思いつくのは、オカレンス情報の各々を主キー毎に作った別々のリストに加えていく方法ですが、これには弱点があります。候補の数が多すぎる場合にリストの数が増えすぎて空間も時間も使いすぎてしまうのです。そこで、リストの数を一定にするために、主キーのハッシュ値を使うことを考えます。さらに、リストオブジェクトを新たに作るための空間と時間のオーバーヘッドがリストの数を制限することになるため、元の配列と同じ領域を使いたくなります。そこで気づくのが、主キーのハッシュ値を使ってカウンティングソート(分布数え上げソート)を行うことです。カウンティングソートの前提条件は比較する値の変域が予め分かっていることですが、ハッシュ値を使うとまさにそれを達成できます。

ということで、65536通りのハッシュ値を使ってカウンティングソートをした上で、同一ハッシュ値の要素群にはクイックソートをかけるようにしました。これによって、65536*(4=sizeof(int))で256KBの空間を使うかわりに、全体をクイックソートした場合よりもlog2(65536)=16で16倍高速化するかもしれない実装になったわけです。まあ実際には分布を数えるためのオーバーヘッドがかかるしオカレンスのハッシュ値が均等に分布するわけでもないため、速度は2倍程度にしかなりませんが。メモリに余裕があれば65536よりでかい変域を使ってもよいですが、細かく努力してもあまり成果がでないというのもまた現実です。

ビットマスク

あれ、さっきの説明だとインデックスから取得してきたposting listを全部オカレンス配列にぶち込んでいるけども、全部ソート処理に回されて効率悪いのはどうなのよと思いませんか。馬鹿正直過ぎます。ところで、隣接しているか確かめなくてもトークンがコラム内にあるかないかを調べるだけである程度は絞り込めます。つまりフィルタをかけて不要なオカレンス情報を予め振り落とすことによって、後で行うソート等の処理の計算量を減らすのです。例えば、「sakura」というクエリの場合、「sak」のposting listはオカレンス配列にそのまま入れるのですが、「ura」のposting listでは「sak」を処理した際に出てきた主キーのもののみをオカレンス配列に入れるのです。

そこで考えつくのが、トークン毎にハッシュを作って、オカレンス毎に主キーを登録していって最後に論理積をとるという方法です。でも、どっちみち後で完全なテストをするのだから、完全なハッシュを作らなくてもよいでしょう。主キーのハッシュ値を使ってビットマスクを作るだけでもよさそうです。そういえばカウンティングソートの時にハッシュ値を作っているのだから、それを流用してしまえばよさそうです。

ということで、クエリから切り出した第1のトークンのオカレンス情報はそのままオカレンス配列に格納しますが、第2トークン以降を処理する際には、第1トークンで作ったビットマスクでフラグが立っているものだけをオカレンス配列に格納することにします。ハッシュ値なので疑陽性になることがありますが、疑陽性のオカレンスも連接判定の際に削られるので問題ありません。いわずもがな、ビットマップによるハッシュ表はバケット1個あたり1ビットしか使いませんので、100万個のバケットのハッシュ表を作っても125KBしか空間を使わないで済むのです。

第2トークン以降のオカレンス情報がフィルタで削れるのはいいとして、じゃあ第1トークンのオカレンス情報はどうやって削りましょうか。オカレンス配列を作った後で、その配列全体をスキャンして、最終トークンの主キーとオフセットに着目してビットマスクを作った上で、もう一度配列全体をスキャンして、最終トークン以外のオカレンス情報をビットマスクを使ってフィルタすることにします。配列全体を2度もスキャンするのは効率悪い気もしますが、あくまで計算量はO(N)なのでソートのO(N log N)の前処理としては効率的とも言えます。

主キーの表現

単純化のために、主キー(文書ID)が数値であることを前提として今までの説明をしてきました。しかし、TCのテーブルデータベースは任意のバイト列を主キーに指定できるので、数値以外のデータもposting list内の文書IDとして指定できる必要があります。多くの検索エンジンでは文書IDが数値であることを利用して効率的な符号化を行っているのですが、任意のバイト列だとそのような効率化ができません。効率が悪いインデックスは存在価値がありませんから、ここはうまいこと方法を考えないといけません。悩んだ末、主キーが数値であることを前提にインデックスの構造を設計し、そうでない場合はエスケープ文字を使って表現することにしました。すなわち、主キーが数値である場合には効率的に処理ができ、数値でない場合にもそれなりに動くという特性になります。

主キーが自然数で10進数の正規形である場合は、つまり ‘0′ から ‘9′ までの文字のみから構成されて先頭が ‘0′ ではなく全体が正の値である場合は、それを数値として扱い、デルタ符号を用いて直列化したデータを記録します。そうでない場合には、先頭にゼロコード(’\0′) を接頭させてそのままのバイト列を記録します。インデックスからposting listを読み出す際には、先頭がゼロコードか否かで数値か任意バイト列かを判定します。数値である場合には10進数の正規形としてバイト列に復元します。

重複キーレコード方式と連結方式

インデックスはB+木のデータベースなのですが、B+木にposting listを格納するには大きく分けて2種類の方法があります。ひとつは、個々のオカレンス情報を別々のレコードとして扱い、キーが重複したレコードを格納できるというB+木の性質を利用して複数のレコードとして格納する方法です。もうひとつは、同一トークンの全てのオカレンス情報を単一のバイト列に直列化して、単一のレコードとして格納する方法です。前者はカーソルを使って特定の文書IDのオカレンス情報を取り出すといった効率化ができる半面、レコード数が多くなるので、個々のレコードのメタデータの分だけ空間効率が悪化します。後者はその逆の性質で、posting listの取得操作や更新操作にあたって全体を一括して処理しなければならない反面、空間効率は高まります。

結論としては、TCでは後者の一括方式を採用しています。検索の際には結局posting list全体をゴニョゴニョするので、個々のオカレンス情報をピンポイントで処理できるという性質はそれほど利点にならないからです。更新を高速化させるには前者の方が有利なのですが、更新操作の効率に関してはキャッシュに頑張らせて稼ぐと割り切って、現在の設計に落ち着いています。

まとめ

長々と説明しましたが、多分、実際のソースコードを読んでもらった方がわかりやすいです。tctdb.cをちょっと覗いてみてください。他のライブラリに全く依存せず、BoostもSTLも使っていないのに、6000行にも満たないコードで全文検索機能付きのテーブルデータベースを実現できています。よくぞここまで簡潔にまとめたものだなと我ながら感心している次第です。