play2.3 の sbt-web を使わず node で代替システムを作るための資料


この記事は Play framework Advent Calendar 2014 の8日目です。
昨日は @gakuzzzz さんの ActionFunction の紹介 でした。
明日は @nazoking さんの play アプリケーションのクラスパス指定を短くする です。

さて、 play2.3 から asset のコンパイルなどを sbt-web という仕組みが受け持つようになりました。

https://www.playframework.com/documentation/ja/2.3.x/Migration23
https://www.playframework.com/documentation/ja/2.3.x/Assets

build.sbt に

pipelineStages := Seq(rjs, digest, gzip)

のように記述してコンパイルすると /assets/javascripts/main.js から

41baf331c9f3eeec351f8cdaa65f99f3-main.js.map
b4a1bbd887d00d800a27468e005e61b1-main.js
b4a1bbd887d00d800a27468e005e61b1-main.js.gz
main.js
main.js.gz
main.js.map
main.js.map.md5
main.js.md5

が生成され、 project-1.0-SNAPSHOT-assets.jar のようなjarに入れられます。

ですが、いろいろ検討した結果 sbt/sbt-web/webjars というエコシステムが未成熟な気がしたので nodejs/gulp/bower で同じような環境を構築しました*1。その中で発見した、上記の生成されたファイルの驚くべき役割について。

play2.3より Assets.versioned という仕組みが導入されました。

route に

GET    /assets/*file    controllers.Assets.versioned(path="/public", file: Asset)

と設定して、 view に

<script src="@routes.Assets.versioned("assets/javascripts/main.js")"></script>

と書くと、

<script src="/assets/javascripts/b4a1bbd887d00d800a27468e005e61b1-main.js"></script>

と展開されます*2。ご想像通り、javascripts/b4a1bbd887d00d800a27468e005e61b1-main.js へのアクセスは積極的にキャッシュされます*3。この仕組みをになっているのが、上記の諸々のファイルと、 AssetBuilder クラスです。
https://github.com/playframework/playframework/blob/master/framework/src/play/src/main/scala/play/api/controllers/Assets.scala

AssetBuilder は次のように展開し、受け取ります。

  • 表示時の展開: main.js のパスがほしいだと? → main.js.md5 が存在して中身が b4a1bbd887d00d800a27468e005e61b1 だな → b4a1bbd887d00d800a27468e005e61b1-main.js と表示してやれ
  • 受信時の展開: b4a1bbd887d00d800a27468e005e61b1-main.js が欲しいだと? → main.js.md5 が存在して、中身が 入力ファイル名と一致するな → Accepts ヘッダに gzip あるな → main.js.gz が存在するな → main.js.gz を主力

ついでに map ファイルの話もしておきますがこれはとソースファイルマップファイルで、 main.js の最後に場所が書かれており、その中身は main.js で、ソースパスはそのままブラウザに渡り、ブラウザ「main.js のソース見たいから main.js.map ください」play:「main.js.map が欲しいだと? main.js.map が存在するな → Accepts ヘッダに gzip あるな → main.js.map.gz がないな → main.js.map どうぞ」のようなやりとりがされます。

お気づきでしょうか?上記の生成されたファイルの一覧に不具合があることに。。。

41baf331c9f3eeec351f8cdaa65f99f3-main.js.map ← どこからも参照されない
b4a1bbd887d00d800a27468e005e61b1-main.js  ← どこからも参照されない
b4a1bbd887d00d800a27468e005e61b1-main.js.gz  ← どこからも参照されない
main.js  ← 存在チェックのみ
main.js.gz  ← レスポンスの出力内容
main.js.map  ← 存在チェックのみ
main.js.map.md5  ← どこからも参照されない
main.js.md5  ← レスポンスの出力内容
<del>main.js.md5.gz</del>  ← 使われるはずなのに生成されてない

なぜこうなっているのかは分かりませんが、 gulp などで代替システムを組む場合、次のように生成すればよいことが分かります。

main.js
+ 結合やminifyとソースファイルマップの生成( browserify とか ) = main.js (同名でminifyする場合), main.js.map
+ md5ファイルの生成 = main.js.md5 , main.js.map.md5
+ gzファイルの生成( gulp-gzip とか) = main.js.gz , main.js.map.gz

最終的な生成物は次のようになります。

main.js  ← 結合minifyされたファイル
main.js.gz  ← gzip圧縮されたファイル
main.js.md5  ← 結合minifyされたファイルの md5
main.js.map  ← ソースマップ(含む 結合minify前の元ファイル)
main.js.map.md5  ← ソースマップの md5
main.js.md5.gz  ← ソースマップの gzip 圧縮されたもの

*.md5 を生成するプログラムだけ見つからなかったので*4、自作しました。.md5 の中身は md5のhex値です*5。

/**
  ファイルが来たら ファイル名.md5 に変換する(本体は事前に gulp.dest しておく)
 */
var through2 = require('through2');
var gutil = require('gulp-util');
var crypto = require('crypto');
module.exports = function(){
  'use strict';
  return through2.obj(function(file, enc, cb) {
    var stream = crypto.createHash("md5");
    file.pipe(stream);
    var hash = stream.read().toString('hex');
    var hashfile = new gutil.File({
      cwd: file.cwd,
      base: file.base,
      path: file.path+'.md5',
      contents: new Buffer(hash)
    });
    this.push(hashfile);
    cb();
  });
};

ご利用ください。

ちなみに 不要っぽいファイルの「b4a1bbd887d00d800a27468e005e61b1-main.js」も、「大量のリクエストを裁くために project-1.0-SNAPSHOT-assets.jar を CDN とかに展開して play ではそのリクエストを処理しないようにする」という目的であれば必要になるかもしれません。

*1:javascriptの事はjavascript界の皆様に任せる感じで……

*2:開発モードでは展開されません

*3:しかしexpireヘッダは設定されない……

*4:b4a1bbd887d00d800a27468e005e61b1-main.js にリネームするものはいくつかある

*5:本当の md5値である必要はなくコンテンツの一意性を示す適当な英数値であれば良さそうだけど、面倒なので本物のmd5使う