ふわっとテック日記

テック系のことをメインに書いていきます。go言語/typescript/javascriptが好きです。たまに趣味(筋トレ)の話や日常系の記事も書きたいな〜と思っています。

gRPC触ってみた

gRPCを使用したサーバ/クライアント間の通信の構築をざっくりと書きます。

RPCとは

ネットワーク経由で、別ホスト上に存在するソフトウェアのプログラムを関数やメソッドを指定して呼び出す方法のことです。

関数やメソッド名をそのまま使って呼び出すため、可読性が高くなったり、別言語での実装が比較的容易になったりするのが特徴です。

gRPCとは

googleが開発したオープンソースのRPCシステムで、protocol buffersを使用してRPC通信を行います。

HTTP/2を使用した通信となります。

protocol buffersとは

googleが開発したIDL(インターフェース定義言語)です。

データのシリアライズフォーマットで、言語としての定義のしやすさや可読性が特徴となっています。

バイナリに変換するのでその状態での可読性はないものの、データサイズが小さく高速な通信が可能です。

環境構築

まずprotocをインストールします。データフォーマットを定義する.protoファイルから各言語のコードを生成するために必要となるコンパイラです。

brew install protobuf (macOS)

次に、以下2つをインストールします。

  1. protoc-gen-go(protocコマンドのプラグインで、protocol buffersコンパイラがgoコードを生成するためのもの。データのシリアライズ、デシリアライズなど)

  2. protoc-gen-go-grpc(.protoファイルのserviceに従って、gRPCのサーバ、クライアントを構築する)

このリンク先でインストールコマンドが記載されています。

Quick start | Go | gRPC

.protoファイルの記述

RPCでやりとりするデータの形式を定義するためのファイルです。基本的にこのファイルがAPI仕様書としての役割を果たし、サーバ、クライアントを実装する際の大元となります。

person.proto

syntax = "proto3"; // バージョン

package person; // パッケージ指定(名前空間を作成)

option go_package = "./pb"; // gRPCのコードを生成する際のディレクトリを指定。goファイル生成後、指定ディレクトリがパッケージとなる

import "other.proto"; // ほか.protoファイルのimportも可能

// message定義
message PersonRequest {
}
message PersonResponse {
    int32 age = 1;
    string name = 2;
}

// service定義
Service PersonService {
    rpc GetPerson (PersonRequest) returns (PersonResponse);
}

message

データを表す基本となる型。多くのデータを内包することができます。

フィールドの右側の番号はタグナンバーと呼ばれ、タグナンバーはprotocol buffersがフィールドを識別するための値です。message内で一意である必要があります。

タグナンバーは必ずしも連番にする必要はありません。

service

リクエスト/レスポンスで渡されるデータと共に定義されるメソッド群です。

Unary(1リクエスト、1レスポンス)通信とストリーム通信が定義可能ですが、上記ではUnary通信のメソッドを定義しています。

ストリーム通信はまた後に記事を書く予定です。

gRPCコンパイル

.protoファイルが書けたらいよいよgRPCのコンパイルを行います。.protoファイルを元に、サーバ/クライアントのソースコードを生成します。

以下のようなコマンドを実行します。

protoc -I. --go_out=. --go-grpc_out=. *.proto

-I

.protoファイルのパス

--go_out

出力先を指定

--go-grpc_out

gRPCのサーバ/クライアント用のソースコードを生成するためのオプション。生成するパスを指定


コマンド実行後、.protoファイルのoption go_package = xxxで指定したディレクトリ配下にgRPCのコードが生成されています。

xxx.goはmessageの定義や各種メソッド(フィールドの取得など)、xxx_grpc.goはサーバ/クライアントのためのコードファイルです。

サーバ実装

上記手順で生成されたperson_grpc.pb.goでは、サーバのインターフェースおよび構造体が次のように定義されています。

type PersonServiceServer interface {
    GetPerson(context.Context, *PersonRequest) (*PersonResponse, error)
    mustEmbedUnimplementedPersonServiceServer()
}

// UnimplementedPersonServiceServer must be embedded to have forward compatible implementations.
type UnimplementedPersonServiceServer struct {
}

func (UnimplementedPersonServiceServer) GetPerson(context.Context, *PersonRequest) (*PersonResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method GetPerson not implemented")
}
func (UnimplementedPersonServiceServer) mustEmbedUnimplementedPersonServiceServer() {}

このPersonServiceServerインターフェースを実装することでサーバ機能を作っていきます。

このインターフェースを満たす構造体はすでにUnimplementedPersonServiceServerとして定義されているので、これをそのまま使います。

サーバ

package main

import (
    "context"
    "grpc_demo/pb"
    "log"
    "net"

    "grpc_demo/pb"

    "google.golang.org/grpc"
)

type Server struct {
    pb.UnimplementedPersonServiceServer
}

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }

    grpcServer := grpc.NewServer()
    personServer := &Server{}

    pb.RegisterPersonServiceServer(grpcServer, personServer)

    if err = grpcServer.Serve(listener); err != nil {
        log.Fatal(err)
    }
}

func (*Server) GetPerson(ctx context.Context, req *pb.PersonRequest) (*pb.PersonResponse, error) {
    res := &pb.PersonResponse{
        Age:  20,
        Name: "John",
    }
    return res, nil
}

このような感じで実装します。

gRPCで定義したGetPersonを実装しています(UnimplementedPersonServiceServerをそのまま使うだけではメソッドが実装されていません)。

クライアント

下記のようにPersonServiceClientインターフェース、およびpersonServiceClient構造体がperson_grpc.pb.goに実装されているので、これを使って上記のサーバにリクエストを行います。

type PersonServiceClient interface {
    GetPerson(ctx context.Context, in *PersonRequest, opts ...grpc.CallOption) (*PersonResponse, error)
}

type personServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewPersonServiceClient(cc grpc.ClientConnInterface) PersonServiceClient {
    return &personServiceClient{cc}
}

func (c *personServiceClient) GetPerson(ctx context.Context, in *PersonRequest, opts ...grpc.CallOption) (*PersonResponse, error) {
    out := new(PersonResponse)
    err := c.cc.Invoke(ctx, "/employee.PersonService/GetPerson", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

下記のような感じで実装をします。

クライアント

package main

import (
    "context"
    "fmt"
    "grpc_demo/pb"
    "log"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func main() {
    conn, err := grpc.Dial("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatal(err)
    }
    client := pb.NewPersonServiceClient(conn)

    getPerson(client)
}

func getPerson(client pb.PersonServiceClient) {
    res, err := client.GetPerson(context.Background(), &pb.PersonRequest{})
    if err != nil {
        log.Fatal(err)
    }

    age := res.Age
    name := res.Name
    fmt.Printf("response: age=%d, name=%s", age, name)
}

これで上記のサーバファイルを実行し、クライアントファイルを実行すると.protoファイルで定めたGetPersonが発火し、レスポンスが返ります。

次回の記事ではgRPCのより詳細な設定や、ストリーム通信についても書いていく予定です。