GAミント至上主義

Web Monomaniacal Developer.

Headless Chromeを使ったPDF変換サーバーをGitHubにあげた

以前から開発していたのが、だいぶ形になってきたのと、他のプロジェクトでも使っているので、共有も含めてGitHubにあげた。

github.com

Dockerで動いて、URLを渡すとそのページをA4のPDFに変換して返してくれるシンプルなやつ。

起動時にChrome本体とページを起動し、以降のリクエストではそれを使いまわすため、2回目以降のリクエストは結構はやい。

自社の開発サーバーでlocalhost内の簡単なページで60ms程度で生成できる(リクエスト周りは含まず)。高速化には基本的に通常のChromeと同じようなHTMLの書き方が必要となる。

外部への直接公開は考えない分、エラー処理とか超雑なおかげで140行以下のためカスタマイズはしやすいかも。

今のところPDFをA4以外にする等のオプションは必要がないので、ソースに書いてしまっているが必要になったらリクエストごとに変えられるようにしたいと思う。プルリ、要望も募集中です。

これからも書式関連のサービスを作っていくbizoceanでは、書式を共有したり印刷するのを考えるとPDFは絶対必要になるので、メンテはちゃんとやるはず。


コードの整理以外にも、GETでURLを渡してPDFを返すほかに、POSTでHTMLを送ってPDFを返すのも実装してある。
PDFサーバーからURLへのリクエストが減らせる分早くなるけど、外部ファイルの読み込みをURL込みで書かなきゃいけなかったり。
もっと早くしようと思うと、前述したとおり通常のWEBページの高速化と同じで、cssもjsもhtml内に展開したりして、外部へのリクエスト数を0に近づけた方がいいなと思ったり、速度を追及するとなかなか面倒。


過去記事はこちら
Headless Chrome + puppeteerを使ったHTML→PDF変換サーバーを作る puppeteer編 - 仕事中の問題と解決メモ。

ヘッドレスChromeを使ったHTML→PDF変換サーバーを作る 改善編 - 仕事中の問題と解決メモ。

ヘッドレスChrome + Node.js + express + DockerでPDF生成サーバーを作る - 仕事中の問題と解決メモ。

Headless ChromeはPDF以外にも今後スクリーンショットとか、テスト用にも使えるし、node.jsも使わざるを得ない状況になり、いい勉強になるのでオヌヌメ

Nodeクックブック

Nodeクックブック

Headless Chrome + puppeteerを使ったHTML→PDF変換サーバーを作る puppeteer編

Headless Chromeを使ったHTML→PDF変換サーバー第3弾。

前回は下記。
ヘッドレスChrome + Node.js + express + DockerでPDF生成サーバーを作る - 仕事中の問題と解決メモ。
ヘッドレスChromeを使ったHTML→PDF変換サーバーを作る 改善編 - 仕事中の問題と解決メモ。

下記の記事をどっかで発見し、つい最近Chromeの開発チームが作っているクライアントPuppeteerがv1になったということでこっちに変更してみた。
www.infoq.com

以前のものはchrome-remote-interfaceを使っていた。
github.com

さすが公式なのか、PDF生成に200ms程度かかっていたのが、Puppeteerに書き直したら100ms以下になった。

ドキュメントもさすがGoogle、しっかりしていてよい。

github.com

まだ動いたばっかりだけどコードもかなりすっきりした。

(async function() {
  const chromeBinary = '/usr/bin/google-chrome'
  const options = {
    landscape: false,
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: false,
    margin :{top:0, right:0, bottom:0, left:0}
  };
  console.log("RenderPDF options\n", options);
  const puppeteer = require('puppeteer');
  const express = require('express');
  const app = express();
  const timeout = require('connect-timeout');
  const timeout_msec = 5000;
  app.use(timeout(timeout_msec));

  const launchOptions = {args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], executablePath: chromeBinary};
  const browser = await puppeteer.launch(launchOptions);
  console.log("chrome version:", await browser.version());
  const page = await browser.newPage();

  app.listen(8000, function(){
    console.log('Listening on 8000');
  });

  app.get('/', async (req, res) => {
    const url = req.query.url;

    if (! url) {
      res.status(404);
      res.end('parameter "url" is not set');
      return;
    }
    try{
      console.time('rennderPdf');
      await page.goto(url, {timeout: 10000, waitUntil:["load", "domcontentloaded"]});
      const buff = await page.pdf(options);
      console.timeEnd('rennderPdf');
      res.contentType("application/pdf");
      res.send(buff);
      res.status(200);
      res.end();
    } catch(e) {
      console.log(e);
      res.status(503);
      res.end();
    }
  });
  // Health Check
  app.get('/hc', function (req, res) {
    console.log('health check ok');
    res.setHeader( 'X-Chrome-Version', chromeVersion);
    res.status(200);
    res.end('ok');
  });
  process.on('SIGINT', async function() {
    await browser.close();
    console.log('process exit with SIGINT');
    await process.exit();
  });
})();

いたるところでawaitが必要になるので、expressも含めすべてをasyncで囲んでいる。
とりあえずawaitしまくればいつもの言語と近い感じで書けるのを覚えてしまった。

ヘッドレスChromeを使ったHTML→PDF変換サーバーを作る 改善編

以前とりあえず作ってみたサーバーが不安定なので、書き直すことにした。

Dockerで動いてGETパラメータでURLを渡したらPDFが返ってくるというシンプルなもの。

PDFのサイズ(現在はA4固定)等のオプションは起動時に固定してしまうので、変えられない。

ローカルネットワーク内でAPIとして使うので、外に直接公開することは考えていない。

そのほか詳細は以前の記事参照
uyamazak.hatenablog.com

Dockerfile

FROM node:9

RUN mkdir /varuna/
WORKDIR /varuna/

# Install google-chrome from .deb file using gdebi for dependencies.
RUN apt-get update && \
    apt-get install -y gdebi
COPY google-chrome-stable_current_amd64.deb ./
RUN gdebi --non-interactive google-chrome-stable_current_amd64.deb

# Install chrome-headless-render-pdf
RUN npm install \
    chrome-headless-render-pdf@1.5 \
    express \
    connect-timeout
RUN chmod 700 ./node_modules/chrome-headless-render-pdf/cli/chrome-headless-render-pdf.js

# add option --no-sandbox
COPY node_modules/chrome-headless-render-pdf/index.js node_modules/chrome-headless-render-pdf/index.js

# Install fonts
COPY fonts /usr/share/fonts

# COPY my app dir
COPY app app

EXPOSE 8000

CMD ["node", "app/pdf-server-proto.js"]

chromeのインストールファイルgoogle-chrome-stable_current_amd64.debは下記ページの「別のプラットフォーム向けの Chrome をダウンロード」から取得して同じディレクトリに配置しておく。

www.google.com

ついでにnodeのバージョンも9にした。

chrome-headless-render-pdfも1.5になり、paper-width、paper-heightを渡せるようになったが、まだ Docker上で動かすのに必要な--no-sandboxを渡せないので、これだけindex.jsの192行目に追加してとりあえず使う。
プルリしたいけどまだ自信ない。

www.npmjs.com



サーバー本体
pdf-server-proto.js

const execSync = require('child_process').execSync;
const chromeBinary = '/usr/bin/google-chrome-stable'
const chromeVersion =  execSync(chromeBinary + " --no-sandbox --version");
console.log("chrome version:", chromeVersion.toString());

const options = {
  printLogs: true,
  printErrors: true,
  chromeBinary: chromeBinary,
  noMargins: true,
  landscape: true,
  paperWidth: 11.70,
  paperHeight: 8.26772,
  includeBackground: true
};

console.log("RenderPDF options\n", options);

const RenderPDF = require('chrome-headless-render-pdf/index');
const renderer = new RenderPDF(options);
renderer.spawnChrome();
renderer.waitForDebugPort();

const express = require('express');
const app = express();
const timeout = require('connect-timeout');
const timeout_msec = 5000;

app.use(timeout(timeout_msec));
app.listen(8000, function(){
  console.log('Listening on 8000');
});

app.get('/', async (req, res) => {
  console.log("async start");
  var url = req.query.url;
  if (! url) {
    res.status(404);
    res.end('parameter "url" is not set');
    return;
  }
  try {
    const buff = await renderer.renderPdf(url, renderer.generatePdfOptions());
    res.contentType("application/pdf");
    res.send(buff);
    res.status(200);
  } catch (e) {
    renderer.error('error:', e);
    console.log(e);
    res.status(503);
  }
  res.end();
});

// Health Check
app.get('/hc', function (req, res) {
  console.log('health check ok');
  res.status(200);
  res.end('ok');
});

process.on('SIGINT', function() {
  console.log('process exit with SIGINT');
  renderer.killChrome();
  process.exit();
});

ざっと下記の部分を直した

  • 2重にexecSync(chrome-headless-render-pdfコマンドと、その内部)していたのを、RenderPDFを直接使うようにして省いた。
  • 一度ファイルに書き出して、それを読み込んでいたのを直接バッファを返すようにした
  • chromeをアクセスのたび起動したり、停止したりしていたのを、起動しっぱなしにした。
  • 最初にChromeのバージョンとかoptionを出してみたり。

まだ本番利用はしていないが、レスポンスはかなり早くなり(A4一枚の書類で200ms程度)、たまに起きる謎の待ち時間、フリーズも見られなくなった。

動きは普通のChromeなので、外部のjs、cssなどのライブラリはなるべくインライン化し、サイズの大きなWEBフォント読み込みはやめてサーバーにインストールしておくと早くなる。キャッシュはしないっぽい?

変数宣言はできるだけconstにしておいた。
まだまだ己のnode.js力の足りなさを実感する。

async、awaitは初めて使ったけどこれなら理解できそう。