🕌

C++でgRPCやるのひよってるやついる?

2021/07/24に公開1

僕がそうでした。
なんならc++でサーバー建立するのもgRPC触るのも両方初めてだったので、ビビりまくってました🥶

事の発端

ある日、バイト先の方にc++でstream通信したいからgRPCサーバーを立てるように言われました。
c++は簡単な計算をg++でbuildしたりしたことしかなかったんで、その時点でひよってました。

gRPCについて、おそらく多くの方は こちらのチュートリアルをやると思うのですが、僕も例に漏れずひたすらここを見ながら進めていきました。CMakeListsを使ったビルドとかも全くわからなかったので、いっちょやってみるかという気持ちでやり始めました。
ただ、開発環境を整えるところからつっかえたりしだして苦労したのでこれでいけたよという方法をここに記していきたいと思います。

前提

とりあえず何か作りながらが良いかなということで、以下ができるように進めていきたいと思っています。

  1. Dockerとdocker-composeで楽にローカル開発
  2. EC2のUbuntu20.04環境にデプロイするつもりで開発 (EC2自体の初期設定・セキュリティグループのインバウンド設定などは設定済み)
  3. 自分の好きなポテチのフレーバーを投票する関数と、結果を確認する関数を定義する。

1については特にいうことはないと思いますが、2でOSをUbuntuで想定しているので、DockerfileもUbuntuのイメージを使っていきます。
3ですが、下記のように決めうちで番号で投票することにします。好きな味は複数投票できる仕様にします。

選択肢
{
	0: "usushio",
	1: "consomme_punch",
	2: "norishio",
	3: "happy_butter",
	4: "shouyu",
}

こちら今回のソースコードです

いざ、実装!!!!

開発環境準備

まずは雑多にファイルを作っていきます。

ターミナル
$ mkdir -p potechi_server/tools potechi_server/protobuf/rpc
$ cd potechi_server
$ touch Dockerfile docker-compose.yml \
CMakeLists.txt main.cpp protobuf/rpc/potechi.proto tools/grpc_cpp.sh
Dockerfile
FROM ubuntu:20.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
    apt-get install -y \
                git \
                wget \
                clang \
                ca-certificates \
                build-essential \
                libssl-dev \
                make \
                cmake \
                autoconf \
                automake \
                pkg-config \
                libtool \
                golang \
                curl
# grpc持ってきて使えるようにしときます。
RUN cd / && git clone -b v1.38.0 https://github.com/grpc/grpc && \
        cd /grpc && \
        git submodule update --init && \
        mkdir -p cmake/build && \
        cd cmake/build && \
        cmake -DgRPC_INSTALL=ON \
              -DgRPC_BUILD_TESTS=OFF \
              -DCMAKE_POSITION_INDEPENDENT_CODE=TRUE \
              ../.. && \
        make && \
        make install && \
        ldconfig
# なんだろう、ここについては聞かないでもらってもいいですか?
RUN cp -r /grpc/third_party/abseil-cpp/absl /usr/local/include/
RUN cp -r /grpc /usr/local/include/

WORKDIR /workspace
docker-compose.yml
version: '3.5'
services:
  potechi:
    container_name: potechi
    build: .
    volumes:
      - type: bind
        source: .
        target: /workspace
    ports:
      - "3000:50051"
    stdin_open: true
    tty: true

スキーマ定義

protobuf/rpc/potechi.proto
syntax = "proto3";

package potechi;

service Potechi {
  rpc Vote (stream VoteRequest) returns (stream VoteReply) {}
  rpc Counting (CountingRequest) returns (CountingReply) {}
}

message VoteRequest {
  repeated int32 flavor_numbers = 1;
  string voter_name = 2;
}

message VoteReply {
  map<string, int32> result = 1;
}

message CountingRequest {
  string voter_name = 1;
}

message CountingReply {
  double contribution_rate = 1;
}

.protoファイルです。今回は意味もなく双方向通信します。シンプルな作りです。
ここらへんで勉強しました
serviceにrpcから始まる関数を定義していく感じです。
引数部分と返り値部分には下で定義しているmessageを使っています。
streamをつけるとストリーミング通信になります。

コード生成用シェルスクリプト準備

ここらの段階でgRPCはコード生成でパッと必要なファイルを作ることを知りました。
コマンドはシェルでまとめておきましょう。毎度全消ししてcodegenディレクトリにコードを生成します。

grpc_cpp.sh
#! /bin/bash

DEST_DIR=./codegen

if [ -d $DEST_DIR ]; then
  rm -rf $DEST_DIR
  mkdir $DEST_DIR
else
  mkdir $DEST_DIR
fi

protoc \
 --proto_path=./protobuf/rpc \
 --grpc_out=${DEST_DIR} --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` ./protobuf/rpc/*.proto

protoc \
 --proto_path=./protobuf/rpc \
 --cpp_out=${DEST_DIR} ./protobuf/rpc/*.proto

とりあえずここまで来れたら、以下のようなディレクトリ構造になっていると思います。

ディレクトリ構造
$ tree
.
├── CMakeLists.txt # 未記入
├── Dockerfile # 記入済み
├── docker-compose.yml # 記入済み
├── main.cpp # 未記入
├── protobuf
│   └── rpc
│       └── potechi.proto # 記入済み
└── tools
    └── grpc_cpp.sh # 記入済み

コード生成

それではコード生成したいので、docker-composeコマンドでコンテナを起動して中に入ります。

ターミナル
$ docker-compose up --build -d
$ docker-compose exec potechi bash

# コード生成
$ sh tools/grpc_cpp.sh

結果...

ディレクトリ構造
$ tree
.
├── CMakeLists.txt
├── Dockerfile
├── codegen
│   ├── potechi.grpc.pb.cc
│   ├── potechi.grpc.pb.h
│   ├── potechi.pb.cc
│   └── potechi.pb.h
├── docker-compose.yml
├── main.cpp
├── protobuf
│   └── rpc
│       └── potechi.proto
└── tools
    └── grpc_cpp.sh

生成されたーーーー!!!!!! 👏

コード本体

main.cppの場合

codegen以下に4つのファイルが生成されたと思います。
main.cppを書いていきましょう。
ちょっと長いのでポテチ関連はpotechi_manager.cppを作ってそちらに分けるといいかもしれないです。

main.cpp
#include <iostream>
#include <string>
#include <map>
#include <vector>

#include <grpc/grpc.h>
#include <grpcpp/server.h>
#include <grpcpp/server_builder.h>
#include <grpcpp/server_context.h>
#include <grpcpp/ext/proto_server_reflection_plugin.h>
#include "potechi_manager.cpp"
#include "codegen/potechi.pb.h"
#include "codegen/potechi.grpc.pb.h"

using namespace std;
using namespace potechi;
using namespace grpc;

class PotechiServiceImpl final : public Potechi::Service {
    Status Vote(ServerContext* context,
                ServerReaderWriter<VoteReply, VoteRequest>* stream) override {
        VoteRequest request;
        while (stream->Read(&request)) {
            if (request.voter_name().size() == 0) return Status(StatusCode::INVALID_ARGUMENT, "error: Argument not found");
            vector<int> flavor_numbers;
            for (int i = 0; i < request.flavor_numbers_size(); i++) {
                flavor_numbers.push_back(request.flavor_numbers(i));
            }
            // DATA: data { key: value, _key: _value }
            map<string, int> data;
            bool ok;
            tie(data, ok) = pm.AddVoting(request.voter_name(), flavor_numbers);
            if (ok) {
                VoteReply reply;
                for (auto x: data) {
		    // マップにはmutable_xxx()にポインタでセットするといけることに気づいた
                    (*reply.mutable_result())[x.first] = x.second;
                }
                stream->Write(reply);
            }
        }
        return Status::OK;
    }
    Status Counting(ServerContext* context, const CountingRequest* request,
                    CountingReply* reply) override {
        if (request->voter_name().size() == 0) return Status(StatusCode::INVALID_ARGUMENT, "error: Argument not found");
        double rate = pm.CheckContribution(request->voter_name());
        string msg = "";
        if (rate > 0.0) {
            msg = "You are very contributing";
        } else {
            msg = "You haven't contributed at all";
        }
	// set_xxx()関数のxxx部分はprotoファイルで設定した返り値のスネークケースが入ります
	// だいたいxxx.pb.hに書いてるので見た方が良いです
        reply->set_contribution_rate(rate);
        reply->set_message(msg);
        return Status::OK;
    }

    private:
        mutex mu_;
        PotechiManager pm;
};
// ここから下はほとんどテンプレみたいです
void RunServer() {
    string server_address("0.0.0.0:50051");
    PotechiServiceImpl service;

    EnableDefaultHealthCheckService(true);
    reflection::InitProtoReflectionServerBuilderPlugin();
    ServerBuilder builder;
    // Listen on the given address without any authentication mechanism.
    builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
    // Register "service" as the instance through which we'll communicate with
    // clients. In this case it corresponds to an *synchronous* service.
    builder.RegisterService(&service);
    // Finally assemble the server.
    unique_ptr<Server> server(builder.BuildAndStart());
    cout << "Server listening on " << server_address << endl;

    // Wait for the server to shutdown. Note that some other thread must be
    // responsible for shutting down the server for this call to ever return.
    server->Wait();
}

int main(int argc, char** argv) {
    RunServer();
    return 0;
}

生成されたコードについてはcodegen/potechi.pb.hを照らし合わせながらrequestとreplyを使っていく感じになると思います。
特にc++に触れたことがなかったので、コード中盤のmutable_xxx()にどうやってmapを突っ込むのかで悩みました。

potechi_manager.cppの場合

ポテチ関連は下記のpotechi_manager.cppに記していきます。

potechi_manager.cpp
#include <iostream>
#include <map>
#include <tuple>
#include <string>

using namespace std;

map<int, string> choices = {
    {0, "usushio"},
    {1, "consomme_punch"},
    {2, "norishio"},
    {3, "happy_butter"},
    {4, "shouyu"},
};

class PotechiManager {
public:
    PotechiManager(){

    };

    tuple<map<string, int>, bool> AddVoting(string voter_name, vector<int> flavor_numbers) {
        if (vote_cnt.count(voter_name) == 0) {
            vote_cnt[voter_name] = 0;
        }
        vote_cnt[voter_name] += flavor_numbers.size();
        for (auto i: flavor_numbers) {
            if (!match_choices(i)) return forward_as_tuple(vote_box, false);
        }
        for (auto i: flavor_numbers) {
            if (vote_box.count(choices[i]) == 0) {
                vote_box[choices[i]] = 0;
            }
            vote_box[choices[i]] += 1;
        }
        return forward_as_tuple(vote_box, true);
    };

    double CheckContribution(string voter_name) {
        int sum = 0;
        int mine = 0;
        for (auto x: vote_cnt) {
            if (x.first == voter_name) {
                mine += x.second;
            }
            sum += x.second;
        }
        return (double)mine / (double)sum;
    }


private:
    // 選択肢ごとの得票数
    map<string, int> vote_box;
    // voter毎の投票回数
    map<string, int> vote_cnt;

    bool match_choices(int i) {
        return i == 0 || i == 1 || i == 2 || i == 3 || i == 4;
    };
};

mapの宣言で詰みそうになりつつも上記のようにクラスを作ってその中でデータを管理してあげます。
c++に手慣れてない感が現れてますが、お気になさらないでください。
エラーハンドリングもtry catchとかあるんだろうなー程度の認識ですのでお察しです。

ビルド用のスクリプト作成

では、最後に手付かずのCMakeLists.txtとビルド用のシェルを作成していきます。

CMakeLists.txtの場合

CMakeLists.txt
cmake_minimum_required(VERSION 3.16.3)

project(potechi)

set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "limited configs" FORCE)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--no-as-needed -lgrpc++_reflection -Wl,--as-needed -ldl")

find_package(Threads)
find_package(Protobuf CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)

set(SOURCE_FILES main.cpp codegen/potechi.grpc.pb.h codegen/potechi.grpc.pb.cc codegen/potechi.pb.cc codegen/potechi.pb.h)

add_executable(main ${SOURCE_FILES})

target_link_libraries(main
        Threads::Threads
        protobuf::libprotobuf
        gRPC::grpc++_unsecure
        gRPC::grpc++_reflection
        ${CMAKE_SHARED_LINKER_FLAGS})
set_target_properties(main PROPERTIES OUTPUT_NAME main)

build.shの場合

tools/build.sh
#!/bin/bash

DEST_DIR=./build

if [ -d $DEST_DIR ]; then
  rm -rf $DEST_DIR
  mkdir $DEST_DIR
else
  mkdir $DEST_DIR
fi

cd build
cmake ..
make
./main

さて、長々とコードをコピペしてもらうだけの時間を過ごしていただきましたが、ついにそんな時間も終わりを迎えました。😭
サーバーを立てる時が来たのです 💪

コンテナ内の/workspaceに移動してコマンドを実行しましょう。

ターミナル
$ cd /workspace
$ sh tools/build.sh
-- The C compiler identification is GNU 9.3.0
-- The CXX compiler identification is GNU 9.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
....
(中略)
....
Scanning dependencies of target main
[ 25%] Building CXX object CMakeFiles/main.dir/main.cpp.o
[ 50%] Building CXX object CMakeFiles/main.dir/codegen/potechi.grpc.pb.cc.o
[ 75%] Building CXX object CMakeFiles/main.dir/codegen/potechi.pb.cc.o
[100%] Linking CXX executable main
[100%] Built target main
Server listening on 0.0.0.0:50051

上記のようになれば正常にサーバーが起動しています🎉

動作確認

ただ、実際にリクエストを送ってみないと実装できているかわからないですよね?
grpcurlを使うと幸せになれそうです。
mac環境だとbrew経由でインストールできます。

$ brew install grpcurl

こちらを参考

使い方は簡単で-dをつけてシングルクォートで囲むと引数を与えることができます。

# Vote 引数オブジェクトを複数突っ込むとstreamingの挙動が確認できる
$ grpcurl -d '{ "flavor_numbers": [0, 1, 4], "voter_name": "カール" } { "flavor_numbers": [3], "voter_name": "カーリー" }' -plaintext localhost:3000 potechi.Potechi.Vote

{
  "result": {
    "consomme_punch": 1,
    "shouyu": 1,
    "usushio": 1
  }
}
{
  "result": {
    "consomme_punch": 1,
    "happy_butter": 1,
    "shouyu": 1,
    "usushio": 1
  }
}
# Counting
$ grpcurl -d '{ "voter_name": "カール" }' -plaintext localhost:3000 potechi.Potechi.Counting

{
  "contribution_rate": 0.75,
  "message": "You are very contributing"
}

上記のようにレスポンスが返ってくると思います!
公式のチュートリアルだと、双方向ストリーミングの時のServerReaderWriterの中どう書けば良いのかわからなかったりするんですよね。
僕のような初めてC++やgRPCに触る人の役に立てれば嬉しいです!🙌

最終的なディレクトリ構造
.
├── CMakeLists.txt
├── Dockerfile
├── README.md
├── build
│   ├── CMakeCache.txt
│   ├── CMakeFiles
│   │   ├── 3.16.3
│   │   │   ├── CMakeCCompiler.cmake
│   │   │   ├── CMakeCXXCompiler.cmake
│   │   │   ├── CMakeDetermineCompilerABI_C.bin
│   │   │   ├── CMakeDetermineCompilerABI_CXX.bin
│   │   │   ├── CMakeSystem.cmake
│   │   │   ├── CompilerIdC
│   │   │   │   ├── CMakeCCompilerId.c
│   │   │   │   ├── a.out
│   │   │   │   └── tmp
│   │   │   └── CompilerIdCXX
│   │   │       ├── CMakeCXXCompilerId.cpp
│   │   │       ├── a.out
│   │   │       └── tmp
│   │   ├── CMakeDirectoryInformation.cmake
│   │   ├── CMakeError.log
│   │   ├── CMakeOutput.log
│   │   ├── CMakeTmp
│   │   ├── Makefile.cmake
│   │   ├── Makefile2
│   │   ├── TargetDirectories.txt
│   │   ├── cmake.check_cache
│   │   ├── main.dir
│   │   │   ├── CXX.includecache
│   │   │   ├── DependInfo.cmake
│   │   │   ├── build.make
│   │   │   ├── cmake_clean.cmake
│   │   │   ├── codegen
│   │   │   │   ├── potechi.grpc.pb.cc.o
│   │   │   │   └── potechi.pb.cc.o
│   │   │   ├── depend.internal
│   │   │   ├── depend.make
│   │   │   ├── flags.make
│   │   │   ├── link.txt
│   │   │   ├── main.cpp.o
│   │   │   └── progress.make
│   │   └── progress.marks
│   ├── Makefile
│   ├── cmake_install.cmake
│   └── main
├── codegen
│   ├── potechi.grpc.pb.cc
│   ├── potechi.grpc.pb.h
│   ├── potechi.pb.cc
│   └── potechi.pb.h
├── docker-compose.yml
├── main.cpp
├── potechi_manager.cpp
├── protobuf
│   └── rpc
│       └── potechi.proto
└── tools
    ├── build.sh
    └── grpc_cpp.sh

参考URL

https://grpc.io/docs/languages/cpp/quickstart/

https://developers.google.com/protocol-buffers/docs/cpptutorial

https://github.com/fullstorydev/grpcurl

Discussion

DragonDragon

.protoファイルのCountingReplyの中身に「String message = 2;」が書かれていないのに、
main.cppで「reply->set_message(msg);」が定義されている?使えなくないでしょうか?