無料のサービスを使ってHubotで動的に画像を生成して返す


Hubotのことを知って、個人的に衝撃を受けたのが、[「GitHub社内のDevOpsを支えるツール「Boxen」と「Hubot」（後編）～DevOps Day Tokyo 2013」](http://www.publickey1.jp/blog/13/githubboxenhubotdevops_day_tokyo_2013.html)という記事のこの画像でした。

![hubot graph me image](http://www.publickey1.jp/blog/13/devopsday222.jpg "hubotのgraph me")

botが画像を動的に作って返す…。これはぜひhubot触ってみないといけない、と思ったのでした。

## 目標

自宅の環境以外で、２４時間サーバが動いていて、いつでもhubotを呼び出せて、画像を生成する、ということが無料で出来る環境を目指します。

## 前提

いくつかキーワードが出てきますが、以下は知ってる前提で。

* [node](http://nodejs.jp/) 
* [hubot](http://hubot.github.com/)
* [heroku](https://www.heroku.com)

また、冒頭の画像は、campfireというチャットサービス上での表示ですが、それもお金かかるので、[hipchat](https://www.atlassian.com/ja/software/hipchat/overview)という５人まで無料のチャットサービスを利用します。

## 探索

hubotはnode上で動いているので、nodeが使えるherokuがまず候補にあがります。

冒頭で紹介したレポートでは、
> （graph meのグラフ化にはgraphiteが使われているとのこと。セッション後のQ&Aより）

と書いてあったので、[graphite](http://graphite.wikidot.com/)を調べてみました。
heroku add-onの[hosted-graphite](https://addons.heroku.com/hostedgraphite)があったのでこちら使えるか見てみましたが、無料だと条件が結構厳しめでした。もう少し自由に使いたいなと。

javascriptで動くグラフ作成ライブラリとしては、[d3.js](http://d3js.org/)というのがあります。d3.jsがnode上で動いて、画像ファイル化できるなら使えそうです。

## 課題

herokuで動かすためには、次の課題を解決する必要があります。

* node上でd3.jsが動くか
* d3.jsで生成した画像データをファイル化できるか
* ファイルをheroku上で保存できるか
* heroku上で保存したファイルを、URL指定で取り出せるか

## node上でd3.jsが動くか？

[ここ](https://npmjs.org/package/d3)に、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の項目](https://github.com/LearnBoost/node-canvas/wiki/Installation-on-Heroku)を参考にします。

```
heroku config:add BUILDPACK_URL=https://github.com/mojodna/heroku-buildpack-multi.git#build-env
```
multi-buildpackという方法で、直接heroku上でcairoライブラリをインストールします。

## ファイルをheroku上で保存できるか？
 
herokuに直接答えが載ってました。
[この記事](https://devcenter.heroku.com/articles/read-only-filesystem)によると、
> 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モジュール](https://github.com/sasaplus1/mktemp)がありましたのでこちらも使います。

```
$ npm install --save mktemp
```


## heroku上で保存したファイルを、URL指定で取り出せるか？

hubotは、チャットインターフェースからではなくて、[この記事](http://qiita.com/daxanya2/items/7c6e09071bed30a5e373)にあるように、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が画像ファイルを画像として返す方です。

``` svg.js
// 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);
```

```tempget.coffee
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座標 半径 色指定」　です。

スクリーンショット：
![screenshot.png](https://qiita-image-store.s3.amazonaws.com/0/32778/9d6c6f4e-9de0-abae-c9ce-75a79448a9bf.png "screenshot")

これはシンプルな円ですが、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に置き換えます。

``` fabric.js
(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度回転させてみました。

![test2.png](https://qiita-image-store.s3.amazonaws.com/0/32778/026aecf6-2445-6ecc-666b-310e0a138eb9.png)

fabricでオブジェクトの整列とかもできるようなので、D3.js以外とも組み合わせてパワフルな画像操作ができそうですね。