豪鬼メモ

一瞬千撃

DBサービスを作ろう その2 基本APIの設計と実装

前々回の記事で、gRPCインストールとアプリケーションのビルド方法を確認し、RPCの基本機能を使ってみた。前回の記事で、ログと起動終了という基本的な管理機能を備えたサーバプログラムが出来ている。今回はいよいよAPIの設計と実装に入る。


tkrzw_rpc.protoというファイルにRPCのAPIを定義する。各メソッドのリクエストやレスポンスに使われるメッセージもここで定義できる。まずは各種メソッドで共通して使われるデータ構造を定義する。Tkrzw元来のローカルなAPIで使われるStatusクラスはStatusProtoというメッセージクラスで伝達することとする。

syntax = "proto3";

package tkrzw;

// ローカルのStatusクラスに対応するProtoメッセージ
message StatusProto {
  // The message code.
  int32 code = 1;
  // The additional status message.
  string message = 2;
}

// 汎用の文字列ペア
message StringPair {
  // レコードのキー。
  string first = 1;
  // レコードの値。
  string second = 2;
}

// 汎用のバイト配列ペア
message BytesPair {
  // レコードのキー。
  bytes first = 1;
  // レコードの値。
  bytes second = 2;
}

次に、各メソッドのリクエストとレスポンスを定義していく。今回は、Get、Set、Removeなどの基本的なメソッドのみを扱う。

// Getメソッドのリクエスト
message GetRequest {
  // DBMオブジェクトのインデックス。0起点。
  int32 dbm_index = 1;
  // レコードのキー。
  bytes key = 2;
}

// Getメソッドのレスポンス
message GetResponse {
  // 結果のステータス。
  StatusProto status = 1;
  // レコードの値。
  bytes value = 2;
}

// Setメソッドのリクエスト
message SetRequest {
  // DBMオブジェクトのインデックス。0起点。
  int32 dbm_index = 1;
  // レコードのキー。
  bytes key = 2;
  // レコードの値。
  bytes value = 3;
  // 上書きするかしないか。
  bool overwrite = 4;
}

// Setメソッドのレスポンス
message SetResponse {
  // 結果のステータス。
  StatusProto status = 1;
}

// Removeメソッドのリクエスト
message RemoveRequest {
  // DBMオブジェクトのインデックス。0起点。
  int32 dbm_index = 1;
  // レコードのキー。
  bytes key = 2;
}

// Removeメソッドのレスポンス
message RemoveResponse {
  // 結果のステータス。
  StatusProto status = 1;
}

// サービスの定義。
service DBMService {
  rpc Get(GetRequest) returns (GetResponse);
  rpc Set(SetRequest) returns (SetResponse);
  rpc Remove(RemoveRequest) returns (RemoveResponse);
}

GetReqeustとRemoveRequestは全く同じ構造だ。SetResponseとRemoveResponseも全く同じ構造だ。しかし、原則的には、メソッド毎の入出力は専用のメッセージとして定義した方がよい。たまたま他のメソッドと同じ構造の入出力があっても、たまたま一致するという理由では統合しない。論理的に必然的に構造が一致して将来もそれが揺らがないという自信がある場合にのみ統合してもよい。

ここで気づいていただきたいのが、各メソッドのリクエストがdbm_indexというフィールドを持っているところだ。つまり、単一のサーバで複数のデータベースを運用できる仕様になっている。どのデータベースを操作するかをdbm_indexで指定するというわけだ。

ユーザにRPCのリクエストやレスポンスの読み書きを直接させるのは格好悪いし、ネットワーク周りの処理も隠蔽したい。よって、それらの機能をカプセル化したクライアントライブラリを提供する。tkrzw_dbm_remote.hに以下のようなAPIを定義する。

class RemoteDBM final {
 public:
  Status Connect(const std::string& host, int32_t port);
  void Disconnect();

  // 操作対象のデータベースのインデックスを切り替える。
  void SetDBMIndex(int32_t dbm_index);

  // レコードの値を取得する。
  Status Get(std::string_view key, std::string* value);

  // レコードの値を設定する。
  Status Set(std::string_view key, std::string_view value, bool overwrite = true);

  // レコードを削除する。
  Status Remove(std::string_view key);
};

基本的にはローカルのAPIとほぼ同じ仕様感になるようにしている。単一の接続でサーバ側の複数のデータベースを扱うためにはdbm_indexパラメータを毎回指定する必要があるのだが、それを全てのメソッドのパラメータに足すのはダサい。よって、SetDBMIndexというメソッドで内部状態としてdbm_indexを設定して、それを使い回すようにした。このクラスは同期APIなのでそれで問題ない。非同期APIの話はもっと複雑になるので、未来に別クラスとして実装するだろう。

満足のいくAPIが設計できたら、実装を開始する。まずはtkrzw_server_impl.hにサーバ側の実装を書いていく。Getメソッドはこんな風になる。

class DBMServiceImpl : public DBMService::Service {
 public:
  ...
  grpc::Status Get(
      grpc::ServerContext* context, const GetRequest* request,
      GetResponse* response) override {
    // クライアントとリクエストの情報をデバッグログとして書き出す。
    LogRequest(context, "Get", request);
    // dbm_indexが範囲内にあるかどうか確認する。
    if (request->dbm_index() < 0 ||
        request->dbm_index() >= static_cast<int32_t>(dbms_.size())) {
      return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dbm_index is out of range");
    }
    // 該当のデータベースオブジェクトを取り出す。
    auto& dbm = *dbms_[request->dbm_index()];
    // ローカルのAPIを呼び出す。
    std::string value;
    const Status status = dbm.Get(request->key(), &value);
    // 結果をレスポンスに書き込む。
    response->mutable_status()->set_code(status.GetCode());
    response->mutable_status()->set_message(status.GetMessage());
    if (status == Status::SUCCESS) {
      response->set_value(value);
    }
    return grpc::Status::OK;
  }
  ...
};

追加したメソッドに対応してtkrzw_server_test.ccにサーバ側のテストを書く。

TEST_F(ServerTest, Basic) {
  std::vector<std::unique_ptr<tkrzw::ParamDBM>> dbms(1);;
  dbms[0] = std::make_unique<tkrzw::PolyDBM>();
  const std::map<std::string, std::string> params = {{"dbm", "tiny"}, {"num_buckets", "10"}};
  EXPECT_EQ(tkrzw::Status::SUCCESS,
            dbms[0]->OpenAdvanced("", true, tkrzw::File::OPEN_DEFAULT, params));
  tkrzw::StreamLogger logger;
  tkrzw::DBMServiceImpl server(dbms, &logger);
  grpc::ServerContext context;
  ...
  {
    tkrzw::GetRequest request;
    request.set_key("one");
    tkrzw::GetResponse response;
    grpc::Status status = server.Get(&context, &request, &response);
    EXPECT_TRUE(status.ok());
    EXPECT_EQ(0, response.status().code());
    EXPECT_EQ("first", response.value());
  }
  ...
}

サーバ側のテストを実行する。

$ make tkrzw_server_test
$ ./tkrzw_server_test
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from ServerTest
[ RUN      ] ServerTest.Basic
[       OK ] ServerTest.Basic (0 ms)
[----------] 1 test from ServerTest (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.

tkrzw_dbm_remote.ccにて、クライアントライブラリにメソッドを追加する。

Status RemoteDBMImpl::Get(std::string_view key, std::string* value) {
  grpc::ClientContext context;
  GetRequest request;
  request.set_dbm_index(dbm_index_);
  request.set_key(key.data(), key.size());
  GetResponse response;
  grpc::Status status = stub_->Get(&context, request, &response);
  if (!status.ok()) {
    return Status(Status::NETWORK_ERROR, GRPCStatusString(status));
  }
  if (response.status().code() == 0) {
    *value = response.value();
  }
  return MakeStatusFromProto(response.status());
}

クライアント側のテストをtkrzw_dbm_remote_test.ccに書く。

TEST_F(RemoteDBM, Get) {
  auto stub = std::make_unique<tkrzw::MockDBMServiceStub>();
  tkrzw::GetRequest request;
  request.set_key("key");
  tkrzw::GetResponse response;
  response.set_value("value");
  EXPECT_CALL(*stub, Get(_, EqualsProto(request), _)).WillOnce(
      DoAll(SetArgPointee<2>(response), Return(grpc::Status::OK)));
  tkrzw::RemoteDBM dbm;
  dbm.InjectStub(stub.release());
  std::string value;
  const tkrzw::Status status = dbm.Get("key", &value);
  EXPECT_EQ(tkrzw::Status::SUCCESS, status);
  EXPECT_EQ("value", value);
}

上記のコードで、EqualsProtoというmatcherを使っているが、これは自分で定義する必要がある。

#include "google/protobuf/util/message_differencer.h"

MATCHER_P(EqualsProto, rhs, "Equality matcher for protos") {
  return google::protobuf::util::MessageDifferencer::Equivalent(arg, rhs);
}

クライアント側のテストを実行する。

$ make tkrzw_rpc_test
$ ./tkrzw_rpc_test --gtest_filter=RemoteDBMTest.Get
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from RemoteDBMTest
[ RUN      ] RemoteDBMTest.Get
[       OK ] RemoteDBMTest.Get (0 ms)
[----------] 1 test from RemoteDBMTest (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.

tkrzw_dbm_remote_util.ccにて、コマンドラインユーティリティからGetメソッドを呼べるようにする。

static int32_t ProcessGet(int32_t argc, const char** args) {
  const std::map<std::string, int32_t>& cmd_configs = {
    {"", 1}, {"--host", 1}, {"--port", 1}, {"--index", 1}
  };
  std::map<std::string, std::vector<std::string>> cmd_args;
  std::string cmd_error;
  if (!ParseCommandArguments(argc, args, cmd_configs, &cmd_args, &cmd_error)) {
    EPrint("Invalid command: ", cmd_error, "\n\n");
    PrintUsageAndDie();
  }
  const std::string key = GetStringArgument(cmd_args, "", 0, "");
  const std::string host = GetStringArgument(cmd_args, "--host", 0, "0.0.0.0");
  const int32_t port = GetIntegerArgument(cmd_args, "--port", 0, 1978);
  const int32_t dbm_index = GetIntegerArgument(cmd_args, "--index", 0, 0);
  RemoteDBM dbm;
  Status status = dbm.Connect(host, port);
  if (status != Status::SUCCESS) {
    EPrintL("Connect failed: ", status);
    return 1;
  }
  dbm.SetDBMIndex(dbm_index);
  bool ok = false;
  std::string value;
  status = dbm.Get(key, &value);
  if (status == Status::SUCCESS) {
    PrintL(value);
    ok = true;
  } else {
    EPrintL("Get failed: ", status);
  }
  dbm.Disconnect();
  return ok ? 0 : 1;
}

SetとRemoveも同様に実装とテストを通す。それから、全体をビルドして、実際に動くかどうかを確認する。サーバとクライアントを別端末で起動して操作する。

$ make
$ ./tkrzw_server --log_level debug
2021/08/26 20:25:59 [INFO] ==== Starting the process as a command ====
2021/08/26 20:25:59 [INFO] Opening a database: #dbm=tiny
2021/08/26 20:25:59 [INFO] address=0.0.0.0:1978, pid=248688
2021/08/26 20:26:15 [DEBUG] ipv4:127.0.0.1:56802 [Set] key: "tokyo" value: "japan" overwrite: true
2021/08/26 20:26:20 [DEBUG] ipv4:127.0.0.1:56804 [Get] key: "tokyo"
2021/08/26 20:26:23 [DEBUG] ipv4:127.0.0.1:56804 [Remove] key: "tokyo"
2021/08/26 20:26:27 [DEBUG] ipv4:127.0.0.1:56804 [Get] key: "tokyo"
2021/08/26 20:26:28 [INFO] Shutting down by signal: 15
2021/08/26 20:26:28 [INFO] The server finished
2021/08/26 20:26:28 [INFO] Closing a database
2021/08/26 20:26:28 [INFO] ==== Ending the process in success ====
$ ./tkrzw_dbm_remote_util set tokyo japan
$ ./tkrzw_dbm_remote_util get tokyo
japan
$ ./tkrzw_dbm_remote_util remove tokyo
$ ./tkrzw_dbm_remote_util get tokyo
NOT_FOUND_ERROR

これでデータベースの基本的なメソッドとその関連機能がend-to-endで実装できたことになる。書かなきゃならないコード量は多いが、やり方さえ知っていれば難易度は低い。刺し身に蒲公英を載せるような仕事である。この一連の流れ作業を、ローカルAPIがサポートするあと20個くらいのメソッドの分だけやるというわけだ。面倒くさいが、まあ暇つぶしにぼちぼちやっていこう。


まとめ。gRPCの流儀に基づき、データベースサービスのAPIを定義して、その実装とテストを書いていく一連の流れを紹介した。gRPCとgTestがよく出来ているので、実装もテストも単純に書ける。次回はストリームAPIを使ってイテレータを実装する予定だ。