puppeteerでフロントエンドISUCONのためのパフォーマンス計測ツールを作りたい

この記事は、 Recruit Engineers Advent Calendar 2017 の2日目です。

リクルートテクノロジーズで パートナーとして働いてる mizchi です。ここでの仕事は、 yosuke_furukawa が忙しくて調べられないことを、勝手に調べてくることです。

今までリクルートでやったことは Next.js, AMP, PWA, Puppeteer って感じ。今回は Puppeteer を使ったE2Eテストの自動化やパフォーマンス評価の話をします。

puppeteer とは

https://github.com/GoogleChrome/puppeteer

リポジトリ名でわかりますが、GoogleChrome チームが公式に提供する Chrome の Headless Driver です。

スペルがとにかく覚えづらい

https://try-puppeteer.appspot.com/

クロスブラウザテスト以外にはかなり万能なツールです。E2Eテスト、スクレイピング、日々の作業の自動化、なんでもござれ。

他のブラウザ自動化ツールと比べた時の利点

とにかく不安定なブラウザ自動化ツールの中で、Google が公式に提供しているというところが安心できます。また、Chrome内部のAPIを叩いているので、 DevToolsで入手できる相当のデータが(頑張れば)手に入ります。生のデータに近いのが魅力です。

使ってみる

スクリーンショットを撮る

const puppeteer = require('puppeteer');

(async() => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://news.google.com/news/');
await page.screenshot({path: 'screenshot.png', fullPage: true});
await browser.close();
})();

puppeteer.lauch({headless: false}) とするとヘッドレスじゃないモードで動きます。ウェイトいれないと動きが早すぎて人間に目視できない動きをしますが…

ブラウザコンテキストでJSを評価

const result = await page.evaluate(() => {
  return Promise.resolve(8 * 7);
});
console.log(result); // prints "56"

注意すべきは、この evaluate はこのコンテキストで評価されるのではなく、ブラウザ側に送られて評価されます。コンテキストが違うので、JSON としてシリアライズできるオブジェクトしか送受信できません。逆に、ブラウザコンテキストのオブジェクト(document とか window とか) を評価できます。

const result = await page.evaluate(data => {
  return document.querySelector(data.selector).innerHTML;
}, { selector: '#my-selector' });
console.log(result);

入力がjson, result が innerHTML の string なので大丈夫です。

じゃあ何しようか

内部データをとったり、任意なJSを発行できるのはわかりました。次にこれをどう活用していくか。

yosuke_furukawa と 「ISUCON盛り上がってるけど、フロントエンドのチューニングもコンテストしたいよね」みたいな話をしていました。それはエンジニア教育用だったり、プロダクションの解析だったり、パフォーマンスコンテストって上手く開催できればブランディングになるよね〜的な話だったり…

じゃあフロントエンドのコンテストのツールってなんだろって考えた時に、それって「高精度なE2E + 内部メトリクスの取得」っていう話じゃないか?と自分は考えました。

で、色々作って実験してみました。

内部メトリクスの取得

というわけで、内部で持ってるパフォーマンス上のデータを引っ張り出してきます。page.trace(...) で devtools の生の内部データを全部引っ張り出すことはできるんですが(キャプチャ結果のbase64とか)、これはさすがにプリミティブすぎる。

ドキュメント読む限り、v0.13 だとこんな感じで内部で持ってるパフォーマンスメトリクスがとれます。

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page._client.send('Performance.enable')

async function getPerformanceMetrics(page) {
  const { metrics } = await page._client.send('Performance.getMetrics')
  return metrics.reduce((acc, i) => ({ ...acc, [i.name]: i.value }), {})
}

かなりバックドア感があるんですが、 次の v1.0.0-rc だと page.metrics() ってメソッドが生えてるんで、これはいらなくなりそう。まだ npm に publish されてないんで、使えないんですが…

で、取れてるデータは、ドキュメント曰くこんな感じ。

https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagemetrics

  • returns: <[Promise]<[Object]>> Object containing metrics as key/value pairs.
    • Timestamp <[number]> The timestamp when the metrics sample was taken.
    • Documents <[number]> Number of documents in the page.
    • Frames <[number]> Number of frames in the page.
    • JSEventListeners <[number]> Number of events in the page.
    • Nodes <[number]> Number of DOM nodes in the page.
    • LayoutCount <[number]> Total number of full or partial page layout.
    • RecalcStyleCount <[number]> Total number of page style recalculations.
    • LayoutDuration <[number]> Combined durations of all page layouts.
    • RecalcStyleDuration <[number]> Combined duration of all page style recalculations.
    • ScriptDuration <[number]> Combined duration of JavaScript execution.
    • TaskDuration <[number]> Combined duration of all tasks performed by the browser.
    • JSHeapUsedSize <[number]> Used JavaScript heap size.
    • JSHeapTotalSize <[number]> Total JavaScript heap size.

この数値の読み方は後述。

作ってみた: ReduxActionReplay

これを使って面白いことはできないか?まず考えたのは、 Redux アプリの E2E テストを自動化できないか、でした。

  • Redux が発行してる Action をキャプチャする
  • Puppeteer で Redux に向けてそのアクションを再生してキャプチャなりメトリクスを集める

何かと不安定な DOM の初期化順に依存せず、Redux のアクションベースで副作用を起こして、その画面をキャプチャする、というわけです。

というわけで作ったのがこれ。

https://github.com/recruit-tech/redux-action-replay

(名前、分かる人には分かると思いますが、某チートツール風)

これは redux の middleware として動きます。組み込み方はこんな感じ。

/* @flow */
// src/store/create.js
import { createStore, applyMiddleware } from 'redux'
import reduxPromise from 'redux-promise'
import reducer from '../reducers'
import { isPuppeteerEnv, createAutomateStore, recorder } from 'redux-action-replay'

export default () => {
  if (isPuppeteerEnv()) {
    return createAutomateStore(reducer)
  }

  return createStore(
    reducer,
    undefined,
    applyMiddleware(reduxPromise, recorder({ ui: true }))
  )
}

実際どんなものかは動いてるのを見るのがわかりやすいと思います。

録画側

再生側

生成された action を myscenario.json として保存して par コマンドで実行。

$ npm install redux-action-replay -g
$ par myscenario.json
delta > counter/add { Timestamp: 0.5370970000003581,
  JSEventListenerCount: 6,
  LayoutCount: 1,
  LayoutDuration: 0.00020599999879777903,
  ScriptDuration: 0.0007769999992887997,
  TaskDuration: 0.003832000002148502,
  JSHeapUsedSize: 31344 }
delta > counter/add { Timestamp: 0.537903999998889,
  JSEventListenerCount: 6,
  LayoutCount: 1,
  LayoutDuration: 0.000404000000344241,
  ScriptDuration: 0.0005920000003242984,
  TaskDuration: 0.003926999996110701,
  JSHeapUsedSize: 31088 }
delta > counter/add { Timestamp: 0.5443740000009711,
  JSEventListenerCount: 6,
  LayoutCount: 1,
  LayoutDuration: 0.00027700000100594,
  ScriptDuration: 0.0012000000006083013,
  TaskDuration: 0.004784000006111497,
  JSHeapUsedSize: 31088 }
delta > counter/add { Timestamp: 0.541692000000694,
  JSEventListenerCount: 6,
  LayoutCount: 1,
  LayoutDuration: 0.00021600000036414995,
  ScriptDuration: 0.0006799999991927014,
  TaskDuration: 0.0036060000002180043,
  JSHeapUsedSize: 31088 }
delta > counter/add { Timestamp: 0.5375689999982569,
  JSEventListenerCount: 6,
  LayoutCount: 1,
  LayoutDuration: 0.0005600000004051301,
  ScriptDuration: 0.0007479999985661977,
  TaskDuration: 0.004369999996924896,
  JSHeapUsedSize: 31088 }
delta > automator/recording-end { Timestamp: 0.5751890000010462,
  TaskDuration: 0.0034789999972417995,
  JSHeapUsedSize: 8928 }

スクリーンショットを仕込むのもいいんですが、各ステップごとに内部のメトリクスを吐き出してみました。これでスクリーンショットをとりつつ、重い rendering を発生させる Action を特定させることができますね?

… と思ったんですが、あまりにも生の数字過ぎて読み方が難しいので、これらの数値を何を意味してるか、追ってみました。

抽象的なデータを録画して、再生する、という手法は、Redux以外にも Rx でも再現できそう、という予感があります。

(正直まだα品質で、ちゃんと運用するにはブラッシュアップ必要な箇所が多いです)

@mizchi/puppteer-helper

↑ だと Redux のストア操作でアプリを自動化しているけど、もっと汎用化すべきだったな、と反省して、もうちょっとプリミティブにデータを読もうとしました。

metrics の意味を追いながら作った副産物がこちら。 https://github.com/mizchi/puppeteer-helper

指定されたURLを読み込んで、メトリクスの数値をそれっぽく整形して表示するツールです。

$ npm install -g @mizchi/puppeteer-helper
$ get-puppeteer-metrics https://dev.to
[Loading Stats]
 - FirstMeaningfulPaint: 0.216s
 - DomContentLoaded: 0.336s
[init~fmp]
TaskDuration: 0.4s / Usage 39%
  - ScriptDuration: 0.066s / 13.5%
  - LayoutDuration: 0.26s / 53.4%
  - RecalcStyleDuration: 0.019s / 3.9%
  - LayoutCount: +13
  - RecalcStyleCount: +19
  • TaskDuration: 内部で発生した処理時間の合計
  • ScriptDuration: TaskDuration のうち、JavaScriptの実行時間にかかった時間
  • LayoutDuration: TaskDuration のうち、リフローにかかった時間
  • RecalcStyleDuration: TaskDuration のうち、DOM の bounding-box の確定にかかった時間
  • LayoutCount: リフローが発生した回数
  • RecalcStyleCount: bounding-box の再計算が行われた回数

これでやっと人間が評価できそうな数値に見えてきました。このスクリプトでは初期化からFMP確定までの各種処理時間を計測しています。

(…FMPとDomConentLoaded はちょっと怪しいです)

これらを redux-action-replay のログに適用すれば、パフォーマンスコンテストに必要なデータはだいたい出揃うんじゃないか、という感じ。

フロントエンドパフォーマンスコンテストを考える

今回、まずは redux に特化したツールを作ってみたけど、実際には「〜という操作を行った際の前後のメトリクスの変化」で競うことになりそう。

また、受け入れテストとしては、「スクリーンショット比較で誤差が指定値に収まるか」、というものになると思います。

https://github.com/mapbox/pixelmatch が便利そうでそのためのスクリーンショットを取ってみました。

const fs = require('fs')
const path = require('path')
const PNG = require('pngjs').PNG
const pixelmatch = require('pixelmatch')

function createImageDiff(a, b) {
  return new Promise(resolve => {
    const img1 = fs
      .createReadStream(a)
      .pipe(new PNG())
      .on('parsed', doneReading)
    const img2 = fs
      .createReadStream(b)
      .pipe(new PNG())
      .on('parsed', doneReading)
    let filesRead = 0

    function doneReading() {
      if (++filesRead < 2) {
        return
      }
      const diff = new PNG({ width: img1.width, height: img1.height })
      const numDiffPixels = pixelmatch(
        img1.data,
        img2.data,
        diff.data,
        img1.width,
        img1.height,
        {
          threshold: 0.1
        }
      )
      diff.pack().pipe(fs.createWriteStream('diff.png'))
      const errorRate = numDiffPixels / (diff.width * diff.height)
      resolve({
        numDiffPixels,
        errorRate
      })
    }
  })
}

createImageDiff(
  path.join(__dirname, '../tmp/screenshots/0.png'),
  path.join(__dirname, '../tmp/screenshots/1.png')
).then(console.log) //=> { numDiffPixels: 6664, errorRate: 0.013883333333333333

よさそう。

実際にコンテストをやるなら

  • 運営が puppeteer でシナリオを書いて、アサーションやスクリーンショットを取得
  • ユーザーは指定されたURLにアップロード
  • そのURLでシナリオを実行。スクリーンショットの差分が誤差以内に収まればAcceptする。
  • アクション間のメトリクスでスコアを決定。

ただ、これで実際に回るかは、実際にいくつかパターンを書いてみて、泥臭いハマりどころを探さないと納得感があるものにはならないと思います。

また、見てるパファーマンスが renderer 偏重な気もしていて、もうちょっとネットワークアクセスしてる数とか直列/並列の最適化とか、そのへんも監視したい。画像配信は…スコープに含めるか、どうだろうなぁ…

課題: puppeteer のハマりどころ

Headless Chrome、 動作は安定しているんですが、よくサンプルで使われてるpage.waitForNavigation(...) が便利メソッドに見えて、ハマりどころが多いです。

これは、ネットワークのコネクション数やDOMのイベント発火数をみてるんですが、「今の数値より増えて、最初の数値に戻ったら終わる」というロジックで、そもそも何も発生してないときはタイムアウトします。これが結構使いにくい。

https://github.com/GoogleChrome/puppeteer/blob/770c17b2eaefe3455b68b1cd29bb387f57c0cc79/lib/Page.js#L502-L513

また、いろんなサイトの数値をみてると、「理由はよくわからないけどpage.load(...)が終わらない」というのが結構頻出します。おそらく裏で長時間生き続けるコネクションが邪魔しています。Chromeのナビゲーションバー同じロジックでいいんですが…、まあ難しいですよね…。

まとめ

  • フロントエンドのパフォーマンス評価のためのツールを作ってる
  • E2Eのテストシナリオ作成もついでに自動化したいよね
  • フロントエンドICUCONみたいなのやろうとすると高精度なE2Eシナリオ作成を求められる
  • 数値の読み方はわかったけどもっとほしい
  • もうちょっと puppeteer 使うのに慣れていって安定したら色々やれるはず…!

puppeteer というおもちゃで結構いろんなことがやれそうです。

僕は昔から Selenium の安定性が不満で、E2Eのやる気がでなかったんですが、これならなんとかこのフロントエンド自動化沼で勝利できそう。

使い方が思いつかない人は、まずは自分の銀行口座の確認などに使うのにおすすめです。それでは。