ぼちぼち日記

おそらくプロトコルネタを書いていることが多いんじゃないかと思います。

東京Node学園祭2013の宿題:Gruntプラグインのdefault configを取得する方法

1. きっかけ

「ブログを書くまでが勉強会〜」ということで、東京Node学園祭2013に参加してきました。細かいセッションレポートは他の人に任せますが、id:yosuke_furukawa さんの「東京Node学園祭 2013にスタッフ兼スピーカーとして参加しました。」で述べられているよう、最後飛び込みLTが続いて学園祭がとても盛り上がったのは楽しかったです。

残念ながら予定をいれてしまい途中で無限LTから退出することになったのですが、 [twitter:@muddydixon]さんのGruntに関するLTで

「Gruntのプラグインがいろいろあるけど config書くのにいちいちドキュメントを参照するのが大変だ。 default の config を出力できるようにするとか、なんとかならない?」

な感じの話があり、私自身も以前同じ事を思ってたので、そうだよなぁ〜と思って会場を後にしました。

最近ちょうどGruntのソースを読んだところだったので、帰り道で「きっとどっかのオブジェクトに _options といったプロパティで隠されているだろうからそれ出力すればいいんじゃねぇ」って軽い気持ちで予想して、週明けにやってみることにしました。でも現実は厳しく、実はそんな簡単にはいかず、結構無理くりして力技になっちゃったという話です。

2. Gruntプラグインのdefault configを取得する方法

で、結果から。一応作りました。

https://github.com/shigeki/grunt-default-config-list

Grunt謹製のプラグイン(grunt-contibute-*)は25種類あるのですが、そのうち18種類は default config を取得できるようになりました。(下述の制限・注意事項を要参照)

2.1 使い方

最初事前準備として contrib プラグインを全部落としてくるところまでして下さい。(既にpackage.json にプラグリンリストが書いてあります。)

$ git clone https://github.com/shigeki/grunt-default-config-list
... (git clone で落としてくる) ...
$ cd grunt-default-config-list/
$ npm install
... (プラグインモジュールのダウンロード) ...

後は grunt コマンド実行して default タスクを起動するだけ。標準出力と default_config.json に出力するようになっています。

$ grunt
{
  "concat": {
    "options": {
      "separator": "\n",
      "banner": "",
      "footer": "",
      "stripBanners": false,
      "process": false
    }
  },
 (中略)
  "less": {
    "options": {}
  }
}

全部一気が嫌な方は、 grunt -h でプラグインタスク名の一覧が取得できます。(これはGruntオリジナルのヘルプの機能)

$ grunt -h
Grunt: The JavaScript Task Runner (v0.4.1)
(中略)
Available tasks
        concat  Concatenate files. *
          copy  Copy files. *
          sass  Compile Sass to CSS *
(中略)
          jade  Compile jade templates. *
      imagemin  Minify PNG and JPEG images *
          less  Compile LESS files to CSS *
       default  Alias for "concat", "copy", "sass", "clean", "htmlmin",
                "csslint", "cssmin", "coffee", "jst", "jshint", "uglify",
                "connect", "handlebars", "compress", "stylus", "jade",
                "imagemin", "less" tasks.
(後略)

"grunt タスク名" で個別タスクの default config を出力することもできます。

$ grunt jshint
{
  "jshint": {
    "options": {
      "force": false,
      "reporterOutput": null
    }
  }
}

3.制限・注意事項

これ、個別のプラグイン固有な処理をしていないのでいくつか制限事項があります。

  • プラグインのタスクファイル内で this.options({デフォルトのオブション群}); で設定しているものを取得しています。これ以外の方法で config を設定しているタスクの場合、configを正確に取得できていない可能性があります。
  • Gruntfile.js の grunt.initConfig で指定するオプションは {} にしています。
  • 大概のプラグインタスクには src/dst のファイル設定が必要ですが、全てのプラグインに便宜上サイズが0の empty.txt を src/dst に設定しています(ファイルも生成しています)。いちおう --no-write オプションで書き込み処理を行わないようにしていますが、プラグインによっては正常に動作していないものもあるかもしれません。
  • 一応 grunt.config.requires で必須化されている設定もひっかけて取得しています(今回の初期プラグインには該当なしですが)。 その場合 config の値に、'*** this config option must be required ***' を入れています。
  • 最後に DISCLAIMER ですが、出力された default config が正しい値でタスクが正常に実行するかまで確認していませんし、無保証です。

contrib 以外のプラグインでも上記条件を満たすものなら package.json の devDependencies に追記すれば取得できるはずです。

今回対象から外した7つのプラグイン(grunt-contrib-nodeunit grunt-contrib-watch grunt-contrib-requirejs grunt-contrib-jasmine grunt-contrib-qunit grunt-contrib-yuidoc grunt-contrib-compass)は、上記方法で簡単に取得できなかったものです。
他の方法なら取得できる可能性もあるので、皆さんからのフィードバックをお待ちしています。ただし、できるだけ一般的な方法でお願いします。

4. で、どうやったの?

最初想定してたどっかのプロパティに残っているのでそれを単純出力という方法はあっけなく挫折しました。grunt.task._options ってあるにはありますが、プラグインのオプションを格納する所じゃありませんでした。

4.1 Gruntのタスク起動までの流れ

grunt のタスクの起動の流れをざっと簡単に説明すると、

  1. grunt-cli (/usr/local/bin/grunt) から grunt 本体モジュールを読み込む。
  2. コマンドラインオプションを解析する。(この時引数でタスク名が指定なければ、起動タスク名として default が指定される)
  3. Gruntfile.js が読み込まれる。
  4. grunt オブジェクトを生成して、grunt オブジェクトを引数として Gruntfile.js を実行する。
  5. Gruntfile.js 内の grunt.loadNpmTasks, grunt.loadTasks からプラグインのタスクファイルを読み込む。
  6. Gruntfile.js 内の grunt.registerTask, grunt.registerMultiTask からタスク名と実行ファイル等タスク情報を登録する。
  7. 読み込んだタスク情報を grunt.task._tasks に格納する。
  8. 引数が指定されていなければ defaultタスクを実行。defaultタスクに登録された順番に、タスクとターゲット毎にタスクを起動する。
  9. タスクが実行される場合は、毎回 Context オブジェクトを生成し、そのオブジェクト内でタスクを実行する。(タスク内の this で参照される)

grunt.task._task に格納されているタスク情報は、以下のようになります。

{concat:
   { name: 'concat',
     info: 'Concatenate files.',
     fn: [Function],
     meta:
      { info: '"grunt-contrib-concat" local Npm module',
        filepath: '/home/ohtsu/tmp/test/grunt-default-config-list/node_modules/grunt-contrib-concat/tasks/concat.js' },
     multi: true }
}

上記 fn にプラグインモジュールのタスク実行する関数がクロージャ内に格納されています。このタスク実行ファイルのJSはgrunt.loadNpmTasks で読み込む場合にはモジュールディレクトリ直下の tasks ディレクトに決め打ちされてます。

4.2 プラグインの default config はどこに?

プラグイン内のタスク実行ファイルのJSでは default の config は以下の様に指定されているのが大半です。

  grunt.registerMultiTask('concat', 'Concatenate files.', function() {
    // Merge task-specific and/or target-specific options with these defaults.
    var options = this.options({
      separator: grunt.util.linefeed,
      banner: '',
      footer: '',
      stripBanners: false,
      process: false
    });
   (省略)
  });

この上記にある var options や this.options の値を取得できればOKということまでわかりました。this.options は指定したデフォルトのオプション値とユーザーが config で指定したオプション値を合わせたものを返します。

4.3 grunt.registerMultiTask を入れ替えて対応

でもここからが大変です。上記で述べた通り、タスクは以下の様に context オブジェクト上で実行されています。

  fn.call(context)

context は外から覗けないので直接値を取ることが難しいんです。いろいろ試したあげく、

  • Gruntfile.js 内で grunt.registerMultiTask を proxy して横取りする。
  • this から context オブジェクトを取得
  • fn 実行直前に this.options を定義しているから、 this.options を getter のみの accessor property にして上書きされても大丈夫なようにする。
  • this.optionsで渡された default config を別に格納して、全タスクが終了したら出力する。

最終的に入れ替えた grunt.registerMultiTask は、こんな感じになりました。

  // override grunt.registerMultiTask to get this.options(default) in context
  var registerMultiTask_orig = grunt.registerMultiTask;
  grunt.registerMultiTask = function(name, info, fn) {
    var fn_orig = fn;
    fn = function() {
      var context = this;
      // define this.options() as an accessor property so as not to be overwritten
      Object.defineProperty(context, 'options', {
        get: function() {return function() {
          var args = [{}].concat(grunt.util.toArray(arguments)).concat([
            grunt.config([name, 'options'])
          ]);
          var options = grunt.util._.extend.apply(null, args);
          // store default options int config_list
          config_list[grunt.task.current.name] = {options: grunt.util._.clone(options)};
          return options;
        }; }
      });
      fn_orig.apply(context, arguments);
    };
    registerMultiTask_orig.call(this, name, info, fn);
  };

うーん、いまいちきれいなやり方じゃないです。

4.4 Grunt-v0.4.2 では、

やっぱりタスクオプションがどう設定されているのか見たいといった同様の要望があったのでしょう。次リリースの Grunt-v0.4.2 ではちょっと変更されています。
GH-749 Output task options in verbose mode
以下の様に verbose モードの出力で Options が表示されるようになりました。

$ grunt -v concat
Running "concat:dist" (concat) task
Verifying property concat.dist exists in config...OK
Files: src/foo.js -> dist/foo.js
Options: separator="\n", banner="", footer="", stripBanners=false, process=false
Reading /home/ohtsu/tmp/grunt_test/Gruntfile.js...OK
Not actually writing dist/foo.js...OK
File "dist/foo.js" created.

Done, without errors.

私のやり方は、最初に述べた通り結構な力技になりました。プラグインでの config の指定は自由で、統一するような設計になっていないというのが背景にあるんでしょう。
他にもっとスマートでエレガントなアプローチがあるのではないかと心配してしまいます。何かいいアイデアがあれば実際に作っていただくか、私までフィードバックをしていただきたいと思います。