Carpe Diem

備忘録

grpc-gatewayでgRPCサーバをRESTで叩けるようにする

概要

gRPCはProtocol Buffersを喋るもの同士ではメリットが大きいですが、RESTしか叩け無いものや、curlでRESTを叩くようにサクッと検証したかったりするときに不便です。
そんな問題を解消してくれるのがgrpc-gatewayで、protobuf定義からREST APIを提供するリバースプロキシを生成してくれます。

github.com

環境

成果物

今回のコードはこちらです。

github.com

実装

proto

死活監視用のサービスと、ユーザ情報に関するサービスの2つを用意してみます。
RESTのエンドポイントとしては

  • GET /alive
  • GET /user/{id}
  • GET /user
  • PUT /user/{id}

です。

alive.proto

syntax = "proto3";

import "google/api/annotations.proto";

package gateway;

service AliveService {
  rpc GetStatus(Empty) returns (AliveResponse) {
    option (google.api.http) = {
      get: "/alive"
    };
  }
}

message Empty {}

message AliveResponse {
  bool status =  1;
}

これまでのprotoとの違いとしては、

...
import "google/api/annotations.proto";
...
    option (google.api.http) = {
      get: "/alive"
    };
...

この辺のコードが追加された点です。
最初のimportはcustom optionを使えるようにするためです。
そのcustom optionで、REST APIのエンドポイントを定義しています。今回だとGET /aliveというエンドポイントになります。

user.proto

syntax = "proto3";

import "google/api/annotations.proto";
import "alive.proto";

package gateway;

service UserService {
  rpc GetUser(GetUserRequest) returns (UserResponse) {
    option (google.api.http) = {
      get: "/user/{id}"
    };
  }

  rpc GetUsersByGroup(UserGroupRequest) returns (UsersResponse) {
    option (google.api.http) = {
      get: "/user"
    };
  }

  rpc UpdateUser(UpdateUserRequest) returns (Empty) {
    option (google.api.http) = {
      put: "/user/{id}"
      body: "*"
    };
  }
}

message GetUserRequest {
  string id = 1;
}

message UpdateUserRequest {
  string id = 1;
  string name = 2;
  int32 age = 3;
}

message UserResponse {
  string id = 1;
  string name =  2;
  int32 age =  3;
}

enum Group {
  USER = 0;
  ADMIN = 1;
  DEVELOPER = 2;
}

message UserGroupRequest {
  Group group = 1;
}

message UsersResponse {
  Group group = 1;
  repeated UserResponse users = 2;
}

path parameter

こちらは先程の応用で、{id}というpath parameterが追加されています。

...
    option (google.api.http) = {
      get: "/user/{id}"
    };
...

path parameterの{id}は、リクエストの定義で用意しなくてはいけません。今回だと以下のコードです。

message GetUserRequest {
  string id = 1;
}

REST APIではbodyを付けずにGET /user/001みたいにするだけなのですが、proto定義ではidを含んだmessageを用意しなくてはいけません。

query string

query stringの場合も同様に、あらかじめmessageで定義しておきます。違いとしては↑のpath parameterのようにpathで使われないものが全てquery stringとして扱われます。

  rpc GetUsersByGroup(UserGroupRequest) returns (UsersResponse) {
    option (google.api.http) = {
      get: "/user"
    };
  }

...

message UserGroupRequest {
  Group group = 1;
}

としていると、/user?group=ADMINといった形で使えます。
今回はついでにenum数値でなく文字列で扱っています。小文字にしたい場合はproto定義を小文字にしてください。

request body

これはrequest bodyを持ったPUTリクエストを定義しています。

...
    option (google.api.http) = {
      put: "/user/{id}"
      body: "*"
    };
...

body: "*"という行が含まれていますが、これはmessageのどの部分を(または全部を)bodyとして受け取るのかの指定です。
通常全フィールドを受け取るので"*"としています。

コンパイル

今回もprotoeasyでさくっとやります。--grpc-gatewayというオプションを付けてください。

$ protoeasy --go \ 
  --go-import-path=github.com/jun06t/grpc-sample/grpc-gateway/proto \
  --grpc --grpc-gateway ./proto

するとalive.pb.gw.gouser.pb.gw.goといったgateway用のコードが生成されます。

server

生成されたコードのインタフェースを実装します。

type aliveService struct{}

func (s *aliveService) GetStatus(ctx context.Context, in *pb.Empty) (*pb.AliveResponse, error) {
    return &pb.AliveResponse{Status: true}, nil
}

type userService struct{}

func (s *userService) GetUser(ctx context.Context, in *pb.GetUserRequest) (*pb.UserResponse, error) {
    return &pb.UserResponse{
        Id:   in.Id,
        Name: "Alice",
        Age:  20,
    }, nil
}

func (s *userService) UpdateUser(ctx context.Context, in *pb.UpdateUserRequest) (*pb.Empty, error) {
    log.Printf("update body is {id: %s, name: %s, age: %d}\n", in.Id, in.Name, in.Age)
    return &pb.Empty{}, nil
}

分かりやすいようにpath parameterやrequest bodyをログに出してます。

それぞれサーバに登録します。

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

    s := grpc.NewServer()
    pb.RegisterAliveServiceServer(s, new(aliveService))
    pb.RegisterUserServiceServer(s, new(userService))
    err = s.Serve(lis)
    if err != nil {
        log.Fatal(err)
    }
}

gateway proxy

endpointにgRPCサーバのエンドポイントをセットします。
またalive.pb.gw.gouser.pb.gw.goにある、RegisterXxxxHandler(ctx, mux, conn)というメソッドを使ってmultiplexerに登録します。

func newGateway(ctx context.Context, opts ...runtime.ServeMuxOption) (http.Handler, error) {
    mux := runtime.NewServeMux(opts...)
    dialOpts := []grpc.DialOption{grpc.WithInsecure()}
    conn, err := grpc.Dial(endpoint, dialOpts...)
    if err != nil {
        return nil, err
    }
    err = pb.RegisterAliveServiceHandler(ctx, mux, conn)
    if err != nil {
        return nil, err
    }
    err = pb.RegisterUserServiceHandler(ctx, mux, conn)
    if err != nil {
        return nil, err
    }

    return mux, nil
}

注意としてこの時のruntimeは標準パッケージでなく"github.com/grpc-ecosystem/grpc-gateway/runtime"というgrpc-gatewayのパッケージです。
あとは通常のサーバのようにListenします。

func Run(address string, opts ...runtime.ServeMuxOption) error {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    gw, err := newGateway(ctx, opts...)
    if err != nil {
        return err
    }

    return http.ListenAndServe(address, gw)
}

func main() {
    if err := Run(":3000"); err != nil {
        panic(err)
    }
}

動作確認

サーバを起動します。

$ go run server/main.go

次にgateway proxyを起動します。

$ go run gateway/main.go

GET /alive

curlGET /aliveを叩いてみます。

$ curl http://localhost:3000/alive
{"status":true}

GET /user/{id}

次にGET /user/{id}を叩いてみます。

$ curl http://localhost:3000/user/100
{"id":"100","name":"Alice","age":20}

ちゃんとpath parameterが反映されてますね。

GET /user

次にGET /user?group=ADMINを叩いてみます。

curl http://localhost:3000/user?group=ADMIN
{"group":"ADMIN","users":[{"name":"Alice","age":20},{"name":"Bob","age":24}]}

ちゃんとquery stringが反映されています。

PUT /user/{id}

最後にPUT /user/{id}を叩いてみます。

$ curl -XPUT http://localhost:3000/user/100 -d '{"name": "bob", "age": 16}'

サーバ側のログは

2017/11/14 23:25:52 update body is {id: 100, name: bob, age: 16}

となりました。問題なくrequest bodyを送ることができました。

ソース