2022年8月25日木曜日

Icon Search の DB をつよつよにした

以前作った Icon Search のDB をつよつよにして、Cloudflare Pages に移行しました。



DB が爆発 そして Cloudflare へ

移行の理由は単純で GitHub Pages の上限に到達したからです。 最近まで icon-db のサイズは 630MB くらいで、GitHub Pages の上限はまだまだ余裕と思っていたのですが、 最近登場した Fluent Emoji の容量が凄まじく、ローカル環境で一気に上限の 1GB をオーバーしました。 このせいもあって改良に滞りが起きていました。 今後も高画質なアイコンが出てくると、思ったよりファイルサイズの増大が早くなるかも知れません。 これは何とかせねばと思い Cloudflare Pages への移行を決めました。 Cloudflare Pages なら 50GB まで運用できるので、まだまだやれます。

移行にあたっては 2つの問題をクリアする必要がありました。 Cloudflare Pages は (1) 20,000ファイルまでしか管理できず、(2) 25MB 以上のファイルは管理できません。 どちらもバックエンドには重要な問題で、ファイル数は inode 枯渇問題、ファイルサイズは CPU/メモリ/ネットワークの圧迫に繋がります。 Cloudflare は CDN 屋さんですから、この 2点へシビアになるのも頷けます。 逆に言えばシビアだからこそディスクの容量を他より大きくできると言っても良いと思います。 フロントエンドの価値が高くなると、その傾向が強くなるかも知れません。

JSON DB を改良した

先ほどの問題を解決するためには JSON DB を改良しないといけません。 今回魔改造した DB を以下にまとめます。 まずバックエンドでは、JSON DB を以下のように管理します。
  • 10MB を超えたら JSON を分割して fetch (a.json --> a.1.json, a.2.json)
  • 5KB 以下のファイルは可能な限り 10MB 単位でまとめて Accept-Ranges fetch
フロントエンドでは heavy.json, light.json を最初にロードします。
  • heavy.json: 10MB を超えるタグのリストと、ファイル分割数
  • light.json: 5KB 以下のタグのリストと、Accept-Ranges のポインタ

heavy.json, light.json のタグにマッチする場合は、fetch の方法を切り替えます。 すべての fetch 情報を事前に読み込んでも良いのですが、たぶん 1MB を超えるのでやや微妙です。 他にも機能を追加したいとも思っているので、転送量は増やさないようにします。 また DB のファイル数を減らそうと思えば徹底的に減らせるのですが、どれだけ減らせるかはあまり意味のない議論です。 これは Accept-Ranges fetch より通常の gzip 圧縮の効いた fetch のほうが効率的だからです。 ほとんど使われないタグを Accept-Ranges fetch にして、使われるタグは通常の fetch にするほうが効率が良いです。 なんにしても、これで最初に 300KB ほどの heavy.json, light.json をロードするだけで、 後は O(1) で検索できるようになりました。ファイル数も 10,000 減って管理しやすくなりました。

割と最強の静的 DB なのでは?

sql.js-httpvfs の仕組みにかなり近づいているので比較すると、以下のような利点があります。 (1) JSON なので git で管理できる、(2) 将来は一緒になるかもしれないけど現状では通信量が〜1/3 で済む、(3) O(1) で検索できる、 (4) 1.5MB の sql.js-httpvfs をロードしないで済む、(5) 難しい実装に依存せず改良できる、 (6) 不均衡データの実装がしやすい、(7) 10MB 単位以外のブロックにも設定変更しやすい、 (8) Accept-Ranges fetch するだけで転用できる。

欠点は (1) タグ数が増えて light.json の容量が大きくなった時に通信量で困ること、 (2) light.json に関連するデータが same-origin でないと動かないだけのように思います。 欠点があるとはいえ sql.js-httpvfs の容量がかなり大きいので、light.json でタグ数 2万くらいまでは普通に管理できます。 ほぼ JSON DB のほうが有利です。 10万くらいになると大差なくなるので sql.js-httpvfs を利用するべきかも知れませんが、 アイコンに関しては超えるとは思えないので、JSON DB が正解そうです。 英語に最適化すればたいていのデータにも適用できるので、意外と応用も効きます。

雑に作った JSON DB も、気付けば一丁前の DB になってきました。 フロントエンドでは write が不要なので自前でも実装でき、 また SQL を使う必要性もないので、複雑な実装も必要ありません。 案外このような DB が最強かも知れません。

DB をつよつよにしたついでに、細かな機能もつよつよにしておきました。

キーワードが浮かばない人のためのコマンドを作った

DB の改良によって類似検索の真似事のようなことができるようになりました。 低頻度のキーワードをまとめた JSON ファイルを作ったので、その JSON ファイルを検索できるようにしました。 検索キーワードが思い付かない人は、キーワード @rare で検索すると、大量にいろいろなアイコンが見られるので、良さげな SVG を簡単に探せます。 類似検索なども作ろうかなとは思ってはいるのですが、 何もキーワードが浮かばないなら一度にたくさんのアイコンを見て、 そこから良いものを選ぶほうが早いかも知れません。 低頻度のヒットしないアイコンにも目が向きますし、なかなか良い案と思いました。

タグ補完を頻度順にした

低頻度のタグが補完で邪魔してくるのが、個人的には多少気になりました。 そこで頻度順に表示してみたところ、結構使いやすくなった気がします。 頻度順に表示しているのでキーワードに困ることも減るかも知れません。 頻度が低すぎるタグは削除してもいいような気がしましたが、ひとまず残しておきました。

SVG の ID 重複排除を高速化した (その1)

Fluent Emoji をレンダリングすると、かなりのアイコンで時間が掛かることがわかりました。 原因としては ID の重複排除ですべての SVG 要素をチェックするときに、ID ごとにすべての要素をチェックしていました。 ID 数と要素数が多くなると酷いことになります。 そこで一度だけすべての要素をチェックして、ID が含まれていそうな要素と属性をリストアップし、 そのリストに対して ID 重複排除処理を実行することで、15倍ほど高速化しました。 ID 重複排除の実装は何度も書き直している気がします。案外難しいですね。

SVG の ID 重複排除を高速化した (その2)

Fluent Emoji はそれでも重い SVG がありました。 特に重い SVG を開いてみると、要素数が 1万、ID も 3000 近くあり、DOM に書き込むことすら時間がかかるし、cloneNode(true) するだけで 1秒近く掛かります。 色々計測してみると、属性名を複数回に分けて置換している処理が重いとわかりました。 ID が 3000ともなると複数回に分けて置換するだけで大変なことになります。 そこで SVG のフォーマットを事前に整え、正規表現で処理してみたところ、100倍ほど早くなりました。

SVG の ID 重複排除を高速化した (その3)

アイコンの表示は十分な速度が出るようになったのですが、今度はアイコンの詳細を表示するときが問題になりました。 要素数が 1万ある SVG をcloneNode(true) したり、再描画しないとアイコンの詳細を表示できないのですが、それだけで 1秒近く掛かります。 これは DOM ノードが多すぎることが原因なので、DOM ノードを減らすには Data URI などを使った表示にすべきという結論になりました。 そういえばその技が使えたな…と気付いてしまったので、いろいろ豪快に実装し直しました。 Benchmark 記事も見つけたのですが、これが一番早いらしいです。 苦労して作った ID 重複処理がいらないとわかったのはショックでしたが、良い気付きになりました。 ちなみに何も考えずに appendChild すると体感的に遅くなるので、XML Parser を使わなくても無理やり Worker に投げて描画する実装は残しています。 初めての人が見るとまったく意味がわからないコードなのですが、Promise もやや遅く感じます。 このへんの実装ってどうするのが良いんですかねえ。

何にしても、この変更でアイコンの詳細表示は耐えられるレベルになりました。 ちなみにまだ微妙に重いためもっと非同期に動作させたいのですが、どうにも難しいです。 原因としては cloneNode の代わりに img.src を使っていて、原理上は async に動くのですが、内部では Data URL のパースで DOMParser が sync に動いてそうなところに問題がありそうです。 直感的には画像のキャッシュがあるので、ctx.drawImage(img, ...) すると高速そうとも思ったのですが全然駄目です。 既にレンダリングしている画像の width/height だけ変えてパースを回避することでごまかそうとも思いましたが、これさえ重いので、width/height を変更した時の再レンダリングが予想以上に大変なのかも知れません。 いずれにしてもあまり良い解決案はなさそうです。 まあ、そもそも要素数が 1万 あるアイコンはさすがにちょっとなと思うので、path で書いて欲しい気はします。

レンダリング順序に自由度をもたせた

DB を 10MB のブロック単位の JSON にしたことで、レンダリング順序に自由度をもたせることができるようになりました。 これまでは一つの JSON にまとまっていたため表示順序に損得が生じており、古めのアイコンのほうが得をするようになっていました。 データ数が増えた時に顕著になっていましたが、ブロック単位にして、ブロックごとにランダム表示するようにしました。 これによって、より様々なアイコンを見つけやすくなったと思います。

今後もちまちま改善を続けたいと思っています。

0 件のコメント: