PHP 開発でも Grunt を使う

PHP Advent Calendar 2013 in Adventar の3日目です。

前日は @matsubo さんの コピペで出来るComposer導入 でした。Composer、私も使ってます。


Grunt とは Node.js で作られた色々な作業を自動化するためのツールです。 普通は Node.js での開発や js とかのフロントエンド開発に使われますが、PHP での開発でもわりと便利です。

PHP のための Grunt プラグインも色々あるので、とりあえず次の2つだけ使ってみます。

前提

php や node や npm はあらかじめインストールしておいてください。

ソースとテストの準備

とりあえず phpunit が実行できるソースツリーを用意します。phpunit は今風に composer でインストールします。

$ find . -type f
./src/Sample.php
./tests/phpunit.xml.dist
./tests/bootstrap.php
./tests/SampleTest.php
./composer.json

composer.json

{
    "autoload": {
        "psr-0": {
            "": "src/"
        }
    },
    "require-dev": {
        "phpunit/phpunit": "3.7.*"
    }
}

Sample.php

<?php
class Sample
{
    public function add($a, $b)
    {
        // @todo 未実装
    }
}

bootstrap.php

<?php
require __DIR__ . '/../vendor/autoload.php';
require_once 'PHPUnit/Framework/Assert/Functions.php';

phpunit.xml.dist

<?xml version="1.0" encoding="utf-8" ?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="https://raw.github.com/sebastianbergmann/phpunit/master/phpunit.xsd"
    bootstrap="./bootstrap.php"
>
    <testsuites>
        <testsuite name="tests">
            <directory>./</directory>
        </testsuite>
    </testsuites>
</phpunit>

SampleTest.php

<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    function test()
    {
        $sample = new Sample();
        assertEquals(3, $sample->add(1, 2));
    }
}

composer で phpunit をインストールします。

$ composer install --dev
Loading composer repositories with package information
Installing dependencies (including require-dev)

 :

Writing lock file
Generating autoload files

phpunit を実行します。Sample::add() の中身が未実装なのでテストはコケます。

$ vendor/bin/phpunit -c tests/ --colors
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

F

Time: 29 ms, Memory: 3.25Mb

There was 1 failure:

1) SampleTest::test
Failed asserting that null matches expected 3.

/home/ore/work/example/php-grunt/tests/SampleTest.php:7

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

grunt の準備

PHP のソースとテストの準備はできたので grunt を準備していきます。

grunt-cli

まずは npm で grunt-cli をグローバルにインストールします。

理由は知りませんが grunt-cli はグローバルにインストールする必要があります。 (手元で試した感じ -g をなくしてプロジェクトローカルにインストールしても大丈夫っぽかったですが・・・よく判りません)

$ npm install -g grunt-cli

package.json

続いて package.json を作成します。

$ echo '{}' > package.json

package.json は PHP で言うところの composer.json です。 これにパッケージを書いておけば npm install だけで必要なパッケージをインストールすることができます。

本来なら npm init で作成するのですが、PHP の開発のためにちょっと使うだけならこれでも十分です、たぶん。

grunt/grunt-phpunit

次に grunt と grunt-phpunit をインストールします。 先ほどの grunt-cli と異なり -g を付けていないので、カレントディレクトリの node_modules にインストールされます。

$ npm install --save-dev grunt
$ npm install --save-dev grunt-phpunit

これは PHP で言うところの composer require --dev ore/are です。 パッケージをインストールしつつ package.json にパッケージ名を追記します。

ただ、package.json が自動で作成されたりはしないので、前の手順で空のファイルを作っています。

Gruntfile.js

Gruntfile.js を作成します。このファイルには実行するタスクの内容を記述します。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit', // phpunit の bin のパス
                configuration: 'tests/',   // --configuration=tests/
                colors: true,              // --colors
                followOutput: true         // phpunit の出力の都度コンソールに表示する
            },
            test: {}                       // テスト対象のディレクトリ
        }
    });

    grunt.loadNpmTasks('grunt-phpunit');
};

この例では次の通りに設定しています。

  • phpunit のパスは vendor/bin/phpunit
  • オプションは --configuration=tests/ --colors
  • phpunit から出力の都度コンソールに表示する(followOutput: true
    • followOutput が false だとすべてのテストが終了してから結果が表示されます
    • テストの進捗がわからなくなるので followOutput は true にしておいた方が良いでしょう

grunt で phpunit を実行

次のように grunt で phpunit を実行できます。

$ grunt phpunit
Running "phpunit:test" (phpunit) task
Starting phpunit (target: test) in
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

F

Time: 26 ms, Memory: 3.25Mb

There was 1 failure:

1) SampleTest::test
Failed asserting that null matches expected 3.

/home/ore/work/example/php-grunt/tests/SampleTest.php:7

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
Fatal error: Command failed:

でも、これだけなら普通に phpunit をすればいいですね・・・

なお、test: {} の部分は普通は次のようにテスト対象のディレクトリを指定します。

    test: {
        dir: "tests/"
    }

が、この例では phpunit.xml.dist でテスト対象を指定しているので空で構いません。

また、test の部分はタスクのターゲット名になっていて、grunt phpunit:test のように指定することが出来ます。 なので、次のようにテストを複数に分割して定義することも出来ます。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit',
                configuration: 'tests/',
                colors: true,
                followOutput: true
            },
            unit: {
                dir: "tests/unit"
            },
            functional: {
                dir: "tests/functional"
            }
        }
    });

    grunt.loadNpmTasks('grunt-phpunit');
};
$ grunt phpunit:unit       # ユニットテスト
$ grunt phpunit:functional # ファンクショナルテスト
$ grunt phpunit            # 両方

grunt phpunit のように : の後を省略するとすべてのタスクが実行されます。 最初の例では phpunit のタスクを 1 つしか設けていないので : の後は省略しています。

grunt でディレクトリ監視して phpunit を実行

次に、grunt でディレクトリを監視してファイルが更新されたら自動的に phpunit が実行されるようにします。

まずはディレクトリ監視のための grunt のプラグインをインストールします。

$ npm install --save-dev grunt-contrib-watch

Gruntfile.js を次のように修正します。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit',
                configuration: 'tests/',
                colors: true,
                followOutput: true
            },
            test: {}
        },
        watch: {
            php: {
                tasks: ['phpunit'],  // ファイルの変更時に実行するタスク
                files: [
                    'src/**/*.php',  // 監視するパス
                    'tests/**/*.php' // 同上
                ]
            }
        }
    });

    grunt.loadNpmTasks('grunt-contrib-watch');
    grunt.loadNpmTasks('grunt-phpunit');

    grunt.registerTask('default', ['watch']); // デフォルトのタスクを watch にする
};

grunt watch でディレクトリ監視を開始します。

$ grunt watch
Running "watch" task
Waiting...

コンソールの応答がここで止まります。コンソールはそのままで src/Sample.php を修正します。

<?php
class Sample
{
    public function func($a, $b)
    {
        return $a + $b;
    }
}

自動的に phpunit が実行されてテストが OK になりました。

Waiting...OK
>> File "src/Sample.php" changed.

Running "phpunit:test" (phpunit) task
Starting phpunit (target: test) in
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

.

Time: 26 ms, Memory: 3.25Mb

OK (1 test, 1 assertion)

Done, without errors.
Completed in 0.461s at Tue Dec 03 2013 22:43:53 GMT+0900 (JST) - Waiting...

この後も Sample.php や SampleTest.php を編集するたびに自動的にテストが実行されます。

なお、grunt を引数なしで実行すると default というタスクが実行されます。 ↑の Gruntfile.js では grunt.registerTask() で default に設定しているので、grunt と打つだけで watch タスクが実行されます。

$ grunt
Running "watch" task
Waiting...

grunt で PHP ビルドインウェブサーバを使う

grunt-php は grunt から PHP のビルドインウェブサーバを実行するためのプラグインです。 ビルドインウェブサーバを実行するにはドキュメントルートが必要なので適当に作っておきます。

public/index.php

<?php
require __DIR__ . '/../vendor/autoload.php';
var_dump(date('Y/m/d H:i:s'));
$sample = new Sample();
var_dump($sample->add(3, 4));

grunt-php をインストールします。

$ npm install --save-dev grunt-php

Gruntfile.js を次のように書き換えます。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        php: {
            options: {
                port: 5000,    // リッスンするポート番号
                base: 'public' // ドキュメントルート
            },
            dist: {}
        }
    });

    grunt.loadNpmTasks('grunt-php');
    grunt.registerTask('default', ['php:dist']);
};

早速実行してみます。

$ grunt
Running "php:dist" (php) task
PHP 5.5.6 Development Server started at Tue Dec  3 22:44:24 2013
Listening on http://127.0.0.1:5000
Document root is /home/ore/work/example/php-grunt/public
Press Ctrl-C to quit.
[Tue Dec  3 22:44:24 2013] 127.0.0.1:35661 [200]: /

Done, without errors.

一瞬だけビルドインウェブサーバが立ち上がって grunt の終了とともに終了しました。

これだけだとなんの役にも立ちませんが、e2e テストとかで Web サーバが必要なときにテストの実行にあわせてビルドインウェブサーバを立ち上げることが出来ます。

例えば、SampleTest.php を次のように書き換えてみます。

SampleTest.php

<?php
class SampleTest extends PHPUnit_Framework_TestCase
{
    function test()
    {
        $body = file_get_contents('http://localhost:5000');
        assertNotEmpty($body);
    }
}

このまま phpunit を実行しても 5000 ポートで待ち受けている Web サーバが無いのでテストはコケます。

$ vendor/bin/phpunit -c tests/ --colors
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

E

Time: 22 ms, Memory: 3.00Mb

There was 1 error:

1) SampleTest::test
file_get_contents(http://localhost:5000): failed to open stream: Connection refused

/home/ore/work/example/php-grunt/tests/SampleTest.php:6

FAILURES!
Tests: 1, Assertions: 0, Errors: 1.

そこで Gruntfile.js を次のように書き換えます。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit',
                configuration: 'tests/',
                colors: true,
                followOutput: true
            },
            test: {}
        },
        php: {
            options: {
                port: 5000,
                base: 'public'
            },
            dist: {}
        }
    });

    grunt.loadNpmTasks('grunt-php');
    grunt.loadNpmTasks('grunt-phpunit');

    // タスクを php:dist -> phpunit と続けて実行する
    grunt.registerTask('default', ['php:dist', 'phpunit']);
};

grunt を実行します。

$ grunt
Running "php:dist" (php) task
PHP 5.5.6 Development Server started at Tue Dec  3 22:46:02 2013
Listening on http://127.0.0.1:5000
Document root is /home/ore/work/example/php-grunt/public
Press Ctrl-C to quit.
[Tue Dec  3 22:46:02 2013] 127.0.0.1:35667 [200]: /

Running "phpunit:test" (phpunit) task
Starting phpunit (target: test) in
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

[Tue Dec  3 22:46:02 2013] 127.0.0.1:35669 [200]: /
.

Time: 30 ms, Memory: 3.25Mb

OK (1 test, 1 assertion)

Done, without errors.

今度はテストの実行中に grunt がビルドインウェブサーバを立ち上げるので、テストが OK になりました。

grunt-php で単にビルドインウェブサーバを起動する

grunt-php の最初の例では、ビルドインウェブサーバが起動した後、すぐに終了してしまいましたが、 Gruntfile.js で次のように指定すればビルドインウェブサーバを起動したままにすることができます。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        php: {
            options: {
                port: 5000,
                base: 'public'
            },
            web: {
                options: {
                    keepalive: true, // ビルドインウェブサーバを起動したままにする
                    open: true       // タスクの実行時にブラウザを開く
                }
            }
        }
    });

    grunt.loadNpmTasks('grunt-php');
    grunt.registerTask('default', ['php:web']);
};

grunt を実行すると・・・ビルドインウェブサーバが起動しつつ、ブラウザも起動して http://localhost:5000/ が開かれます。

$ grunt
Running "php:web" (php) task
PHP 5.5.6 Development Server started at Tue Dec  3 22:51:42 2013
Listening on http://127.0.0.1:5000
Document root is D:\ore\work\example\php-grunt\public
Press Ctrl-C to quit.
[Tue Dec  3 22:51:43 2013] 127.0.0.1:63381 [200]: /
[Tue Dec  3 22:51:43 2013] 127.0.0.1:63382 [200]: /

終了するときは Ctrl-C で終了してください(ブラウザは手で閉じてください)。

でもこれだけだと普通にビルドインウェブサーバを実行すればいいですね・・・

grunt-php でビルドインウェブサーバを起動して LiveReload する

次に grunt でビルドインウェブサーバを立ち上げつつ LiveReload でファイル更新時にブラウザを自動的にリロードするようにします。

LiveReload に関しては このへん で調べればいいと思います。

私は FireFox のアドオンを使ってます。

それでは早速 Gruntfile.js を編集します。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        php: {
            options: {
                port: 5000,
                base: 'public/'
            },
            web: {
                options: {
                    open: true
                }
            }
        },
        watch: {
            php: {
                options: {
                    livereload: true // ファイルの更新時に LiveReload する
                },
                tasks: [],
                files: [
                    'public/*.php',
                    'src/**/*.php'
                ]
            }
        }
    });

    grunt.loadNpmTasks('grunt-contrib-watch');
    grunt.loadNpmTasks('grunt-php');

    // タスクを php:web -> watch と続けて実行する
    grunt.registerTask('default', ['php:web', 'watch']);
};

grunt を実行します。

$ grunt
Running "php:web" (php) task
PHP 5.5.6 Development Server started at Tue Dec  3 22:59:14 2013
Listening on http://127.0.0.1:5000
Document root is D:\ore\work\example\php-grunt\public
Press Ctrl-C to quit.
[Tue Dec  3 22:59:14 2013] 127.0.0.1:63404 [200]: /

Running "watch" task
Waiting...[Tue Dec  3 22:59:14 2013] 127.0.0.1:63405 [200]: /

ブラウザが起動するのでアドオンの LiveReload を有効にします。 FireFox なら live-off な感じのアイコンをクリックして live-on な感じになれば OK です。

そしておもむろに Sample.php を編集します。

Sample.php

<?php
class Sample
{
    public function func($a, $b)
    {
        return $a * $b;
    }
}

自動的にブラウザがリロードされましたね。

$ grunt
Waiting...[Tue Dec  3 22:59:14 2013] 127.0.0.1:63405 [200]: /
OK
>> File "public\index.php" changed.

... Reload public\index.php ...
... Reload public\index.php ...
Completed in 0.000s at Tue Dec  3 2013 22:59:19 GMT+0900 (東京 (標準時)) - Waiting...
[Tue Dec  3 22:59:19 2013] 127.0.0.1:63411 [200]: /

Gruntfile.js をまとめる

ここまでの例では簡単化のために phpunit の Gruntfile.js と livereload の Gruntfile.js を別々に作りましたが、1 つのファイルにまとめることも出来ます。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        php: {
            options: {
                port: 5000,
                base: 'public/'
            },
            dist: {},
            web: {
                options: {
                    open: true
                }
            }
        },
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit',
                configuration: 'tests/',
                colors: true,
                followOutput: true
            },
            test: {}
        },
        watch: {
            web: {
                options: {
                    livereload: true
                },
                tasks: [],
                files: [
                    'public/*.php',
                    'src/**/*.php'
                ]
            },
            test: {
                options: {
                    livereload: false
                },
                tasks: ['phpunit'],
                files: [
                    'src/**/*.php',
                    'tests/**/*.php'
                ]
            }
        }
    });

    grunt.loadNpmTasks('grunt-contrib-watch');
    grunt.loadNpmTasks('grunt-php');
    grunt.loadNpmTasks('grunt-phpunit');

    grunt.registerTask('web', ['php:web', 'watch:web']);
    grunt.registerTask('test', ['php:dist', 'watch:test']);
};

この Gruntfile.js では次の通りにタスクを定義しています。

  • grunt web
    • ビルドインウェブサーバを起動
    • ブラウザを開く
    • ディレクトリを監視して livereload でブラウザをリロードする
  • grunt test
    • ビルドインウェブサーバを起動
    • ディレクトリを監視して phpunit を実行する

grunt-contrib-watch を grunt-este-watch に変更する

grunt-contrib-watch と同じようなプラグインに grunt-este-watch というものがあります。

grunt-este-watch の方が CPU 使用率が低くてオススメらしいので、↑で試した phpunit を自動実行する Gruntfile.js を grunt-este-watch を使うように変更してみます。

まずは grunt-este-watch をインストールします。

$ npm install -save-dev grunt-este-watch

Gruntfile.js を書き換えます。説明は省くので↑の記事とかを参考にしてください。

Gruntfile.js

module.exports = function (grunt) {

    grunt.initConfig({
        php: {
            options: {
                port: 5000,
                base: 'public/'
            },
            dist: {}
        },
        phpunit: {
            options: {
                bin: 'vendor/bin/phpunit',
                configuration: 'tests/',
                colors: true,
                followOutput: true
            },
            test: {}
        },
        esteWatch: {
            options: {
                dirs: [
                    // 監視対象のディレクトリ
                    // ファイルではなくディレクトリを指定します
                    'src/**/',
                    'tests/**/'
                ],
                livereload: {
                    // LiveReload を無効にする
                    // すぐしたのコメントと入れ替えれば有効になります
                    enabled: false
                    //enabled: true,
                    //port: 35729,
                    //extensions: ['php', 'js', 'css']
                }
            },
            // `php:` の部分で変更されたファイルの拡張子を指定します
            // 戻り値で実行するタスクを返します
            // filepath には変更されたファイルのパスが入っています
            php: function (filepath) {
                return ['phpunit'];
            }
        }
    });

    grunt.loadNpmTasks('grunt-este-watch');
    grunt.loadNpmTasks('grunt-phpunit');
    grunt.loadNpmTasks('grunt-php');

    grunt.registerTask('default', ['php:dist', 'esteWatch']);
};

grunt を実行してソースファイルを修正すると自動的にテストが実行されます。

$ grunt
Running "php:dist" (php) task
PHP 5.5.6 Development Server started at Tue Dec  3 22:48:47 2013
Listening on http://127.0.0.1:5000
Document root is /home/ore/work/example/php-grunt/public
Press Ctrl-C to quit.
[Tue Dec  3 22:48:47 2013] 127.0.0.1:35682 [200]: /

Running "esteWatch" task
>> Waiting...
4 dirs watched within 4 ms.
>> User action.
>> File changed: src/Sample.php

Running "phpunit:test" (phpunit) task
Starting phpunit (target: test) in
PHPUnit 3.7.28 by Sebastian Bergmann.

Configuration read from /home/ore/work/example/php-grunt/tests/phpunit.xml.dist

[Tue Dec  3 22:48:49 2013] 127.0.0.1:35684 [200]: /
.

Time: 27 ms, Memory: 3.00Mb

OK (1 test, 1 assertion)

Running "esteWatch" task
>> Waiting...

なお、grunt-este-watch はディレクトリ監視を Linux なら inotify、Windows なら ReadDirectoryChangesW で実装されているため(正しくは grunt-este-watch が使っている fs.watch が)、 ネットワークファイルシステムなどで動作しない場合があります(Linux の cifs では動作しませんでした)。

さいごに

他にも定番ものだと grunt-phpcpdgrunt-phpmd などもあるので、 CPD や PMD をやってる人は使ってみるといいのではないでしょうか。