Kibana 4のプラグインをつくってみよう

この投稿は、[AngularJS Advent Calendar 2014 (Adventarの方)](http://www.adventar.org/calendars/350)の7日目の記事です。

# はじめに

Kibanaって便利ですよね。
Kibana 4ではElasticsearchのAggregationsに対応したので、より柔軟な表現ができるようになりました。
とは言え、標準で用意されているグラフだけでは表現できないケースがあるのも事実です。

ところで、KibanaはAngularJSでつくられています。
幸いなことにぼくはAngularJSチョットデキルので、Kibanaを拡張することもできるんじゃないだろうか考えていたところ、Kibana 4のソースコードの中に`plugins`というディレクトリがあるのを発見しました。

喜び勇んでpluginsディレクトリを開いてみたわけですが、README.txtには`PLEASE DON'T WRITE CUSTOM PLUGINS`と書いてあります。どうやらまだプラグインの仕様が定まっていないため、意図的にドキュメントも用意していないようです。そして正式にサポートされる時期も未定なようです。

* https://github.com/elasticsearch/kibana/issues/1948

それでもプラグインつくりたいですよねー。
というわけで、今回紹介する手法は仕様が変わった時に使えなくなる可能性が高いですが、Kibanaプラグインのつくり方を紹介してみたいと思います。

# プラグインをつくる

## Kibanaのビルド環境

まずはKibanaをビルドする環境を用意します。
Node.jsとRuby 1.9.3とJavaがインストールされていれば、以下の手順でKibanaをビルドして実行することができます。
`grunt dev`の実行時に`--with-es`を付与すれば、Elasticsearchも一緒に起動してくれます。

~~~
$ git clone https://github.com/elasticsearch/kibana.git
$ cd kibana
$ npm install
$ bower install
$ cd src/server
$ bundle
$ cd ../../
$ grunt dev --with-es
~~~

[CONTRIBUTING.md](https://github.com/elasticsearch/kibana/blob/master/CONTRIBUTING.md)参照

## どんなプラグインをつくろう？

今回は`circle_vis`というグラフのプラグインをつくることにします。

例えばApacheのaccess.logを解析するときに、リクエストの種類やホストなどでグルーピングした結果を円で囲み、円の大きさでリクエスト数の多さやレスポンスタイムを表現するような可視化をおこなうものを想定します。

描画部分については、D3.jsのサイトのExampleにある[Circle Packing](http://bl.ocks.org/mbostock/4063530)を使わせてもらうと思います。

## ファイルの構成

`src/kibana/plugins`ディレクトリの下に`circle_vis`というディレクトリを作成し、以下のようなファイルを追加していきます。

~~~
kibana
 └─ src
    └─ kibana
       └─ plugins
          └─ circle_vis
             ├─ circle_chart.js          : グラフの描画処理
             ├─ circle_vis.html          : グラフの描画領域のテンプレート
             ├─ circle_vis.js            : グラフの定義を記述したファイル
             ├─ circle_vis.less          : グラフのスタイルを記述
             ├─ circle_vis_controller.js : コントローラ
             ├─ circle_vis_params.html   : このグラフ特有のパラメータを入力する画面のテンプレート
             └─ index.js                 : 最初に読み込まれるファイル
~~~

各ファイルの詳細については、以降で説明します。

## グラフの定義

まずは`index.js`を作成します。
`plugins`ディレクトリの下に`index.js`を置いておくと、Kibanaが自動的にロードしてくれる仕組みとなっているようです。

~~~javascript:index.js
define(function (require) {
  // 新しいグラフの種類を登録
  require('registry/vis_types').register(function (Private) {
    return Private(require('plugins/circle_vis/circle_vis'));
  });
});
~~~

Kibanaはモジュール管理にRequireJSを利用しているので、`define`の中に処理を書くようにします。

まず、新しいグラフを追加する場合は、`require('registry/vis_types').register`を利用します。この関数に`circle_vis.js`を読み込んだ結果を返す関数を渡します。
なお`Private`は、Kibanaが独自で用意しているサービスで、`require`で読み込んだ関数の引数の名前から依存関係を解決して、サービスをインジェクションしてくれます(AngularJSのDIの仕組みを利用)。

`circle_vis.js`は次のようになります。

~~~javascript:circle_vis.js
define(function (require) {
  // 利用するCSSとControllerを読み込む
  require('css!plugins/circle_vis/circle_vis.css');
  require('plugins/circle_vis/circle_vis_controller');

  return function (Private) {
    var TemplateVisType = Private(require('plugins/vis_types/template/template_vis_type'));
    var Schemas = Private(require('plugins/vis_types/_schemas'));

    return new TemplateVisType({
      // グラフを一意に示す名前
      name: 'circle',
      // 画面に表示される名前
      title: 'Circle',
      // アイコン(Font Awesomeのアイコン名を指定)
      icon: 'fa-circle-o',
      // このグラフのテンプレートファイル
      template: require('text!plugins/circle_vis/circle_vis.html'),
      // このグラフ特有のパラメータ
      params: {
        // パラメータのデフォルト値
        defaults: {
          // 一番外側の円の直径
          diameter: 960
        },
        // パラメータ入力画面(画面左のサイドバーに表示される)
        editor: require('text!plugins/circle_vis/circle_vis_params.html')
      },
      // Elasticsearchに投げるクエリのAggregationsの定義
      schemas: new Schemas([
        {
          // 円のサイズを決めるための項目
          group: 'metrics',
          name: 'metric',
          title: 'Metric',
          min: 2,
          max: 2,
          defaults: [
            { schema: 'metric', type: 'count' }
          ]
        },
        {
          // グルーピングの項目。複数入力可能
          group: 'buckets',
          name: 'segment',
          title: 'Grouping',
          min: 1
        }
      ])
    });
  };
});
~~~

まず、利用するCSSとControllerを`require`で読み込みます。ここでCSSを読み込んでおけば、HTMLにCSSを読み込む処理を書く必要はありません。

グラフの定義は、TemplateVisTypeのインスタンスに各種プロパティを設定することでおこないます。
以下、各種プロパティの解説です。

### `name`, `title`, `icon`

`name`には内部的な名前を、`title`には画面に表示される名前を登録します。
`icon`はFont Awesomeのものが利用できるので http://fortawesome.github.io/Font-Awesome/icons/ から好きなものを選んで、名前を指定します。

### `template`

`template`には、このグラフのテンプレートファイルを指定します。
テンプレートファイルは以下のようなものを用意しておきます。
具体的な描画処理はD3.jsを使っておこなうので、ここではコントローラを指定するだけで十分です。

~~~html:circle_vis.html
<div ng-controller="circleVisController" class="circle-vis">
</div>
~~~

### `params`

`params`には、このグラフ特有のパラメータを指定することができます。
ここでは、最も外側の円の直径をピクセル単位で指定できるように`diameter`というパラメータを追加し、入力画面として`circle_vis_params.html`を指定します。

~~~html:circle_vis_params.html
<div class="form-group">
  <label>Diameter</label>
  <input type="range" ng-model="vis.params.diameter" class="form-control" min="320" max="1280" />
</div>
~~~

このテンプレートファイルは、画面左側のサイドバーのview optionsの中に表示されることになります。

今回はグラフの一番外側の円の大きさを指定するためのパラメータをスライダーで入力できるようにします。
パラメータは`$scope.vis.params.diameter`にバインドしておきましょう。


### `schemas`

次に`schemas`では、Elasticsearchに投げるAggregationsを入力するための定義を指定します。
Aggregationsとは、検索結果に対して集計処理をおこなうことができる仕組みです。以下のページが分かりやすくて参考になりました。

* [Aggregations - ファセットよりも柔軟な集計](http://blog.johtani.info/blog/2014/05/07/aggregation-example/)
* [Aggregationあれこれ](https://speakerdeck.com/johtani/aggregationarekore)

今回のグラフでは、どの項目でグループ化するのかを`buckets`で指定し、円の大きさを`metrics`で指定することとします。
グルーピングは入れ子にすることもできるように、複数個の入力が可能なようにします。

## コントローラ

つぎにコントローラを定義します。

~~~javascript:circle_vis_controller.js
define(function (require) {
  var _ = require('lodash');

  // moduleサービスを取得
  var module = require('modules').get('kibana/circle_vis', ['kibana']);

  // moduleにコントローラを登録
  module.controller('circleVisController', function ($scope, Private) {

    var CircleChart = Private(require('plugins/circle_vis/circle_chart'));
    var chart = new CircleChart();

    // ユーザーが入力したAggregationsの情報を取得
    var metricId = $scope.vis.aggs.bySchemaGroup.metrics[1].id;
    var bucketIds = $scope.vis.aggs.bySchemaGroup.buckets;

    // Elasticsearchのレスポンスの変化を監視
    $scope.$watch('esResponse', function (resp) {
      if (resp) {
        // D3.jsで描画するときに扱いやすいように、Elasticsearchのレスポンスを加工する
        var createChildren = function (data, index) {
          return _.map(data[bucketIds[index].id].buckets, function (v) {
            var child = {'name': v.key};
            if (v[metricId]) child['size'] = v[metricId].value;
            if (bucketIds[index + 1] && v[bucketIds[index + 1].id]) child['children'] = createChildren(v, index + 1);
            return child;
          });
        };
        var res = {
          'name': 'root',
          'children': createChildren(resp.aggregations, 0)
        };

        // グラフの描画をおこなう
        chart.draw(res, $scope.vis.params.diameter);
      }
    });
  });
});
~~~

`require('modules').get`は、`angular.module`とほぼ同等の機能ですが、指定した名前のモジュールが作成済みであれば既存のものを返し、未作成であれば新しく作成したものを返してくれます。便利ですね。

ユーザーが入力したAggregationsの情報は、`$scope.vis.aggs.bySchemaGroup.metrics`と`$scope.vis.aggs.bySchemaGroup.buckets`から取得することができます。
Elasticsearchのレスポンスを解析するときに必要なIDをここから取得します。

Elasticsearchのレスポンスは`$scope.esResponse`に入るので、`$scope.$watch`を使ってレスポンスを受け取ったときに描画処理が実行されるようにします。
`$scope.$watch`で登録した関数では、D3.jsで描画するときに扱いやすいようにElasticsearchのレスポンスを加工して、描画処理を呼び出します。パラメータ設定画面で指定した`$scope.vis.params.diameter`も一緒に渡すようにします。

## 描画処理

最後はコントローラから呼び出される描画処理です。
D3.jsのサイトのExampleにある[Circle Packing](http://bl.ocks.org/mbostock/4063530)のソースコードとほぼ同じ内容なので、解説は省略します。

~~~javascript:circle_chart.js
define(function (require) {
  return function CircleChartFactory(d3, Private) {

    function CircleChart() {
    }

    CircleChart.prototype.draw = function (data, diameter) {
      var format = d3.format(',d');

      var pack = d3.layout.pack()
        .size([diameter - 4, diameter - 4])
        .value(function (d) {
          return d.size;
        });

      d3.select('.circle-vis').selectAll('svg').remove();

      var svg = d3.select('.circle-vis')
        .append('svg')
        .attr('width', diameter)
        .attr('height', diameter)
        .append('g')
        .attr('transform', 'translate(2,2)');

      d3.select(this.frameElement).style('height', diameter + 'px');

      var node = svg.datum(data).selectAll('.node')
        .data(pack.nodes)
        .enter().append('g')
        .attr('class', function (d) {
          return d.children ? 'node' : 'leaf node';
        })
        .attr('transform', function (d) {
          return 'translate(' + d.x + ',' + d.y + ')';
        });

      node.append('title')
        .text(function (d) {
          return d.name + (d.children ? '' : ': ' + format(d.size));
        });

      node.append('circle')
        .attr('class', 'circle')
        .attr('r', function (d) {
          return d.r;
        });

      node.filter(function (d) {
        return !d.children;
      }).append('text')
        .attr('dy', '.3em')
        .style('text-anchor', 'middle')
        .text(function (d) {
          return d.name.substring(0, d.r / 3);
        });
    };

    return CircleChart;
  };
});
~~~

スタイルはcssかlessで記述します。

~~~scss:circle_vis.less
.circle-vis {
  .circle {
    opacity: 1;
    fill: rgb(31, 119, 180);
    fill-opacity: .25;
    stroke: rgb(31, 119, 180);
    stroke-width: 1px;
  }

  .leaf .circle {
    fill: #ff7f0e;
    fill-opacity: 1;
  }

  text {
    font: 10px sans-serif;
  }
}
~~~

lessで記述した場合は、`tasks/config/less.js`のsrcに`'<%= plugins %>/circle_vis/circle_vis.less'`を追加する必要があります。
gruntを実行すると`circle_vis.less`と同じディレクトリに`circle_vis.css`が生成されます。

# 動かしてみよう

さっそく実行してみましょう。
Visualizeタブを開き、`New Visualization` → `From a new search`を選択すると、`Circle`というグラフが選択できるようになっています。

![select_chart.png](https://qiita-image-store.s3.amazonaws.com/0/3489/0267a22d-6f83-9a51-d4f5-9d9be1a1aed2.png)

そして、画面左のサイドバーからAggregationsを設定し、Applyボタンを押します。
するとこんな画面が表示されました！成功です！

![circle_vis.png](https://qiita-image-store.s3.amazonaws.com/0/3489/9f189654-0716-5480-501c-466b57530b13.png)

Aggregationsの入力に応じてグルーピングの方法が変化したり、円のサイズが変わったり、Diameterのバーを操作して外側の円のサイズを変化させたりできます。

# デバッグ

プラグインをつくろうと思っても、最初はなかなか思い通りに動いてくれないものです。

そんなときは、Visualize画面の下の方にある`∧`ボタンを押してみましょう。
すると、グラフの下に以下のような画面が現れます。

![vis_debug_spy.png](https://qiita-image-store.s3.amazonaws.com/0/3489/f3093c0a-968c-b1eb-3d83-2fa3a8d66743.png)

この画面では、Elasticsearchとやりとりしているリクエストやレスポンスなどの情報をみることができます。


# まとめ

プラグインをつくるためには、AngularJSとD3.jsとElasticsearchの知識が必要にはなりますが、比較的プラグインがつくりやすい仕組みが提供されていると感じられました。
はやく正式にサポートされてほしいものですね。

今回作ったプラグインのソースはこちら。

* https://github.com/zoetrope/kibana/commit/8c181dede4ce252ed7c9ee1063ea95744aaa109f

エラーケースをあまり考えていないので、実用しようと思うとまだまだ手を加える必要はありますが、プラグインをつくる際の参考にしてもらえればと思います。