Technote

by sizuhiko

AngularJSでngDialog中の値をngModelでバインドしたいとき注意すること

AngularJS でモーダルダイアログを表示するために、何を使うでしょうか? 多くの場合 ngDialog というコンポーネントを使うのではないかと思います。

で、ダイアログ上の値は、それを表示したコントローラのスコープにバインドする、という良くあるシナリオを想定してください。

まずうまく動作するサンプルを紹介します。

Open Dialogというリンクをクリックして、ダイアログを表示したら、チェックボックスをON/OFFしてください。 ダイアログ背景のページで check: truecheck: false がトグルするはずです。

<div data-ng-app="myApplication">
    <div data-ng-controller="MainController">
        <a href="" ng-click="ShowNgDialog()">Open Dialog</a>
        <span>check: {{FormData.allcheck}}</span>
    </div>
</div>
var myApplication = angular.module('myApplication', ['ngDialog']);

myApplication.controller('MainController', function ($scope, ngDialog) {
    $scope.FormData={allcheck: false};
    $scope.ShowNgDialog = function () {
        ngDialog.open({            
            template: '<div><input type="checkbox" ng-model="FormData.allcheck"/></div>',
            plain: true,
            scope:$scope

        });
    }    
});

とても簡単な例ですが、AngularJSを使ってモーダルダイアログを表示して、チェックボックスの値をコントローラのスコープ変数 FormData.allcheck にバインドしています。

なぜか変数だとバインドされない

一方で、こちらは動作しないサンプルです。

Open Dialogというリンクをクリックして、ダイアログを表示したら、チェックボックスをON/OFFしてください。 ダイアログ背景のページは check: false のままです。

<div data-ng-app="myApplication">
    <div data-ng-controller="MainController">
        <a href="" ng-click="ShowNgDialog()">Open Dialog</a>
        <span>check: {{allcheck}}</span>
    </div>
</div>
var myApplication = angular.module('myApplication', ['ngDialog']);

myApplication.controller('MainController', function ($scope, ngDialog) {
    $scope.allcheck = false;
    $scope.ShowNgDialog = function () {
        ngDialog.open({            
            template: '<div><input type="checkbox" ng-model="allcheck"/></div>',
            plain: true,
            scope:$scope

        });
    }    
});

変わったのは、コントローラのスコープ変数にバインドするオブジェクトです。

うまく動作するのは $scope.FormData={allcheck: false}; のようにスコープのプロパティはオブジェクトで、オブジェクトに値を保持しているケースです。 一方うまく動作しないのは $scope.allcheck = false; のようにスコープのプロパティに変数で値を保持しているケースです。

ngDialogでなければ変数でバインドできる

ngDialogでなく普通に表示される範囲にある場合は、動作するサンプルです。

チェックボックスをON/OFFしてください。ページで check: truecheck: false がトグルするはずです。

<div data-ng-app="myApplication">
    <div data-ng-controller="MainController">
        <div><input type="checkbox" ng-model="allcheck"/></div>
        <span>check: {{allcheck}}</span>
    </div>
</div>
var myApplication = angular.module('myApplication', ['ngDialog']);

myApplication.controller('MainController', function ($scope, ngDialog) {
    $scope.allcheck = false;
});

まとめ

ngDialogを使うときのちょっとした小ネタなのですが、解決策を見つけるまで結構時間がかかりました。 もし、ngDialogを使ってうまくデータバィンディングできない!という人の参考になればと思います。

gulp-rev-replace を使ってリビジョン管理をするときに注意したいこと

JavaScriptでモダンな開発をするとき、gulpというビルドシステムを使うと、簡単にビルド過程を自動化できます。 さらに、JavaScriptでアプリケーションを作ったとき、ライブラリはCDNなどから取得するとして、自分で作ったスクリプトファイルは1つのファイルにまとめてミニファイズする、ということをgulpのタスクで書くでしょう。 一般的にJavaScriptやCSSをHTML上に記述するとき、以下のようにします。

<script type="text/javascript" src="/js/app.js"></script>
<link rel="stylesheet" href="/css/app.css" type="text/css">

リリースしたのにファイルの変更が読み込まれない

良くあるシーンとして、JavaScriptファイルやCSSファイルを差し替えたのに、変更がブラウザに反映されない、というケースです。 これはブラウザのキャッシュが有効になっていて、JavaScriptやCSSのファイルをWebサーバへ取得しに行かないために発生します。

そこで、この課題に対応するため、以下のどちらかの方法を採用すると思います。

  • ファイル名のGETパラメータに、乱数を付加して /js/app.js?_リビジョン番号 のようにする
  • ファイル名にリビジョン番号を入れて /js/app-リビジョン番号.js のようにする

こうすると、リビジョンが変更になった(リリースした)ときにファイルが必ず読み込まれるようになります。

GETパラメータの付加は推奨されない

前記の対応のうち、GETパラメータにリビジョン番号を追加する方法はあまり推奨されません(参照:High Performance Web Sites)。 この方法は、ブラウザやWebサーバがキャッシュを利用しないため、サイトの負荷につながります。

もちろん毎回リクエストが来ても問題ないサイトや、利用者が想定されていれば問題ないかもしれないですが、利用できるのであればキャッシュが有効になっていてページが速く表示できた方が良いことはいうまでもありません。

gulp-revを使ってGETパラメータにリビジョン番号を入れたい場合は、gulp-rev-appendを使うとクエリ文字列としてハッシュ値を入れられるようになります。

ビルド時にファイル名を変更する

推奨される方法は /js/app-リビジョン番号.js のように、ファイル名を変更することです。 gulpのタスク上に、gulp-revのREADMEに書いてあるとおりの方法で対応します。

var gulp = require('gulp');
var rev = require('gulp-rev');

gulp.task('default', function () {
    // by default, gulp would pick `assets/css` as the base, 
    // so we need to set it explicitly: 
    return gulp.src(['assets/css/*.css', 'assets/js/*.js'], {base: 'assets'})
        .pipe(gulp.dest('build/assets'))  // copy original assets to build dir 
        .pipe(rev())
        .pipe(gulp.dest('build/assets'))  // write rev'd assets to build dir 
        .pipe(rev.manifest())
        .pipe(gulp.dest('build/assets')); // write manifest to build dir 
});

プロジェクトによっては、このように単純な構成ではなく、複数のストリームを使ってビルドすることもあるでしょう。

gulp.task('build_js', function () {
    return gulp.src('src/*.js')
        .pipe(sourcemaps.init())
        .pipe(concat({path: 'bundle.js', cwd: ''}))
        .pipe(rev())
        .pipe(sourcemaps.write('.'))
        .pipe(gulp.dest('dist'))
        .pipe(rev.manifest())
        .pipe(gulp.dest('dist'));

たとえばjsとcssを分けてビルドしなくてはいけないようなケースですね。 元ファイル名と、置き換えられたファイル名のマッピングを出力するために、rev.manifest()というAPIを呼び出します。 出力先は、そのあとのdestAPI呼び出しで指定します。ファイル名は省略時には manifest.json というファイル名になります。

{
    "app.css": "app-098f6bcd.css",
    "app.js": "app-273c2cin.js"
}

ファイル名の変更を反映する

HTMLファイルのjsやcssのファイル名を書き換えるのに使うのが、gulp-rev-replaceです。 マニフェストファイルを入力として、HTMLファイルのビルド(コピー)過程で差し込むことができるようになっています。

gulp.task("revreplace", ["revision"], function(){
  var manifest = gulp.src("./" + opt.distFolder + "/rev-manifest.json");

  return gulp.src(opt.distFolder + "/index.html")
    .pipe(revReplace({manifest: manifest}))
    .pipe(gulp.dest(opt.distFolder));
});

revReplace() というAPIを使って、指定したマニフェストの内容と一致する部分を置換します。

<script type="text/javascript" src="/js/app-273c2cin.js"></script>
<link rel="stylesheet" href="/css/app-098f6bcd.css" type="text/css">

とても便利、でも…

私が遭遇したケースで説明しましょう。 ビルド済みファイル名が domain.js というファイル名でそれにリビジョン番号を追加する必要がありました。 さらにドメイン名のチェック用に、is-valid-domain.jsというライブラリも読み込んでいました。

<script type="text/javascript" src="/lib/is-valid-domain.js"></script>
<script type="text/javascript" src="/js/domain.js"></script>

ここでビルドしたところ

<script type="text/javascript" src="/lib/is-valid-domain-リビジョン番号.js"></script>
<script type="text/javascript" src="/js/domain-リビジョン番号.js"></script>

のようになってしまいました。domain.js だけでなく、is-valid-domain.js も変わってしまいます。 なぜこうなるか、ソースを見てみました。

renames.forEach(function replaceOnce(rename) {
  contents = contents.split(rename.unreved).join(rename.reved);
  if (options.prefix) {
    contents = contents.split('/' + options.prefix).join(options.prefix + '/');
  }
});

まぁですよね。ファイルを読み込んで domain.js に一致するところで分割、domain-リビジョン番号.js を追加して繰り返す、という実装です。 ファイルのどこに入っているか厳密に識別するのは困難(正規表現を使えばできなくはないかもしれないけど)です。

で、このようなケースにならなそうなら、そのまま gulp-rev-replace を使ってもらえば問題ないと思います。 ライブラリの挙動がわかっていれば利用するのも安心ですね。

私は gulp-template を使いました

で、私は gulp-rev-replace 使うのやめました。

ちょっと予期しない動作をするのは怖かったので、リビジョン番号が入って欲しいところを明示するようにしたかったのです。 そこで使ったのが gulp-template です。

<h1>Hello <%= name %></h1>

<%= => で囲んだ部分に値を差し込むことができるので、以下のように記述します。

<script type="text/javascript" src="/lib/is-valid-domain.js"></script>
<script type="text/javascript" src="/js/<%= data['domain.js'] %>"></script>

そこにマニフェストJSONをfs-extraで読み込んで、templateAPIに流し込むようにします。 そのままだと、変換前JSファイル名が変数名になって取り出しずらいので、variableオプションを指定してdataという変数名にバインドするようにします。

var gulp = require('gulp');
var template = require('gulp-template');
var fs = require('fs-extra');

gulp.task('build_html', function () {
    var manifest = fs.readJsonSync('./' + opt.distFolder + '/rev-manifest.json', {throws: false})
    return gulp.src('src/*.html')
        .pipe(template(manifest, {variable: 'data'}))
        .pipe(gulp.dest('dist'));
});

まとめ

静的ファイルのリビジョン管理って結構面倒なんですが、gulp使うと便利なライブラリあって簡単に実装できます。 今回は私が遭遇した特殊なケースかもしれないので、そのままgulp-revだけで完結できることも多々あるでしょう。 gulp-revや、その関連ライブラリには便利な機能がまだあるので、一度使ってみてください。

Cakebox を使ってCakePHP3アプリケーションを作ってみよう

CakePHP3の開発環境を構築するのは、以前にも書いたとおり FriendsOfCake/vagrant-chef を便利に使っていたのですが、先日 Twitter の TL に流れてきた Cakebox というのが気になっていたので、使ってみました。

結論としては「CakePHPで何かつくってみたいなら、使わない理由がない」ということです。

Cakeboxとは

CakeboxはAlt<3 Because projects need loveというオランダのプロジェクトのリポジトリにあり、アムステルダムのbravo-kernel氏が中心になって作っているようです。またCakePHPのコアデベロッパでもあるceeram氏もcontributeしているので、アムステルダムでは著名なプロジェクトなのかもしれません。

Cakeboxの詳しいドキュメントに書いてあるとおり、chefでubuntuベースのboxファイルを生成し、cakeboxではそれを使ってvagrantとvirtualboxで起動する流れです。

後で説明しますが、便利なコンソールアプリがあり、それ自体がCakePHP3でできているので、CakePHP3のアプリケーションサンプルとしても役立つのではないかと思います。

boxファイルに入っているソフトウェアは上記ドキュメントに詳しく書いてあるので、そちらを参照してください。

なんと簡単、環境構築

Cakeboxのインストール

最初にCakeboxをダウンロード(クローン)します。以下はCakeboxのREADMEに書いてあるままの内容です。 注意点として、以下の前提条件が必要となります。

  • VirtualBox 4.0 以上
  • Vagrant 1.6.0 以上

どちらかが満たされていないと、まったく起動しないので注意してください(私は踏みましたw)。

git clone https://github.com/alt3/cakebox.git
cd cakebox
cp Cakebox.yaml.default Cakebox.yaml
vagrant up

設定ファイル(yaml)には、詳細な設定を指定できるようですが、いったん何も設定しなくても問題はありませんでした。 以下のようなログが出力されます。 最初はCakeboxのboxファイルをCDNからダウンロードするので時間がかかります。 予め時間があり、回線に余裕があるときにvagrant upだけは済ませておくと良いですね。

Bringing machine 'default' up with 'virtualbox' provider...
==> default: Importing base box 'cakebox'...
# ... 省略
==> default: ---------------------------------------------------------------
==> default: Please wait... installing Cakebox Commands and Dashboard
==> default: ---------------------------------------------------------------
==> default: * Self-updating Composer
==> default: * Updating Composer cache permissions
==> default: * Creating project
==> default: * Composer installing
==> default: Installation completed successfully!
==> default: Running provisioner: shell...
    default: Running: inline script
==> default: ---------------------------------------------------------------
==> default: CakePHP v3.0.0 Console
==> default: ---------------------------------------------------------------
==> default: Self-updating cakebox
==> default: Self-updating Composer
==> default: * Updating cache permissions
==> default: Updating Cakebox Commands and Dashboard
==> default: * Detecting branch
==> default: * Updating git repository
==> default: * Updating composer packages
==> default: Updating CakePHP Code Sniffer
==> default: * Composer updating
==> default: Updating HHVM configuration
==> default: * Creating system start/stop links
==> default: * Correcting HHVM session.save_path
==> default: * Restarting service
==> default: Updating Elasticsearch configuration
==> default: * Decreasing required memory
==> default: * Updating initialization script
==> default: * Stopping service
==> default: Self-update completed successfully
==> default: Running provisioner: shell...
    default: Running: inline script
==> default: ---------------------------------------------------------------
==> default: CakePHP v3.0.0 Console
==> default: ---------------------------------------------------------------
==> default: Setting Cakebox Dashboard protocol to http
==> default: Command completed successfully
==> default: Running provisioner: shell...
    default: Running: inline script
# ... 省略

==> default: Machine 'default' has a post `vagrant up` message. This is a message
==> default: from the creator of the Vagrantfile, and not from Vagrant itself:
==> default: 
==> default: Your box is ready and waiting.
==> default: 
==> default: => Login to your Dashboard by browsing to http://10.33.10.10
==> default: => Login to your virtual machine by running: vagrant ssh

boxファイルのインストールが終わると、ダッシュボードアプリをインストールし、そのアプリのCLIを使ってアプリ自身と、各種モジュールを設定するようです。

ここまで終わったら、指示どおり http://10.33.10.10 にアクセスしてみましょう。

かっこいいダッシュボード画面が表示されました。こういうのがあるとテンション上がりますよね!

アプリケーションの構築

Cakeboxを使って開発環境が構築できたら、CakePHP3アプリケーションを構築してみましょう。vagrant sshでCakeboxにログインします。

localhost:cakebox $ vagrant ssh
Welcome to Ubuntu 14.04.1 LTS (GNU/Linux 3.13.0-24-generic x86_64)

CakeboxのREADMEに書いてあるとおり、cakeboxコマンドを使ってアプリケーションを生成します。とりあえずCakePHP3のブログチュートリアルを作ってみたいと思います。

vagrant@cakebox:~$ cakebox application add blog-tutorial.app
---------------------------------------------------------------
CakePHP v3.0.0 Console
---------------------------------------------------------------
Creating application http://blog-tutorial.app

Configuring installer
Creating installation directory
Composer installing CakePHP 3.x application sources
Creating virtual host
* Successfully created PHP-FPM virtual host
Creating databases
* Successfully created main database
* Successfully created test database
Configuring permissions
Updating configuration files
Application created using:
  database => blog-tutorial_app
  framework => cakephp
  framework_human => CakePHP 3.x
  framework_short => cakephp3
  installation_method => composer
  majorversion => 3
  path => /home/vagrant/Apps/blog-tutorial.app
  source => cakephp/app
  url => blog-tutorial.app
  webroot => /home/vagrant/Apps/blog-tutorial.app/webroot

Remember to update your hosts file with: 10.33.10.10 http://blog-tutorial.app

Installation completed successfully

はい、終わり。hostsファイルに追加してね というメッセージが出ているので、hostsファイルに追加します。

$ vim /etc/hosts

# この行を追加します
10.33.10.10 blog-tutorial.app

さっそく http://blog-tutorial.app にアクセスしてみましょう。

うわー、すげー。 ディレクトリの権限とか、DBの設定とか全部できちゃっているよ。 CakePHP3で composer create-project やったことあればわかると思うのですが、ここまで設定するのもちょっと面倒です。

Cakeboxのダッシュボード画面も見てみましょう。

アプリケーションが1つ、データベースが2つ、バーチャルホストが2つ(最初は1なので1つ増えてます)になっています。 つまり cakebox application add 実行すると

  • CakePHP3の composer create-project でスケルトン作って
  • nginxのsite-availablesにバーチャルホスト追加して
  • DB(mysql)にdefaultとtestの2つのDBを作って
  • CakePHP3のconfigをモロモロ設定、パーミッションも設定

してくれるというわけです。なんと便利、そして簡単なんでしょう。 blogチュートリアルの1ページ目のうち、Creating the Blog Database のテーブル生成以外のステップは(nginxの設定まで)コマンド一つで終わりです。

ブログチュートリアルのテーブル作成

せっかくなので、マイグレーションプラグインを使って、テーブルを生成します。

vagrant@cakebox:~$ cd Apps/blog-tutorial.app/
vagrant@cakebox:~/Apps/blog-tutorial.app$ ./bin/cake migrations create CreateArticles
Welcome to CakePHP v3.0.1 Console
---------------------------------------------------------------
App : src
Path: /home/vagrant/Apps/blog-tutorial.app/src/
---------------------------------------------------------------
using migration path /home/vagrant/Apps/blog-tutorial.app/config/Migrations
using migration base class Phinx\Migration\AbstractMigration
using default template
created ./config/Migrations/20150419074519_create_articles.php

upとdownを以下のように記述します。

<?php
    public function up()
    {
        $table = $this->table('articles');
        $table->addColumn('title', 'string', ['limit' => 50])
              ->addColumn('body', 'text')
              ->addColumn('created', 'datetime')
              ->addColumn('modified', 'datetime')
              ->create();
    }
    public function down()
    {
        $this->dropTable('articles');
    }
    ?>

マイグレーションを実行してテーブルを作成します。

vagrant@cakebox:~/Apps/blog-tutorial.app$ ./bin/cake migrations migrate

Welcome to CakePHP v3.0.1 Console
---------------------------------------------------------------
App : src
Path: /home/vagrant/Apps/blog-tutorial.app/src/
---------------------------------------------------------------
using migration path /home/vagrant/Apps/blog-tutorial.app/config/Migrations
using environment default
using adapter mysql
using database blog-tutorial_app

 == 20150419074519 CreateArticles: migrating
 == 20150419074519 CreateArticles: migrated 0.1178s

All Done. Took 0.1780s

これでチュートリアルの1ページ目は終了です。

ブログチュートリアルを進めよう

チュートリアルって環境構築ではまるケースが多い(特に今まで使ったことないフレームワークとか特に)のですが、このように簡単に始められるのは大きいですね。 あとはパート2の内容を、進めていけば大丈夫です。 ここではチュートリアルそのものを解説するわけではないので、ワープします。 指定されたファイルに、そのままコピペしていけば大丈夫です。

コピペしたあとにテストという投稿を追加してみた結果が以下のとおりです。

さいごに

Cakeboxを使ってブログチュートリアルを進めてみましたが、もし今までCakePHPを使ったことがなくても、CakePHP2は使っていたけど、3はまだ、という人にも環境構築のステップが簡略されているのは、とても大きいと思います。

実はCakebox Multi-Framework PHP Development Environment と書いてあるとおり、CakePHP3だけのためにあるわけではないようです。

# Fresh preconfigured PHP framework applications
$ cakebox application add mycake3.app
$ cakebox application add mycake2.app --majorversion 2
$ cakebox application add mylaravel.app --framework laravel

なんとCakePHP2もいけるし、今話題の laravel5 の環境も作れるみたいですよ! これは現時点でのサポート状況ということで、今後増えていくことも想定されます。 これはもう Cakebox を試してみるしかないですね。

そんなCakePHP3ですが、CakePHP3 もくもく会(勉強会) #14 が 2015-04-28(火)19:00 - 21:30 に Co-Edoで開催されます。 PHP勉強会と日程かぶっていますが、もしCakePHP3に興味があればこちらにも参加してみてください。

BambooのChatWork通知プラグインを作成しました

現在作業支援している現場では Atlassian JIRA,Bitbucket,Bambooと、コミュニケーションツールとしてChatWorkを使っています。 CIツールであるBambooは通知機能としてEmail,HipChat,IMに対応しているのですが、汎用的な通知機能は持っていないので、ChatWorkプラグインを自作してみました。

Bamboo ChatWork Plugin

ここではBambooのプラグインを作る手順と、そのときにハマったポイントなどを解説します。

はじめに

BambooのプラグインをAtlassian SDKをインストールします。 Set up the Atlassian Plugin SDK and Build a ProjectからプラットフォームにあったSDKをインストールします。

SDKをインストールしたらStep 3: Try an atlas commandのとおり以下のコマンドを実行します。

mkdir 作業ディレクトリ名
cd 作業ディレクトリ名
atlas-run-standalone --product bamboo

atlas-run-standaloneを実行すると、大量のmaven installが動きます。 かなり時間がかかるので、ネットワークが速い環境と、時間にゆとりがあるときに実行した方が良いです。

プラグインのスケルトンを生成する

以下のコマンドを実行してください。 sh atlas-create-bamboo-plugin 以下の表の入力を求められるので、適切に入力します。

Define value for groupId作成するプラグインのパッケージパスを指定
Define value for artifactId作成するプラグインの名前を指定
Define value for version作成するプラグインのバージョンを指定
Define value for packagegroupIdとartifactIdを結合した値を指定
入力した内容はpom.xmlに反映されるだけなので、後でpom.xmlを編集すれば大丈夫です。

プラグイン名のディレクトリにpom.xmlsrc/main フォルダに以下のファイルが自動生成されます。

  • {Define value for package}のパス/MyPluginComponent.java
  • {Define value for package}のパス/MyPluginComponentImpl.java
  • /resources/atlassian-plugin.xml

正直、自動生成されたコードは役に立たないので、削除してしまって良いでしょう。 atlassian-plugin.xmlはデフォルト値になっているので、適切に編集します(これは後で大丈夫です)。

以下のコマンドを入力して、プラグイン環境を実行します。

cd プラグインの名前
atlas-run

atlas-runを実行すると、大量のmaven installが動きます。 かなり時間がかかるので、ネットワークが速い環境と、時間にゆとりがあるときに実行した方が良いです(2度目)。

プラグインを実装する

Bambooの通知プラグインとして、最初からインストールされているものはHipChat通知プラグインです。 とりあえずプラグインを作るための情報があまりに少ないので(Wikiを見て進めても肝心な箇所ほどJavaDocを見ろになってしまう。だがJavaDocを見てもわかるはずがない)、通知プラグインを作りたいと思った人はHitChatプラグインまたは、私の作ったChatWorkプラグインをコピー&ペーストするのがお勧めです。

私もまずHipChatプラグインのソースコードを参考にしました。

HipChatプラグインやChatWorkプラグインのコードを見るとわかりますが、実装するファイルはわずかです。 src/mainディレクトリに以下のファイルを配備します。

  • AbstractNotificationRecipientを継承した通知プラグインの設定画面コントローラ
  • NotificationTransportを実装した通知プラグインの通知コントローラ
  • resources/atlassian-plugin.xml(プラグインの設定ファイル)
  • Freemaker形式のテンプレートファイル(設定入力画面、設定表示画面、通知メッセージなど)

実装する量は多くなく、コツさえ掴めば通知プラグインを作るのは難しくありません。コツさえ掴めば…ですが。

通知プラグインの設定画面コントローラを作成する

ChatWorkプラグインでは、ChatworkNotificationRecipient.javaというクラスを作成しました。 作成したというよりはHipChatプラグインのHipchatNotificationRecipient.javaを丸々コピーして微修正した程度です。

実際に差分を見るとわかりますが、クラス名など変更した程度です。

このコントローラに対応する画面はatlassian-plugin.xmlで指定します。

<notificationRecipient key="recipient.chatwork" name="Chatwork Recipient" class="jp.tokyo.open.bamboo.plugin.chatwork.ChatworkNotificationRecipient" weight="10">
    <description>ChatWork</description>
    <resource type="freemarker" name="edit" location="templates/plugins/notifications/chatwork/editNotification.ftl"/>
    <resource type="freemarker" name="view" location="templates/plugins/notifications/chatwork/viewNotification.ftl"/>
</notificationRecipient>

resourcenameeditである場合、設定編集画面のファイルパスを指定します。 viewの場合は、導入済み通知プラグインが設定一覧に表示される画面部品のファイルパスを指定します。

実際の画面を見てみましょう

[@ww.textfield labelKey="chatwork.api.token" name="chatWorkApiToken" value="${chatWorkApiToken!}" required='true'/]
[@ww.textfield labelKey="chatwork.room" name="chatWorkRoom" value="${chatWorkRoom!}" required='true'/]
[@ww.checkbox labelKey="chatwork.notify" name="chatWorkNotifyUsers" value="${chatWorkNotifyUsers!?string}"/]

これはFreemakerというテンプレートエンジンを使っているのですが、なんとなく想像できるレベルです。 HTMLと似ています。labelKeyはリソースファイルに定義した内容をバインドするので国際化対応できます。

ここでコツ1

Freemakerのテンプレート上のname属性と、コントローラクラスの設定入力画面の項目名 がマッピングされています。

この画面はBambooで通知設定を入力するときに以下のようなHTMLに変換されます。

<input type="text" name="chatWorkApiToken" value="xxxxx" required>
<input type="text" name="chatWorkRoom" value="oooooo" required>
<input type="check" name="chatWorkNotifyUsers" value="1" checked>

想像どおりですか? 注意しなければならないのは「設定画面にはすべてのプラグインのHTMLが並ぶ」ということです。 えっ?何を言っているかわからない?ではどのようになっているかというと以下のようなHTMLになるのです。

<div class="hipchat-plugin" style="display:none">
  <input type="text" name="apiToken" value="xxxxx" required>
  <input type="text" name="room" value="oooooo" required>
  <input type="check" name="notifyUsers" value="1" checked>
</div>
<div class="chatwork-plugin">
  <input type="text" name="chatWorkApiToken" value="xxxxx" required>
  <input type="text" name="chatWorkRoom" value="oooooo" required>
  <input type="check" name="chatWorkNotifyUsers" value="1" checked>
</div>

このようなフォームが生成されていて、選択した通知の部分だけが見えるようになるのです。 カンの良い人は気付いたかもしれません。 私は最初HipChatプラグインをコピペして、項目名を変更していなかったので、以下のようなHTMLが生成されていて、うまくフォームから値を取得できませんでした。

<div class="hipchat-plugin" style="display:none">
  <input type="text" name="apiToken" value="xxxxx" required>
  <input type="text" name="room" value="oooooo" required>
  <input type="check" name="notifyUsers" value="1" checked>
</div>
<div class="chatwork-plugin">
  <input type="text" name="apiToken" value="xxxxx" required>
  <input type="text" name="room" value="oooooo" required>
  <input type="check" name="notifyUsers" value="1" checked>
</div>

同一フォームに同じname属性を持つHTMLが生成されて、submitされるのでコントローラのpopulateメソッドのMapに正しく値が入らなくなっていました。 なのでなるべく重複しない名前を指定しておくことが重要です。 ところが標準の通知プラグインであるHipChatが一等地の名前を持っているのです。Bambooとしてどのような名前規則を推奨しているのかドキュメントには記載していないので、パッケージ名を付加するなどの工夫が必要です。

なお保存すると、保存完了メッセージが表示されるのですが、このメッセージのカスタマイズ方法はわかっていません。 そもそもカスタマイズできるのか調査中ですが、Bambooのコードはオープンソースではないので…(ry

通知プラグインの通知コントローラを作成してChatWorkに通知する

ChatWorkプラグインでは、ChatworkNotificationTransport.javaというクラスを作成しました。 作成したというよりはHipChatプラグインのHipchatNotificationTransport.javaを丸々コピーして修正しました。

まず通知には通知メッセージが必要ですよね。 通知メッセージをFreemakerのテンプレートで記述したいのですが、ここで圧倒的なドキュメント不足に遭遇します。 ほぼ自力での解決はムリなので、Bambooの標準テンプレートをコピペして作業した方が良いでしょう。

HipChatプラグインのソースコードを見ると、特に通知クラスを作ったり、通知のテンプレートを指定することはしていないようです。 Bambooの通知機能はHipChatに連携することに依存していて、HipChatはBamboo標準のテンプレートを使っています。 通知プラグインを作るためのドキュメントを確認します。 Building a Notification Pluginを読むと、カスタムリスナーを登録する手順が書いてありますが、独自の通知タイプを作らない限りこのとおりやらなくても大丈夫です。 実際、私もカスタムリスナーを作成してドキュメント通りやってみたのですが、うまく動作しませんでした。atlassian-plugin.xmlにリスナー定義を追加してやってみたりしたのですが….

ここでコツ2

そこでChatWorkプラグインでは、ChatworkNotificationRecipientからChatworkNotificationTransportのインスタンスを作成するときに、TemplateRendererというFreemakerのテンプレートを操作できるインスタンスを渡すようにしました。ChatworkNotificationTransportで独自に取得することはできないようです。 ChatworkNotificationRecipientではDIによって値がセットされるようになっているようです(ドキュメントなし)。

ChatworkNotificationTransport::sendNotification()メソッドが送信指示として呼び出されるので、TemplateRendererを使って独自テンプレートを呼び出して文字列に変換します。 文字列変換したメッセージをChatWork APIを使って指定したルームに送信するだけです。

以下の部分がChatWork用に実装したコードです。

    static String getChatworkApiURL(String room) {
        return CHATWORK_API_URL.replaceAll("\\{room_id\\}", room);
    }


    private String getChatworkContent() {
        String templateLocation = "templates/plugins/notifications/chatwork/BuildCompleted.ftl";
        return templateRenderer.render(templateLocation, populateContext());
    }

    private Map<String, Object> populateContext()
    {
        Map<String, Object> context = Maps.newHashMap();
        context.put("build", plan);
        context.put("buildSummary", resultsSummary);
        return context;
    }

getChatworkContent()を使ってFreemakerテンプレートからメッセージを取得します。 実際のテンプレートファイルは以下のよな内容です。

[#-- @ftlvariable name="build" type="com.atlassian.bamboo.build.Buildable" --]
[#-- @ftlvariable name="buildSummary" type="com.atlassian.bamboo.resultsummary.BuildResultsSummary" --]
[#include "/notification-templates/notificationCommons.ftl"]
[#include "/notification-templates/notificationCommonsText.ftl" ]
[#assign authors = buildSummary.uniqueAuthors/]

[#if buildSummary.successful][#lt]
[info][title][@buildNotificationTitleText build buildSummary/] was SUCCESSFUL[/title]
[@showRestartCount buildSummary/]
[#if buildSummary.testResultsSummary.totalTestCaseCount >0] [@showTestSummary buildSummary.testResultsSummary/][/#if].
[#if authors?has_content] [@showAuthorSummary authors/][/#if][#lt]
${baseUrl}/browse/${buildSummary.planResultKey}/
[/info]
[#else][#lt]
[info][title][@buildNotificationTitleText build buildSummary/] has FAILED[/title]
[@showRestartCount buildSummary/]
[#if buildSummary.testResultsSummary.totalTestCaseCount >0] [@showTestSummary buildSummary.testResultsSummary/][/#if].
[#if authors?has_content] [@showAuthorSummary authors/][/#if][#lt]
${baseUrl}/browse/${buildSummary.planResultKey}/
[/info]
[/#if][#lt]

先ほども書きましたが、これをJavaDocの情報を元に生成するのはほぼムリ、というか完全にムリです。 最初の手順でインストールしたSDKの中に実際のBambooがインストールされるので、Bambooのwarファイルが展開されたWEB-INF/classes/notification-templatesのBuildCompleted.ftlファイルの内容をコピペして、ChatWorkメッセージ記法に合わせて修正しています。

後はSDKで実行したBambooにプラグインをインストールして、デバッグしながら確認するのが近道です。

プラグインをビルドする

プラグインは管理画面からjarファイルとしてアップロードします。 そのためコンソールから以下のコマンドを実行します。

cd プラグインの名前
atlas-mvn clean package

atlas-mvnはmvnのラッパーのようなのですが、mvnを直接実行するのでなく、こちらのコマンドを使った方が良いみたいです。 あとは通常のmavenのビルドと一緒ですが、初回はまた大量のmvn installが動くのでネットワークと時間には余裕を持って挑みましょう。

ビルドが終わるとtargetディレクトリにjarファイルが生成されます。

さいごに

ChatWork通知プラグインとHipChat通知プラグインの差分はほんとうにわずかで、それほど難しいものではありません。 Bambooの通知プラグインモジュールで得た経験は以下のようなところです。

  • ドキュメント読めばなんとなくわかるけど、「詳しくはJavaDocへ」でつまづく(心が折れる)
  • 入力画面など生成されたものはブラウザのデバッガを使って、名前空間がバッティングしていないか確認が必要
  • EclipseのJDなどを使って、Bamboo自体のソースコードをリバースして調べながら実装しないとムリ

プロジェクト都合じゃないとなかなかBambooとか使ったりする機会がないのですが、もしBamboo使うことになって通知プラグインを作りたいと思った方は参考にしていただければと思います。

長くなったので、このあたりで終わりにしますが、他にもいろいろわかったことはあるので、また気が向いたら書こうかなと思います。

Fabricateのversion2を作成しました

これまでCakePHP2用のデータジェネレータプラグインとして開発を続けてきたFabricateV2として各種ORMへ対応するようなコアモジュールへと変更しました。 またFabiricateリポジトリのmasterブランチへは統合されていませんが、CakePHP3のリリース時期を合わせて、V2ブランチを本流にする予定です。 これまでのCakePHP2用ライブラリはcakephp2ブランチでメンテナンスを続ける予定です。

データジェネレータって何?誰得?という方は、以下の投稿を一読していただけると理解が深まります。

FabricateはRubyのFabricationとFactory_Girlに影響された「fixtureはDRYではないので、Fixture replacement を使おう」という流れに乗ったPHPクローンです。

Version2を作成するきっかけ

このキッカケはいくつかあります。 1つには昨年の CakeFest2014 で Fabricate の LT をした後で、 CodeIgniter の開発者だったことでも知られるPhil Sturgeon から、「これいいね、他のフレームワークでも使えるようにしてよ」と言われたことでした。 他のフレームワークでも使えるようにしたいという思いは、少しあったのですがまだ確約できなかったので「maybe」と返しただけでした。(このときPhilはかなり酔っていたので、私に話した事を覚えているのかどうかも怪しいですがw)

さらにもう1つおおきなキッカケがCakePHP3でのPSR対応によって、namespaceなど外部ライブラリを使いやすくなり、さらに外部ライブラリからフレームワーク本体のコードへのアクセスしやすくなったということがあります。 CakePHP2ではフレームワークへアクセスするにはアプリケーションまたはプラグインでないと困難で、特にORMに依存したライブラリをフレームワークをまたいで作成するのは困難でした。これがPSRの対応でやりやすくなったということ、CakePHP3への対応に合わせてV2を作って、他のフレームワークにも対応できるようにしようと思ったのです。

Fabricate V2の主な変更点

Fabricateの利用方法はほとんど変わっていませんが、各ORM用にアダプターと呼ばれるORMの差分を吸収するクラスを準備する必要があります。 Fabricateの本体にはFabricate\Adaptor\FabricateArrayAdaptorという連想配列構造を返すサンプル用のアダプターを用意しています。これを参考にアダプターを実装すればDoctrinePropelYiiCodeIgniterでも利用可能になります。

アダプターの実装方法

とはいえ実際にORMに接続するアダプターもないと、ということでCakePHP3用のアダプターCakePHP Fabricate Adaptorも作成しました。

ここではCakePHP3のアダプター実装を例に、アダプターで何を実装しなくてはいけないのかを解説したいと思います。

作成するクラス

アダプタークラスはFabricate\Adaptor\AbstractFabricateAdaptorクラスを継承します。

<?php
use Fabricate\Adaptor\AbstractFabricateAdaptor;

class CakeFabricateAdaptor extends AbstractFabricateAdaptor
{
    ...
}

実装するメソッド

アダプターとして実装する必要があるメソッドは以下の3つです。

  • getModel
  • create
  • build

それぞれのメソッドの実装を確認しましょう。

getModel

getModelメソッドは各ORMの差分を吸収するためのモデルインスタンスを返却するデータジェネレータの定義部分です。 データジェネレータとしては最も重要な機能です。

<?php
use Fabricate\Model\FabricateModel;

public function getModel($modelName)
{
    $model = new FabricateModel($modelName);
    $table = TableRegistry::get($modelName);
    $schema = $table->schema();
    foreach ($schema->columns() as $name) {
        if ($this->filterKey($table, $name)) {
            continue;
        }
        $attrs = $schema->column($name);
        $options = [];
        if (array_key_exists('length', $attrs)) {
            $options['limit'] = $attrs['length'];
        }
        if (array_key_exists('null', $attrs)) {
            $options['null'] = $attrs['null'];
        }
        $model->addColumn($name, $attrs['type'], $options);
    }
    foreach ($table->associations()->keys() as $key) {
        $association = $table->associations()->get($key);
        $target = $association->target();
        $className = get_class($target);
        $alias = $target->alias();
        switch ($association->type()) {
            case Association::ONE_TO_ONE:
                $model->hasOne($alias, $association->foreignKey(), $className);
                break;
            case Association::ONE_TO_MANY:
                $model->hasMany($alias, $association->foreignKey(), $className);
                break;
            case Association::MANY_TO_ONE:
                $model->belongsTo($alias, $association->foreignKey(), $className);
                break;
        }
    }
    return $model;
}

getModelはFabricateModelクラスのインスタンスを生成する責務があります。 FabricateModelはPHPのマイグレーションツールであるPhinxに影響を受けていて、スキーマの定義方法が似ています。

<?php
// Phinx
$users = $this->table('users');
$users->addColumn('username', 'string', array('limit' => 20))
      ->addColumn('password', 'string', array('limit' => 40))
      ->addColumn('password_salt', 'string', array('limit' => 40))
      ->addColumn('email', 'string', array('limit' => 100))
      ->addColumn('first_name', 'string', array('limit' => 30))
      ->addColumn('last_name', 'string', array('limit' => 30))
      ->addColumn('created', 'datetime')
      ->addColumn('updated', 'datetime', array('null' => true))

// FabricateModel
$users = new FabricateModel('users');
$users->addColumn('username', 'string', array('limit' => 20))
      ->addColumn('password', 'string', array('limit' => 40))
      ->addColumn('password_salt', 'string', array('limit' => 40))
      ->addColumn('email', 'string', array('limit' => 100))
      ->addColumn('first_name', 'string', array('limit' => 30))
      ->addColumn('last_name', 'string', array('limit' => 30))
      ->addColumn('created', 'datetime')
      ->addColumn('updated', 'datetime', array('null' => true))

利用できるカラム型はPhinxと同様で、オプションについて現時点ではlimit(長さ)のみ対応しています。

CakePHP3では

<?php

$table = TableRegistry::get($modelName);
$schema = $table->schema();

という記述でスキーマ情報が連想配列で取得できるので、それを繰り返してaddColumnを呼び出しています。

スキーマ定義を作成したら、次にモデルの関連を定義します。 モデルの関連定義は、以下のようにassociationを使って関連構造を一度に作成する場合に必要となります。

<?php
Fabricate::create('Users', function($data, $world) {
    return [
        'username' => 'taro',
        'posts' => $world->association('Posts', 3),
    ];
});

この例のように関連を設定するには、以下のようにFabricateModel::hasMany()やbelongsTo()を使います。

<?php
$users->hasMany('posts', 'post_id', 'Posts');
$posts->belongsTo('users', 'post_id', 'Users');

パラメータの指定方法は、hasManyもhasOneもbelongsToも同じで、(別名、外部キーカラム名、モデル名)を指定します。

create

createメソッドは、Fabricateによって生成された連想配列構造のデータを、ORMを使ってDBに保存する機能を実装します。

<?php
public function create($modelName, $attributes, $recordCount)
{
    $table = TableRegistry::get($modelName);
    $entities = $table->newEntities($attributes, ['validate' => $this->options[self::OPTION_VALIDATE]]);
    $table->connection()->transactional(function () use ($table, $entities) {
        foreach ($entities as $entity) {
            $ret = $table->save($entity);
            if (!$ret) {
                return false;
            }
        }
        return true;
    });
    return $entities;
}

$modelNameにはFabricate::create('Users',と記述した場合の、Usersが渡ります。 $attributesには生成された連想配列が渡ります。例えば以下のとおりです。

<?php
array (
  0 => 
  array (
    'title' => 'Lorem ipsum dolor sit amet',
    'body' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.',
    'created' => '2013-10-09 12:40:28',
    'updated' => '2013-10-09 12:40:28',
  ),
  1 => 
  array (
  ....

$recordCountは生成する件数ですが、この値はcount($attributes)の値と一致します。 CakePHP3では以下の流れでDBへ保存しています。

  • TableRegistry::get()でテーブルインスタンスを取得
  • Table::newEntities()で連想配列からエンティティを生成
  • Table::save()でエンティティをDBに保存
build

buildメソッドは、Fabricateによって生成された連想配列構造のデータから生成したエンティティを返却する機能を実装します。

<?php
public function build($modelName, $data)
{
    $table = TableRegistry::get($modelName);
    $entity = $table->newEntity($data, ['validate' => $this->options[self::OPTION_VALIDATE]]);
    return $entity;
}

$modelNameにはFabricate::create('Users',と記述した場合の、Usersが渡ります。 $dataには生成された連想配列が1インスタンス分だけ渡ります。例えば以下のとおりです。

<?php
array (
  'title' => 'Lorem ipsum dolor sit amet',
  'body' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.',
  'created' => '2013-10-09 12:40:28',
  'updated' => '2013-10-09 12:40:28',
)

さいごに

現在CakePHP3用のアダプターしかないので「xxxx ORMについてアダプター実装したよ!」という連絡を待っています。 Fabricateのcomposer.jsonのsuggestに追加してPull Requestをもらえるととても助かります。

    "suggest": {
        "sizuhiko/cake_fabricate": "for integration with CakePHP3"
        // ここに追加したPRをお待ちしています
    }

皆様のPull Requestをお待ちしております!!