freeeの開発情報ポータルサイト

Webpackのビルド時間を短くするための取り組み

メリークリスマス。freeeでエンジニアをやってます @yo_waka です。 この記事は freee Developers Advent Calendar 2017 の25日目です。

Webpack でビルドしてますか?僕は今日もビルドしています。
弊社ではフロントエンドのビルドに Webpack を用いているのですが、サービスの規模が大きくなるとともにビルド時間が長くなってきて困ってきました。

会計freee というサービスのフロントエンドの規模的にはこのような感じです。

  • JSコード行数: 275421行
  • Webpack エントリポイント数: 108

そこそこですね。 煩悩の数だけJSのエントリポイントがあります。
エントリポイントが多い理由は、元々は Rails の標準である Sprockets のみでビルドしていたものを少しずつ移行していったためです。
元々SPAで作られているアプリケーションではないのと、機能依存の処理もかなり大きいため1ファイルにするデメリットも多いため、機能の数だけエントリポイントが存在しています。

こちらが現在のビルド時間です。 f:id:yo_waka:20171225110354p:plain

以前から HappyPack という並列実行ライブラリを使っていました。
HappyPack は loader ごとに並列で実行してくれるのですが、前述の通り babel-loader のトランスパイル対象のJSファイルが多いこと、処理するエントリポイントが多いことが問題になってきたので効果が少なくなっていました。 (多量の loader を使っている環境では高速化の恩恵にあずかれるかもしれません)

どうにかならないかなと Webpack のドキュメントを見ていたら、thread-loaderとcache-loaderを使ってみるとよいとの記述を見つけて試してみたのがこちらの記事になります。

webpack loader の仕組み

Webpack は Loader API という仕組みを提供しており、これに則って loader は作られています。
基本的に loader はコールバックを実行する関数を提供するのみです。

以下は全てのファイルの先頭に「Powered by freee」というコメントを追加するだけの単純な loader です。
content にはビルド対象となるファイルコンテンツが渡ってきます。

module.exports = function(content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
}

function someAsyncOperation(content, callback) {
  var comment = '/** Powered by freee. */¥n';
  callback(null, comment + content);
}

この loader を loaders ディレクトリに置いたとして、webpack.config.js の resolveLoader に指定してあげれば使えるようになります。

module.exports = {
  resolveLoader: {
    modules: [
      'node_modules',
      path.resolve(__dirname, 'loaders')
    ]
  }
}

変わった仕組みとして、Pitching loader という仕組みがあります。
これは「pitch」という関数を loader に生やすと、loader の実行前にpitch関数のみがloaderとは逆の順番に実行されるというものです。
通常はuseディレクティブに指定された loader は最後から最初に向かって実行されますが、pitch関数は最初から最後に向かって実行されます。
また、pitch関数で return されると、以降のpitch関数のチェーンおよび loader の実行がスキップされます。

以下は公式ドキュメントにも記載されている処理フローです。

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

pitch関数を実装することでこんな感じにできます。

|- a-loader `pitch`
  |- b-loader `pitch` returns a module
|- a-loader normal execution

以下の loader の処理をスキップしつつ独自にloaderを実行するようなインターセプタを作ることが可能です。
thread-loader はこれを利用して並列に動くワーカにloaderの処理を実行させて高速化を図っています。

thread-loaderの内部実装

以上を踏まえて、thread-loader の実装を見て行きます。

import { getPool } from './workerPools';

function pitch() {
  const options = loaderUtils.getOptions(this) || {};
  const workerPool = getPool(options);
  const callback = this.async();
  workerPool.run({
    loaders: this.loaders.slice(this.loaderIndex + 1).map((l) => {
      return {
        loader: l.path,
        options: l.options,
        ident: l.ident,
      };
    }),

    /** 中略 */
  }, (err, r) => {
    if (r) {
      r.fileDependencies.forEach(d => this.addDependency(d));
      r.contextDependencies.forEach(d => this.addContextDependency(d));
    }
    if (err) {
      callback(err);
      return;
    }
    callback(null, ...r.result);
  });
}

function warmup(options, requires) {
  const workerPool = getPool(options);
  workerPool.warmup(requires);
}

export { pitch, warmup };

pitch関数が実装されていますね。
worker に渡ってくる this.loaders というのは webpack.config.js の useディレクティブで指定された loader 配列です。
各 loader に渡されたオプションをworkerに渡し直しています。

warmup という関数をビルド前に呼ぶことでworkerプロセスを事前に起動することができるようです。
コードを見ると、worker が作られる際に child_process#spawn が呼ばれ、pipe で入出力を繋げられるようになっていました。
README にも書かれていますが、子プロセスの起動オーバーヘッドは数百msになるため、warmup を使った方がよさそうです。

class PoolWorker {
  constructor(options, onJobDone) {
    /** 中略 */

    this.worker = childProcess.spawn(process.execPath, [].concat(options.nodeArgs || []).concat(workerPath, options.parallelJobs), {
      stdio: ['ignore', 1, 2, 'pipe', 'pipe'],
    });
    const [, , , readPipe, writePipe] = this.worker.stdio;
    this.readPipe = readPipe;
    this.writePipe = writePipe;
    this.readNextMessage();
  }

worker ではオプションで渡される parallelJobs(デフォルトは200)の数に従って、useディレクティブに指定された loader (thread-loader 以降に指定されたもの)の処理を並列実行します。 (内部では asyncパッケージの 'async/queue' が利用されています)

cache-loaderの内部実装

cache-loader は1ファイルのシンプルな実装になっています。
ここでもpitch関数が実装されていて、キャッシュヒットすると以降の処理をスキップしてキャッシュされた結果を返す、ここでキャッシュヒットしなかった場合は通常フローで loader 関数が実行され、結果をキャッシュ保存する、というようになっています。

function pitch(remainingRequest, prevRequest, dataInput) {
  /** 中略 */

  const callback = this.async();
  readFn(data.cacheKey, (readErr, cacheData) => {
    if (readErr) {
      callback();
      return;
    }
    if (cacheData.remainingRequest !== remainingRequest) {
      // in case of a hash conflict
      callback();
      return;
    }
    async.each(cacheData.dependencies.concat(cacheData.contextDependencies), (dep, eachCallback) => {
      fs.stat(dep.path, (statErr, stats) => {
        if (statErr) {
          eachCallback(statErr);
          return;
        }
        if (stats.mtime.getTime() !== dep.mtime) {
          eachCallback(true);
          return;
        }
        eachCallback();
      });
    }, (err) => {
      if (err) {
        data.startTime = Date.now();
        callback();
        return;
      }
      cacheData.dependencies.forEach(dep => this.addDependency(dep.path));
      cacheData.contextDependencies.forEach(dep => this.addContextDependency(dep.path));
      callback(null, ...cacheData.result);
    });
  });
}

pitch関数が何をやっているのかを理解すると、loader の実装が追えるようになって楽しいですね!
全然話は変わりますが loader は asyncパッケージがふんだんに使われていてそっちのAPIを見るのに時間がかかってしまいました。。。

結果

thread-loader はファイル単位で並列loader実行してくれること、cache-loader はそれをキャッシュして2回目以降は loader 処理をスキップしてくれることから、freee が抱えているビルドの問題を解消してくれると思われ、導入していく気持ちが高まりました。

というわけで結果です。 f:id:yo_waka:20171225123921p:plain

2回目以降のビルドについて58%ほど高速化されました。

webpack.config.js はこのようにしました。

const threadLoader = require('thread-loader');

const jsWorkerOptions = {
  workers: require('os').cpus().length - 1,
  workerParallelJobs: 50,
  poolTimeout: 2000,
  poolParallelJobs: 50,
  name: 'js-pool'
};
threadLoader.warmup(jsWorkerOptions, [ 'babel-loader', 'babel-preset-es2015', 'eslint-loader' ]);

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          { loader: 'cache-loader' },
          { loader: 'thread-loader', options: jsWorkerOptions },
          { loader: 'babel-loader?cacheDirectory=true' },
          { loader: 'eslint-loader' }
        ],
        exclude: [ /third_party/, /node_modules/, /jst/ ]
      },
  ...
}

webpack-dev-server と組み合わせると、それなりに早くはなったものの、まだ快適かといわれるとギャップはあります。 cache-loader について、ファイルごとのキャッシュになっているため、エントリポイント単位のキャッシュにできないか試してみたところ、今度はI/Oがボトルネックになってしまい、あまり早くならず。
この辺りは実装を工夫すれば解決できそうな気はするので、もう少しやってみて公開したいと思っています。

まとめ1

Webpack は難しい。

まとめ2

freee のサービスは会計、人事労務並びに請求や支払いなど複数の業務ドメインが抱える課題を解決することを目指しています。
誰でも使えるUIを目指すと、可能な項目や作業が増えていくので、それをシンプルにするために複雑なUI実装が必要になり、結果としてフロントエンドがボリューミーになっていきます。
よって、JSのビルド時間は何もしないと増えていくので、ビルド周りの改善は都度都度行なっています。

大規模なフロントエンドを快適に実装するための基盤を作りつつ、ユーザーさんに価値を届けたい方、ぜひ一緒にやりましょう。

jobs.freee.co.jp

@ymrl からスタートした freee Developers Advent Calendar 2017 ですが、今年はこれでおしまいです。
来年もまた、freee の開発チームが取り組んだ新しいチャレンジについてお伝えできればと思います。