Tkrzw本体でやりたい事をやり切ったので、データベースサービスでも作ってみよう。既にガチなデータベースサービスは山ほどあるので、レッドオーシャンに敢えて飛び込むのも気が引ける。とはいえ、gRPCを使うとかなり気軽に高性能なサービスを実装できるので、日曜大工のつもりで取り組む。おそらく10回くらいの連載になるだろうか。これからしばらくデータベースサービスの設計と実装について述べていきたい。
初回は、gRPCの基本的な使い方を確認する。インストール方法、ビルドの自動化、APIの定義、実装、そしてテストまでの一連の流れを追う。
まず技術選定についてだが、これはもうgRPC一択なのだ。Tokyo TyrantやKyoto Tycoonでは、socketとかselectとかpollとかepollとかkqueueとかいったシステムコールを駆使して自分でRPC機能を実装していたが、面倒くさすぎる。競争力のある性能に到達するにはそれなりの試行錯誤を要するし、様々な実行環境への移植性を確保するのは本当に大変だ。今回はそこに工数をかけずに、既存の仕組みに乗っかりたい。RPCのフレームワークとしては他にもRESTやらSOAPやらCORBAやらに基づくものが山ほどあるが、性能と利便性と可搬性においてgRPCが今のところ最強だと思う。
gRPCの詳細に関しては本家のサイトを参照いただくのがよいだろう。gRPCはネットワーク機能を隠蔽してくれる。gRPCを使わない場合、実装依存のシステムコールを使ってネットワークとその多重化の機能をゴリゴリ実装しないといけないのだが、gRPCはそういった泥臭い部分を隠蔽しつつ、十分に競争力のある性能を提供してくれる。また、gRPCはメッセージの交換にProtocol Buffersを使うので、柔軟なAPIを簡単に設計および実装することができ、様々な言語や実行環境で容易にクライアントを実装できる。繰り返しになるが、gRPCがなくても同等のことはできるのだが、gRPCを使うとめっちゃ楽なのだ。楽であることが要点なので、楽に始められるようにメモを残しておきたい。
まずはインストールだ。本家の手順だと、GitHubのプロジェクトをcloneしてcmakeでゴニョゴニョする感じになっているのだが、Ubuntu Linuxだとそのあたりは簡略化できる。aptを使ってサクッとインストールすればよい。
$ apt-get install libprotobuf-dev libgrpc-dev libgrpc++-dev
多分、libgrpc++-devだけ入れようとすれば残りの二つも自動的にインストールされる。いずれにせよ、上記を実行すると、Protocol Buffersのコンパイラと、そのgRPC用プラグインと、gRPCのラインタイムライブラリが全てインストールされる。
まず、ビルドを自動化する方法については横に置いて、手動でビルドする方法を学ぶべきだ。Protocol Buffersと毎回書くのは長いので、以後PBと呼ぶ。gRPCでは、インターフェイスをprotoファイルで定義しておいて、それをC++等のソースコードに変換する。C++の場合、protoファイルから2種類のソースファイルを出力する。ひとつは通常のPBメッセージを実装するのためのもので、もうひとつはgRPCのサービスを実装するためのものだ。後者はprotocにgRPC用のプラグインgrpc_cpp_pluginを渡して生成する。例えばtkrzw_rpc.protoから両者を生成するには、以下のようなコマンドを実行する。
protoc -I . --cpp_out=. tkrzw_rpc.proto protoc -I . --grpc_out=. --plugin=protoc-gen-grpc=grpc_cpp_plugin tkrzw_rpc.proto
結果として、tkrzw_rpc.pb.h、tkrzw_rpc.pb.h、tkrzw_rpc.pb.cc、tkrzw_rpc.grpc.pb.h、tkrzw_rpc.grpc.pb.ccが生成される。あとは、自分のプロジェクトの中でそれらを組み込んだC++のコードを書けばよい。サーバ側のプログラムでもクライアント側のプログラムでも、tkrzw_rpc.pb.oとtkrzw_rpc.grpc.pb.oの両者をリンクするとともに、gRPC共通のランタイムともリンクする必要がある。すなわち、サーバとクライアントは以下のようなコマンドでビルドできることになる。
$ g++ -I. -o server server.cc tkrzw_rpc.pb.cc tkrzw_rpc.grpc.pb.cc \ -lgrpc++_reflection -lgrpc++ -lgrpc -lprotobuf $ g++ -I. -o client client.cc tkrzw_rpc.pb.cc tkrzw_rpc.grpc.pb.cc \ -lgrpc++_reflection -lgrpc++ -lgrpc -lprotobuf
実際には、ソースから直で実行可能バイナリを作るのではなく、オブジェクトファイルを作って、それをまとめたライブラリを作って、それをリンクして実行可能バイナリを作るという手順になる。それを自動化したい。本家が推し推しのcmakeを使ってアプリケーションをビルドするのもよいが、ここは敢えて古き良きconfigureとMakefileの組み合わせでいってみよう。
説明のために、ものすごく単純化したMakefileを書くなら、以下のようにできる。
# ビルドターゲット all : tkrzw_server tkrzw_client # protoからC++ソースを作る tkrzw_rpc.pb.h tkrzw_rpc.pb.cc : tkrzw_rpc.proto protoc -I . --cpp_out=. tkrzw_rpc.proto tkrzw_rpc.grpc.pb.h tkrzw_rpc.grpc.pb.cc : tkrzw_rpc.proto protoc -I . --grpc_out=. --plugin=protoc-gen-grpc=grpc_cpp_plugin tkrzw_rpc.proto # C++ソースからオブジェクトファイルを作る .SUFFIXES : .SUFFIXES : .cc .o .cc.o : g++ -c -I . -O2 $< tkrzw_rpc.o : tkrzw_rpc.pb.h tkrzw_rpc.grpc.pb.h tkrzw_server.o : tkrzw_server_impl.h # オブジェクトファイルからライブラリを作る libtkrzw_rpc.a : tkrzw_rpc.o tkrzw_rpc.pb.o tkrzw_rpc.grpc.pb.o ar rv $@ $^ # クライアントとサーバの実行可能バイナリを作る。 tkrzw_server : tkrzw_server.o libtkrzw_rpc.a g++ -I . -O2 -o $@ $< -L . -ltkrzw_rpc \ -lgrpc++_reflection -lgrpc++ -lgrpc -lprotobuf -ltkrzw \ -lstdc++ -lrt -lpthread -lm -lc tkrzw_client : tkrzw_client.o libtkrzw_rpc.a g++ -I . -O2 -o $@ $< -L . -ltkrzw_rpc \ -lgrpc++_reflection -lgrpc++ -lgrpc -lprotobuf -ltkrzw \ -lstdc++ -lrt -lpthread -lm -lc
実際の設定はconfigure.inとMakefile.inを読んでもらうのが良いだろう。
ビルド方法がわかったところで、実際の実装作業に入っていく。いきなりたくさんの機能を作り込むと大変なので、まずはサーバに送った文字列をそのまま送り返すという単純なAPIだけを実装しよう。これはサーバの生存確認にも使える。tkrzw_service.protoに以下のような内容を書く。
syntax = "proto3"; package tkrzw; message EchoRequest { string message = 1; } message EchoResponse { string echo = 1; } service DBMService { rpc Echo(EchoRequest) returns (EchoResponse); }
DBMServiceという名前のサービスに、Echoというメソッドが定義されている。それは入力としてEchoRequestというPBメッセージを受け取り、出力としてEchoResponseというPBメッセージを返す。EchoRequestは任意の文字列が入れられるmessageというフィールドを持ち、EchoResponseは同じ内容の文字列が入れられるechoというフィールドだけを持つ。
サーバの実行可能バイナリを実装しよう。tkrzw_server_impl.hというファイルだ。まず、以下のヘッダをincludeする。
#include <grpc/grpc.h> #include <grpcpp/security/server_credentials.h> #include <grpcpp/server.h> #include <grpcpp/server_builder.h> #include <grpcpp/server_context.h> #include "tkrzw_rpc.pb.h" #include "tkrzw_rpc.grpc.pb.h"
tkrzw_rpc.pb.hをincludeすると、EchoRequestとEchoResponseが使えるようになる。tkrzw_rpc.grpc.pb.hをincludeすると、tkrzw::DBMService::Serviceという抽象クラスが使えるようになるが、その仮想関数をオーバーライドしてやると、実際の挙動が定義できることになる。
class DBMServiceImpl : public DBMService::Service { public: grpc::Status Echo(grpc::ServerContext* context, const tkrzw::EchoRequest* request, tkrzw::EchoResponse* response) override { response->set_echo(request->message()); return grpc::Status::OK; } };
あとは、tkrzw_server.ccにサービスを起動する処理を書けばよい。アドレスを0.0.0.0にすると、ローカルホストの任意のネットワークインターフェイスにバインドされる。ポート番号は空いていそうなものを適当に設定する。また、起動したサーバが停止シグナルを受け取ったら停止するようにコールバックを仕掛ける。シグナルハンドラは複数同時に実行される可能性があるので、アトミック変数で排他制御を行っている。
// シグナルハンドラとして、サーバを停止させる std::atomic<grpc::Server*> g_server = nullptr; void ShutdownServer(int signum) { grpc::Server* server = g_server.load(); if (server != nullptr && g_server.compare_exchange_strong(server, nullptr)) { PrintL("Shutting down"); const auto deadline = std::chrono::system_clock::now() + std::chrono::seconds(10); server->Shutdown(deadline); } } // サーバを実行する void RunServer() { const std::string server_address("0.0.0.0:1978"); DBMServiceImpl service; grpc::ServerBuilder builder; builder.AddListeningPort(server_address, grpc::InsecureServerCredentials()); builder.RegisterService(&service); std::unique_ptr<grpc::Server> server(builder.BuildAndStart()); PrintL("Listening on ", server_address); g_server = server.get(); std::signal(SIGINT, ShutdownServer); std::signal(SIGTERM, ShutdownServer); std::signal(SIGQUIT, ShutdownServer); server->Wait(); PrintL("Done"); }
サーバの実装は普通1つで十分なので、必ずしもライブラリ化しなくてもよい。ただし、テストのためにtkrzw_server.ccとtkrzw_server_impl.hの分割はしている。一方で、クライアント側の処理は再利用するのが普通なので、ライブラリにした方が良い。tkrzw_dbm_remote.hというヘッダでAPIを定義しよう。
class RemoteDBMImpl; class RemoteDBM final { public: RemoteDBM(); ~RemoteDBM(); // サーバと接続する Status Connect(const std::string& host, int32_t port); // 接続を切断する void Disconnect(); // メッセージのエコーバックを行う Status Echo(std::string_view message, std::string* echo); private: // 実装のポインタ RemoteDBMImpl* impl_; };
クライアント側では、中でgRPCが使われていることを完全に隠蔽したい。C++において実装の完全隠蔽を達成するにはPimplイディオムを使うしかない。クラスのメンバ変数に実装クラスのポインタだけを持たせることにより、gRPCやPBのシンボルがヘッダに一切登場しないようにできるのだ。
当然、実装はtkrzw_dbm_remote.ccで行う。まず、以下のヘッダをincludeする。
#include <grpc/grpc.h> #include <grpcpp/channel.h> #include <grpcpp/client_context.h> #include <grpcpp/create_channel.h> #include <grpcpp/security/credentials.h> #include "tkrzw_rpc.h" #include "tkrzw_rpc.grpc.pb.h" #include "tkrzw_rpc.pb.h"
実装クラスは以下のように書くことになる。
class RemoteDBMImpl final { public: Status Connect(const std::string& host, int32_t port); void Disconnect(); Status Echo(std::string_view message, std::string* echo); private: std::unique_ptr<DBMService::StubInterface> stub_; }; // サーバに接続する Status RemoteDBMImpl::Connect(const std::string& host, int32_t port) { // ホスト名とポート番号を連結してアドレス文字列を作る const std::string server_address(StrCat(host, ":", port)); // チャンネルを作る auto channel = grpc::CreateChannel(server_address, grpc::InsecureChannelCredentials()); // 接続できるまで待つ const auto deadline = std::chrono::system_clock::now() + std::chrono::seconds(10); while (true) { auto status = channel->GetState(true); if (status == GRPC_CHANNEL_READY) { break; } if ((status != GRPC_CHANNEL_IDLE && status != GRPC_CHANNEL_CONNECTING) || !channel->WaitForStateChange(status, deadline)) { return Status(Status::NETWORK_ERROR, "connection failed"); } } // スタブを作る。 stub_ = DBMService::NewStub(channel); return Status(Status::SUCCESS); } // 接続を切断する void RemoteDBMImpl::Disconnect() { // スタブを破棄する。デストラクタで接続が切断される。 stub_.reset(nullptr); } // エコーバックを行う Status RemoteDBMImpl::Echo(std::string_view message, std::string* echo) { // コンテキストと入出力を準備 grpc::ClientContext context; EchoRequest request; request.set_message(std::string(message)); EchoResponse response; // RPCを同期的に呼ぶ grpc::Status status = stub_->Echo(&context, request, &response); // ステータスの確認 if (!status.ok()) { return Status(Status::NETWORK_ERROR, GRPCStatusString(status)); } // 結果の取り出し *echo = response.echo(); return Status(Status::SUCCESS); }
gRPCの操作の成否はgrpc::Statusという構造体で返されるのだが、ここではそれをtkrzw::Statusに変換している。gRPCのシンボルは一切外に出さないポリシーなのだ。
あとは、上記のライブラリを利用するクライアントの実行可能バイナリを書けば良い。tkrzw_dbm_remote_util.ccというファイルだ。
void run() { RemoteDBM dbm; dbm.Connect("localhost", 1978); std::string echo; status = dbm.Echo("hello", &echo); std::cout << echo << std::endl; }
サーバとクライアントを起動するには、それぞれ別ターミナルで以下のようにすればよい。
$ tkrzw_server
$ tkrzw_dbm_util echo hello hello
gTestでユニットテストを書く。当然ながら、ユニット毎に、すなわちサーバとクライアントで別々に、テストを書かなければならない。まずはサーバ側だが、tkrzw_server_test.ccというファイルに定義する。DBMServiceImplクラスのメソッドをそのまま呼べるので比較的楽に書ける。
TEST_F(ServerTest, Basic) { tkrzw::DBMServiceImpl server; grpc::ServerContext context; tkrzw::EchoRequest request; request.set_message("hello"); tkrzw::EchoResponse response; grpc::Status status = server.Echo(&context, &request, &response); EXPECT_TRUE(status.ok()); EXPECT_EQ("hello", response.echo()); }
次にクライアント側だが、こちらはgMockを使う。gRPCのヘッダを書き出すprotocコマンドを書き換えて、スタブのモッククラスも自動生成させる。出力形式を "." から "generate_mock_code=true:." に変えればよい。
protoc -I . --grpc_out=generate_mock_code=true:. --plugin=protoc-gen-grpc=grpc_cpp_plugin tkrzw_rpc.proto
そうすると、tkrzw_rpc_mock.grpc.pb.hが生成される。あとは、それを使ったテストをtkrzw_dbm_remote_test.ccというファイルに定義する。
TEST_F(RemoteDBMTest, Basic) { // テストスタブを定義して、それに期待されるアクセスパターンを記述する。 auto stub = std::make_unique<tkrzw::MockDBMServiceStub>(); tkrzw::EchoResponse response; response.set_echo("hello"); EXPECT_CALL(*stub, Echo(_, _, _)).WillOnce( DoAll(SetArgPointee<2>(response), Return(grpc::Status::OK))); // クライアントを作って、テストスタブを渡す。 tkrzw::RemoteDBM dbm; client.InjectStub(stub.release()); // メソッドを呼び出して、結果を検査する。 std::string echo; const tkrzw::Status status = dbm.Echo("hello", &echo); EXPECT_EQ(tkrzw::Status::SUCCESS, status); EXPECT_EQ("hello", echo); }
テストスタブを注入するためにInjectStubというメソッドが追加されている。正直これはダサいコードだ。これはgRPCを隠蔽したことの副作用とも言える。仕事であれば、アプリケーション側の責任でスタブを作ってサーバに接続した上で、RemoteDBMクラスのコンストラクタにそのスタブを渡すようなインターフェイスにするだろう。利用者もgRPCに精通していることが期待できるので、隠蔽する必要がないからだ。しかし、ここでは、gRPCやスタブの存在を隠蔽して、Connectメソッドが接続を担うようにした。そうするとテストスタブが渡せなくなってしまうので、仕方なくInjectStubというメソッドが必要になったというわけだ。
まとめ。データベースライブラリTkrzwの各種機能をサービスとして利用できるようにすべく、gRPCを使ってサービスの雛形を実装した。慣れないプラットフォームを使う場合、Makefileの構造を決めるのが最も面倒だったりするのだが、今回でそれをこなした。ここまでの知識さえあれば、もう勝ったも同然だ。protoファイルにAPIを追加して、その実装をtkrzw_server.ccに定義して、それを呼び出すコードをtkrzw_rpc.hとtkrzw_rpc.ccに定義すればよい。次回以降は具体的なAPIを定義して、実用的なプログラムに少しずつ近づけていく。