mixi engineer blog

*** 引っ越しました。最新の情報はこちら → https://medium.com/mixi-developers *** ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

DBMによるテーブルデータベース その参

最近、忙しさを理由に英会話レッスンをサボりがちになってよろしくないなと猛省するmikioです。今回は、Tokyo CabinetのテーブルデータベースをTokyo Tyrantを使ってデータベースサーバとして利用する方法について述べます。

とりあえず使ってみる

Tokyo CabinetとTokyo Tyrantの最新版(1.4.41.1.12)がリリースされていますので、インストールしておいてください。またも社員名簿を作ってみましょう。まずは、TTのサーバを実行します。データベースファイルの接尾辞には「.tct」を指定して、テーブルデータベースファイルと接続します。

ttserver casket.tct

別の端末でクライアントを操作して、"put" コマンドで社員を登録しましょう。「-sep」は、コラムのキーと値を区切る文字を指定するオプションです。"|" 以外でも任意の区切り文字を指定できます。

tcrmgr put -sep "|" localhost 1 \\
  "name|空条承太郎|sex|male|hdate|20050321|div|brd,dev"
tcrmgr put -sep "|" localhost 1 \\
  "name|空条承太郎|sex|male|hdate|20050321|div|brd,dev"
tcrmgr put -sep "|" localhost 81 \\
  "name|東方仗助|sex|male|hdate|20060601|div|dev"
tcrmgr put -sep "|" localhost 92 \\
  "name|汐華初流乃|sex|male|hdate|20070311|div|hr"
tcrmgr put -sep "|" localhost 127 \\
  "name|空条徐倫|sex|female|hdate|20070523|div|brd,hr"

ちゃんと登録されているか確認してみましょう。"list" で一覧したり、"get" で特定のレコードを取り出したりできます。

tcrmgr list -pv -sep '|' localhost
----
1    name|空条承太郎|sex|male|hdate|20050321|div|brd,dev
81   name|東方仗助|sex|male|hdate|20060601|div|dev
92   name|汐華初流乃|sex|male|hdate|20070311|div|hr
127  name|空条徐倫|sex|female|hdate|20070523|div|brd,hr
----
tcrmgr get -sep '|' localhost 1
----
name|空条承太郎|sex|male|hdate|20050321|div|brd,dev

クエリを使った検索は、"misc" コマンドで行います。パラメータは区切り文字によって構造化します。"addcond" は絞り込み条件式の指定、"setorder" は順序の指定、"setmax" は最大数の指定、"columns" は結果に主キー以外のコラムも出力する指定です。

tcrmgr misc -sep '|' localhost search \\
  "addcond|name|STRBW|空条" "columns"

tcrmgr misc -sep '|' localhost search \\
  "addcond|hdate|NUMGE|20060101" \\
  "addcond|sex|STREQ|male" "columns"

tcrmgr misc -sep '|' localhost search \\
  "addcond|div|STROR|brd,hr" \\
  "setorder|hdate|NUMASC" "setmax|1" "columns"

ちゃんと動きましたよね? TCを直接呼び出すのとはちょっと異なるインターフェイスになっていますが、だいたいの機能はTTを介しても使えるようになっています。

テーブルデータベースAPIの抽象データベース対応

ここからは、上記のインターフェイスに落ち着いた背景を説明していきます。おさらいになりますが、Tokyo Cabinet(TC)は以下の7種類のデータベースをサポートしています。

  • TCMDB: メモリ上のハッシュ表
  • TCNDB: メモリ上のスプレー木
  • TCHDB: ファイル上のハッシュ表
  • TCBDB: ファイル上のB+木
  • TCFDB: ファイル上の固定長配列
  • TCTDB: ファイル上のテーブル
  • TCADB: 上記6種類の抽象インターフェイス

ここで、TCADB(抽象データベースAPI)に注目してください。DBM(key/valueデータベース)なのでキーに関連づけて値を管理するというところは共通ですから、その共通部分を抽出して、同一のAPIセットで各種のデータベースを操作できるようにしたものです。個々のデータベースに特有の操作(B+木のカーソルなど)を利用する場合や抽象化によるオーバーヘッドを極力省きたい場合は別ですが、一般的なDBMとしての機能が欲しいだけならばTCADBを利用するのがオススメです。そして、TCをネットワークサーバとして運用するためのTokyo Tyrant(TT)もTCADBを利用しており、ネットワーク経由で6種類のデータベース実装が操作できるようになっています。

とはいえ、元々は別個の実装であり独自の機能を持つものを、各々の機能性を保ちながら全く同一のインターフェイスに抽象化するのには結構頭を使います。特にTCTDBとその他のデータベースとの差は大きいので苦労しました。以下の比較を見ると違いは歴然です。

[レコードを追加する]
TCADB: bool tcadbput(TCADB *adb, const void *kbuf, int ksiz, const void *vbuf, int vsiz);
TCTDB: bool tctdbput(TCTDB *tdb, const void *pkbuf, int pksiz, TCMAP *cols);

[レコードを取得する]
TCADB: void *tcadbget(TCADB *adb, const void *kbuf, int ksiz, int *sp);
TCTDB: TCMAP *tctdbget(TCTDB *tdb, const void *pkbuf, int pksiz);

つまり、TCADBは単なるバイト配列をレコードの値として扱い、TCTDBはマップオブジェクト(連想配列)を値として扱うわけです。この時点で両者があまりに違いすぎるので断念しようと思いましたが、抽象化しないでネットワークインターフェイスを作るとなるとTTに相当するサーバを別途に実装しなくちゃいけないのでだるすぎます。ちょっと粘って何とか対応を考えた方が楽そうですし、かのSQLも本来別々の機能群を無理やりまとめて出来上がっているわけで、なんとかそれっぽく仕上げることも可能なんじゃないかと思い直しました。

まず、バイト列(文字列)とマップを同一視しなければなりません。マップはキーと値のペアの集合なわけですが、それを直列化してバイト列として扱う表現方法は実は結構あります。CSV、TSV、XML、JSON、YAMLあたりがまず思い浮かびます。XMLやJSONやYAMLは柔軟だし人が読みやすいのですが、空間効率が悪すぎるしパーザを書くのが面倒そうなので却下。CSVはエスケープ方法で亜種が多すぎるので却下。ということで、キーと値をタブや改行で区切って並べた文字列を指定することにしようと思ったのですが、最終的にはそれも却下しました。ブログの記事をコラムに入れたいというのがそもそもの開発動機なので、改行をエスケープしなきゃならないのは嫌だったのです。

で、ゼロ文字('\0'。ヌル文字、ニル文字とも言う)区切りのバイト列を採用しました。"name\0mikio\0sex\0male" のように表現されるデータです。ゼロ文字は通常の文字列には現れないので(UTF-16やUTF-32は例外ですが)、エスケープ表現を気にする必要はありません。端末のコマンドラインから入力するのがちょっと大変という欠点はありますが、先ほど出てきた「-sep」オプションなどで代替の区切り文字を指定できるようにして後で '\0' に置換すれば実用上は問題ないでしょう。あとは、マップ(TCMAP)とゼロ区切り文字列の相互変換を行うユーティリティを用意すれば、API上もそれほど手間がなく使えるでしょう。値そのものにゼロ文字を含むバイナリ表現などは今のところ扱えませんが、それは別の手段(後述)で救済します。

リモートデータベースAPIでテーブルをしばく

リモートデータベースAPI(TCRDB)はTTが提供するAPIであり、サーバ側で実行されるTCADBをリモートから呼び出すためのRPCっぽい機構です。TCRDBとTCADBのインターフェイスは、コンストラクタ以外はほぼ同じになっています。ピュアPerlインターフェイスピュアRubyインターフェイスピュアJavaインターフェイスピュアErlangインターフェイスなどもあるので、スクリプト言語からも簡単に使え、テーブルデータベース独自の機能も利用できます。

putやgetなどのDBM的な操作(つまり主キーで対象を指定する操作)は、TCMAPとバイト列の相互変換にのみ注意すれば比較的簡単です。マップからバイト列を作る tcstrjoin4 と、バイト列からマップを作る tcstrsplit4 が便利です。

#include <tcrdb.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdint.h>

int main(int argc, char **argv){

  /* コネクションを開く */
  TCRDB *rdb = tcrdbnew();
  tcrdbopen(rdb, "localhost", 1978);

  /* レコードを追加する */
  char pkbuf[32];
  int pksiz = sprintf(pkbuf, "%d", 12345);
  TCMAP *cols = tcmapnew3("name", "mikio", "sex", "male", NULL);
  int csiz;
  void *cbuf = tcstrjoin4(cols, &csiz);  // マップをバイト列に
  tcrdbput(rdb, pkbuf, pksiz, cbuf, csiz);
  free(cbuf);

  /* レコードを取得する */
  cbuf = tcrdbget(rdb, pkbuf, pksiz, &csiz);
  if(cbuf){
    cols = tcstrsplit4(cbuf, csiz);      // バイト列をマップに
    printf("name: %s\\n", tcmapget2(cols, "name"));
    printf("sex: %s\\n", tcmapget2(cols, "sex"));
    tcmapdel(cols);
    free(cbuf);
  }

  /* コネクションを閉じる */
  tcrdbclose(rdb);
  tcrdbdel(rdb);

  return 0;
}

一方で、主キー以外を使ったテーブル操作はかなり複雑なものになります。検索クエリや結果集合をTCADBの既存の関数で表現することは難しいので、リスト(TCLIST)を使って構造化した任意のパラメータを渡して、同じくリストで構造化された任意の戻り値が返却される、汎用インターフェイスが用意してあります。テーブル独自の機能は全部ここに詰め込んでしまいます。

TCLIST *tcrdbmisc(TCRDB *rdb, const char *name, int opts, const TCLIST *args);

さて、検索は関数 tcrdbmisc のサブ関数 "search" で行います。引数に "addcond" か "setorder" か "setmax" か "columns" か "out" を接頭させてゼロ文字区切りで演算式を記述した文字列を与えることでクエリを組み立てます。例えば "addcond\0name\0STRBW\0john" と "setorder\0age\0NUMASC" を指定すると、「コラム "name" の値が "john" で始まるレコードをコラム "age" の数値の昇順で取り出す」というクエリになります。戻り値は該当するレコードの主キーのリストです。ただし、"columns" が指定されると、コラム名と値のマップをゼロ区切り文字列で返します。"columns\0name\0age" などとして特定のコラムに絞り込むこともできます。"out" が指定されると、該当のコラムを削除します。"columns" と "out" を組み合わせるとキューとして利用することができます。サブ関数 "genuid" は、ユニークなID番号を採番して返します。

...とかいう仕様では理解しにくいので、実際に検索を行うコード例を見てください。

/* パラメータ全体のリストを作る */
TCLIST *args = tclistnew();

/* 絞り込み条件を指定する */
TCLIST *expr = tclistnew3("addcond", "name", "STREQ", "mikio", NULL);
int esiz;
char *ebuf = tcstrjoin2(expr, &esiz);
tclistpush(args, ebuf, esiz);
free(ebuf);
tclistdel(expr);

/* 結果の順序を指定する */
expr = tclistnew3("setorder", "name", "STRASC", NULL);
ebuf = tcstrjoin2(expr, &esiz);
tclistpush(args, ebuf, esiz);
free(ebuf);
tclistdel(expr);

/* 結果の書式を指定する */
tclistpush2(args, "columns");

/* 実際に検索する */
TCLIST *rvals = tcrdbmisc(rdb, "search", RDBMONOULOG, args);

/* 結果を表示する */
if(rvals){
  for(int i = 0; i < tclistnum(rvals); i++){
    int rsiz;
    const char *rbuf = tclistval(rvals, i, &rsiz);
    TCMAP *cols = tcstrsplit4(rbuf, rsiz);
    printf("primary key: %s\\n", tcmapget2(cols, ""));
    printf("name: %s\\n", tcmapget2(cols, "name"));
    printf("sex: %s\\n", tcmapget2(cols, "sex"));
    tcmapdel(cols);
  }
  tclistdel(rvals);
}

/* 後始末 */
tclistdel(args);

我ながら、凶悪なインターフェイスに仕上がっていますね。リストを直列化したゼロ区切り文字列をさらにリストの中に入れるというところが厄介すぎます。論理的にはTCTDBを直接叩いているのと変わらないのですが、アプリケーションプログラマにマップやリストを直列化するフォーマットまで意識させるのは最悪です(その点ではSOAPやXML-RPCもひどいが、可読性が低い分だけこっちの方ががもっとひどい)。

TCTDB風のラッパーインターフェイス

さすがに上記のインターフェイスを提供して「これでTTでもテーブルデータベースを簡単に使えます」と言い張るのは気が引けるので、TCTDBと似たラッパーインターフェイスを「テーブル拡張」という隠しAPIとして提供することにしました。tcrdb.h に完全な仕様の記述がありますが、以下の関数を備えます。

bool tcrdbtblput(TCRDB *rdb, const void *pkbuf, int pksiz, TCMAP *cols);
レコードを格納する(上書きモード)
bool tcrdbtblputkeep(TCRDB *rdb, const void *pkbuf, int pksiz, TCMAP *cols);
レコードを格納する(既存値優先モード)
bool tcrdbtblputcat(TCRDB *rdb, const void *pkbuf, int pksiz, TCMAP *cols);
レコードを格納する(カラム追加モード)
bool tcrdbtblout(TCRDB *rdb, const void *pkbuf, int pksiz);
レコードを削除する
TCMAP *tcrdbtblget(TCRDB *rdb, const void *pkbuf, int pksiz);
レコードを取得する
bool tcrdbtblsetindex(TCRDB *rdb, const char *name, int type);
コラムインデックスを設定する
int64_t tcrdbtblgenuid(TCRDB *rdb);
ユニークなID番号を採番する
RDBQRY *tcrdbqrynew(TCRDB *rdb);
クエリオブジェクトを生成する
void tcrdbqrydel(RDBQRY *qry);
クエリオブジェクトを破棄する
void tcrdbqryaddcond(RDBQRY *qry, const char *name, int op, const char *expr);
クエリオブジェクトに絞り込み条件を追加する
void tcrdbqrysetorder(RDBQRY *qry, const char *name, int type);
クエリオブジェクトに結果の順序を設定する
void tcrdbqrysetmax(RDBQRY *qry, int max);
クエリオブジェクトに結果の最大取得件数を設定する
TCLIST *tcrdbqrysearch(RDBQRY *qry);
検索を行って主キーのリストを取得する
bool tcrdbqrysearchout(RDBQRY *qry);
検索を行って該当のレコードを削除する

接頭辞がtctdbでなくてtcrdb(もしくはtcrdbtbl)になっている以外はTCTDBのAPIと同じになっています。上記の全ての関数は、TCADBにおけるput/out/getなどの抽象化を無視して、全て汎用関数tcadbmiscに埋め込んであるテーブル専用機能を呼び出しています。したがって、区切り文字の制限を回避して、任意のバイナリ表現でコラムを扱うことができます。インターフェイスも綺麗だし、煩わしい制限もないし、こちらの方が圧倒的に使いやすいですね。だったらTCADBの解説なんてすっ飛ばしてテーブル拡張の説明だけすればいいだろうという声も聞こえてきそうですが、モデルの進化の過程を理解していただきたかったのと、既存のスクリプト言語インターフェイスとの相互運用性を維持するのに有用な情報であることから、敢えてとりあげました。

テーブル拡張を使ったサンプルコードは以下のようになります。当然ながらTCTDBのサンプルとほとんど同じ構成になっています。

#include <tcrdb.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdint.h>

int main(int argc, char **argv){

  TCRDB *rdb;
  int ecode, pksiz, i, rsiz;
  char pkbuf[256];
  const char *rbuf, *name;
  TCMAP *cols;
  RDBQRY *qry;
  TCLIST *res;

  /* コネクションを開く */
  rdb = tcrdbnew();
  if(!tcrdbopen(rdb, "localhost", 1978)){
    ecode = tcrdbecode(rdb);
    fprintf(stderr, "open error: %s\\n", tcrdberrmsg(ecode));
  }

  /* レコードを追加する */
  pksiz = sprintf(pkbuf, "%ld", (long)tcrdbtblgenuid(rdb));
  cols = tcmapnew3("name", "mikio", "age", "30", "lang", "ja,en,c", NULL);
  if(!tcrdbtblput(rdb, pkbuf, pksiz, cols)){
    ecode = tcrdbecode(rdb);
    fprintf(stderr, "put error: %s\\n", tcrdberrmsg(ecode));
  }
  tcmapdel(cols);

  /* レコードを素朴な方法で追加する */
  pksiz = sprintf(pkbuf, "12345");
  cols = tcmapnew();
  tcmapput2(cols, "name", "falcon");
  tcmapput2(cols, "age", "31");
  tcmapput2(cols, "lang", "ja");
  if(!tcrdbtblput(rdb, pkbuf, pksiz, cols)){
    ecode = tcrdbecode(rdb);
    fprintf(stderr, "put error: %s\\n", tcrdberrmsg(ecode));
  }
  tcmapdel(cols);

  /* レコードを検索する */
  qry = tcrdbqrynew(rdb);
  tcrdbqryaddcond(qry, "age", RDBQCNUMGE, "20");
  tcrdbqryaddcond(qry, "lang", RDBQCSTROR, "ja,en");
  tcrdbqrysetorder(qry, "name", RDBQOSTRASC);
  tcrdbqrysetmax(qry, 10);
  res = tcrdbqrysearch(qry);
  for(i = 0; i < tclistnum(res); i++){
    rbuf = tclistval(res, i, &rsiz);
    cols = tcrdbtblget(rdb, rbuf, rsiz);
    if(cols){
      printf("%s", rbuf);
      tcmapiterinit(cols);
      while((name = tcmapiternext2(cols)) != NULL){
        printf("\t%s\t%s", name, tcmapget2(cols, name));
      }
      printf("\\n");
      tcmapdel(cols);
    }
  }
  tclistdel(res);
  tcrdbqrydel(qry);

  /* コネクションを閉じる */
  if(!tcrdbclose(rdb)){
    ecode = tcrdbecode(rdb);
    fprintf(stderr, "close error: %s\\n", tcrdberrmsg(ecode));
  }
  tcrdbdel(rdb);

  return 0;
}

ここまでやれば、TTでもテーブルデータベースを簡単に使えると言ってよいでしょう。「アトミックな更新」だけはコールバック関数を直列化するのが難しいので実装できていませんが、同様の需要はLua拡張で充足できるようにする予定です。

まとめ

Tokyo Tyrantでテーブルデータベースを使う方法について説明しました。テーブルデータベースの機能を抽象データベースに落とし込み、それをTokyo Tyrantに載せて操作可能にするという構造になっています。インターフェイスはゴツゴツしすぎていますが、TCTDBのほぼ全機能をネットワーク経由で利用できるようになるとともに、それをTTのレプリケーション機能やLua拡張などと組み合わせて運用できるようになっています。

次回は、テーブルデータベースを作る途中で思いついた付加的な機能と性能検証について書きます。