Hubotのことを知って、個人的に衝撃を受けたのが、「GitHub社内のDevOpsを支えるツール「Boxen」と「Hubot」(後編)~DevOps Day Tokyo 2013」という記事のこの画像でした。
botが画像を動的に作って返す…。これはぜひhubot触ってみないといけない、と思ったのでした。
目標
自宅の環境以外で、24時間サーバが動いていて、いつでもhubotを呼び出せて、画像を生成する、ということが無料で出来る環境を目指します。
前提
いくつかキーワードが出てきますが、以下は知ってる前提で。
また、冒頭の画像は、campfireというチャットサービス上での表示ですが、それもお金かかるので、hipchatという5人まで無料のチャットサービスを利用します。
探索
hubotはnode上で動いているので、nodeが使えるherokuがまず候補にあがります。
冒頭で紹介したレポートでは、
(graph meのグラフ化にはgraphiteが使われているとのこと。セッション後のQ&Aより)
と書いてあったので、graphiteを調べてみました。
heroku add-onのhosted-graphiteがあったのでこちら使えるか見てみましたが、無料だと条件が結構厳しめでした。もう少し自由に使いたいなと。
javascriptで動くグラフ作成ライブラリとしては、d3.jsというのがあります。d3.jsがnode上で動いて、画像ファイル化できるなら使えそうです。
課題
herokuで動かすためには、次の課題を解決する必要があります。
- node上でd3.jsが動くか
- d3.jsで生成した画像データをファイル化できるか
- ファイルをheroku上で保存できるか
- heroku上で保存したファイルを、URL指定で取り出せるか
node上でd3.jsが動くか?
ここに、npmでd3がインストールできるパッケージが置いてあるので、この課題はクリアです。
$ npm install --save d3
でインストールできます。--saveでpackage.jsonのdependenciesに追記されます。
d3.jsで生成した画像データをファイル化できるか?
d3.jsで生成されるのは、svg形式の画像ファイルフォーマットです。このまま保存しても画像ファイルとして表示してくれないので、png形式に変換する方法を調べます。
canvasとcanvgというモジュールを使うと、node上でsvgをpngに変換かけられるとのこと。こちらを試してみます。
参考URL : https://github.com/yetzt/node-canvg/issues/1
$ npm install --save canvas
$ npm install --save canvg
一点、問題があって、cairoライブラリがインストールされていないと動きません。
heroku上でcairoライブラリを入れるには、この記事のUsing a custom build packの項目を参考にします。
heroku config:add BUILDPACK_URL=https://github.com/mojodna/heroku-buildpack-multi.git#build-env
multi-buildpackという方法で、直接heroku上でcairoライブラリをインストールします。
ファイルをheroku上で保存できるか?
herokuに直接答えが載ってました。
この記事によると、
There are two directories that are writeable: ./tmp and ./log (under your application root).
アプリケーションの直下の、./tmpと./logディレクトリだけは書き込めるよーということなので、こちらに書けばよさそうです。
※heroku run bash等でherokuサーバに入って./tmpファイル内に画像が入っているか確認しようとしても、起動中のインスタンスとbashのインスタンスが違うからか、./tmpディレクトリは見えない作りのようです。
./tmpディレクトリに作るファイルということで、mktemp的なものがないかなと思ったら、まんまのmktempモジュールがありましたのでこちらも使います。
$ npm install --save mktemp
heroku上で保存したファイルを、URL指定で取り出せるか?
hubotは、チャットインターフェースからではなくて、この記事にあるように、urlを直接叩いて呼び出すことができます。
hipchatのチャット画面でURLを貼付けた場合、そのURLが画像っぽい(拡張子で判断してる?)と、チャット画面上に画像を直接表示してくれます。
./tmpに格納されている画像ファイルをレスポンスとして返すhubot-scriptを作ればよさそうです。
作るよー
- チャットでhubotに円を書いてねという指示を出す
- hubotが内部のd3を使って円を作る(パラメータはチャット上で入力したものを使う)
- canvasに書いて、canvsのイベントでフックする
- canvasのstreamをテンポラリファイルに書き込む
- 書き込んだらURLをhubotが返す
- URLはhubotの画像取得リクエストになっていて、hipchatから自動で呼び出される
- 画像取得リクエスト経由で、画像を返す
スクリプトは、hubot/scriptsディレクトリに格納します。
画像作る側はどうしてもcoffeescriptで作れなかった(インデントエラーが出る)ので、途中までcoffeescriptで作って、最後jsで調整しました。
./tmpディレクトリですが、hubot-scriptは一つ下のscriptsディレクトリがカレントディレクトリになるので、一段ずらしています。
svg.jsが画像を作る方で、tempget.coffeeが画像ファイルを画像として返す方です。
// Generated by CoffeeScript 1.6.3
(function() {
var Canvas, canvg, fs, mktemp, d3;
d3 = require('d3');
Canvas = require('canvas');
mktemp = require('mktemp');
canvg = require('canvg');
fs = require('fs');
path = require('path');
module.exports = function(robot) {
return robot.hear(/svg ([0-9]+) ([0-9]+) ([0-9]+) (\w+)/, function(bot) {
var canvas, svg, temppath;
var width = 750;
var height = 750;
d3.select("body").node().innerHTML = '';
var svgimage = d3.select("body").append("svg").attr({"width": width, "height": height });
svgimage.append('circle')
.attr({
cx:bot.match[1],
cy:bot.match[2],
r:bot.match[3],
fill:bot.match[4]
});
robot.send(bot.envelope, "writing..");
canvas = new Canvas(width, height);
temppath = path.join(__dirname, '..', 'tmp');
url = process.env.HEROKU_URL
if (url === undefined) {
url = 'http://localhost:8080'
}
var svg = d3.select('body').node().innerHTML;
canvg(canvas, svg, {
renderCallback: function() {
try {
fs.statSync(temppath);
} catch (e) {
console.log(e);
fs.mkdirSync(temppath, 0700);
}
mktemp.createFile(path.join(temppath,'XXXXXXXX.png'), function(err, filename) {
var outStream, stream;
stream = canvas.createPNGStream();
outStream = fs.createWriteStream(filename);
outStream.on('close', function() {
robot.send(bot.envelope, url + "/hubot/tempget.png?id=" + path.basename(filename));
});
stream.on('data', function(chunk) {
outStream.write(chunk);
});
stream.on('end', function(chunk) {
outStream.end();
});
});
}
});
});
};
}).call(this);
querystring = require('querystring')
fs = require('fs')
path = require('path')
module.exports = (robot) ->
robot.router.get "/hubot/tempget.png", (req, res) ->
query = querystring.parse(req._parsedUrl.query)
tmp = path.join(__dirname, '..', 'tmp',query.id)
path.exists(tmp, (exists) ->
if(exists)
console.log "exist! #{tmp}"
fs.readFile tmp,(err,data) ->
res.writeHead(200, {'Content-Type': 'image/png'})
res.end(data);
else
console.log "not! #{tmp}"
res.status(404).send('Not found')
)
こう使います
チャット画面で、「svg 100 100 100 yellow」のように入れます。
表記は、「svg x座標 y座標 半径 色指定」 です。
これはシンプルな円ですが、d3で表現できることならなんでもできるはずなので、綺麗で複雑な画像をぜひhubotに作らせてみてくださいね。
追記
D3関係の記事見てたら、canvg.jsの代わりに、fabric.jsを使った方がよさそうということがわかりました。
fabric.jsの利点
- canvasに追加した要素をオブジェクトとして管理出来る
- オブジェクトに対して操作ができる
- 拡張しやすそう
cairoライブラリを使うのは変わらないようなので、canvg.jsと比べて同等以上と言えそうです。
svg.jsを置き換えてみた
canvgモジュールを使わない代わりに、fabricモジュールを追加します
$ npm uninstall --save canvg
$ npm install --save fabric
canvgで処理していたところを、fabricに置き換えます。
(function() {
var fabric, fs, mktemp, d3;
d3 = require('d3');
mktemp = require('mktemp');
fabric = require('fabric').fabric;
fs = require('fs');
path = require('path');
module.exports = function(robot) {
return robot.hear(/rect ([0-9]+) ([0-9]+) ([0-9]+) ([0-9]+) (\w+)/, function(bot) {
var svg, temppath;
var width = 750;
var height = 750;
d3.select("body").node().innerHTML = '';
var svgimage = d3.select("body").append("svg").attr({"width": width, "height": height });
svgimage.append('rect')
.attr({
x:bot.match[1],
y:bot.match[2],
width:bot.match[3],
height:bot.match[4],
fill:bot.match[5]
});
robot.send(bot.envelope, "writing..");
var canvas = fabric.createCanvasForNode(width, height);
temppath = path.join(__dirname, '..', 'tmp');
url = process.env.HEROKU_URL
if (url === undefined) {
url = 'http://localhost:8080'
}
var svg = d3.select('body').node().innerHTML;
console.log(svg);
fabric.loadSVGFromString(svg , function(objects, options) {
options.top = 0;
options.left = 0;
for (var i=0; i<objects.length; i++) {
objects[i].set ({ angle: 10 });
}
var svgGroups = fabric.util.groupSVGElements(objects, options);
canvas.add(svgGroups).renderAll();
try {
fs.statSync(temppath);
} catch (e) {
console.log(e);
fs.mkdirSync(temppath, 0700);
}
mktemp.createFile(path.join(temppath,'XXXXXXXX.png'), function(err, filename) {
var outStream, stream;
stream = canvas.createPNGStream();
outStream = fs.createWriteStream(filename);
outStream.on('close', function() {
robot.send(bot.envelope, url + "/hubot/tempget.png?id=" + path.basename(filename));
});
stream.on('data', function(chunk) {
outStream.write(chunk);
});
stream.on('end', function(chunk) {
outStream.end();
});
});
});
});
};
}).call(this);
動かしてみる
チャット画面で、「rect 100 100 200 200 yellow」のように入れます。
表記は、「rect x座標 y座標 幅 高さ 色指定」 です。
今回は、fabric側の操作を入れてみたかったので、fabricにsvgを渡した後で、10度回転させてみました。
fabricでオブジェクトの整列とかもできるようなので、D3.js以外とも組み合わせてパワフルな画像操作ができそうですね。