November 2016
Volume 31 Number 11
働くプログラマ - MEAN あれこれ: Gulp を使う
Ted Neward | November 2016
「MEANers」の皆さん、お帰りなさい。
10 月号の前回のコラムでは、コードベースの "仕切り直し" について説明しました (Yeoman を使用してその基本およびグルー コードのスキャフォールディングで実現)。前回のコラムをご覧いただいたなら、新しいツールが大した説明もなしに記事の中で使われていたことにお気付きのことでしょう。もちろん、サーバーを起動するため、およびスキャフォールディングされたクライアント アプリケーションをクライアント ブラウザーで開くために使用した Gulp ツールのことです (msdn.com/magazine/mt742874、英語)。
前回のコラムを見逃していたとしても、遅れを取り戻すのは簡単です。まず、Yeoman ジェネレーターと angular-fullstack ジェネレーターを両方とも Node.js 開発環境にインストールします (この開発環境では、MongoDB のコピーもローカルで実行します)。
npm install –g yeoman angular-fullstack-generator
yo angular-fullstack
次に、Yeoman を使って所定の位置にスキャフォールディングするツールに関する質問に答えます (この選択は今回のコラムではほとんど関係ありません)。その後、Yeoman によって npm install が開始され、ランタイムと開発の依存関係がすべて取得されます。そこで、gulp test または gulp start:server を実行すると、アプリケーションでテストの準備が整ったことが Yeoman から報告されます。
Gulp が何であれ、概念的には Make、MSBuild、または Ant に似た何らかのビルド ツールであるのは明らかです。ただし、これらの 3 つのツールとはそのしくみが少し異なるため、説明が必要になります。
Gulp を開始する
ビルドしない言語に対して、Gulp を "ビルド ツール" と呼ぶのは厳密には正しくありませんが (ECMAScript が一般的にはインタプリタ言語として意図されているのを思い出してください)、開発後 (または開発中) に実行し、すべての準備が完了済みであることの確認を目的としたツールに対しては、実際のところ最適な用語です。すべてを考慮した場合にさらに適切な名前は "開発自動化ツール" でしょう。こちらの言葉の方がより厳密で、コードをコンパイルして配置可能な成果物に組み立てる作業までを含むためです。ただし、これでは長くて言いにくく、"ビルド ツール" の方が発音しやすいので、当面は Gulp をビルド ツールと考えることにしましょう。
Gulp を開始するにあたり、これまでのスキャフォールディングされたコードとは決別し、ゼロから始めて Gulp の機能 (および Gulp のしくみ) に注目します。最初に、グローバル Gulp コマンド ライン ツールをインストールします (npm install --g gulp-cli)。次に、以下のコマンドを実行して、新しいディレクトリに空の Node.js プロジェクトを作成します。
npm init
npm install --save-dev gulp
このようにすると、Gulp が package.json ファイル内で開発者の依存関係として参照されるようになります。そのため、プロジェクトを取得する場合に、Gulp を忘れずにインストールしなくてもよくなります。新しい環境で次に npm install を実行すると Gulp も一緒にインストールされるからです。
ここで、お好みのテキスト エディターを使って gulpfile.js という新しいファイルを作成します。
const gulp = require('gulp');
gulp.task('default', function() {
console.log("Gulp is running!");
});
次に、同じディレクトリからストック Gulp コマンドを発行します。このコマンドは、(おそらく当然ですが) 単に "gulp" です。 Gulp はそのコマンドを一瞬で処理した後、次のような結果を返します。
[18:09:38] Using gulpfile ~/Projects/code/gulpdemo/gulpfile.js
[18:09:38] Starting 'default'...
Gulp is running!
[18:09:38] Finished 'default' after 142 μs
悪くない結果です。この時点でここまでの処理は必要ないようにも思えますが、悪くありません。
Gulp でタスクを行う
多くのビルド ツールと同様、Gulp は "タスク" を基準に処理を行い、さらに重要なこととして、こうしたタスクどうしの間で依存関係を追跡する方法を判断します。そのため、先ほどの簡単な gulpfile では、Gulp は default という 1 つのタスクがあることを認識し (default は、よく知られた規則で、コマンド ラインで何も指定されていない場合に実行するタスクであることを意味します)、このタスクの実行を求められた場合、関連する関数リテラルの本体を実行します。タスクに別の名前を付けるのは簡単で、次のようにします。
const gulp = require('gulp');
gulp.task('echo', function() {
console.log("Gulp is running!");
});
Gulp がコードにすぎないことは明らかです。そのため、コードで実行できる処理はすべて、Gulp タスクの本体でも実行できます。これにより、すばらしい数々の選択肢がもたらされます。たとえば、データベースを読み取ってコードから生成する必要がある要素を見つける、他のオンライン サービスと対話して構成を設定する、次のように現在の日時を出力するなどです。
const gulp = require('gulp');
gulp.task('echo', function() {
console.log("Gulp is running on " + (new Date()));
});
この結果は以下のようになります。
Teds-MacBook-Pro:gulpdemo tedneward$ gulp echo
[18:16:24] Using gulpfile ~/Projects/code/gulpdemo/gulpfile.js
[18:16:24] Starting 'echo'...
Gulp is running on Wed Sep 14 2016 18:16:24 GMT-0700 (PDT)
[18:16:24] Finished 'echo' after 227 μs
依存関係のあるタスクは、タスク名とその関数リテラル本体の間に、文字列の配列として単純に一覧表示されます。そのため、別のタスクに依存するタスク echo がある場合は次のようになります。
const gulp = require('gulp');
const child_process = require('child_process');
gulp.task('gen-date', function() {
child_process.exec('sh date > curdate.txt');
});
gulp.task('echo', ['clean', 'gen-date'], function() {
console.log("Gulp is running on " + (new Date()));
});
ここでは、gen-date タスクは標準の Node.js パッケージである child_process を利用し、外部ツールを開始して、日付をファイルに書き込んでいます。このコードの目的は、それが可能であると証明することだけです。実際のところ、これはこれでうまくいっていますが、一般的にビルド ツールは、コンソールに書き込んだり、現在の日時を算出したりするだけではなく、もっと重要な処理の実行が期待されます。
Gulp をもう少し掘り下げる
もう少し掘り下げてみましょう。それほど良くないコード部分も含め、次の ECMAScript コードを含む index.js ファイルを作成します。
// index.js
function main(args) {
for (let arg in args) {
if (arg == "hello")
console.log("world!");
console.log("from index.js!");
}
}
console.log("Hello, from index.js!")
main()
確かにこれは少しばかり愚かな行為で、それに伴う問題がいくつかあるのも明らかです。しかし、それが重要なのです。こうした一部の問題を特定し、そうした問題について報告できるツールがあればよいのです (あえて言えば、"コンパイル時のチェック" でしょうか)。 さいわい、JSHint (jshint.com、英語) にそのようなツールが存在します。ただし、JSHint は既定でコマンド ライン ツールとしてインストールされるため、毎回忘れずに実行しなければならないことが負担になります。
幸運にも、そのために存在するのがビルド ツールです。gulpfile に戻り、各ソース ファイルに対して Gulp で JSHint を実行しましょう (現時点では、ソース ファイルは 1 つだけです)。これを行うには、Gulp に問題のソース ファイルについて指示し、各ファイルに対して JSHint を実行するように要求します。
// Don't need it in this file, but we need it installed
require('jshint');
require('jshint-stylish');
const gulp = require('gulp');
const jshint = require('gulp-jshint');
gulp.task('jshint', function() {
return gulp.src('*.js')
.pipe(jshint())
.pipe(jshint.reporter('jshint-stylish'));
});
JSHint を実行すると、現在のディレクトリの js ファイルすべて (gulpfile 自体も含む) に対して、推奨される変更がいくつか指摘されます。耳を疑われたでしょうか。ですが、gulpfile の確認についてはそれほど気にする必要はありません。そこで、ファイルのコレクションから gulpfile を取り除くために、次を実行します。
gulp.task('jshint', function() {
return gulp.src(['*.js', '!gulpfile.js'])
.pipe(jshint())
.pipe(jshint.reporter('jshint-stylish'));
});
これにより、現在のファイル リストから gulpfile.js が削除されます。その後、パイプラインの次の段階にファイル リストが渡されます。実際問題として、ほとんどのコードを src ディレクトリに (または server ディレクトリおよび client ディレクトリそれぞれに) 配置することもあるでしょう。そのため、これらのディレクトリとそのサブディレクトリを処理するファイル リストに追加するには、次を実行します。
gulp.task('jshint', function() {
return gulp.src(['*.js', '!gulpfile.js', 'server/**/*.js', 'client/**/*.js'])
.pipe(jshint())
.pipe(jshint.reporter('jshint-stylish'));
});
各パスの "2 つの星印" は、これらの各サブディレクトリにあるすべての js ファイルを取り出す再帰的な動作を実現します。
表面上はすばらしいのですが、実際にはそれほど役に立ちません。 修正が必要な対象を確認するときには、やはり毎回手動で「gulp jshint」 (gulp jshint を default タスクに依存関係として関連付けている場合は単に「gulp」) と入力する必要があるからです。IDE のように、コードが変更されるたびにこの処理を実行できないかとお考えでしょうか。
もちろんできます。やってみましょう (図 1 参照)。
図 1 Gulp を使用した継続的自動化
// Don't need it in this file, but you need it installed
require('jshint');
const gulp = require('gulp');
const jshint = require('gulp-jshint');
gulp.task('default', ['watch']);
gulp.task('watch', function() {
gulp.watch(['*.js', '!gulpfile.js', 'client/**/*.js', 'server/**/*.js'],
['jshint']);
});
gulp.task('jshint', function() {
return gulp.src(['*.js', '!gulpfile.js', 'client/**/*.js', 'server/**/*.js'])
.pipe(jshint())
.pipe(jshint.reporter('default'));
});
ここで gulp コマンドを実行すると、コマンド ラインは単純に一時停止して待機します。このとき、Gulp は "監視" モードになり、gulp.watch 呼び出しに渡されるすべてのファイルを監視し続けます。こうしたファイルに変更が加えられた場合は (つまり、ファイルが保存された場合です。残念ながら、Gulp はテキスト エディターの中を確認することはできません)、ファイル一式に対して jshint タスクが即座に実行されます。その後、監視を続けます。
Gulp をさらに掘り下げる
Gulp タスクによるファイルの処理方法を理解するための鍵の 1 つは、パイプの呼び出しの中に存在します。Gulp は、タスクやファイルよりも、"ストリーム" を基準にして処理を行います。たとえば、src ディレクトリから dest ディレクトリにファイルをコピーする単純な Gulp タスクなら、次のようになるでしょう。
gulp.task('copy-files', function() {
gulp.src('source/folder/**')
.pipe( gulp.dest('dest/folder/**') );
});
基本的に、ファイルはそれぞれ gulp.src によって取得され、変更されないまま、gulp.dest によって指定されたコピー先に配置されます。これらのファイルで発生する必要があるすべての処理は、単純にパイプラインのステップとして追加されます。各ファイルがそのパイプラインを通過した後、パイプラインの次のステップに進みます。これは、"パイプとフィルター" という Unix のアーキテクチャ スタイルで、驚くほど優れた方法により Node.js エコシステムに組み込まれています。Windows PowerShell も同じようなアーキテクチャを基に構築されています。このようなものを以前に .NET の世界で見たことがあると考えたなら、それが理由でしょう。
したがって、たとえば、パイプラインを通過する各ファイルに Gulp がアクセスする必要がある場合は、そのためのプラグイン (gulp-filelogger) が存在します。このプラグインは、Gulp がアクセスした各ファイルについての情報をコンソールに出力します。
gulp.task('copy-files', function () {
gulp.src(srcFiles)
.pipe(filelogger())
.pipe(gulp.dest(destDir));
});
この結果は、次のようになります。
Teds-MacBook-Pro:gulpdemo tedneward$ gulp copy-files
[20:14:01] Using gulpfile ~/Projects/code/gulpdemo/gulpfile.js
[20:14:01] Starting 'copy-files'...
[20:14:01] Finished 'copy-files' after 14 ms
[20:14:01] [/Users/tedneward/Projects/code/gulpdemo/index.js]
Teds-MacBook-Pro:gulpdemo tedneward$
Gulp による完了報告後の出力表示に注目してください。Gulp は、多くの場合、これらのストリームを非同期に処理でき、実際にそのように処理されています。その結果として、"ビルド" 時間が短縮されています。たいてい、開発者は処理が並列に行われていることを知りませんし、気にもしていません。ですが、正確な順番での処理が重要な場合のために、Gulp プラグイン コミュニティには当然のことながら、実行をシリアル化し、確実にすべてを順番に処理するプラグインがいくつか用意されています。Gulp 4.0 では、この動作がさらにわかりやすくなるよう、並列 (parallel) と順次 (serial) という 2 つの新機能が追加される予定です。ただし、まだリリースはされていないため、待つしかありません。
ところで、Gulp 自体はここまでに紹介してきたgulp.task、gulp.watch、gulp.src、および gulp.dest の 4 つの機能のみで構成されます。他はすべてプラグイン、npm モジュール、または手動入力です。そのため、Gulp 自体は非常に簡単に理解できるようになっています。実際、ワード単位で報酬が支払われる記事の執筆者としては、悲しくなるほどに簡単です。
Gulp で可能なたくさんの処理を知る
Gulp そのものはそれほど複雑なツールではありません。ただし、このようなツールすべてに言えることですが、その真価は、Gulp に関するコミュニティから登場した膨大な種類のプラグインと補完的なツールにあります。完全な一覧は gulpjs.com/plugins (英語) から利用できますが、図 2 に Gulp レシピの代表的なサンプルを示しています。このサンプルでは、GitHub へのプロジェクトのリリースを自動化する方法を説明しており、マスターにプッシュするための Git コマンドも含んでいます。
図 2 Gulp レシピ
var gulp = require('gulp');
var runSequence = require('run-sequence');
var conventionalChangelog = require('gulp-conventional-changelog');
var conventionalGithubReleaser = require('conventional-github-releaser');
var bump = require('gulp-bump');
var gutil = require('gulp-util');
var git = require('gulp-git');
var fs = require('fs');
gulp.task('changelog', function () {
return gulp.src('CHANGELOG.md', {
buffer: false
})
.pipe(conventionalChangelog({
preset: 'angular' // Or to any other commit message convention you use.
}))
.pipe(gulp.dest('./'));
});
gulp.task('github-release', function(done) {
conventionalGithubReleaser({
type: "oauth",
token: '' // Change this to your own GitHub token.
}, {
preset: 'angular' // Or to any other commit message convention you use.
}, done);
});
gulp.task('bump-version', function () {
// Hardcode the version change type to "patch," but it might be a good
// idea to use minimist (bit.ly/2cyPhfa) to determine with a command
// argument whether you're doing a "major," "minor" or a "patch" change.
return gulp.src(['./bower.json', './package.json'])
.pipe(bump({type: "patch"}).on('error', gutil.log))
.pipe(gulp.dest('./'));
});
gulp.task('commit-changes', function () {
return gulp.src('.')
.pipe(git.add())
.pipe(git.commit('[Prerelease] Bumped version number'));
});
gulp.task('push-changes', function (cb) {
git.push('origin', 'master', cb);
});
gulp.task('create-new-tag', function (cb) {
var version = getPackageJsonVersion();
git.tag(version, 'Created Tag for version: ' + version, function (error) {
if (error) {
return cb(error);
}
git.push('origin', 'master', {args: '--tags'}, cb);
});
function getPackageJsonVersion () {
// Parse the json file instead of using require because require caches
// multiple calls so the version number won't be updated.
return JSON.parse(fs.readFileSync('./package.json', 'utf8')).version;
};
});
gulp.task('release', function (callback) {
runSequence(
'bump-version',
'changelog',
'commit-changes',
'push-changes',
'create-new-tag',
'github-release',
function (error) {
if (error) {
console.log(error.message);
} else {
console.log('RELEASE FINISHED SUCCESSFULLY');
}
callback(error);
});
});
この例ではさまざまな処理を示しています。特定の順番でタスクを実行する方法、規則変更設定ファイルを生成するための Gulp プラグインの使用、GitHub スタイルのリリース メッセージの実行、セマンティック バージョンの番号増加など、他にも盛りだくさんです。すべて Gulp から実行できる、強力な手法です。
まとめ
今回はコードが特別多い記事ではありませんでしたが、アプリケーション全体を仕切り直し、数多くの機能を取得して、この 1 年くらいで構築してきたアプリケーションと同じ (またはそれ以上の) レベルまで実質的に引き上げました。スキャフォールディングを気に入らないはずがありません。
さらに重要なのは、すべての部分を 1 つ 1 つ手動で構築してからスキャフォールディングしたことで、全体的なコードと、どこで何が起こっているかを一層簡単に理解できるようになっていることです。たとえば、routes.js を開くことは、以前に手動で構築したルーティング テーブルにとっては慣れたものでしょう。また、(プロジェクト ディレクトリのルートにある) package.json は巨大化するものの、これまで使ってきたものと同じです。
実際、Yeoman の使用を除けば、新しい作業は、ビルド ツールを導入して関係するすべての部分を適切な場所に集めることだけです。これについては次回説明します。それまでコーディングに励んでください。
Ted Neward は、シアトルを拠点に活躍している、ポリテクノロジーに関するコンサルタント、講演者、および指導者です。これまでに 100 本を超える記事を執筆している Ted は、F# MVP であり、さまざまな書籍を執筆および共同執筆しています。仕事への協力を依頼する場合、連絡先は ted@tedneward.com (英語のみ) です。また、blogs.tedneward.com (英語) でブログを公開しています。
この記事のレビューに協力してくれた技術スタッフの Shawn Wildermuth に心より感謝いたします。