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 NAME
(grapi generate NAME
)- Google API Design Guide に準拠
- protobuf のスキーマや gRPC server の実装スケルトンも生成できる
- → 開発者は server の中身の実装に集中できる
-
-
grapi server
を実行すればデフォルトで:3000
にapplication/json
な http server が立つ- ちょっと設定を変えれば
application/grpc
も話せるようになる
- ちょっと設定を変えれば
プロジェクト新規作成
grapi init APP_NAME
でプロジェクトのボイラプレートが生成される.APP_NAME
に .
を入れるとカレントディレクトリに生成される.
いまは2分くらいかかってるが,これは dep ensure
がこっそり2回走っているのが原因.
生成されるファイルは以下.
-
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 定義
API スキーマの生成
grapi の API は gRPC の IDL に google.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ステップのループになるはず(厳密にはテスト書いたりいろいろあるけど,そのへんはよしなに補完してほしい).
-
.proto
ファイルの更新 -
protoc
の実行 - 実装の変更
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 実装
とりあえずモックデータを返す
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:3000
に application/json
で話しかけると返事をしてくれる.
これは内部で2つのサーバを動かすことで実現されている.
- gRPC server
- unix domain socket(デフォルトだと
tmp/server.sock
)を listen
- unix domain socket(デフォルトだと
-
grpc-gateway server
- tcp の
:3000
を listen - gRPC client デフォルトだと
tmp/server.sock
にある gRPC server を見に行く
- tcp の
これらのサーバがどこを listen するかは,以下に挙げる grapi の起動オプションで切り替えることが可能.
- grapiserver.WithGrpcAddr
- grapiserver.WithGrpcInternalAddr
- grapiserver.WithGatewayAddr
- grapiserver.WithAddr
当然だけど,grpc-gateway を経由せず gRPC server と直接 application/grpc
で通信することもできる.
なので,「今後 gRPC を導入していきたいのでアプリケーションレイヤでの知見がほしい・IDL を先行利用していきたい」みたいなユースケースでとりあえず grapi を使っておいて,サーバ・クライアント・インフラすべての準備が整ったタイミングで gateway を剥がす…という使い方も可能(というか,まさにその用途で使いたくて grapi を作った).
既存のパッケージ・ツールとの比較
gin や echo, 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 を利用できるに越したことはないはず.