Chienomi

Chienomiの検索機能を刷新

開発::web

実は、ほとんど使われていないという噂のChienomiの検索機能が刷新された。

これは長年放置されていたPureBuilder Simply SearchUtilsの改修に併せて、その適用第一号として実装されたものだ。

実は従来、検索機能はかなりの力技で提供されていて、割と色々問題があった。 そのあたりが改善したのだが、代わりに検索機能はモダンなウェブブラウザでなければ動作しなくなった。

実は遠大な計画

PureBuilder Simplyの検索機能は比較的初期に実装されており、WordPressからのインポートと同レベルで必要なものとみなされていた。

初期の実装はそもそもPureBuilder Simplyとは協調しない構造をしており、出力されたHTMLからインデックスを生成し、grep(1)で検索するという方法をとっていた。

このため非常に速いのだが、ちゃんと構造化されていないデータを使うことに起因する問題が色々あったのだ。

そもそもSearchUtilsの開発時に、実際の実装とは別に.indexes.rbmを覗くという方法が検討されていた。 だがこれは、検索のために欲しい機能を.indexes.rbm、つまりはPureBuilder Simplyそのものに持たせるということを意味する。

サイトの全文検索を行おうとするとき、クライアントが全記事データをダウンロードする仕様にしない限りはウェブアプリケーションが必要である。 だが、PureBuilder Simply自体は静的ファイルをサーブできるウェブサーバーさえあれば良い。 このため、ウェブアプリケーションの機能をPureBuilder Simplyに組み込もうとすると、どうしても筋が悪いものになり、またソース側のデータをビルド側に持たせることにもなってしまう。

そこで次に検討されたのが、「ビルド時にインデックスを生成する」ということであった。 当初はpluginsによって実現することが検討されたが、どうしてもサイトの事情に特化したpluginsを書くことになってしまうため、シェアするのが難しい。 それに、かなり効率が悪く、そこでとった方法によって検索するアプリケーションの構造が全く変わってしまうために「どこまで固定するか」ということに悩まされてしまう。

それを踏まえて、「ビルド時により柔軟に扱えるhooks機能が欲しい」と考えた。 これは1.8のときには既にあった構想で、この機能を追加することが2.1の目標となっていた。 だが、ご存知の方も多いように、2.1は実際にはリリースにたどり着けなかった。

とはいえ、構想自体は存在していたので、1.9で追加された祝福機能もそこへ向けた準備であり、1.10/1.11のリファクタリングもhooksを追加することを念頭において行われた。

さらに言うならば、そもそも2.1自体はできていたにもかかわらずリリースされなかったのは、「Hooks機能のリリースはSearchUtilsと併せて」と考えていたが、SearchUtilsのほうがうまく書けなかった(いいアイディアもわかなかったし、それ以前にモチベーションも上げられなかった)ためにリリースにたどり着けなかったのだ。

3.0でHooksの機能がリリースされたにも関わらず、SearchUtilsは3.2が出てからだいぶ経ってからのリリースになった。 ここまで遅れたのは、そもそも使われていないし、私もあんまり困ってないからだ。

ただ、気に入らないソフトウェアは直したいものなので、そのうちやりたいとは思っていた。

アーキテクチャ

基本的には、PureBuilder SimplyのHooksとしてインデックスファイルを生成し、これを検索に使うというものだ。 インデックスファイルはJSONなので、ここから検索するウェブアプリケーションを実装すれば実現可能であり、やってることは簡単だし、ビギナーの練習台にもいいような題材だろう。

SearchUtilsは2つのアプローチを試した。

ひとつは、grep(1)で予め候補を絞り込む方法、もうひとつはすべてのJSONファイルをロードして検索する方法。 試した結果割と一長一短で、必ずしもgrep(1)を使うアプローチが優れていなかったことから、すべてのJSONファイルを読むというアプローチを前提に開発が進められている。 (なのでgrep版はバグがある)

pbsearch-pureruby.rbはライブラリとしてロードして使うもので、検索結果の配列を返す。 ここまでを利用する想定であれば、作るものは非常に限定されてくるだろう。

pbsearch.cgiはRackup/CGIを使ったCGIアプリケーションとして動作するもの、pbsearch.rurackup(1)で使うためのRackミドルウェア版。 どちらもpbsearch-pureruby.rbとの橋渡しをするだけで、レスポンスはJSONで返す。

pbsearch.jsはウェブページでロードされ、透過的に検索を行う。 検索クエリを渡すことと、結果をHTMLとしてビルドすることがメイン。 JavaScriptでビルドする形になったため、検索結果の表示がeRubyを使っていた従来とは比べ物にならないほどきれいな形になったのが最大のメリット。

もちろん、pbsearch-pureruby.rbを使うサーバーアプリケーションがHTMLを返すようにすれば、JavaScriptを使わない方式も採れる。 (pbsearchssr.cgiは実際にそれを行うものである。)

トリック

多くはないが、いくつかのトリックがあるので、解説していこう。

URL渡し

pbsearch.jsdeferでロードされた上で、pbsearch()を自動的に呼び出す。 このpbsearch()が検索結果をロードし、HTMLに追加するものになっている。

これ自体はファイルで定義と呼び出しを行い、deferつきでロードすれば良いだけだ。

ちょっとしたトリックになっているのは、URLを渡す、ということだ。

pbsearch.jsをロードするページは/search.htmlという静的なHTMLファイルになっている。ここで検索を行って、このページのHTML要素に結果を追加するわけだが、肝心の検索クエリを入力しているのは別ページであり、/search.html内で入力させるチャンスはない。 しかも、このページのロードはウェブブラウザによって行われるから、クエリを保留することもできない。

だが、フォームのGET/serach.htmlを呼んだ場合、クエリパラメータつきのページが読まれる。 これは静的なファイルなのでクエリパラメータは完全に無視されるが、JavaScriptからは自身のURLのクエリパラメータを認識可能なので、これをそのままリクエストに渡すことで透過的呼び出しが可能になる。

が、じゃあどうやって「そのまま渡す」のか。

実はnew URL(url)することで得られるURLオブジェクトは、fetch()に渡すのに都合のいい操作が色々できる便利なものになっている。 が、fetchに渡すだけならurl.searchでクエリパラメータを抜き出した文字列が得られるので、これを付け足すだけで動作する。

で、実際にどの程度これが互換性に影響を与えているのかというと、比較的モダンな機能を見ると

機能 Chrome Edge Firefox Opera Safari
URL() 19 12 26 15 14.1
() => 45 12 22 32 10
await 55 14 52 42 10.1
fetch() 42 14 39 29 10.1
const 21 12 36 9 5.1

となっている。 基本的には(fetch()よりも)awaitが一番厳しい要求だと言えるが、SafariだけはURL()が2021年にリリースされた14.1からサポートということで随分最近の話しになっている。

日時とJSON

JSONは日時オブジェクトを扱えない。 このことについて、日時に関して意外と深い知識が必要になる。

まず、JavaScriptのJSON.stringifyは「Date文字列」に変換する。 これはそのまま人間が読める文字列だが、JSON.parseで読むとDateオブジェクトになる。

Ojの場合、{"^t": 1700000000.000000000}という形式のオブジェクトに変換する。 これをOjでロードした場合はTimeオブジェクトに戻る。 Dateオブジェクトの場合は、Ojが持っている独自クラスのオブジェクトをダンプするための拡張機能を使って出力する。

RubyのJSONライブラリの場合はTimeをTime文字列にするが、JavaScriptのDate文字列と似てはいるが互換性はない。また、JSON.loadで読んでもTimeには戻らない。 Dateは単純に文字列になる。

同一実装上でやりとりする想定であれば、例えばブラウザのJavaScriptとNode.jsの間ではDateオブジェクトが透過的にやりとりできるイメージでいいのだが、そうでない場合はやりとりするための規則を導入する必要がある。

UNIXtimeを使ったエポックタイム(整数)が一番無難……に思えるだろうが、実は一般的にエポックタイムは秒(必要に応じて浮動小数点数)なのに対して、JavaScriptではミリ秒であり、単に整数というだけではなく、細かいところまで決めておく必要がある。

エポックタイムはタイムゾーンの情報も持っていないという点でも完璧ではない。 時刻の扱いを言語をまたいで正しく行えるようにするのは難しいため、特にJSONでやりとりする場合は相互の了解を以て独自の型定義を行うのが無難。

なお、YAMLにはTimestampsという型(タグ)が公式にあるが、Rubyのライブラリではデフォルトで扱えない。 Timestampsはかなり自由度が高いため、ちゃんと交換できるかは実際に動かして確認したほうが良いだろう。

テキスト生成

検索対象になるテキストは、ソースのMarkdown/ReSTであっても、生成されたHTMLであっても余計な情報が入り込んでしまい、検索に適していない。 そこで、検索対象テキストはplain textにしている。

が、ちょっとむずかしい要素として、PureBuilder Simplyはドキュメントの生成を外部に頼っており、なおかつ多くのエンジンから選択できるようになっている。 そのため、「テキストを生成する」といっても結構難しい話になる。

pbsearchはPureBuilder Simplyの設定を読んで、そのエンジンで処理する。 ただし、サポートされているのはpandoc, Kramdown, Redcarpet, CommonMarkerだけで、Docutils, RDocはサポートされていない。 これらはPureBuilder Simplyからすれば「一応サポートしている」程度のものである。もちろん、熱心にメンテナンスする人が現れれば別だが……

さて、それぞれなかなか実際にplain textに変換する機会はないと思うのでやり方を振り返ろう。

Pandocでは-t plainオプションで行える。

pandoc -t plain -o out.txt source.md

RedcarpetはRedcarpet::Render::StripDownを使うことで実現できる。

md = Redcarpet::Markdown.new(Redcarpet::Render::StripDown)
md.render(File.read procdoc)

CommonMarkerはto_plaintextというシンプルなメソッドがある。

doc = CommonMarker.render_doc(File.read procdoc)
doc.to_plaintext

が、残念ながらKramdownにはないため、Kramdownを使っている場合はHTMLで出力したあと、Nokogiriを使ってplain textに変換している。

マッチ位置のスライス

PureBuilder Simply Searchはマッチ位置の前後を表示する。 これはちょっと挙動を知っておいたほうが便利。

まずRubyの場合。

"foo bar baz quux".index("baz")8になる。

"foo bar baz quux"[8]bである。

"foo bar baz quux"[0,8]foo barになり、"foo bar baz quux"[8..]baz quuxになる。

よって、str[0, index] + str[index..]は元の文字列が得られる。

"foo bar baz quux"[8, 0]は空文字列になる。

"foo bar baz quux"[8, 1000]baz quuxになり、過剰なサイズを指定するのは問題ない。また、"foo bar baz quux"[8..1000]と範囲を指定した場合も問題ない。

起点が0より小さくなると、意図したようにはならない。

str[0, index] + str[index, length]は、strの先頭からindex + lengthの長さの文字列が得られる。

このスライスはRubyが文字列のエンコーディングを理解している必要があり、正しく認識している前提でそのエンコーディングにおける「文字数」である。

ちなみに、UTF-8で(U+306F)にU+3099を合成したばの長さは2になる。 もちろん、(U+3070)の長さは1である。 このため、Rubyの文字列スライスが元の文字列を絶対に破壊しないというわけではない。 ただし、これが気になるのであれば、str.unicode_normalize!(:nfkc)とすれば問題になる可能性は大きく下がる。

では、str = "foo bar baz quux"で、index = 8であるという前提でJavaScriptに渡ったとする。

str.slice(0, index)foo barで、str.slice(index)baz quuxになるので、str.slice(0, index) + str(index)で元の文字列が得られる。

JavaScriptのstr[start, end]はRubyではstr[start...end]と同等である。つまり、startは開始位置のインデックス、endは終端位置のインデックス(ただし終端を含まない)である。

少し特殊な話として、str[null, index]str[0, index]と同じ結果になる。

JavaScriptでもstr[index, 1000]のような大きすぎる値を与えても末尾までになるだけである。

JavaScriptでは文字列はUTF-16であり、UTF-16の1ペアが長さ1の文字列になる。つまり、Rubyのような合成文字の問題だけでなく、サロゲートペアの境界にはさまった場合も文字列を破壊する可能性があり、またRubyとJavaScriptの文字列の長さは一致するとは限らない。

次に、(実際にそうしているように)indexの位置でprepostに分かれているとする。

長さは過大でも問題ないため、Rubyではpost[0, 32]で、JavaScriptではpost.slice(0, 32)で先頭から長さ32の文字列が得られる。

逆にpreの末尾の文字列を得たい場合、Rubyではstr[-6..]で、JavaScriptではstr.slice(-6)でどちらもz quuxと末尾6文字が得られる。

ただし、Nodeではstr.slice(-100)と長さよりも手前のインデックスを与えると文字列全体が得られるが、Rubyでstr[-100..]とするとnilになってしまう。

このため、Rubyでは「文字列の末尾N文字」を得るのは少しむずかしく、str.reverse[0, 100].reverseのようにするのが一番話が早くて確実。 遅そうなイメージのある#reverseだが、実は超速い。なんなら値を変更する(異なる値を返す)メソッドの中ではトップクラスに速い。 賢そうな(速そうな)コードを書くより、#reverseを呼ぶのが圧倒的に速いということはよくある。これはString#reverseArray#reverseもである。

JavaScriptは伝統的にlengthに基づいた処理を書くようになっており、String.slice()も同じ感じで、長さを書けないのでちょっと不便に感じるが、こういうときはかなり便利。

範囲の終端が開始よりも手前にあるケースに関しては、Rubyのstr[8..7], JavaScriptのstr.slice(8, 7)ともに空文字列になる。

開始が範囲外あった場合にRubyがnil、JavaScriptが空文字列になるのは、一見「異なる挙動」に見えるが、Rubyでは空文字列は真になるので、どちらも偽になる値を返すという意味で同じ挙動である。