サーバ上でHTMLから画像に変換する方法を比較してみた
松尾 裕幸
本記事は リクルートライフスタイル Advent Calendar 2019 の 17 日目の記事です。
こんにちは! 新卒でリクルートライフスタイルに入社し、Airレジ ハンディ のサーバサイドの開発を担当している松尾です。
現場に配属されて早々、Airレジ ハンディのとあるエンハンス案件を任せてもらえることになりました。 実装すべき処理を洗い出していく中で、「サーバサイドで動的に画像を生成して出力する」という処理が必要になることが分かりました。
上図のように、クライアントから送られてきたデータと、サーバ側で持っているテンプレートを用いてレンダリングし、生成した画像を返却するというイメージです。
レイアウトやレンダリングにはいくつかの方法が考えられます。
描画系 API(Java なら Graphics2D
など)で地道にレイアウトすることもできますが、リッチなレイアウトになると実装が大変です。
今回は、高品質なレイアウトをスピーディーに実装するため、HTML / CSS でレイアウトを作成して画像に変換する ことにしました。 シーケンス図で描くと以下のようになります。
(1) の HTML でレイアウトする部分については、テンプレートエンジン(Spring なら Thymeleaf など)の利用が考えられます。
問題は (2) の部分です。 サーバ上の処理としてはあまり見かけない類のものなので、実現方法にはさらに色々なものが考えられそうです。
この記事では、このような「サーバ上で HTML をレンダリングし、画像として出力する」ための方法を 3 つ取り上げ、それぞれ代表的なツールを挙げながら比較・検討してみます。
なお、以下ではアプリケーションサーバとして Java / Spring Boot を利用していることを想定しています。
検証用レイアウト
以下のシンプルなレイアウトを用いて検証します。
See the Pen ZEYpPEe by Hiroyuki Matsuo (@h-matsuo) on CodePen.
方法1:Java レイヤでのレンダリング(CSSBox)
Java で記述された HTML / CSS のレンダリングエンジンを探してみると、CSSBox という OSS が見つかりました。
以下のサンプルコードで、前述の検証用レイアウト(index.html
)を output.png
に変換することができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final URL url = new URL("file:///path/to/index.html");
final int width = 500;
final int height = 50;
// ソースコードを取得・パースして DOM を生成
final DocumentSource docSource = new DefaultDocumentSource(url);
final DOMSource parser = new DefaultDOMSource(docSource);
final Document doc = parser.parse();
// DOM にスタイルシートを適用
final DOMAnalyzer da = new DOMAnalyzer(doc, url);
da.attributesToStyles(); // インラインスタイルシートを適用
da.addStyleSheet(null, CSSNorm.stdStyleSheet(), DOMAnalyzer.Origin.AGENT); // 標準スタイルシートを適用
da.getStyleSheets(); // ユーザ定義のスタイルシートを適用
// レンダリングエンジンでレンダリング
final Dimension viewPort = new java.awt.Dimension(width, height);
final BrowserCanvas browser = new BrowserCanvas(da.getRoot(), da, viewPort, url);
ImageIO.write(bufferedImage, "png", new File("output.png"));
実際に出力された画像が以下です。
残念ながら、スタイルシートの大部分がうまく適用されませんでした。 HTML5 / CSS3 を使ったモダンなレイアウトや、複雑なレイアウトの再現は厳しそうです。 リポジトリの issues でもレイアウト関係のものがいくつもオープンになっています。
CSSBox は個人開発のプロジェクトのようで、OSS としての展望の観点からもプロダクトへの採用は難しそうです。
pros / cons
所感 | |
---|---|
pros |
|
cons |
|
最後の実行速度については、実際に以下の構成の PC で実行時間を計測してみました。
構成 | 内容 |
---|---|
本体 | MacBook Pro (13-inch, 2018, Four Thunderbolt 3 Ports) |
OS | macOS Catalina 10.15.1 |
CPU | 2.3 GHz クアッドコアIntel Core i5 |
RAM | 16 GB 2133 MHz LPDDR3 |
ストレージ | 250 GB SSD |
サンプルコードの 5 〜 20 行目の部分の実行時間を 10 回計測して平均をとると、約 1.8 [s] でした。 これは後の 2 つに比べてもかなり遅い結果となっています。
方法2:CLI ツールの利用(wkhtmltopdf)
wkhtmltopdf は、HTML をレンダリングして画像フォーマットに変換する CLI ツールです。 wkhtmltopdf と wkhtmltoimage の 2 つのコマンドが含まれており、前者は PDF ファイルに、後者は PNG や JPEG などの画像ファイルに変換することができます。
1
2
# 注: --width オプションでビューポートの幅を指定する
$ wkhtmltoimage --width 500 /path/to/index.html output.png
実際に出力された画像が以下です。
Flexbox を用いてレイアウトしていた部分が反映されていません。
HTML5 / CSS3 のサポートが不完全
issue によると、内部で用いているレンダリングエンジン(QtWebKit)のバージョンの関係で、HTML5 / CSS3 の機能の一部が使えないようです。
ベンダープレフィックス(-webkit-
)により使えるものもあるようですが、複雑なレイアウトでは崩れてしまう可能性がありそうです。
pros / cons
所感 | |
---|---|
pros |
|
cons |
|
単純なレイアウトで、かつ短時間に大量の変換を行うようなことがなければ、導入が簡単な wkhtmltopdf は魅力的です。
当初は Airレジ ハンディでも wkhtmltopdf を採用していました。 しかし、コーディング中に手元のブラウザで表示されていたものと、実際にサーバ上で画像生成されたものが微妙に違うことがあり、ピクセル単位の修正に何度も悩まされました。
また、毎プロセスの立ち上げではリソースの使用効率が悪いこともあり、最終的にこの方法は不採用となりました。
なお、こちらもコマンド全体の実行時間を 10 回計測して平均をとると、約 1.3 [s] でした。
方法3:ヘッドレスブラウザ(Chromium)+ドライバ(Puppeteer)
最近の Google Chrome や Chromium には、ヘッドレスモード という GUI 無しに Web ブラウザを動作させるモードが搭載されています。
Puppeteer は、ヘッドレスモードのドライバの一つで、Node.js 用のライブラリとして提供されています。 E2E テストの自動化など、ブラウザをプログラムから制御したいときの幅広い用途に用いることができます。
以下は、Puppeteer で引数で与えられた HTML ファイルを読み込み、スクリーンショットを撮影するサンプルコードです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// puppeteer.js
const puppeteer = require('puppeteer');
let browser = null;
(async () => {
const inputHtmlPath = process.argv[2];
const outputImagePath = process.argv[3];
if (!inputHtmlPath || !outputImagePath) {
throw new Error('Specify input and output.');
}
// ブラウザを起動
browser = await puppeteer.launch({
headless: true
});
// 新規ページ (タブに相当) で対象のファイルを開く
const page = await browser.newPage();
await page.goto(`file://${inputHtmlPath}`);
// ページコンテキスト内でスクリーンショット対象の要素を取得
// 注: 出力画像のサイズを要素と一致させるために必要
const selector = '#screenshot-target';
const targetElement = await page.$(selector);
// 要素をスクリーンショット
await targetElement.screenshot({
path: outputImagePath
});
})().catch(err => {
console.error(err);
process.exit(1);
}).finally(() => {
if (browser) browser.close();
})
上記のスクリプトは、以下のようにして実行できます。
1
$ node puppeteer.js /path/to/index.html output.png
実際に出力された画像が以下です。
何と言ってもレンダリングしているのは Chromium なので、Flexbox のようなモダンなスタイルも正常に適用されています。
Puppeteer は定期的なアップデートで Chromium のサポートバージョンを上げているため、比較的新しい HTML5 / CSS3 の機能も問題なく使える可能性が高いでしょう。
インスタンスを再利用してリソースを節約
上記サンプルコードでは、実行のたびに Node.js と Chromium のインスタンスを起動するため、パフォーマンスがあまり優れていません。
Puppeteer Cluster といったインスタンスプール系のツールを導入し、サービスという形で常駐させることでこの辺りも改善できそうです。
こういった柔軟な使い方ができるのも、プログラムから制御できるこの方法の利点でしょう。
pros / cons
所感 | |
---|---|
pros |
|
cons |
|
Chromium + Puppeteer の場合、実行速度が非常に速いのも特徴でした。 これまでと同様に 10 回計測して平均をとったところ、以下のようになりました。
計測区間 | 実行時間の平均 |
---|---|
コマンド全体 (毎回 Node.js, Chromium を立ち上げる状況を想定) |
約 0.8 [s] |
サンプルコードの 24 〜 32 行目 (Node.js, Chromium のインスタンスを再利用する状況を想定) |
約 0.4 [s] |
これまでの2つの方法に比べて、明らかに速いことが分かります。
まとめ
この記事では、サーバサイドで HTML をレンダリングして画像に変換する 3 パターンの方法と、それぞれの代表的なツールを紹介し、サンプルコードと所感を述べました。
改めて比較結果をまとめると、下表の通りです。 最終的には、OSS としての展望や柔軟性から、Chromium + Puppeteer の構成を採用しています。
方法 | pros | cons | インターネット上の知見 | OSS としての展望 | 実行時間の平均 [s] |
---|---|---|---|---|---|
CSSBox | Java との親和性◎ | レンダリング結果に難あり、学習コスト高 | × | × | 1.8 |
wkhtmltopdf | セットアップ簡単 | レンダリング結果にやや難あり | ◯ | ◯ | 1.3 |
Chromium + Puppeteer | レンダリング結果◎、柔軟性◎ | セットアップやや面倒、スケーラビリティ△ | ◎ | ◎ | 0.4 〜 0.8 |
サーバサイドで HTML をレンダリングするという少しトリッキーな状況でしたが、いかがだったでしょうか。 担当した部分がリリースされ、世の中で使われていくのが楽しみです。
最後までお読みいただきありがとうございました!