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.ru
はrackup(1)
で使うためのRackミドルウェア版。
どちらもpbsearch-pureruby.rb
との橋渡しをするだけで、レスポンスはJSONで返す。
pbsearch.js
はウェブページでロードされ、透過的に検索を行う。
検索クエリを渡すことと、結果をHTMLとしてビルドすることがメイン。
JavaScriptでビルドする形になったため、検索結果の表示がeRubyを使っていた従来とは比べ物にならないほどきれいな形になったのが最大のメリット。
もちろん、pbsearch-pureruby.rb
を使うサーバーアプリケーションがHTMLを返すようにすれば、JavaScriptを使わない方式も採れる。
(pbsearchssr.cgi
は実際にそれを行うものである。)
トリック
多くはないが、いくつかのトリックがあるので、解説していこう。
URL渡し
pbsearch.js
はdefer
でロードされた上で、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
を使うことで実現できる。
= Redcarpet::Markdown.new(Redcarpet::Render::StripDown)
md .render(File.read procdoc) md
CommonMarkerはto_plaintext
というシンプルなメソッドがある。
= CommonMarker.render_doc(File.read procdoc)
doc .to_plaintext doc
が、残念ながら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
の位置でpre
とpost
に分かれているとする。
長さは過大でも問題ないため、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#reverse
もArray#reverse
もである。
JavaScriptは伝統的にlength
に基づいた処理を書くようになっており、String.slice()
も同じ感じで、長さを書けないのでちょっと不便に感じるが、こういうときはかなり便利。
範囲の終端が開始よりも手前にあるケースに関しては、Rubyのstr[8..7]
,
JavaScriptのstr.slice(8, 7)
ともに空文字列になる。
開始が範囲外あった場合にRubyがnil
、JavaScriptが空文字列になるのは、一見「異なる挙動」に見えるが、Rubyでは空文字列は真になるので、どちらも偽になる値を返すという意味で同じ挙動である。