このブログの更新は Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama

メールでのご連絡は hiyama{at}chimaira{dot}org まで。

はじめてのメールはスパムと判定されることがあります。最初は、信頼されているドメインから差し障りのない文面を送っていただけると、スパムと判定されにくいと思います。

参照用 記事

最近のビルドツールって何なの?

TypeScriptでは、コンパイルが必要です。プログラムをブラウザーとNode.jsの両方で使おうとすると、さらに加工が必要です。ミニファイだの文書も作るだのすると、ちょっとしたビルドプロセスとなるので手作業では辛くなります。

今更Makeでもないよなー、と思い、最近のビルドツールを試してみました。

内容:

  1. 流行りすたりが激しすぎる
  2. gulpを使ってみる:こんなサンプル
  3. gulpのビルドスクリプト
  4. スクランナーってのはビルドツールとは違うのか?
  5. ビルドツールは進化したのか

参考資料:

  1. 例題のファイルとコマンドの一覧
  2. ソースファイル

追加の話:

流行りすたりが激しすぎる

「確かGruntってツールがあったよな」と、インストールと使い方を調べていると、やたらにgulpって単語が目立つんですよね。Gruntのライバルの新興勢力らしいです。

「Grunt、ありゃもうオワコン、これからはgulpだぜぃ」みたいな記事がイッパイあるんです。でも、gulpの台頭って、ここ1年くらいの話ですよね。それまでは「Gruntスゲー、Gruntまじ便利」とか騒いでいましたよね。

わずか1、2年の期間でそこまで衰退しちゃうの? まるで一発屋芸人の世界みたい。こういう状況だと、今はチヤホヤされているgulpも、いつまでモツのかなー、と不安になります。「日本エレキテル連合」が一時的なバブルで、「8.6秒バズーカー」は息の長い芸人になると考えますか? 熱心なファンを除けば、「あいつらもたぶん…」でしょ*1

ビルドツールって、地味ながらも開発の基盤を支える重要なツールです。それを取っ替え引っ替えしてたら、移行のコストがバカにならないでしょうよ*2。ビルドツールを芸人さんで言えば、サンマやダウンタウンのような看板ではないが、関根勤さんみたいな存在であるべきです(って、この喩えが適切か疑問だけど)。Makeなんて、内海桂子師匠みたいなもんですよ(この喩えも(略)。

gulpを使ってみる:こんなサンプル

印象だけでものを言ってはいけません。gulpを使ってみることにします。でも、この記事はgulpの使い方を説明することが目的ではありません。Gruntは使ったことないので比較もできません。とりあえず、使用するサンプルは次の図のようなものです。

元になるファイル(ピンク色)は、greeter.ts, hello.ts, Hello.md の3つで、この記事の末尾の「続きを読む」に貼り付けてあります。その他のファイルはツールで生成されるもので、黄色は中間ファイル、水色が配布物になるファイルです。図で辺ラベルになっている tsc, brow, min, doc, apidoc が生成のために実行する作業(タスク)で、すべてシェル・コマンドラインから実行可能です。これらのシェル・コマンドに関する詳細も末尾の「続きを読む」にあります。

tsc, brow, min, doc, apidoc に対応する作業の手順をgulpfile.jsというJavaScriptファイルに書きます。ビルドスクリプトの記述言語が汎用言語なのはいいですね。新しいマイナー言語を憶える必要ないし、いざとなったら何でも出来るし。

gulpのビルドスクリプト

gulpfile.jsは次のようにまります*3

var gulp = require("gulp");

// tsc: Typescriptコンパイル
// tsc --noImplicitAny --module commonjs greeter.ts hello.ts --outDir js
var typescript = require("gulp-typescript");

gulp.task("tsc", function() {
  var tscResult = 
    gulp.src(['./greeter.ts', './hello.ts'])
    .pipe(typescript(
      {
        noImplicitAny: true,
        module: 'commonjs' 
      }));
  return tscResult.js.pipe(gulp.dest('./js/'));
});

// brow: ブラウザー化
// browserify js/greeter.js js/hello.js -o lib/hellolib.js
var browserify = require('browserify');
var source = require('vinyl-source-stream');

gulp.task('brow', function() {
    var bundler = 
        browserify({entries: ['./js/greeter.js', './js/hello.js']})
    return bundler.bundle()
        .pipe(source('hellolib.js')) // 出力ファイル名
        .pipe(gulp.dest('./lib/')); // 出力ディレクトリ
});


// min: ミニファイ
// uglifyjs lib/hellolib.js -o lib/hellolib.min.js
var uglify = require('gulp-uglify');
var rename = require('gulp-rename');

gulp.task('min', function() {
  return gulp.src('./lib/hellolib.js')
    .pipe(uglify())
    .pipe(rename('hellolib.min.js')) // これがないと破壊的上書き
    .pipe(gulp.dest('./lib/'));
});

// doc: HTML文書生成
// pandoc Hello.md -o doc/Hello.html
var pandoc = require('gulp-pandoc');

gulp.task('doc', function() {
  gulp.src('./Hello.md')
    .pipe(pandoc({
      from: 'markdown', // 必須
      to: 'html5',      // 必須
      ext: '.html',     // 必須
      args: ['--smart'] // 必須、空文字も不可!
    }))
    .pipe(gulp.dest('./doc/'));
});

// apidoc: APIリファレンス生成
// typedoc --mode file --module commonjs greeter.ts hello.ts -out apidoc
var typedoc = require("gulp-typedoc");

gulp.task("apidoc", function() {
    return gulp
        .src(['./greeter.ts', './hello.ts'])
        .pipe(typedoc({ 
          mode: "file",
          module: "commonjs", 
          out: "./apidoc/", 
        }))
    ;
});

末尾の参考資料に列挙してあるシェル・コマンドライン(gulpfile.jsにもコメントとして埋め込んである)をそのままJavaScript/gulp構文に翻訳したものです。しかし、コマンドライン機械的に変換できるわけではなくて、次のような注意が必要でした。

  • gulp-typescriptでは、出力ストリームの構造が他とは違う。
  • browserifyはgulpプラグインを使わない(gulp-browserifyは非推奨)。そのため、vinyl-source-streamという別パッケージが必要。
  • gulp-uglifyの出力ファイルの名前を変えるために、gulp-renameというプラグインが必要。ファイルの簡単な操作でもプラグインが必要。
  • gulp-pandocは、コマンドラインでは不要なオプションが必須になっている。argsがないとエラー。あっても空文字列を指定するとエラー(バグっぽい)。

多くの人が、gulpfile.jsはGruntのビルドスクリプトより短く簡単だと賞賛しているのですが、これが短く簡単なの? プラグインをインストールして、その使い方を調べて、ときにハマりながら*4、やっていることはコマンドラインの引き写しでしょ。だったら、コマンドラインをそのまま使えばいいんじゃないの。ずっと短くて簡単だよ。

#!/bin/sh

case "$1" in
    "tsc")
	tsc --noImplicitAny --module commonjs greeter.ts hello.ts --outDir js
	;;
    "brow") 
	browserify js/greeter.js js/hello.js -o lib/hellolib.js
	;;
    "min")
	uglifyjs lib/hellolib.js -o lib/hellolib.min.js
	;;
    "doc") 
	pandoc Hello.md -o doc/Hello.html
	;;
    "apidoc") 
	typedoc --mode file --module commonjs greeter.ts hello.ts -out apidoc
	;;

    *)
	echo "Usage: $0 <task>"
	echo " task: tsc, brow, min, doc, apidoc"
	exit 1
	;;
esac

スクランナーってのはビルドツールとは違うのか?

シェルスクリプトでいいじゃん」に対しては、すぐさま反論があるでしょうね。

  • JavaScript(Node.js)で書かれたツールなら、プロセスを起動せずに同一プロセス内で処理できる。
  • ツールをAPI経由で使うと、コマンドラインより精密な制御が可能。
  • 単なるプロセス起動でも、追加機能(例:出力のカラーリング)を付けられる。
  • 入出力がストリーミングなので中間ファイルが不要。
  • タスクを並列に処理することが可能。

色々と高速化が期待できるってことですよね。確かにビルドが遅いのはフラストレーションだし、弊害もあります。しかし、いくら速くてもタスクを実行すれば時間がかかります。一番速いのは無駄なタスクを実行しないことです。そして、実行しない工夫こそがビルドツールの本領だと僕は思っていたのですが。

同じ内容のビルドスクリプトをmakeで書いてみます。([追記]usageもPHONYターゲットに修正しました。[/追記]

.PHONY : usage
usage:
	@echo "Usage: make <task>"
	@echo " task: tsc, brow, min, doc, apidoc"

.PHONY : tsc brow min doc apidoc

tsc : js/greeter.js js/hello.js
js/greeter.js js/hello.js : greeter.ts hello.ts
	tsc --noImplicitAny --module commonjs $^ --outDir js

brow : lib/hellolib.js
lib/hellolib.js : js/greeter.js js/hello.js
	browserify $^ -o $@

min : lib/hellolib.min.js
lib/hellolib.min.js : lib/hellolib.js
	uglifyjs $^ -o $@

doc : doc/Hello.html
doc/Hello.html : Hello.md
	pandoc $^ -o $@

apidoc : apidoc/index.html
apidoc/index.html : greeter.ts hello.ts
	typedoc --mode file --module commonjs $^ -out apidoc

このMakefileを実行してみましょう。apidocを作る例です。


$ make apidoc
typedoc --mode file --module commonjs greeter.ts hello.ts -out apidoc

Using TypeScript 1.4.1 from C:\Installed\nodist-master\bin\node_modules\typedoc\node_modules\typescript\bin
Rendering [========================================] 100%

Documentation generated at C:\Users\hiyama\Work\JsDev\gulptest\apidoc


$ make apidoc
make: Nothing to be done for `apidoc'.

$

二度目はもう実行しません。同じことをやる必要がないことを判断して Nothing to be done for `apidoc'. と言ってます。

gulpは:


$ gulp apidoc
[15:30:52] Using gulpfile ~\Work\JsDev\gulptest\gulpfile.js
[15:30:52] Starting 'apidoc'...

Using TypeScript 1.4.1 from C:\Users\hiyama\Work\JsDev\gulptest\node_modules\gulp-typedoc\node_modules\typedoc\node_modules\typescript\bin
Rendering [========================================] 100%

Documentation generated at C:\Users\hiyama\Work\JsDev\gulptest\apidoc

[15:30:55] Finished 'apidoc' after 2.55 s

$ gulp apidoc
[15:31:00] Using gulpfile ~\Work\JsDev\gulptest\gulpfile.js
[15:31:00] Starting 'apidoc'...

Using TypeScript 1.4.1 from C:\Users\hiyama\Work\JsDev\gulptest\node_modules\gulp-typedoc\node_modules\typedoc\node_modules\typescript\bin
Rendering [========================================] 100%

Documentation generated at C:\Users\hiyama\Work\JsDev\gulptest\apidoc

[15:31:02] Finished 'apidoc' after 2.58 s

$

同じことを何度でも繰り返します。いくら腕力があって爆速でも、やらない賢さには負けます。

生成されたファイルを全部消してから、makeに欲しいファイルを指定してみましょう。


$ make lib/hellolib.min.js
tsc --noImplicitAny --module commonjs greeter.ts hello.ts --outDir js
browserify js/greeter.js js/hello.js -o lib/hellolib.js
uglifyjs lib/hellolib.js -o lib/hellolib.min.js

$ make lib/hellolib.min.js
make: `lib/hellolib.min.js' is up to date.

$

makeは、lib/hellolib.min.jsを作るには、tsc、browserify、uglifyjs をこの順で実行する必要があることを、依存関係グラフをたどりながらゴールからの逆向き推論で判断して実行します。もちろん、二度目には `lib/hellolib.min.js' is up to date. と、不要なことをやろうとはしません。

gulpでも依存関係を指定できるようなのでやってみます。gulp.task('brow', ['tsc'], ...)gulp.task('min', ['brow'], ...) と変更しただけ。


$ gulp min
[15:41:57] Using gulpfile ~\Work\JsDev\gulptest\gulpfile.js
[15:41:57] Starting 'tsc'...
[15:41:58] Finished 'tsc' after 1.05 s
[15:41:58] Starting 'brow'...
[15:41:58] Finished 'brow' after 88 ms
[15:41:58] Starting 'min'...
[15:41:58] Finished 'min' after 88 ms

$ gulp min
[15:42:06] Using gulpfile ~\Work\JsDev\gulptest\gulpfile.js
[15:42:06] Starting 'tsc'...
[15:42:07] Finished 'tsc' after 1.04 s
[15:42:07] Starting 'brow'...
[15:42:07] Finished 'brow' after 81 ms
[15:42:07] Starting 'min'...
[15:42:07] Finished 'min' after 86 ms

$

必要なタスクの連鎖は理解しますが、同じことを繰り返します。作業の最適化の計画はしないようです。Antのようにプラグイン(Ant用語のタスク)側で頑張る*5のかとも思いましたが、ちょっと見た限りでは、gulpプラグインはターゲット(出力ファイル)の状態を考慮しないでひたすら作業を遂行するようです。

そう言えば、gulpはスクランナー(task runner)と紹介されることが多いですね。(まー、ビルドツール、ビルドシステムとも呼ばれてもいますけど)。これって、タスクを実行することに特化されていて、ビルドの手間を最小にする計画性を期待するのはお門違いってこと?

ビルドツールは進化したのか

Makeは依存関係と作業の最小化について考えるし、OMakeはもっと賢く進化したツール*6です。賢くなる事がビルドツールの進化の方向なんだと僕は思っていたのですが、gulpの(たぶんGruntも)「何も考えてなさ」を見ると、この方向は受け入れられてないみたいです。少なくともWeb制作のフロントエンド界隈では、「考えている暇があるなら、四の五の言わずにやっちまえ!」方式みたいです。

ビルドスクリプトを書くのは面倒くさいので、ユーザーがビルドスクリプトを書かなくて済むようになったらいいな、とは誰でも夢想するでしょう。一種の知識ベースとして汎用ビルド規則を備えた(いちおうMakeも持っている)システムがあります。ところが一方、gulpのビルド規則再利用へのアプローチはと言えば、コピペです。

なんだか退化しているように思えるんですが、過去のビルドツールの使いにくさから考えると、「何も考えない」という割り切りも理解はできます。

  • 汎用のメタ・ビルド規則を書くのは難しい。そのくせ、メタ・ビルド規則がそのまま利用できないで個別設定を書くハメになることが多い。
  • プロジェクトの多様性やツールの進化速度が増大しているので、メタ・ビルド規則がそれに追従できない。
  • ビルドスクリプトの再利用なんて、いずれにしても出来た試しがない。
  • リソース間の依存関係の把握は、ビルドツールがいくら頑張っても無理。個々のファイルに関する知識を持っている依存関係スキャナーが必要。例えば、gcc -M 。
  • 使える依存関係スキャナーがない。コンパイラやトランスパイラ自身は依存関係を認識できるが、それなら当のツールを起動してコンパイル/トランスパイルしてしまったほうが単純明快。
  • 計算リソース/ストレージリソースが相対的に安くなっているので、「時間とリソースに関して最小化」という目標の重要度は下がっている。ビルド時間が数秒なら待つことができる。ディスク/メモリ/CPUは気にしない方向で。
  • ファイルの変更やバージョン管理システムへのコミットを監視して、バックグラウンドでビルドすれば、体感的な「ビルド時間」は(目標ゼロで)短縮できる。

ビルドツールの夢であった「賢い完全自動ビルド」は文字どおりの「夢」であって、実現できる見込みがないのなら、もう「何も考えない」のも現実的なのかも知れません。

しかしですね、gulpfile.jsが長くて書くのメンドクセー!



追加の話:

以下に参考資料:

例題のファイルとコマンドの一覧

tsc: TypeScriptコンパイル

  • 入力ファイル: greeter.ts, hello.ts
  • 出力ファイル: js/*.js
  • コマンド: tsc --noImplicitAny --module commonjs greeter.ts hello.ts --outDir js

brow: ブラウザー

  • 入力ファイル: js/greeter.js, js/hello.js
  • 出力ファイル: lib/hellolib.js
  • コマンド: browserify js/greeter.js js/hello.js -o lib/hellolib.js

min: ミニファイ

  • 入力ファイル: lib/hellolib.js
  • 出力ファイル: lib/hellolib.min.js
  • コマンド: uglifyjs lib/hellolib.js -o lib/hellolib.min.js

doc: HTML文書生成

  • 入力ファイル: Hello.md
  • 出力ファイル: doc/Hello.html
  • コマンド: pandoc Hello.md -o doc/Hello.html

apidoc: APIリファレンス生成

  • 入力ファイル: greeter.ts, hello.ts
  • 出力ファイル: apidoc/**
  • コマンド: typedoc --mode file --module commonjs greeter.ts hello.ts -out apidoc

ソースファイル

./greeter.ts

// file: greeter.ts

/**
 * This function greets to someone.
 */
export 
function greet(greeting: string, toWhom: string) : void {
  console.log(greeting + " " + toWhom);
}

./hello.ts

// file: hello.ts

import greeter = require('./greeter');
/**
 * This class provides a nice method to say hello.
 */
export
class Hello {
  greeting: string;
  constructor(greeting: string) {
    this.greeting = greeting;
  }
  say(toWhom: string = "World") : void {
    greeter.greet(this.greeting, toWhom);
  }
}

./Hello.md

<!-- file: Hello.md  -->

# Hello

`Hello` is an *awesome* program
which produces a **fully-customizable**
surprisingly enchanting greeting.

./package.json

{
  "name": "HelloLib",
  "version": "0.0.1",
  "description": "fully-customizable greeting library",
  "main": "hellolib.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "hello",
    "typescript",
    "javascript"
  ],
  "author": "Masayuki HIYAMA",
  "license": "MIT",
  "devDependencies": {
    "browserify": "^9.0.8",
    "gulp": "^3.8.11",
    "gulp-pandoc": "^0.2.1",
    "gulp-rename": "^1.2.2",
    "gulp-uglify": "^1.2.0",
    "vinyl-source-stream": "^1.1.0",
    "gulp-typedoc": "^1.1.0",
    "gulp-typescript": "^2.6.0"
  }
}

*1:2、3年後にこの記事読むと意味不明かも知れません。もしその頃まで「8.6秒バズーカー」が生き残っていたら、それはそれでメデタイ話です。

*2:それとも、今どんなツールを使っているかが、コストより優先するファッションなのかな。

*3:browserifyのプラグインtsifyを使うと、tscとbrowの2つのタスクをまとめることが出来ます。

*4:入出力の取り扱いのために、streamとvinylについて知っておいたほうがいいみたいです。

*5:Antでも、頑張らないで依存性無視なタスクもあります。

*6:OMakeの開発はもう止まっている感じです。