はじめに
初めまして、k.s.ロジャースの西谷です。
自分自身はPHPで開発することが多いですが、他のプロジェクトではGoを利用しており、その中でREST APIからgRPCに移行する話が出てきています。
しかし、過渡期ということもあり、REST APIとgRPC両方の対応をする方針で進んでいます。
この場合はRESTとgRPCの実装を分けてしまうと、実装時も大変ですが、リリース後の改修等のメンテナンスコストも大きく上がるため、良くないと考えています。
そこで、grpc-gatewayを使えばgRPCをREST APIで叩けると聞いたため、今回検証をしてみようと思います。
今回は全体の流れを大まかに記載し、次回以降各部位の詳細について記載出来たらと思います。
間違い・助言等があればコメントにてお知らせいただけたらと思います。
環境構築
環境構築については他の記事にお任せしたいと思います。
本検証のために以下の記事が非常に参考になりました。
https://budougumi0617.github.io/2018/02/03/grpc-gateway-for-rest-api/
https://christina04.hatenablog.com/entry/2017/11/15/034455
protbufでAPIの定義
今回はサンプルとしてユーザのCRUDを作ろうと思います。
つまり次のAPIを作成する必要があります。
Method | URL | 役割 | |
---|---|---|---|
ListUsers | GET | /api/v1/users | ユーザ一覧取得 |
GetUser | GET | /api/v1/users/{encrypted_id} | 1ユーザを取得 |
CreateUser | POST | /api/v1/users | ユーザ作成 |
UpdateUser | PUT | /api/v1/users/{encrypted_id} | ユーザ更新 |
DeleteUser | DELET | /api/v1/users/{encrypted_id} | ユーザ削除 |
そこで以下のprotoファイルを定義しました。
syntax = "proto3";
package sample;
import "google/api/annotations.proto";
service UserService {
rpc ListUsers (ListUserRequest) returns (ListUsersResponses) {
option (google.api.http) = {
get: "/api/v1/users"
};
};
rpc GetUser (GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/api/v1/users/{encrypted_id}"
};
};
rpc CreateUser (CreateUserRequest) returns (User) {
option (google.api.http) = {
post: "/api/v1/users"
body: "*"
};
};
rpc UpdateUser (UpdateUserRequest) returns (User) {
option (google.api.http) = {
put: "/api/v1/users/{encrypted_id}"
body: "*"
};
};
rpc DeleteUser (DeleteUserRequest) returns (Empty) {
option (google.api.http) = {
delete: "/api/v1/users/{encrypted_id}"
};
};
}
message Empty {
}
message ListUserRequest {
}
message GetUserRequest {
string encrypted_id = 1;
}
message CreateUserRequest {
string name = 1;
}
message UpdateUserRequest {
string encrypted_id = 1;
string name = 2;
}
message DeleteUserRequest {
string encrypted_id = 1;
}
message User {
string encrypted_id = 1;
string name = 2;
}
message ListUsersResponses {
repeated User users = 1;
}
上記ファイル作成後、以下コマンドでgRPC用のコードとgatewayのコードが生成されます。
protoc -I ./proto ./proto/sample.proto \
-I$GOPATH/src \
-I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
--grpc-gateway_out=logtostderr=true:./proto \
--go_out=plugins=grpc:proto
gRPCサーバの実装
サーバはgolangで実装しました。
本来はDB接続を行いますが、今回はAPIの動作確認が目的のため変数で代用しました
また、エラー処理も一切含まれていません。
package main
import (
"flag"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
pb "grpc-gateway/proto"
"log"
"math/rand"
"net"
"time"
)
// DBの代わり(手抜きすみません)
var USERS = map[string]string{"12345abcde": "hogehoge", "zxcvb09876": "fugafuga"}
type userService struct{}
// ユーザ一覧取得
func (e *userService) ListUsers(ctx context.Context, req *pb.ListUserRequest) (*pb.ListUsersResponses, error) {
var users = []*pb.User{}
for k, v := range USERS {
users = append(users, &pb.User{EncryptedId: k, Name: v})
}
return &pb.ListUsersResponses{Users: users}, nil
}
// 1ユーザの取得(本来は詳細情報を付与して返す)
func (e *userService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
return &pb.User{EncryptedId: req.EncryptedId, Name: USERS[req.EncryptedId]}, nil
}
// ユーザの作成
func (e *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
encrypedId := GetRandString(10)
USERS[encrypedId] = req.Name
return &pb.User{EncryptedId: encrypedId, Name: req.Name}, nil
}
// ユーザの更新
func (e *userService) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) {
USERS[req.EncryptedId] = req.Name
return &pb.User{EncryptedId: req.EncryptedId, Name: req.Name}, nil
}
// ユーザ削除
func (e *userService) DeleteUser(ctx context.Context, req *pb.DeleteUserRequest) (*pb.Empty, error) {
delete(USERS, req.EncryptedId)
return &pb.Empty{}, nil
}
// 乱数の生成(encrypted_id用)
func GetRandString(n int) string {
var letterRunes = []rune("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func main() {
rand.Seed(time.Now().UnixNano())
var port string
flag.StringVar(&port, "port", "50000", "")
flag.Parse()
listen, err := net.Listen("tcp", ":"+port)
if err != nil {
log.Fatalln(err)
}
server := grpc.NewServer()
pb.RegisterUserServiceServer(server, &userService{})
reflection.Register(server)
if err := server.Serve(listen); err != nil {
log.Fatalln(err)
}
}
go run server/main.go
などのコマンドで実行できます。
gRPCクライアント
REST APIの前にgRPCのクライアントを実装して動作確認します。
package main
import (
"bufio"
"flag"
"fmt"
"golang.org/x/net/context"
"google.golang.org/grpc"
"log"
"os"
"time"
pb "grpc-gateway/proto"
)
// 全ユーザ取得
func ListUser(conn *grpc.ClientConn) {
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.ListUsers(ctx, &pb.ListUserRequest{ })
if err != nil {
log.Fatalln(err)
}
for _, v := range resp.Users {
log.Printf("EncryptedId: %s, Name: %s\n", v.EncryptedId, v.Name)
}
}
// 1ユーザ取得
func GetUser(conn *grpc.ClientConn, encryptedId string){
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{EncryptedId:encryptedId})
if err != nil {
log.Fatalln(err)
}
log.Printf("EncryptedId: %s, Name: %s\n", resp.EncryptedId, resp.Name)
}
// ユーザ作成
func CreatUser(conn *grpc.ClientConn, name string){
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.CreateUser(ctx, &pb.CreateUserRequest{Name:name})
if err != nil {
log.Fatalln(err)
}
log.Printf("EncryptedId: %s, Name: %s\n", resp.EncryptedId, resp.Name)
}
// ユーザ更新
func UpdateUser(conn *grpc.ClientConn, encryptedId string,name string){
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.UpdateUser(ctx, &pb.UpdateUserRequest{EncryptedId:encryptedId, Name:name})
if err != nil {
log.Fatalln(err)
}
log.Printf("EncryptedId: %s, Name: %s\n", resp.EncryptedId, resp.Name)
}
// ユーザ削除
func DeleteUser(conn *grpc.ClientConn, encryptedId string){
client := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := client.DeleteUser(ctx, &pb.DeleteUserRequest{EncryptedId:encryptedId})
if err != nil {
log.Fatalln(err)
}
log.Printf("deleted")
}
// 標準入力受付
func GetStdInput(message string) string {
fmt.Printf(message)
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
return scanner.Text()
}
func main() {
var addr string
flag.StringVar(&addr, "addr", "localhost:50000", "")
flag.Parse()
conn, err := grpc.Dial(addr, grpc.WithInsecure())
if err != nil {
log.Fatalln(err)
}
defer conn.Close()
L:
for true {
cmd := GetStdInput("Input ListUsers/GetUser/CreateUser/UpdateUser/DeleteUser/exit\n")
switch cmd {
case "ListUsers":
ListUser(conn)
case "GetUser":
encryptedId := GetStdInput("encryptedId:")
GetUser(conn, encryptedId)
case "CreateUser":
name := GetStdInput("name:")
CreatUser(conn, name)
case "UpdateUser":
encryptedId := GetStdInput("encryptedId:")
name := GetStdInput("name:")
UpdateUser(conn, encryptedId, name)
case "DeleteUser":
encryptedId := GetStdInput("encryptedId:")
DeleteUser(conn, encryptedId)
case "exit":
break L
}
}
}
go run client/main.go
などのコマンドで実行できます。
次のように対話形式でAPIを実行出来ると思います。
(データは内部変数でしか保持していないため、サーバを終了するとリセットされます)
REST API対応
こちらは表題のgrpc-gatewayを利用すれば簡単に出来ました。
package main
import (
"flag"
"net/http"
"github.com/golang/glog"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"golang.org/x/net/context"
"google.golang.org/grpc"
pb "grpc-gateway/proto"
)
var (
userServiceEndpoint = flag.String("user_service_endpoint", "localhost:50000", "Users sample")
)
func run() error {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithInsecure()}
err := pb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, *userServiceEndpoint, opts)
if err != nil {
return err
}
return http.ListenAndServe(":50001", mux)
}
func main() {
flag.Parse()
defer glog.Flush()
if err := run(); err != nil {
glog.Fatal(err)
}
}
実装はREADMEの「Write an entrypoint」を数行変更するくらいで、むしろgRPCのサーバとクライアントの実装が余程時間がかかりました。
(省略)
// 各自のprotoを参照するように変更
- gw "path/to/your_service_package"
+ pb "grpc-gateway/proto"
(省略)
var (
// ポートと名前を変更
- echoEndpoint = flag.String("echo_endpoint", "localhost:9090", "endpoint of YourService")
+ userServiceEndpoint = flag.String("user_service_endpoint", "localhost:50000", "Users sample")
)
(省略)
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithInsecure()}
// protobufから生成したサービスに変更
- err := gw.RegisterYourServiceHandlerFromEndpoint(ctx, mux, *echoEndpoint, opts)
+ err := pb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, *userServiceEndpoint, opts)
if err != nil {
return err
}
go run gateway/main.go
などのコマンドで実行できます。
serverとgatewayが実行されている状態でREAT APIを実行できます。
テストクライアントはinsomniaを利用しました。
- 新規ユーザの追加
- ユーザ一覧取得
終わりに
今回はgrpc-gatewayを用いたREST対応について説明しました。
検証の意味合いが強く、説明が不十分な箇所は多々あると思います。
需要があれば、次回以降でgRPCの最初から詳しく調査して記事にしていこうと思います。
Wantedlyでもブログ投稿してます
Techブログに加えて会社ブログなどもやっているので、気になった方はぜひ覗いてみてください。
https://www.wantedly.com/companies/ks-rogers