こんにちは、Cacooチームの平山です。僕は対外的には技術のことをお話しする機会は最近はあまり無いのですが、今回はCacooのUIリニューアルに伴い追加された、内部的には「自動保存」と呼ばれる機能の技術的な仕組みについてお話しします。
皆さん(きっと)ご存知の通り、Cacooは2018年6月28日に全面的なユーザーインターフェースの刷新を行いました。これに伴い、以前は図の閲覧用と編集用に分かれていたページを、編集機能を持つ一つのエディタで担うという変更を実施しました。Google Docsをはじめとする、オンラインのドキュメントツールと同等のユーザー体験に倣うような変更とも言えます。
さて、以前からCacooを利用されている方はおそらくご存知かと思いますが、ダッシュボード(図の一覧表示)のサムネイルや、ブログ等に張り付けるための画像などは、図の編集中に「保存」という操作を行わなければ更新されませんでした。この「保存」のときに何が行われていたかというと、ユーザーが図を編集しているエディタの中で、図に含まれるそれぞれのシートの内容からPNG画像を生成し、サーバーに送信してデータベースに保存する。という処理を行っていました。
ただし、新しいユーザーインターフェースでは前述のとおり図を閲覧するだけのページはなくなるため、図を保存する必要性が理解しづらい状況になることが予想されました。そのため、ユーザーが「保存」という操作を行わずとも、サーバーサイドで編集された図からPNG画像を生成し、データベースを更新するという処理が必要になりました。これが「自動保存」と呼んでいる機能です。
Headless Chromeとは?
自動保存を実現するため、「エクスポートしたSVGを画像に変換する。」などのいくつかの異なる方法が考えられましたが、パフォーマンスや精度、将来性などを考え、最終的に採用したのは”Headless Chrome”を利用する方法です。
Headless Chromeとは、皆さんご存知のブラウザであるChromeを、ヘッドレス環境というディスプレイやキーボードなどの入出力デバイスが無い環境で、GUIなしで起動しコマンドラインベースでWebページの読み込みや各種の操作を行う手段です。読み込んだWebページに含まれるDOMの制御やボタンクリックなどのインタラクション、スクリプトの実行などChromeやDevToolsで行えるあらゆる操作を、画面に表示させることなくコマンドラインから実行することができます。
Headless Chromeについては、こちらの記事で詳しく紹介されています。登場時点での記事のため多少古い内容も含まれますが、一通りの基本的なことを知るには良い内容がまとめられています。
Puppeteerについて
Headless Chromeを制御するには”DevTools Protocol“という専用のプロトコルでChromeと通信を行う必要がありますが、Cacooではこのプロトコルの上にハイレベルなAPIを提供してくれる、”Puppeteer“というNodeのオープンソースライブラリを利用しています。なお、PuppeteerはChromeのDevToolsチームによりメンテナンスされていますので、ほぼ公式に近い位置づけのライブラリと考えてよいと思います。
Puppeteerが提供するAPIを利用することで、Headless Chromeの起動やWebページのロード、DOMの変更の検知、Webページを画像やPDFとしてファイルに保存するといったことが、比較的簡単なNode.jsのコードで実行することができます。
PuppeteerのAPIやサンプルなどは、こちらのGithubのページから参照できます。
自動保存の構成
自動保存を実現するための構成図を示します。
Headless Chromeを含む画像生成を行う部分は、単一のNode環境のDockerコンテナにまとめられています。このDockerコンテナには、ExpressというWebサーバーの実装が含まれており、自動保存の処理を実行するためのRest APIを提供するために使われています。当然、PuppeteerやChrome(CacooではChromiumを使っています)も含まれます。当初、DockerではなくAWSのLambdaを使えないかという案もあったのですが、Lambdaにはファイルサイズの上限などの制限があるため、専用のDockerコンテナを利用することになりました。また、Dockerコンテナの数は負荷に応じて自動で増減されるよう、Amazon ECSのほうでオートスケーリングの設定がされています。
PuppeteerでHeadless Chromeを扱う上での注意点
ここで、今回自動保存を実現するにあたり、いくつかPuppeteerでHeadless Chromeを扱う上で試行錯誤したポイントがあったので紹介します。この情報は、将来のPuppeteerおよびChromeのバージョンアップによって不要になったり、状況が変わったりする可能性があります。
Chromeの起動オプションに”–disable-dev-shm-usage“を指定する
Chromeのデフォルトでは/dev/shmというディレクトリに共有メモリファイルを出力するのですが、Docker環境では通常このディレクトリには十分な容量が無く、Chromeの動作を阻害する可能性があります。このオプションを指定すると、/tmpディレクトリが使われるようになり、Dockerのホストと共有された十分に容量のある場所が使われるようになります。
Chromeの起動と終了の制御
Chromeの起動は比較的負荷が高く時間がかかる処理になります。そのため、画像の生成が終わってもChromeのプロセスをすぐに終了させず、別の図の画像生成でも使いまわすようにしています。これを実現するために、ひとつのChromeで生成する画像ごとにページ(Pageクラス)を追加し、画像生成が終了したらPageをcloseする。というようにしています。
一方、こうするとChrome自体は起動したままとなりますが、そのままではDockerコンテナを終了させたいときに、プロセスが正常に終了できないという問題も起きます。(オートスケーリングを設定しているので、自動的にDockerコンテナが終了することはあり得ます)
そのため、Nodeのprocessで”SIGINT”シグナルをハンドリングし、起動しているChromeを終了させてからprocessを終了させるようにする必要があります。
var option = { timeout: 0, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], headless: true }; var browser = await puppeteer.launch(option); process.on('SIGINT', async () => { log.info("Receive signal SIGINT"); await browser.close(); process.exit(0); });
自動保存の処理フロー
次に、自動保存の処理の流れを示します。
まずユーザーが何かしら図を編集したときには、更新が必要な図・シートが分かるようにフラグがデータベースにセットされます。その後、その図・シートの画像が必要とされたとき(例えば誰かがダッシュボードを開いてサムネイルが表示されるときなど)、自動保存の処理が実行されます。画像の生成は比較的負荷のかかる処理のため、図の編集に合わせて逐次行うのではなく、必要になったときにオンデマンドで行うようにしています。
自動保存の処理は、アプリケーションサーバーが画像生成を行うDockerコンテナのAPIを呼び出すことで開始します。この時、該当する図やシートを指定するパラメータが渡されます。
画像生成のDockerコンテナのほうでは、渡されたパラメータに応じて、Headless Chromeを起動し、エディタをアプリケーションサーバーから読み込み該当する図を開きます。ここで読み込むエディタ自体は、通常のエンドユーザーが使うものと全く同じものですが、自動保存用の特別なモードの上で実行されます。エディタを共通で利用することで、開発の効率化を図りつつ、エンドユーザーが見ているものと同一のもので画像が生成されるということを保証できるというメリットが得られます。
この自動保存モードのエディタは、図のレンダリングが完了すると特殊なDOM要素を出力します。このDOM要素の更新をPuppeteerで検知し、Headless Chromeの画面(エディタの画面)からPNG画像を生成します。PNG画像が生成できたら、図の持つオフセット等の情報を付け加えてアプリケーションサーバー側のコールバック用のAPIを通じて渡します。アプリケーションサーバーで受け取った画像等でデータベースを更新し、画像を表示するための処理を続行することになります。
フォントのレンダリングについて
以上がHeadless Chromeを用いた自動保存処理の概要になるのですが、Cacooのようなサービス特有の問題で、フォントに関する問題があります。
CacooではPCにインストールされているフォントを利用してテキストを図に含めることができますが、それらのフォントの大多数はサーバー側は持っていないため、そのままではサーバーサイドでユーザーが見ている状態と同じようにレンダリングすることはできません。この問題を解決するため、エディタで何かしらテキストを編集したときには、そのテキスト部分だけで画像を生成し、リアルタイムでサーバーのデータベースに保存するということも行っています。自動保存の処理では、テキストそのものではなく、そのテキストから生成された画像を使って最終的なシート全体の画像を生成しています。
Cacoo以外の一部のサービスでは、ユーザーがフォントファイルをアップロードするようにして対処しているものもあるようですが、個々のフォントファイルに適用されている利用ライセンスは様々で、場合によってはアップロード等の手段はライセンスに違反する可能性もあると考えています。そのため、Cacooでは一見遠回りながらもテキストごとに画像を生成し、極力ユーザーが意図したとおりの図をサーバーサイドで生成できるようにしています。
今回、自動保存を実現するために初めてプロダクション環境でHeadless Chromeを使いましたが、思ったよりもトラブルなく運用できています。他にも、既存のSVGやPDFエクスポート処理をHeadless Chromeで改善できる可能性があるため、今回の知見を頼りに、よりCacooをパワーアップできたらと夢を膨らませています。