豪鬼メモ

一瞬千撃

DBMの設計と実装 その2 APIの草案

DBMのAPIをほぼ決めて文書にまとめた。その解説をしよう。文書はここに置いてある。


製品の名前は、「Tkrzw」である。綴りに特に意味はなく、既存の単語と被らないようにしようというSEO上の配慮でこうなっているだけだ。「本来の名前は人間には発音不能とされ、綴りはその呼称を便宜的に表記したものに過ぎない」とか言うと中二病っぽくて良いかもしれない。

概要としては、DBMなので、基本的には連想配列に毛が生えたようなAPIになっている。このインターフェイスDBMというクラスにまとめられていて、それを継承したHashDBM、TreeDBM、SkipDBMなどなどの具象クラスが各メソッドを実装するパターンだ。

std::mapやstd::unordered_mapとできることはだいたい同じだ。Setメソッドでレコードを記憶させ、Getメソッドで値を取り出し、Removeメソッドでレコードを消すのだ。そして利用する前にはOpenメソッドでファイルを開き、利用し終わった後にはCloseメソッドでファイルを閉じる。レコードの一覧が取得したい場合、MakeIteratorメソッドでイテレータを作る。イテレータのFirstメソッドを呼んで最初のレコードの位置に飛ばしてから、Getでキーと値を取得する。そしてNextで次のレコードに進む。こんな感じのコードになるはずだ。

#include "tkrzw_dbm_hash.h"

// Main routine.
int main(int argc, char** argv) {
  // All symbols of Tkrzw are under the namespace "tkrzw".
  using namespace tkrzw;

  // Creates the database manager.
  HashDBM dbm;

  // Opens a new database.
  dbm.Open("casket.tkh", true);

  // Stores records.
  dbm.Set("foo", "hop");
  dbm.Set("bar", "step");
  dbm.Set("baz", "jump");

  // Retrieves records.
  std::cout << dbm.GetSimple("foo", "*") << std::endl;
  std::cout << dbm.GetSimple("bar", "*") << std::endl;
  std::cout << dbm.GetSimple("baz", "*") << std::endl;
  std::cout << dbm.GetSimple("outlier", "*") << std::endl;

  // Traverses records.
  std::unique_ptr<DBM::Iterator> iter = dbm.MakeIterator();
  iter->First();
  std::string key, value;
  while (iter->Get(&key, &value) == Status::SUCCESS) {
    std::cout << key << ":" << value << std::endl;
    iter->Next();
  }

  // Closes the database.
  dbm.Close();

  return 0;
}

上記にて、GetSimpleて何だろうと思うことだろう。全ての基本的なメソッドは成否をStatusとして返すので、Getが本来返したいstringを返せない。よって、GetのAPIはパラメータとしてstringのポインタを受け取ってそこに値の文字列を書き込むようになっている。それが煩わしく感じるカジュアルなユースケースでは、代わりにGetSimpleを使う。成功ならそのレコードの値を返し、失敗なら第2パラメータで指定したデフォルト値を返す。第2パラメータは省略可能で、その場合は空文字列になる。そうすると、Python連想配列のgetメソッド的なノリで気軽に使える。

一方、まともな製品のコードでは厳密なエラーチェックをするだろう。既に述べたが、各メソッドの成否はStatusというクラスのオブジェクトとして返すことにする。それはスレータスコードとエラーメッセージをまとめたものだ。成功ならStatus::SUCCESSというコードを内包するStatusが返り、それはコードと直接比較できるようにオペラレータオーバロードが仕掛けてある。また、まともな製品のコードではデータベースのチューニングもするだろう。その場合、OpenAdvancedというメソッドを使い、それにTuningParametersという構造体を渡す。よって、真面目なコードはこんな感じになるだろう。

#include "tkrzw_cmd_util.h"
#include "tkrzw_dbm_hash.h"

// Main routine.
int main(int argc, char** argv) {
  // All symbols of Tkrzw are under the namespace "tkrzw".
  using namespace tkrzw;

  // Creates the database manager.
  HashDBM dbm;

  // Tuning parameters.
  // The update mode is in-place writing.
  // Using a 4-byte integer for addressing.
  // Using 3-bit = 8 byte unit alignment.
  // Using 7 buckets for the hash table.
  HashDBM::TuningParameters tuning_params;
  tuning_params.update_mode = HashDBM::UPDATE_IN_PLACE;
  tuning_params.offset_width = 4;
  tuning_params.align_pow = 3;
  tuning_params.num_buckets = 7;
  
  // Opens a new database, OPEN_TRUNCATE means to clear the file content.
  // The result is returned as a Status object.
  Status status = dbm.OpenAdvanced(
      "casket.tkh", true, File::OPEN_TRUNCATE, tuning_params);
  if (status != Status::SUCCESS) {
    // Failure of the Open operation is critical so we stop.
    Die("Open failed: ", status);
  }
  
  // Stores records.
  // Bit-or assignment to the status updates the status if the original
  // state is SUCCESS and the new state is an error.
  status |= dbm.Set("foo", "hop");
  status |= dbm.Set("bar", "step");
  status |= dbm.Set("baz", "jump");
  if (status != Status::SUCCESS) {
    // The Set operation shouldn't fail.  So we stop if it happens.
    Die("Set failed: ", status);
  }

  // Closes the database.
  status = dbm.Close();
  if (status != Status::SUCCESS) {
    // The Close operation shouldn't fail.  So we stop if it happens.
    Die("Set failed: ", status);
  }

  // Opens the exisiting database as a reader mode.
  status = dbm.Open("casket.tkh", false);
  if (status != Status::SUCCESS) {
    // Failure of the Open operation is critical so we stop.
    Die("Open failed: ", status);
  }

  // Retrieves records.
  // If there was no record, NOT_FOUND_ERROR would be returned.
  std::string value;
  status = dbm.Get("foo", &value);
  if (status == Status::SUCCESS) {
    std::cout << value << std::endl;
  } else {
    std::cerr << "missing: " << status << std::endl;
  }

  // Traverses records.
  std::unique_ptr<DBM::Iterator> iter = dbm.MakeIterator();
  if (iter->First() != Status::SUCCESS) {
    // Failure of the First operation is critical so we stop.
    Die("First failed: ", status);
  }
  while (true) {
    // Retrieves the current record data.
    std::string iter_key, iter_value;
    status = iter->Get(&iter_key, &iter_value);
    if (status == Status::SUCCESS) {
      std::cout << iter_key << ":" << iter_value << std::endl;
    } else {
      // This happens at the end of iteration.
      if (status != Status::NOT_FOUND_ERROR) {
        // Error types other than NOT_FOUND_ERROR are critical.
        Die("Iterator::Get failed: ", status);
      }
      break;
    }
    // Moves the iterator to the next record.
    status = iter->Next();
    if (status != Status::SUCCESS) {
      // This could happen if another thread removed the current record.
      if (status != Status::NOT_FOUND_ERROR) {
        // Error types other than NOT_FOUND_ERROR are critical.
        Die("Iterator::Get failed: ", status);
      }
      std::cerr << "missing: " << status << std::endl;
      break;
    }
  }

  // Closes the database.
  // Even if you forgot to close it, the destructor would close it.
  // However, checking the status is a good manner.
  status = dbm.Close();
  if (status != Status::SUCCESS) {
    // The Close operation shouldn't fail.  So we stop if it happens.
    Die("Set failed: ", status);
  }

  return 0;
}

さらに言うと、全てのレコード操作は該当レコードの既存の値を受け取って新しい値を返すコールバック関数として定義され、Processというメソッドに渡される。コールバックを表現するコンテナとしてRecordProcessorというクラスが定義されている。既存のレコードがある場合は、そのキーと値をパラメータにしてProcessFullというメソッドが呼ばれ、既存のレコードがない場合には、キーだけをパラメータにしてProcessEmptyというメソッドが呼ばれる。それらが戻り値として返した値がそのレコードの新たな値となる。ただし、NOOPという値が返された場合は現状を変更せず、REMOVEという値が返された場合には既存のレコードが削除される。GetとSetとRemoveはProcessのラッパーに過ぎない。

class RecordProcessor
 public:
  virtual std::string_view ProcessFull(std::string_view key, std::string_view value);
  virtual std::string_view ProcessEmpty(std::string_view key) ;
};

このProcessメソッドが実行されている間は、そのレコードはロックされている。なので、レコード単位のアトミックな処理が簡単に実装できる。これこそがDBMのインターフェイスの肝なのだ。Compare-and-Swapもこれを使って実装できるし、CASが実装できれば何だって実装できる。こんな風な応用例も考えられる。アクセスカウンターだ。既存の値を10進数値とみなして1を加算した上で結果をまた10進数値として記録したい。しかし、Getで値を取り出して変更してからSetで戻すという分割操作は許されない。なぜなら二つのスレッド同じレコード操作を同時に行うと競合が発生するからだ。スレッドAのGet、スレッドBのGet、スレッドAのSet、スレッドBのSetという順番で操作が行われてしまうと、スレッドAのSetはなかったことになってしまう。よって、読み取りと書き込みをアトミックに行う機構が必要となるのだ。

#include "tkrzw_cmd_util.h"
#include "tkrzw_dbm_hash.h"
#include "tkrzw_str_util.h"

// Main routine.
int main(int argc, char** argv) {
  // All symbols of Tkrzw are under the namespace "tkrzw".
  using namespace tkrzw;

  // Creates the database manager.
  HashDBM dbm;

  // Opens a new database,
  dbm.Open("casket.tkh", true, File::OPEN_TRUNCATE);

  // Record processor to count events.
  class Counter : public DBM::RecordProcessor {
   public:
    // Update an existing record.
    virtual std::string_view ProcessFull(std::string_view key, std::string_view value) {
      new_value_ = ToString(StrToInt(value) + 1);
      return new_value_;
    }
    // Register a new record.
    virtual std::string_view ProcessEmpty(std::string_view key) {
      return "1";
    }
   private:
    std::string new_value_;
  };

  // Procedure to count up an event.
  // DBM::IncrementSimple does the same job.
  const auto CountUp = [&](std::string_view name) {
    Counter counter;
    dbm.Process(name, &counter, true);
  };

  // Counts up events.
  CountUp("foo");
  CountUp("foo");
  CountUp("bar");

  // Reports counts.
  std::cout << dbm.GetSimple("foo", "0") << std::endl;
  std::cout << dbm.GetSimple("bar", "0") << std::endl;
  std::cout << dbm.GetSimple("baz", "0") << std::endl;

  // Closes the database.
  dbm.Close();

  return 0;
}

他にも、レコード数を返すCountとか、全部のレコードを消すClearとか、データベースの再構築を行うRebuildとか、チューニング用に内部状態を報告するInspectとかいったメソッドも提供される。

APIさえ決まれば、先にテストが書ける。うまいことテストが書ければ、あとはテストが通るように実装を書くだけじゃん。簡単簡単。