Cacooのエクスポート機能が4倍速くなりました!SVGの生成手法の刷新によるパフォーマンスの向上

Cacoo開発チームの木村(@cohhei)です。Cacooでは図形の描画にScalable Vector Graphics(以下、SVG)を採用しています。本記事では、CacooのサーバーサイドにおけるSVGの生成手法の刷新と、それによって得られた図のエクスポート機能のパフォーマンス向上についてご説明します。

要約

  • サーバーサイドでのSVGの生成方法を変更しました。
  • 結果として、SVGエクスポートが約4倍速くなりました。
  • PDFエクスポートもその恩恵を受けて早くなりました。
    • 図の内容に依存しますが、1シートあたり1.5〜2倍ほど高速化しました。
  •  技術的にはHeadless Chromeからjsdomを使ったSVG生成に切り替えました。
  • クライアントサイドのTypeScriptのコードをNode.jsで実行させています。

SVG?エクスポート?生成?とは?

SVGとはXMLをベースとした2次元の画像を表現するためのマークアップ言語です。Cacooではブラウザ上で図形を描画するのためにSVGを使用しています。SVGを採用した経緯や技術的背景については下記の記事にて詳しく解説されています。

目指すのはぶっちぎりの速さ! なぜ HTML5 版CacooはSVGを採用するのか

一方で、SVGの生成はクライアントサイドだけでなくサーバーサイドでも行っています。Cacooで作成した図は様々なファイル形式にエクスポートできるのですが、SVGファイルのエクスポートにはサーバーサイドで生成したものが使われます。理由としては、

  • PDFファイルにエクスポートするときや図を印刷するときにもSVGが必要
  • エクスポートしたい図に画像ファイルが含まれている場合、画像データをbase64でエンコードして直接SVGファイルに埋め込む処理が必要

などが挙げられます。サーバーサイドにおけるSVGファイルの生成とエクスポート機能の関係については後述しますので、今はサーバーサイドでもSVG生成してる点だけご認識ください。ちなみにSVGやPDFエクスポートは有料プランでのみご利用可能です。

速くなったのはエクスポート機能?それだけ?

SVGのエクスポートだけでなくPDFへのエクスポートや印刷機能も高速化されています。また、エクスポート機能以外の機能への利用を検討しています。

例えばCacooでは外部ウェブページヘの図の埋め込み機能で画像化された図が使われています。また、画像リンクを直接ブログやウェブサイトに埋め込むと、Cacooの図を編集するだけでそのページの画像を更新できます。この記事で使われている画像もすべてCacooで管理しています。Cacoo側で図を編集すればブログ記事を編集しなくても画像が更新されるので非常に便利です。

これらの機能に使用される画像の生成にサーバーサイドで生成したSVGファイルを利用しようと妄想計画しています。パフォーマンスの改善が期待できます。

埋め込みの例

画像リンクの取得方法

どのくらい速くなった?

公式のワイヤーフレーム用テンプレートを使った計測では、約4秒前後かかっていたSVGエクスポートが約1秒で完了するようになりました。もちろん、エクスポートにかかる時間は図の要素やお使いのネットワーク環境などによって変わります。下の図はパフォーマンスを比較したときに作成した箱ひげ図です。

改良前後でのSVGエクスポートにかかった時間の比較

上が改良後、下が改良前で、横軸がSVGエクスポートにかかった時間(秒)です。ブラウザのデベロッパーツールを使って計測しています。Cacooで作成した同じ図に対してそれぞれ10回ずつSVGエクスポートを実行し、SVGファイルのダウンロード完了までにかかった時間を比較しています。

処理時間が4秒前後だったのが1秒前後になっており、約4倍ほど以前より速くなっているのがわかります。同じ図を使ってテストしているため分散が小さくなっています。箱ひげ図にするのは適切でないかもしれません。ですが、安定して短い時間で処理が完了しているのがわかります。

また、PDFエクスポートも1シートあたり1.5〜2倍ほど高速化しました。

試してみたけど、まだ遅くない?

新しいSVGエクスポート機能を早速お試しいただきありがとうございます!ご存知の通り、Cacooのエクスポート機能にはまだまだパフォーマンス改善の余地があります。画像が含まれている図では、どうしても時間がかかってしまいます。一方で、公式のシェイプや表、グラフなど、Cacooにおけるプリミティブな要素だけを利用した図ではかなり高速になっていると思います。

また、先述したように、この変更によってサーバーサイドで生成したSVGファイルを他の用途への利用を検討しています。今回の対応はこれから行うパフォーマンス向上の様々な施策の第一歩だと受け取っていただければ嬉しく思います。

技術的なおはなし

ここからは、技術的な説明です。今回の変更以前の仕組みはどうなっていたのか、どのような問題を抱えていたか、新たにどのような仕組みを導入したかをご説明します。

以前のSVG生成方法

以前のSVGの生成ではpuppeteerを使っていました。サーバーサイドにはCacoo内部で使用するためのビューアがあり、そこにブラウザでアクセスするとCacooで作成した図がSVGで描画されます。ビューア上ではクライアント用のTypeScriptコードがそのまま動きます。そこにHeadless Chromeでアクセスしてクライアントに返すためのSVGファイルを作成します。図にすると下のようになります。

SVGエクスポート(旧)

クライアントとHeadless Chromeの間にGoのアプリケーションがありますが、これはCacooの図の編集画面向けの汎用的なバックエンドアプリケーションです。DBの操作や外部のアプリケーションとの連携などを行うためのものです。ここではbackend-goと呼ぶことにします。SVGエクスポートの場合はリクエストの認証・認可などを行っています。

ここにPDFエクスポートのフローを加えるとさらに複雑になります。それが下の図です。

SVGとPDFエクスポート(旧)

複雑でなんだかよくわかりませんね。僕もよくわかっていません

PDFエクスポートはJavaのアプリケーションが受けます。Cacooの編集画面のサーバーサイドには(歴史的経緯から)先程のbackend-goと、このJavaアプリケーションが存在しています。ここではbackend-javaと呼ぶことにします。このbackend-javaがさらにPDF Generatorと呼ばれる別のアプリケーションを呼びます。PDF GeneratorはWebページを指定すると、そのページをPDF化してくれます。

このPDF GeneratorにHeadless ChromeのURLを渡すことで、表示された図をPDF化します。つまりCacooの図をPDF化する場合、裏ではSVGが作られており、それがPDFに変換されるのです。

ところが、図にグラフが含まれている場合のみHeadless Chromeが使われます。それ以外の図ではパフォーマンスの都合で使っていません。代わりにbackend-javaがCacooの図からSVGを生成し、それをPDF Generatorに渡します。グラフが含まれている図でHeadless Chromeが使われるのは、backend-javaが比較的新しい機能であるグラフ機能をサポートしていないためです。

技術的課題

backend-javaの継続的なアップデート

以前のSVG及びPDFエクスポートの仕組みではいくつか課題がありました。そのひとつがbackend-javaの継続的なアップデート問題です。図の描画をbackend-javaで行うため、Cacooで表現できる図の要素をすべてJavaで生成しなければいけません。そのため、フロントエンドの機能に合わせてSVGの描画機能をアップデートする必要がありました。新しい機能がCacooに追加されても、それをサポート出来なければbackend-java側でSVGの生成ができません。

実際の問題としてグラフの描画は出来なかったため、Headless Chromeを利用しています。できればサーバーサイド専用のコードではなく、クライアント用のTypeScriptのコードをそのまま実行してSVGを生成したいところです。そうすればクライアントの機能がアップデートされれば、自動的にサーバーサイドのSVG生成の機能もアップデートされるからです。

パフォーマンス

そういった需要があってHeadless ChromeによるSVGの生成を行っているわけですが、こちらにはパフォーマンス上の問題がありました。そのため、PDFエクスポートでは使用していません。Headless Chromeを使ってSVGを生成する場合、

  1. Headless Chromeのタブを開く
  2. Cacooの図にアクセスする
  3. SVGを取得する
  4. Headless Chromeのタブを閉じる

という手順を踏む必要があり、この一連の処理に時間がかかってしまいます。SVGの生成方法を一元化したいけどパフォーマンスの都合上できない、というジレンマを抱えていました。

複雑なアーキテクチャ

PDFをエクスポートするためのフローが複雑になっています。図の状態によってbackend-javaとHeadless ChromeのどちらがSVGを作るのかが変わってしまいます。開発チームの規模が小さかったり、人の入れ替わりがないうちはいいかもしれません。ところが、数年後はどうなるかわかりません。新しい開発者がチームに加わったり、現状の仕組みを理解している開発者がチームからいなくなってしまうと困ったことになるでしょう。どこで、いつ、どのアプリケーションが呼ばれているのかわからないと、何か問題が見つかったときにデバッグが難しくなるでしょう。一般論として、シンプルな仕組みで解決できる問題ならばエンジニアが理解しやすいようにシンプルさを維持したほうが望ましいと思われます。

現在のSVG生成方法

このような課題を解決するために、SVGの生成方法を刷新しました。jsdomを内部で利用し、単独でSVGを生成できます。jsdomはJavaScriptによるWeb標準の実装です。Node.js上でWebブラウザーのサブセットをエミュレートすることができます。

jsdom is a pure-JavaScript implementation of many web standards, notably the WHATWG DOM and HTML Standards, for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a web browser to be useful for testing and scraping real-world web applications.

jsdom/jsdom: A JavaScript implementation of various web standards, for use with Node.js

新しいSVG/PDFエクスポートのフローを図にすると下のようになります。

SVGとPDFエクスポート(新)

これだとかなりすっきりしましたね。新しく作ったSVG生成用のアプリケーションはSVG Rendererと呼んでいます。

さらに、backend-javaでSVGを作る必要がなくなったので、クライアントから直接リクエストを受けるREST APIをbackend-goに統一することができます。Cacooのマイクロサービス化に伴い、backend-javaのコードのbackend-goへ以降している最中なので、これは開発チームの方針とも一致する良い変更です。この変更はまだリリースできていませんが、近いうちに対応する予定です。

SVGとPDFエクスポート(妄想)

技術的優位性

この新たなSVG生成方法は、以前の方法に存在していた課題を解決してくれました。ひとつはコードのメンテナンス性です。クライアント用のTypeScriptがそのまま実行されるので、フロントエンドの新しい機能が自動的に反映されます。もちろん、それらの新しいコードがブラウザだけでなくjsdom上でも動作するかどうかの確認が必要になります。ですが、JavaのSVG生成用コードをメンテナンスしたり、図によってJava側かHeadless ChromeのどちらでSVGを作るか判断するよりはずっとマシな状態だと言えます。

パフォーマンスも良好です。Headless Chrome版よりも高速なJava版よりもさらに高速にSVGが作れます。アーキテクチャも非常にシンプルになりました。シンプルってすばらしい。

実装

今回新しく開発したSVG Rendererですが、jsdomを使ってSVGの作成を行いました。フロントエンドの技術もよくわからず、TypeScriptも初めて書きました。手探りの状態で始めることになりましたが、いくつか工夫した点や感想をご紹介します。

  • コードはフロントエンドのリポジトリと同居
  • Express+TypeScriptでサーバーを実装
    • サーバーはts-nodeで起動
    • 開発中はts-node-dev
  • jsdomで実装されていないinterfaceはひとつひとつclassを作って中身を実装
  • webpackのビルドを通すためにサーバーサイド用のコードを除外する設定をtsconfig.jsonに追加

コードはフロントエンドのリポジトリと同居

コードを新たにフロントエンドのリポジトリに追加しました。これは単純に開発に取り掛かりやすかったからそうしただけ、な面もあります。一方で同一リポジトリにすることでフロントエンドの開発に自動でSVG Rendererが追従できるというメリットもあります。

CI/CDで自動化しているので、フロントエンドのコードが更新されると自動でビルドとデプロイが走ります。リポジトリのブランチと各環境へのデプロイの関係は下の図のようになります。

ブランチとCI/CD

本番以外の環境は完全に自動更新ですが、本番環境へはChatOpsでデプロイするようにしています。コンテナイメージのビルドは自動なので、すぐに最新のコードを本番環境にデプロイすることができます。図にすると下のようになります。ただし、これはあくまで現状のリポジトリ管理です。将来的には必要に応じてリポジトリを分けてフロントエンドのコードをライブラリとして読み込むような形にするかもしれませんし、それとはまた違う構成にするかもしれません。

Express+TypeScriptでサーバーを実装

特に難しい点はないと思います。普通にTypeScriptのコードを書いてts-nodeで実行できます。ts-node-devを使えばホットリロードが効くので、コードを書き換えるだけでアプリケーションの再起動は不要なので便利でした。プロダクション環境でts-nodeを使う場合は–transpile-onlyオプションを使って型チェックは行わずにJavaScriptへのトランスパイルだけにすると良いようです。

npm install ts-node
npx ts-node app.ts
npx ts-node-dev app.ts #開発中の場合
npx ts-node --transpile-only app.ts #トランスパイルのみ

Node.js標準のライブラリを使う場合、requireは下のようなimportで置き換えることができます。

import * as http from 'http'; // require('http'); と同じ

TypeScript初挑戦でしたが、割とすんなり馴染めたと思います。普段から僕はVisual Studio Codeで開発をしているので、TypeScriptとの相性は非常に良かったです。型があるので、コード補完も効きます。実行中のアプリケーションにアタッチしてデバッグ実行も簡単にできます。何より型が後置なのが良いですね。普段はGoを書いているので違和感なく書くことが出来ました。

jsdomで実装されていないinterfaceはひとつひとつclassを作って中身を実装

開発のほとんどはこれでした。Cacooでは、様々なSVG関連のinterfaceを利用しているのですが、jsdomでは実装されていないものもありました。例えばSVGMatrixがそうです。これは行列を保持して計算するメソッドを持っているので、それらを実現するclassを実装する必要がありました。

また、必要なフィールドをObject.definePropertyで追加しました。フロントエンドのコードを解析し、使われているinterfaceで定義されいるフィールドをSVGElementのprototypeに追加していきます。例えば、下のようなコードを読み込むことでSVGForeignObjectElementのwidth.baseVal.valueに値を代入することでSVG要素の属性にwidthを追加することができます。ひとつひとつ丁寧に、温かみのある手作業で追加していくことにより最終的に作られるSVGファイルに味と深みが加わります。

function SVGForeignObjectElement() {
  throw new TypeError("Illegal constructor");
}

Object.setPrototypeOf(SVGForeignObjectElement.prototype, SVGElement.prototype);
Object.setPrototypeOf(SVGForeignObjectElement, SVGElement);
SVGForeignObjectElement.__width = null;
SVGForeignObjectElement.__height = null;

Object.defineProperty(SVGElement.prototype, "width", {
  get() {
    if (this.__width == null) {
      this.__width = new SVGAnimatedLength(this, "width");
    }
    return this.__width;
  },

  enumerable: true,
  configurable: true
});

Object.defineProperty(SVGElement.prototype, "height", {
  get() {
    if (this.__height == null) {
      this.__height = new SVGAnimatedLength(this, "height");
    }
    return this.__height;
  },

  enumerable: true,
  configurable: true
});

class SVGAnimatedLength {
  readonly baseVal: SVGLength;
  constructor(element: SVGElement, name: string) {
    this.baseVal = new SVGLength(element, name);
  }
}



class SVGLength {
  element: SVGElement;
  name: string;
  constructor(element: SVGElement, name: string) {
    this.element = element;
    this.name = name;
  }

  set value(n: any) 
    this.element.setAttribute(this.name, n);
  }

  set valueAsString(s: string) {
    this.element.setAttribute(this.name, s);
  
}
// 上のコードを読み込んでおけば下のコードが動く
const fobj: SVGForeignObjectElement = <SVGForeignObjectElement>document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
fobj.width.baseVal.valueAsString = "100px";
fobj.height.baseVal.valueAsString = "100px";

webpackのビルドを通すためにサーバーサイド用のコードを除外する設定をtsconfig.jsonに追加

tsconfig.jsonのexcludeにサーバーサイド用のコードが入っているディレクトリを追加してあげるといい感じでした。Cacooチームの環境では、フロントエンドのコードに変更が入るたびにwebpackによるビルドと、テストが走ります。共通のリポジトリを使っているため、サーバーサイド用とフロントエンド用のビルド両方ともパスする状態にしておく必要があります。そのため、サーバーサイド用のコードはビルドから除外する必要がありました。

"exclude": [
  "./path/to/svg-renderer"
]

また、compilerOptionsのtypesは空にする必要がありました。ここにnodeを指定してしまうと、型情報として@types/nodeが優先されてしまい、フロントエンド用に書かれたTypeScriptコードが動かないことがあります。

{
  "compilerOptions": {
    "types": [],

例えば、setTimeoutが返す値は通常であればnumber型のIDを返すのですが、Node.jsの場合、@types/nodeではNodeJS.Timeoutという型で定義されています。そのため、下のようなコードがあると、ビルド時にエラーになってしまいます。

let id: number;
id = setInterval(() => { console.log('Hello'); }, 1000);
doSomething();
clearInterval(id)

まとめ

サーバーサイドでのSVG生成方法の刷新についてご説明しました。パフォーマンスやメンテナンス性の面で課題があったのですが、jsdomを使ったアプローチでそれらの課題を解決できました。TypeScript初挑戦でしたが、楽しんで開発できました。型っていいよね。

最後まで読んでいただきありがとうございます。また、TypeScriptやフロントエンド関連の知識が皆無だった僕を継続的にサポートしてくれた同じCacoo開発チームのフロントエンドエンジニアの川端さんには本当に感謝しています。次回の宮古島はきっと晴れると思います。画像関連の処理を最適化してくださった國廣さん、ありがとうございました。よきパパになれると思います。

はじめのうちは、このjsdomを使った手法が技術的にうまくいくかどうか誰にもわからなかったため手探りの状態でした。なんとかリリースまでこれたのは、フォローしてくれたCacooチームのみなさん、自分の強みを発揮してサービスをよくしてくれているプロフェッショナルなヌーラバーのみなさん、いつもCacooやヌーラボのプロダクトを使ってくださるユーザーのみなさん、各種OSSの開発者のみなさん、そして何より僕自身の類まれなる才能と不断の努力の賜物だと思います。この場を借りてお礼申し上げます。ありがとうございました。

フローチャートやワイヤーフレーム、プレゼン資料まで作れる | Cacoo(カクー)

チームのアイデアを、いつでもどこからでも視覚的に共有しよう