Go
api
golang
gRPC
grapi
Go3Day 25

grapi : #golang で interface driven かつボイラプレートに悩まされない API 開発

Go Conference 2018 Spring にて, Go で快適に Web API 開発をするための CLI + ライブラリである grapi について話した.
本記事では,grapi で典型的なAPIをどう実装するかのワークフローとともに,grapi の特徴や思想を紹介する.

記事中では grapi v0.3.2 について扱う.

grapi の特徴・やること

  • 開発者は gRPC IDL でスキーマを定義し,gRPC server を実装する
  • Ruby on Rails を意識した file generator
    • rails new に対応した grapi init
    • rails gに対応した, grapi g NAMEgrapi generate NAME
      • Google API Design Guide に準拠
      • protobuf のスキーマや gRPC server の実装スケルトンも生成できる
      • → 開発者は server の中身の実装に集中できる
  • grapi server を実行すればデフォルトで :3000application/json な http server が立つ
    • ちょっと設定を変えれば application/grpc も話せるようになる

プロジェクト新規作成

grapi init APP_NAME でプロジェクトのボイラプレートが生成される.APP_NAME. を入れるとカレントディレクトリに生成される.
いまは2分くらいかかってるが,これは dep ensure がこっそり2回走っているのが原因.

asciicast

生成されるファイルは以下.

  • api
    • protobuf のスキーマ定義と生成されたコードが入る
  • app
    • アプリケーションコードが入る
    • cmd/server に合わせて pkg/server にしたら良かったってちょっと後悔している)
  • cmd
    • エントリポイント(main パッケージ)が入る
    • cmd/CMD_NAME があるとき grapi CMD_NAME でコマンドが起動できる(後述)
  • tools.go
    • gex が利用する,ツール間利用マニフェストファイル
    • protoc や grapi のプラグインはこのファイルで管理されている
  • Gopkg.{toml,lock}
    • dep
    • Go Module 安定したらそっちを柄用にしたい
  • grapi.toml
    • grapi の設定ファイル
    • protoc のプラグインや引数などもここに記述する
:) % tree -I "vendor|bin"
.
├── Gopkg.lock
├── Gopkg.toml
├── api
│   └── protos
│       └── type
├── app
│   ├── run.go
│   └── server
├── cmd
│   └── server
│       └── run.go
├── grapi.toml
└── tools.go

7 directories, 6 files

ここまでは大したことはしていない.

API 定義

asciicast

API スキーマの生成

grapi の API は gRPC の IDLgoogle.api.Http で HTTP へのマッピングを記述したもので定義される.
grapi g service でそのスキーマ定義 + Go の server 実装の雛形が生成できる.また,grapi g scaffold-service を利用すると Google API Design Guide の Standard Methods に則った形式の,いわゆる RESTful っぽいスキーマ定義を生成することができる.

ここは完全に rails g (scaffold_)?controller を意識している.
普通に gRPC server を実装しようとすると「.proto を書いて」「頑張って protoc の引数を組み立てて実行して」「生成された interface を実装した Go のオブジェクトを作る」までやったあとにようやくサーバの実装に取り掛かれる.
grapi g (scaffold)?-service だとその準備をすべてすっ飛ばして最低限の雛形ができるので,結構きもちいい.

API スキーマの更新

grapi による API 開発における開発者のメンタルモデルは,unary な gRPC server を実装しているときとだいたい同じ,大まかにつぎの3ステップのループになるはず(厳密にはテスト書いたりいろいろあるけど,そのへんはよしなに補完してほしい).

  1. .proto ファイルの更新
  2. protoc の実行
  3. 実装の変更

grapi はこのうち「protoc の実行」について面倒を見る.grapi protoc を叩くことで .proto ファイル全てに対してそれぞれ必要なプラグインすべてを実行してくれる.これは先述した grapi.toml に記述されているとおりに実行される.

# `grapi init` で生成される `grapi.toml` の一部
# 標準では `protoc-gen-go`, `protoc-gen-grpc-gateway`, `protoc-gen-swagger` の3つが利用される

[protoc]
protos_dir = "./api/protos"
out_dir = "./api"
import_dirs = [
  "./api/protos",
  "./vendor/github.com/grpc-ecosystem/grpc-gateway",
  "./vendor/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis",
]

  [[protoc.plugins]]
  name = "go"
  args = { plugins = "grpc", paths = "source_relative" }

  [[protoc.plugins]]
  name = "grpc-gateway"
  args = { logtostderr = true, paths = "source_relative" }

  [[protoc.plugins]]
  name = "swagger"
  args = { logtostderr = true }

API 実装

とりあえずモックデータを返す

asciicast

grapi init で生成したプロジェクトでは,grapi server で API サーバを起動できる.
しかし,grapi g (scaffold-)?service でファイル生成した直後は,どれだけ curl しても返事がない(正確には 501 Not Implemented が返ってくるが).

$ curl localhost:3000/todos                                                                                     
{"code":12,"message":"Not Implemented"}

まず最初に,grapi のサーバに生成されたサーバ実装を登録する必要がある.

diff --git a/app/run.go b/app/run.go
index c9c9016..46ac33c 100644
--- a/app/run.go
+++ b/app/run.go
@@ -1,6 +1,7 @@
 package app

 import (
+   "github.com/izumin5210-sandbox/todoapp-grapi/app/server"
    "github.com/izumin5210/grapi/pkg/grapiserver"
 )

@@ -9,7 +10,7 @@ func Run() error {
    s := grapiserver.New(
        grapiserver.WithDefaultLogger(),
        grapiserver.WithServers(
-       // TODO
+           server.NewTodoServiceServer(),
        ),
    )
    return s.Serve()

ここで再度 curl すると,微妙に結果が変わる.

$ curl localhost:3000/todos                                                                                     
{"code":12,"message":"TODO: You should implement it!"}

これは grapi g (scaffold-)?service で生成された Go のコードがそういう実装になっているため.

// `grapi g scaffold-service todo` で生成された Go のコードの一部
// rpc の実装はすべて `codes.Unimplemented` を返すようになっている

func (s *todoServiceServerImpl) ListTodos(ctx context.Context, req *api_pb.ListTodosRequest) (*api_pb.ListTodosResponse, error) {
    // TODO: Not yet implemented.
    return nil, status.Error(codes.Unimplemented, "TODO: You should implement it!")
}

ここのコードをとりあえずモックを返すように書き換えてみる.

diff --git a/app/server/todo_server.go b/app/server/todo_server.go
index eab2a9c..f71bb38 100644
--- a/app/server/todo_server.go
+++ b/app/server/todo_server.go
@@ -26,8 +26,14 @@ type todoServiceServerImpl struct {
 }

 func (s *todoServiceServerImpl) ListTodos(ctx context.Context, req *api_pb.ListTodosRequest) (*api_pb.ListTodosResponse, error) {
-   // TODO: Not yet implemented.
-   return nil, status.Error(codes.Unimplemented, "TODO: You should implement it!")
+   return &api_pb.ListTodosResponse{
+       Todos: []*api_pb.Todo{
+           &api_pb.Todo{TodoId: "1", Title: "Write Go 4 Advent Calendar at 2018/12/05", Done: true},
+           &api_pb.Todo{TodoId: "2", Title: "Write Go 2 Advent Calendar at 2018/12/15", Done: true},
+           &api_pb.Todo{TodoId: "3", Title: "Write Go 3 Advent Calendar at 2018/12/20", Done: true},
+           &api_pb.Todo{TodoId: "4", Title: "Write Go 3 Advent Calendar at 2018/12/20", Done: false},
+       },
+   }, nil
 }

 func (s *todoServiceServerImpl) GetTodo(ctx context.Context, req *api_pb.GetTodoRequest) (*api_pb.Todo, error) {

これで改めて curl をすると,それっぽい JSON が返ってくるようになる.

$ curl localhost:3000/todos | jq .                                                                              
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current                                    
                                 Dload  Upload   Total   Spent    Left  Speed                                      
100   315  100   315    0     0  45559      0 --:--:-- --:--:-- --:--:-- 52500                                     
{                                                                                                                  
  "todos": [                                                                                                       
    {                                                                                                              
      "todo_id": "1",                                                                                              
      "title": "Write Go 4 Advent Calendar at 2018/12/05",                                                         
      "done": true                                                                                                 
    },                                                                                                             
    {                                                                                                              
      "todo_id": "2",                                                                                              
      "title": "Write Go 2 Advent Calendar at 2018/12/15",                                                         
      "done": true                                                                                                 
    },                                                                                                             
    {                                                                                                              
      "todo_id": "3",                                                                                              
      "title": "Write Go 3 Advent Calendar at 2018/12/20",                                                         
      "done": true                                                                                                 
    },                                                                                                             
    {                                                                                                              
      "todo_id": "4",                                                                                              
      "title": "Write Go 3 Advent Calendar at 2018/12/20"                                                          
    }                                                                                                              
  ]                                                                                                                
}

grapi が面倒を見てくれるのはここまで: API Desing Guide に従ったコード生成と protoc のラップ,あとはコマンドの実行とビルドだけ.
実際のアプリケーション開発はここからが本番になるが,その「本番」に至るまでの瑣末事を肩代わりするのが grapi の仕事になる.

おまけ

DB をつなぎ込む

実際にデータ永続化とかをするときは,*sql.DB などのコネクションなりクライアントなりを fooServiceServerImpl struct にもたせると良い.
ちょっと古い & 内容が異なるサンプルだけど,Go + grpc-gateway でつくる JSON API 速習会 @ Wantedly で利用したサンプルリポジトリの sqlx 導入 PR が参考になると思う.

アプリケーションが大きくなってくると server に *sql.DB を直接もたせるんじゃなくて,DAO や repository などの抽象化パターンの導入を検討するといい.

gRPC を使う

grapi init した直後のプロジェクトは普通に localhost:3000application/json で話しかけると返事をしてくれる.
これは内部で2つのサーバを動かすことで実現されている.

  • gRPC server
    • unix domain socket(デフォルトだと tmp/server.sock)を listen
  • grpc-gateway server
    • tcp の :3000 を listen
    • gRPC client デフォルトだと tmp/server.sock にある gRPC server を見に行く

これらのサーバがどこを listen するかは,以下に挙げる grapi の起動オプションで切り替えることが可能.

当然だけど,grpc-gateway を経由せず gRPC server と直接 application/grpc で通信することもできる.

なので,「今後 gRPC を導入していきたいのでアプリケーションレイヤでの知見がほしい・IDL を先行利用していきたい」みたいなユースケースでとりあえず grapi を使っておいて,サーバ・クライアント・インフラすべての準備が整ったタイミングで gateway を剥がす…という使い方も可能(というか,まさにその用途で使いたくて grapi を作った).

既存のパッケージ・ツールとの比較

ginecho, net/http.Server を使った実装との比較のメリットとしては,やはり json.Unmarshal をしなくていいことに尽きると思っている,ちゃんとした struct の形でパラメタが渡ってくるのが一番嬉しい.これはコード生成ベースでやることの大きな強みである.

goa は同じくコード生成ベース・interface driven な開発ができるパッケージである.これに対する強みとしては前述した通り「gRPC 移行への前段階として利用できる」ほか,「protobuf は実装言語に依存しない IDL で記述するので,多言語・プラットフォームからの利用も簡単」というのは大きいはず.

生 gRPC server との比較としては…,「gRPC をいつでも使いはじめられる環境」が既にあるのなら grpc-gateway を通さずに使うべきである.grpc-gateway を使わないとしても,grapi のもつ Google API Design Guide に則ったボイラプレート生成は有効に使えると思う.また,この「gRPC をいつでも使いはじめられる環境」というのは実は結構難度が高い(インフラ的な障壁, スキーマ共有どうする, 生成コードはどう扱う, etc).なので,そこまですぐに用意できない・だけど近い内に gRPC を導入しておきたい という状況のときには grpc-gateway を内包する grapi は強い味方になると思う.

ちなみに,「IDL から(サーバ・クライアントの)コード生成」というのは microservices architecture かつサービス数が増えれば増えるほど欲しくなってくる.なので,そういう状況に置かれてる現場では gRPC の投入を見越しつつ grapi の採用を検討してもらえると嬉しい.

雑感

grpc-gateway は直感的でわかりやすいんだけど,むだに json -> pb 変換が挟まるので用途によってはパフォーマンスが気になるかもしれない.
grpc-gateway 以外の,でも gRPC は使わないアプローチとしては

  • reflection protocol をうまく使ったやり方(e.g. mercari/grpc-http-proxy)を考える
  • protobuf をしゃべる http client を生成 & protobuf をそのまま proxy するだけの server を作る
  • protobuf IDL から http.Server & HTTP クライアントを生成

等があるかもしれない.
一方で,protobuf & gRPC まわりはかなりエコシステムが発展してきているので,最終的には生で gRPC を利用できるに越したことはないはず.