mruby-redis に送った HMSET, HMGET のパッチの話

#mruby #redis

mruby-redis で Redis の HMGETHMSET を使いたかったのでパッチを送った。

mruby も Hiredis もよくわからない状態からの出発だったので、小さいパッチの割に時間がかかってしまった。Hiredis は README.md に書かれている API の説明を読み、mruby はソースコードのコメントアウトに書かれた仕様を読み漁った。

内容の正しさは十分に保証出来ないが、調べたことを備忘録として書いておく。なお、mruby はバージョン 1.2.0 のコードを読んだ。

HMGET の実装

まず、HMGET のコードは以下の通りである。HMSET も似たような実装なので、そちらは割愛する。

static mrb_value mrb_redis_hmget(mrb_state *mrb, mrb_value self)
{
  mrb_value *mrb_argv;
  mrb_int argc = 0;

  mrb_get_args(mrb, "*", &mrb_argv, &argc);
  argc++;

  const char *argv[argc];
  size_t argvlen[argc];
  argv[0] = "HMGET";
  argvlen[0] = sizeof("HMGET") - 1;

  int ai = mrb_gc_arena_save(mrb);
  for (mrb_int argc_current = 1; argc_current < argc; argc_current++) {
    mrb_value curr = mrb_str_to_str(mrb, mrb_argv[argc_current - 1]);
    argv[argc_current] = RSTRING_PTR(curr);
    argvlen[argc_current] = RSTRING_LEN(curr);
    mrb_gc_arena_restore(mrb, ai);
  }

  mrb_value array = mrb_nil_value();
  redisContext *rc = DATA_PTR(self);
  redisReply *rr;
  rr = redisCommandArgv(rc, argc, argv, argvlen);
  if (rr->type == REDIS_REPLY_ARRAY) {
    if (rr->elements > 0) {
      array = mrb_ary_new(mrb);
      for (int i = 0; i < rr->elements; i++) {
        if (rr->element[i]->len > 0) {
          mrb_ary_push(mrb, array, mrb_str_new(mrb, rr->element[i]->str, rr->element[i]->len));
        } else {
          mrb_ary_push(mrb, array, mrb_nil_value());
        }
      }
    }
  } else {
    mrb_raise(mrb, E_ARGUMENT_ERROR, rr->str);
  }

  freeReplyObject(rr);
  return array;
}

...

mrb_define_method(mrb, redis, "hmget", mrb_redis_hmset, (MRB_ARGS_REQ(2) | MRB_ARGS_REST()));

最終行の mrb_define_method() で Redis#redis メソッドを定義しており、ここが今回の起点となる。

mrb_define_method()

mrb_define_method() はメソッドを定義する mruby API である。第2引数がクラス名、第3引数がメソッド名、第4引数がメソッドの呼び出す関数、第5引数がメソッドの引数情報である。引数情報には、引数の数やキーワード、ブロックなどを指定する。

// https://github.com/mruby/mruby/blob/1.2.0/include/mruby.h#L260
MRB_API void mrb_define_method(mrb_state *mrb, struct RClass *cla, const char *name, mrb_func_t func, mrb_aspec aspec);

ソースコードのコメントアウトに “Defines a global function in ruby.” とある通り、第2引数に mrb->kernel_module を指定し、Kernelモジュールにグローバル関数を定義することもできる。

今回の実装では、redis クラスの "hmget" メソッドが mrb_redis_hmget 関数を呼び出している。また、HMGET key field [field …] というコマンドなので、引数情報は (MRB_ARGS_REQ(2) | MRB_ARGS_REST()) で「2つまたはそれ以上」としたつもり。

引数に関するマクロは include/mruby.h#L682-L730 に定義がある。今回使った MRB_ARGS_REQ(n) と MRB_ARGS_REST() は以下のように定義されている。

// https://github.com/mruby/mruby/blob/1.2.0/include/mruby.h#L688
#define MRB_ARGS_REQ(n)     ((mrb_aspec)((n)&0x1f) << 18)

// https://github.com/mruby/mruby/blob/1.2.0/include/mruby.h#L709
#define MRB_ARGS_REST()     ((mrb_aspec)(1 << 12))

これで Redis#hmget というメソッドが定義された。次はメソッドの内部実装である mrb_redis_hmget() を見ていく。

mrb_redis_hmget()

mrb_redis_hmget() は Redis に HMGET key field [field …] というコマンドを送信し、リプライを Ruby の Array として返す処理を行っている。

はじめに、引数とその数を取得するところから始まる。

引数の取得

mrb_get_args() は引数を取得する mruby API である。mrb_argv に引数が、argc には引数の数がそれぞれ格納される。HMGET は任意のフィールド数を指定できるため、mrb_args_format は引数を配列で受け取る * にした(が、今思えば a が良かったかも)。フォーマットの詳細は mruby/mruby.h#L732-L758 にある。

static mrb_value mrb_redis_hmget(mrb_state *mrb, mrb_value self)
{
  mrb_value *mrb_argv;
  mrb_int argc = 0;

  mrb_get_args(mrb, "*", &mrb_argv, &argc);

Redis へ送るコマンドの組立

続いて、引数を元に、Redis に送信するコマンドを組み立てていく。

以下のコードでは、配列 argv の各要素に引数のアドレスを、配列 argvlen の各要素に引数の長さをそれぞれ格納している。 argv の最初の要素は "HMGET" で、 argv[1] から mrb_argv の中身を格納していくため、 argc を1つインクリメントして数を合わせている。

  argc++;

  const char *argv[argc];
  size_t argvlen[argc];
  argv[0] = "HMGET";
  argvlen[0] = sizeof("HMGET") - 1;

  int ai = mrb_gc_arena_save(mrb);
  for (mrb_int argc_current = 1; argc_current < argc; argc_current++) {
    mrb_value curr = mrb_str_to_str(mrb, mrb_argv[argc_current - 1]);
    argv[argc_current] = RSTRING_PTR(curr);
    argvlen[argc_current] = RSTRING_LEN(curr);
    mrb_gc_arena_restore(mrb, ai);
  }

例えば、 client.hmget "myhash", "field1", "field2" のようにコマンドを組み立てた場合、argv は以下のようなイメージになるはず。

また、ループ内の mrb_value curr は一時的なオブジェクトなので、mrb_gc_arena_save() と mrb_gc_arena_restore() で GC arena 領域の消費を抑えている。正しく理解できているか分からないので、詳しくは Matz の日記を読んでもらいたい。

Hiredis を使った Redis との通信

コマンド argv, argc の準備ができたら、次は Hiredis を用いて Redis に送信し、リプライを得る。

以下のコードでは、redisCommandArgv() を用いてコマンドを Redis に送信し、得られたリプライを mrb_value array に格納している。ただし、引数に問題がある場合は ArgumentError 例外が発生する(この例外が荒っぽいので直したい)。

  mrb_value array = mrb_nil_value();
  redisContext *rc = DATA_PTR(self);
  redisReply *rr;
  rr = redisCommandArgv(rc, argc, argv, argvlen);
  if (rr->type == REDIS_REPLY_ARRAY) {
    if (rr->elements > 0) {
      array = mrb_ary_new(mrb);
      for (int i = 0; i < rr->elements; i++) {
        if (rr->element[i]->len > 0) {
          mrb_ary_push(mrb, array, mrb_str_new(mrb, rr->element[i]->str, rr->element[i]->len));
        } else {
          mrb_ary_push(mrb, array, mrb_nil_value());
        }
      }
    }
  } else {
    mrb_raise(mrb, E_ARGUMENT_ERROR, rr->str);
  }

redisContext は Redis との接続状態を保持する構造体である。DATA_PTR は include/mruby/data.h で定義されるマクロである(詳細な解説は「mruby で C 言語の構造体をラップしたオブジェクトを作る正しい方法」にあるので、詳しくは記事を読んでもらいたい。)mruby-redis の DATA_PTR(self) には、Redis#new (mrb_redis_connect()) で redisContext が格納されている。

redisReply はコマンドに対して返されたリプライを格納する構造体である。HMGET の場合はマルチバルクリプライ (REDIS_REPLY_ARRAY) が返るため、rr->elements にリプライの要素数が格納され、rr->element[..index..] から各要素の値にアクセスできる。

// https://github.com/redis/hiredis/blob/v0.13.3/hiredis.h#L112-L119
typedef struct redisReply {
    int type; /* REDIS_REPLY_* */
    long long integer; /* The integer when type is REDIS_REPLY_INTEGER */
    int len; /* Length of string */
    char *str; /* Used for both REDIS_REPLY_ERROR and REDIS_REPLY_STRING */
    size_t elements; /* number of elements, for REDIS_REPLY_ARRAY */
    struct redisReply **element; /* elements vector for REDIS_REPLY_ARRAY */
} redisReply;

さて、Hiredis のコマンド送信 API は数種あるが、今回は redisCommandArgv() を用いた。得られた結果は redisReply 型の rr に格納している。

// https://github.com/redis/hiredis/blob/v0.13.3/hiredis.h#L217
void *redisCommandArgv(redisContext *c, int argc, const char **argv, const size_t *argvlen);

リプライの種類 rr->type がマルチバルクリプライ (REDIS_REPLY_ARRAY) で、要素数が 0 より大きい場合、リプライの各要素を mrb_ary_push() で array にプッシュしていく。要素が nil の場合もあるので、要素の長さが 0 の場合は mrb_nil_value() をプッシュしている。

// https://github.com/mruby/mruby/blob/1.2.0/include/mruby/array.h#L77
MRB_API void mrb_ary_push(mrb_state *mrb, mrb_value array, mrb_value value);

Ruby の Array オブジェクトを返す

最後は、freeReplyObject() で不要になった redisReply オブジェクトを解放し、array を返す。

  freeReplyObject(rr);
  return array;

終わりに

まだまだ理解出来ていないところは多いが、mruby や Hiredis を駆使したパッチを書いてみることによって、理解が深まった気がする。とはいえ、C言語の知識不足は明らかなので、引き続き勉強していきたい。