nogahighland

グチばかりですみません

ConfluenceのページツリーChrome拡張を公開しました

chrome.google.com

ソースコードはこちら。 github.com

開発に至った経緯

今所属している楽天では、全社的にConfluenceが広く使われていて、ラクマでもノウハウを貯めるためにスペースを作成して各プロジェクトやチーム、開発内など様々な軸でツリーを成長させています。 Confluenceはマークダウンが使えないという点ではエンジニアに優しくないのですが、ストック型ドキュメントを整理するという意味では結構使えるツールだと感じています。

一方で、この「ストック型」というのが難点にも成り得てしまい、これが今回のChrome拡張を作成する動機になりました。

  • ドキュメントの場所を覚えられない
  • 場所は覚えているんだけどページツリーを辿るのが大変

その結果以下のような問題が発生してしまいます。

  • せっかく蓄積したノウハウが利用されない
  • 類似ドキュメントを作成してしまう

Confluenceのエコシステムを利用して「スペースを限定して検索する」という解決方法もあるのですが、いくつかの問題が存在しています。

  • ページ右上のグローバルサーチバーではデフォルトでスペースを絞り込めない
    • 楽天内には多くのスペースが存在していて、ビッグワードで検索すると無数の関係ないページがヒットしてしまうので、検索結果画面でスペースを絞り込むというステップが必要になる
    • インスタントサーチ結果がボックス下に表示されるが、スペースが絞り込まれていないのでかなり詳細なクエリを必要とする
  • スペース内に限定する検索ボックスも設置可能だが、画面遷移してしまう・検索スピードが遅いという課題が存在する
    • こちらはインスタント検索に対応していない
  • 検索精度が不十分
    • 例えばGoogleドキュメントを参照しているページを検索するために docs.google.com というクエリを打ち込んでも、実際にこのクエリを含むページが検索結果に表示されない

検索精度に関しては結果的に今回のソリューションに直接関係はなかったのですが、

検索精度を信頼できない →検索を選択肢として利用する自信が持てない →ドキュメントの場所にたどり着ける確証が持てない

なので、「せっかく蓄積したノウハウが利用されない」という問題の遠因となります。

また、上記に挙げた代替手段は相互に補完関係にあるものの、探しているページを瞬時に探すための総合的なソリューションとして利用できないです。

ConfluenceにはもともとPage Tree Macroというものが存在するのですが、場所がわかっているのに階層が深いとトップの階層から下るということが非常に苦痛になってきます。 各種検索に頼ってもなかなかヒットしない場合や、場所がわかっているのに検索結果が表示されるまでの時間が長いというストレスも増えてきました。

「場所がわかりきっている」というのをもう少し掘り下げてみると、「ページタイトルの一部を知っている」ということが大半を占めるのではないかと思いました。ここで、ソリューションがミートする範囲を「ドキュメントがあるかどうか検索する」ことよりも「既にあることが分かっているドキュメントを見つける(経験的におそらくあるだろう、も含む)」 に絞り込みました。

何があればいいのか

ここまでをまとめると

  • 頭の中に情報のインデックスは張られていて、タイトルの一部は覚えている
  • または、傾向的に〇〇プロジェクトの議事録的なものは「プロジェクト名 2020XXXX」というタイトルで存在している可能性が高いと分かっている
  • しかしページに辿り着くまでの手段に効率的なものが存在しない

ということになりました。ということは、ページツリーの中から入力したキーワードが部分一致するページのみを表示することが実現できれば良さそうです。 ページツリーのインクリメンタルサーチです。

※実際はここまで言語化せずに「ツリー検索無いとかマジか!!!もう怒ったった!!!作るったら作る!!!」という半ば衝動的な行動でした。カッとなってしまいました。

そこで作った

こんなものを作りました。

何もしていない状態 クエリを打ち込んだ状態 親ページを開いた状態

どうやって作ったか

大まかな設計を考え、最近覚えたてのVue.jsで作ろうと考えました。

  1. ページツリーデータを取得して再帰的に親子関係をレンダリングする
  2. クエリをstateとして管理し、各ノードのコンポーネントがクエリの変化に応じて自身を表示するかどうかの判定ロジックを以て表示を切り替える
  3. 子が開いている状態では親も開き、開いているノードは太字にして階層を認識しやすくする

f:id:nogahighland:20200307232227p:plain

まずは、 el を使って既存のツリーをVueコンポーネント化してしまうことができないか検討しましたが、早々にできないっぽいことがわかり断念しました。そうなると、既存のツリーをまるまるリプレースするしかなくなり、ツリーデータを取得するしかなくなりました。

ツリーデータの取得

既存のツリーをハックしようと、Chromeのインスペクタを開いて階層構造のハックを試みます。

  • 階層構造は、初期表示時には全件取得されておらず、ツリーを開いたタイミングでXHRで取得している(レスポンスはそのまま描画できるHTML形式😇)
  • XHRのページIDを変えることで、異なる親のツリーを取得できる

という事がわかり、子コンポーネントにページIDを渡して、 monted のタイミングでその更に子、孫・・・を都度取得するように実装しましたが、これだとロード回数が半端なくて、毎度開き直す度に数百のリクエストが生じるのは流石にユーザービリティ的に悪そうです。さらに、社内や果ては全世界のConfluenceユーザーに使ってもらいたいのにこの無駄なリクエストが走ってしまうのは社内のConfluence管理者たちに申し訳なさすぎます。

ということで、ツリー全体を取得できる方法が存在しないかリクエストをよく観察してみると startDepth というパラメータがあったので試しに100を設定してみると見事全階層取得できました。もしお使いのスペースで100以上の階層がある場合は・・・PRください。

この全ページ目次のHTMLを再帰的にJavascriptオブジェクトとしてパースし、子TreeNodeに渡して再帰的に描画していくとツリーの完成です。ツリーと再帰は大変相性がいいですね。

VueのscopedなCSSで、子に padding: 10px とかすれば簡単に階層構造が視覚化できます。

ツリーデータのキャッシュ

ツリーデータのサイズに応じて取得には5秒ほどかかる場合もあります。毎回ページを開くごとにこれだけの時間を要するのはちょっと効率的ではありません。 調べてみるとChrome ExtensionにはStorage機構が存在するので、これをキャッシュとして利用します。key: value形式でデータを5MBくらい保存できます(詳細は下記)

developer.chrome.com

keyにはスペースごとのID, valueにはツリーデータと最終取得日時をそれぞれ保存します。そうすることで

  • 10分以内のデータであれば通信で再取得せずに使う
  • 10分経過後であれば、ひとまず古いデータで描画して描画後に通信で最新のツリーデータを取得する

という制御ができ、初回データ取得後は特に大きく待ち時間が発生しないUXを実現できました。 10分以内のページであればSlackで「ページ作りました」と直接共有されそうなので即ツリーに反映されて無くても当面は問題なさそうです。

厳密性を求めるならばリフレッシュボタンでデータを強制的に最新化するという機能を追加しても良さそうです。

ここでVue.jsのデータフローによりデータを更新しさえすれば0実装で簡単にツリーを再描画できることと、Virtual DOMによって差分のみ再描画されるというエコな仕組みを利用できるのが最高ですね。

フィルタリング処理

inputとのデータバインディング方法は onInput時にイベントハンドラevent.target.value を取得してStateにセットします。

ページをフィルタリングするUIのイメージはGithubのファイル検索UIです。検索はどうやって実装されているかよくわからなかったし、調べてもないのですが「多分こんなかんじじゃね」で作ったものが意外と使い心地が良いので特に深入りしていません。

a b と入力されたら a.*b という正規表現を作成して取得できるgetterメソッドをStoreに作成して各TreeNodeコンポーネントで取得して、合致していればtrueそうでなければfalseを v-if してあげます。実装のことを説明するとだんだん日本語が喋れなくなってきますね。要は、入力した順に部分一致してればいいんでしょ、というノリで作りました。

気になったのは数百のコンポーネントが入力値をsubscribeしており、そのフィルタリング処理がUIスレッドで行われてしまっていることで、入力イベントに応じて一気に判定処理を始めるので一瞬処理が固まってしまいます。この体験が悪いのでなんとかしたいなぁと思うのですが、普段からUI周りを触っていないことからすぐにはいいアイデアが出てこないのが残念です。コントリビューションに期待しています。いちおう、inputイベントはlodashのdebounceで間引きしてはいます。

また、クエリにマッチした部分を太字にしたいけど、これは後の改善モチベーションやコントリビューションに期待します。

ツリー開閉処理

単純にTreeNodeオブジェクトでクリックイベントをハンドリングしてtoggle状態を切り替えれば良さそうです。

  • v-ifで子ツリー要素の表示を切り替える
  • 開閉ボタンのclass要素を切り替えて中身のシンボル + -CSSで変える

あとは、階層の下の方のページを開いたときにツリー上のどこに存在するか確認するために、親までさかのぼって開いてあげる処理を実装しました。 今回はコンポーネントがマウントされた際に実行されるmountedメソッドで、そのコンポーネントが表示対象だった場合に親コンポーネントopenChild イベントをemitするということで実現しました。そうすることで「逆再帰的」に親にイベントが伝播し、やりたいことが少ないコード量で実装できたのでなかなか気持ちよかったです。

jp.vuejs.org

ほかのライフサイクルイベントでもっと効率的に処理できるのであればmountedでの実装を見直せるかもしれません。

jp.vuejs.org

ツリーの開閉とフィルタリングの関係

各ページの表示状態はツリーの開閉状況とクエリどちらに依存すればいいのかという問題が出てきました。ここでどういう試行錯誤とをしたのかあまり覚えてはいないのですがこういう感じで実装しました(あくまで暫定の仕様ではありますが)

  1. クエリ入力中はクエリに一致すれば表示する
  2. クエリが入力されていなければ親コンポーネントが子コンポーネントを開いているかどうかに依存する

上手く言語化できませんが、なんとなくこれが自分的には釈然としたのでこういうふうにしています。

この仕組みをハックすれば

  1. ある親要素をクエリで検索する
  2. その要素の+ボタンを押して開く
  3. クエリを削除する
  4. 2のサブツリーだけが開いている状態にする

ということができます。良い仕様があるかもしれませんが、それも今後のコントリビューションや自分のモチベーションに期待しようと思います。

最後に

ちょっと身の回りをハックするツールを作るとき、直感に従って対してその仕様意図を言語化せずに作ってしまうことが多いのですが、今回こうして言語化してみると意外と多くのことを考えていたのだと気づけました。同僚や部下で「改善アイデアがなかなか出ない」という人の相談に乗ることがたまにあってあまり上手な返答ができなかったのですが「なにかにペインを感じたらそこがスタートになる」ということと「直感で感じたペインを風化させずに真正面から取り組む」ということを伝えていけるのかなという気がしました。

とくにペインというのは意識しなければ多くの場合見過ごしがちで、他の代替手段で根本欲求がかき消されてしまいます。そのペインのシグナルを逃さずキャッチするというのは、ある程度訓練が必要なことかもしれません。自分は「感じる」「考える」というのを区別して捉えているのですが、直感レベルで感じたことをしっかり言語化(自分の場合は即プログラミング言語化でしたが)するということが大事だと思います。

普段からアウトプットする習慣が身についていないのに急にこの記事をアウトプットしたのもなにかの直感なのだと思いますが、おそらく「思った以上に出来が良かった」「そのプロセスを知ってもらって参考にしてもらいたかった」というモチベーションなのかもしれません。ちょっと有頂天気味なので、想定外の突っ込みがあると急に恥ずかしくなって拡張の提供をやめてしまうかもしれないのでお手柔らかにお願いします。

実務でフロントエンドの実装に携わることが無いので趣味でこの拡張を作る前にVue.jsを覚えたのですが、UIをコンポーネントとして捉えることがとても自然で生産性の高い開発方法だと気づくことができました。もともとネイティブアプリがコンポーネントとしてUIを実装するというアプローチだったと思うのですが、Vue.jsではさらにstateやcomputedといった仕組みが導入されていて、これはこれで大変便利です。それより前の既知の言葉で言うと「データをpub/subしている」つまりデータの変更にUIが「反応する」というアプローチの強力さに感動しました。

本当にUI系の界隈には不勉強で辛いのですが、たぶんReduxから始まったこういうアーキテクチャがネイティブアプリにも良い影響をもたらしてReactNativeが活発になっているんでしょうね(適当)