複数のデータベースをまとめてSELECTできるツール「mdq」を公開しました

こんにちは。
2016新卒でLobiのサーバーサイドエンジニアをやっているmorikuniです。

今回は、mdqというGo製のツールを作ったので、その紹介をします。 mdqは複数のデータベースに並列にクエリを投げて、結果をJSONで出力してくれるというものです。

github.com

mdqに近いコンセプトのツールとして、弊社の荒賀(@ken39arg)が作ったshard_promptがあります(#6「Shardingマジ怖い」tech.kayac.com Advent Calendar 2012)。 こちらはSQLを解析し、複数データベースにまたがる集約関数を一部サポートしていますが、MySQL専用かつ直列にクエリを投げるというものでした。 mdqでは複数データベースにまたがる集約関数はサポートしませんが、PostgreSQLなどMySQL以外のデータベースへのクエリや、並列処理による速度向上などを目指しました。

はじめに

LobiではMySQLへの書き込みをスケールさせるために、一部テーブルの水平分割を行っています。
水平分割によって書き込みには強くなりますが、「ターミナルからクエリを投げてデータを取得したい!」となったときには複数回コマンドを実行しなければいけなかったり、その度にホスト名などを変更しないといけなかったりと、オペレーションの手間が増えてしまうという側面もあります。

また、ログ解析にはRedshiftを使っており、ログのデータを見るときにはpsqlコマンド、サービスのデータを見るときにはmysqlコマンドと、データベースによってコマンドを使い分ける必要があります。

データベースにクエリを投げたいだけで、ホスト名とかコマンドとかをいちいち考えたくないよね、というモチベーションで作ったのがmdqです。

mdq

mdqを使えば、設定ファイルに接続先の情報とタグを書いておき、クエリを投げる際にはタグを指定するだけでよくなります。

説明するよりは例を出した方が分かりやすいと思うので、実際にMySQLとPostgreSQLのデータベースにクエリを投げてみます。(実際にMySQLとPostgreSQLに同じクエリを投げたいということはないと思いますが。)

まずはデータベースを用意します。 両方のデータベースにexampleというデータベースとhelloというテーブルを作成し、いくつかデータを挿入しています。

mysql.sql

CREATE DATABASE example;
USE example;
CREATE TABLE hello (
    id int,
    name text
);
INSERT INTO hello values (1, 'foo'), (2, 'bar');

postgres.sql

CREATE DATABASE example;
\c example;
CREATE TABLE hello (
    id int,
    name text
);
INSERT INTO hello values (3, 'hoge'), (4, 'fuga');
$ docker run --name mysql -e MYSQL_ROOT_PASSWORD=root --rm -d -p 33060:3306 mysql

$ mysql -u root -h 127.0.0.1 -P 33060 -p < mysql.sql

$ docker run --name postgres -e POSTGRES_PASSWORD=postgres --rm -d -p 54320:5432 postgres

$ psql -U postgres -h 127.0.0.1 -p 54320 < postgres.sql

次に接続先の情報を書いた設定ファイルを用意します。 PostgreSQLにはpostgres、MySQLにはmysqlというタグをつけておきました。

$ cat ~/.config/mdq/config.yaml
dbs:
  - name: "mysql_db"
    driver: "mysql"
    dsn: "root:root@tcp(127.0.0.1:33060)/example"
    tags: ["mysql"]
    
  - name: "postgres_db"
    driver: "postgres"
    dsn: "user=postgres password=postgres host=127.0.0.1 port=54320 sslmode=disable dbname=example"
    tags: ["postgres"]

ここまでで準備は出来たので、mdqでクエリを投げてみます。 mysqlpostgresの両方にクエリを投げた結果がJSONで出力されています。

$ mdq "select * from hello" | jq .
[
  {
    "Database": "postgres_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": 3,
        "name": "hoge"
      },
      {
        "id": 4,
        "name": "fuga"
      }
    ]
  },
  {
    "Database": "mysql_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": "1",
        "name": "foo"
      },
      {
        "id": "2",
        "name": "bar"
      }
    ]
  }
]

タグを指定すると片方のデータベースにのみクエリを投げることも出来ます。

$ mdq --tag mysql "select * from hello" | jq .
[
  {
    "Database": "mysql_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": "1",
        "name": "foo"
      },
      {
        "id": "2",
        "name": "bar"
      }
    ]
  }
]

$ mdq --tag postgres "select * from hello" | jq .
[
  {
    "Database": "postgres_db",
    "Columns": [
      "id",
      "name"
    ],
    "Rows": [
      {
        "id": 3,
        "name": "hoge"
      },
      {
        "id": 4,
        "name": "fuga"
      }
    ]
  }
]

このように設定ファイルを用意しておけば、

  • 複数のデータベースにクエリを投げる
  • MySQLとPostgreSQLを同じコマンドで扱う

ということができるようになります。 出力形式もJSONなのでjqを使うなどして、ターミナル上である程度データの加工などもできます。

Goでの実装について

mdqはGoで作られていますが、どんな値が入っているかわからないデータベース上のデータを静的型付け言語であるGoのデータ構造に変換するには少し工夫が必要でした。 なので実装についても少し紹介しようと思います。

mdqでは標準のdatabase/sqlパッケージによってデータベースを扱っています。 全体の処理の概要としては、各データベースにDB.Queryを使ってクエリを投げ、Rows.Scanで値を読み込み、結果を結合するというものです。

Rows.ScanではScannerインターフェースに対応している型を受け取ることができるため、なんでも読み込めるScanner型を実装することにしました。 Scannerの定義を見てみると、以下のように、対応すべき型が定義されています。

type Scanner interface {
        // Scan assigns a value from a database driver.
        //
        // The src value will be of one of the following types:
        //
        //    int64
        //    float64
        //    bool
        //    []byte
        //    string
        //    time.Time
        //    nil - for NULL values
        //
        // An error should be returned if the value cannot be stored
        // without loss of information.
        Scan(src interface{}) error
}

[]byteだけはJSONに変換したときに望ましくない結果になる場合が多いのでstringに変換しますが、その他の型はそのまま使えそうです。 なので、基本的にはsrcの値をそのまま使って、[]byteの場合はstringに変換するという実装にしました。 以下が定義したAnyValue型です。

type AnyValue struct {
    Value interface{}
}

func (v *AnyValue) Scan(src interface{}) error {
    v.Value = src
    if bs, ok := src.([]byte); ok {
        v.Value = string(bs)
    }
    return nil
}

AnyValue型を使うことによって、任意のデータ型をデータベースから読み込めるようになります。 エラー処理は省きますが、使用例も載せておきます。

rows, _ := db.Query("select id, ....")
columns, _ := rows.Columns()
columnSize := len(columns)

var result [][]interface{}
for rows.Next() {
    values := make([]AnyValue, columnSize)
    valuePtrs := make([]interface{}, columnSize)
    for i := range columns {
        valuePtrs[i] = &values[i]
    }
    rows.Scan(valuePtrs...)

    row := make([]interface{}, columnSize)
    for i, val := range values {
        row[i] = val.Value
    }
    result = append(result, row)
}

最終的なresultには、レコード数×カラム数のデータが読み込まれます。 ポイントとなるのはvaluePtrsを定義しているところです。 Rows.Scanの引数は...[]interfaceなので、[]*AnyValueをそのまま渡すことはできません。 カラム数が固定の場合にはrows.Scan(&a, &b, &c)のように書けますが、そうでない場合は一度[]interface{}に変換する必要があります。

おわりに

mdqで使ったdatabase/sqlもそうですが、Goでは標準ライブラリで多くのインターフェースが提供されています。 オープンソースのライブラリもそれに対応している場合が多いので、同じインターフェースで様々なプロダクトを扱うことができます。 クロスビルドやシングルバイナリ生成といった特徴からも、今CLIツールを作るのに最もアツイ言語の1つだと思います。
弊社にはみんなのGo言語の著者のひとりであるfujiwara (id:sfujiwara)もいるので、Go言語に興味がある方はカヤックで働いてみるのはいかがでしょうか?

みんなのGo言語【現場で使える実践テクニック】

みんなのGo言語【現場で使える実践テクニック】