みなさんこんにちは、watnowの代表?をしている気がしている27卒のしょちぇむです。
今年はwatnowのアドベントカレンダー8日目を担当することになりました。
去年watnowの運営に参加することになったのですが、もう1年以上経っているそうです。あっという間ですね。
今回はインターンでキャッチアップをして現在実際に使っているgRPCについて紹介(アウトプット)したいと思います。
gRPCとは
gRPCはGoogleによって開発されたRPCフレームワークで、マイクロサービス間での通信や、フロントエンドとバックエンドサーバー間の通信で使われるものです。(ちなみに、gRPCでは、異なる言語間であっても通信がもちろんできます。)
そもそもRPCって?
RPCとは、Remote Procedure Callの略であり(知らなかった)、HTTPと同じクライアント・サーバ型の通信プロトコルみたいなものです。クライアント側はリモートの関数を呼び出すだけなため、処理の中身に関心を持つことなくレスポンスを受け取ることができます。
gRPCはこのRPCの概念を導入することで、サーバ上にある処理をクライアント上で実行することができます。
gRPCとHTTPの違い
簡単にネットに落ちているものやドキュメントを見て知った違いをまとめてみました。
| 比較ポイント | REST (HTTP/1.1) | gRPC (HTTP/2) |
|---|---|---|
| データ形式 | テキスト (JSON) | バイナリ (Protocol Buffers) |
| 通信プロトコル | HTTP/1.1 | HTTP/2 (ヘッダー圧縮・多重化) |
| API定義 | 任意 (OpenAPIなど) | 必須 (.protoファイルで行う) |
| 型安全性 | 弱い (ドキュメント依存になってしまう) | 強い (コード自動生成) |
| ストリーミング | リクエスト待ち→レスポンスのシンプルな往復が普通。WebSocket や SSE を別途構築すればストリーミングは実装できる。 | クライアント→サーバー、サーバー→クライアント、双方向 の4パターンのやり取りが可能であり、長時間のデータのやり取りやリアルタイム更新に有効 |
| ブラウザ対応 | ◎ (そのままブラウザで呼び出せる) | △ (gRPC-Webが必要になるため、簡単にはできない) |
参考:
何が嬉しいのか
ここで、個人的に使ってる中でこれ旨味だなあと思ったものを列挙します。
注意
完全主観で使ってていいなあと思ったものなので、それ目的じゃねえよ!と言ったコメントは勘弁してください🙏
普通にそんなん言われたら家に帰って一人で寂しく泣きます😭
Remote Procedure Callなので、関数を呼び出すイメージ。
GET、POST、PUT、DELETEといった「メソッド」にとらわれることなく、やりたい操作(関数)をそのまま実行することができます。
つまり、ビジネスロジックに沿った開発を行うことができるわけで、CQRSで実装するときに柔軟なコマンド、クエリを実装することができるのです。
gRPCではスキーマを.proto ファイルで定義し、コード生成を行うため、型安全を保証することができます。
例えばこんな感じで.protoのファイルで以下のように定義をして、
syntax = "proto3";
package user;
option go_package = "user/v1";
import "add_message.proto";
service UserService {
rpc Add (UserServiceAddRequest) returns (UserServiceAddResponse);
}
syntax = "proto3";
package user;
option go_package = "user/v1";
message UserServiceAddRequest {
string id = 1;
string name = 2;
}
message UserServiceAddResponse {
string id = 1;
}
goの生成コマンドを実行すると。。。
// Code generated by protoc-gen-go. DO NOT EDIT.
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
type UserServiceAddRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
}
func (x *UserServiceAddRequest) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *UserServiceAddRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type UserServiceAddResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
}
func (x *UserServiceAddResponse) GetId() string {
if x != nil {
return x.Id
}
return ""
}
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
type UserServiceClient interface {
Add(ctx context.Context, in *UserServiceAddRequest, opts ...grpc.CallOption) (*UserServiceAddResponse, error)
}
type userServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient {
return &userServiceClient{cc}
}
func (c *userServiceClient) Add(ctx context.Context, in *UserServiceAddRequest, opts ...grpc.CallOption) (*UserServiceAddResponse, error) {
out := new(UserServiceAddResponse)
err := c.cc.Invoke(ctx, "/user.UserService/Add", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
type UserServiceServer interface {
Add(context.Context, *UserServiceAddRequest) (*UserServiceAddResponse, error)
mustEmbedUnimplementedUserServiceServer()
}
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer) {
s.RegisterService(&UserService_ServiceDesc, srv)
}
と、このように簡単に生成できちゃいます。かなりの量の言語で対応しているので、どの言語を選んで実装しようとしても使うことができます。
自動で生成してくれるため、手製でschemaを定義して実装することがないから安全!
サービスごとに異なる言語を用いたい時でも、protoファイルを挟むことで各言語のサーバーコードとクライアントコードを自動生成できるため、多言語間の型変換を楽に行うことができ、呼び出すことができます。
まずRESTとgRPCのやり取りの違いを説明しましょう。どんな形式でやり取りしているかみてみましょう。
RESTではJSON形式でやりとりします。つまり、全てを文字列として送受信するわけです。
{
"id": 101,
"name": "Tanaka",
"email": "tanaka@example.com",
"is_active": true
}
下記のようにバイト列でやりとりすることになります。クライアントからメソッドが呼ばれた段階で、バイナリに変換する処理が組まれています。(知らなかった)
08 65 12 06 54 61 6e 61 6b 61 1a 12 ...
と、このようにやりとりするデータがバイナリの方が軽いので大量のデータをやりとりすることもでき、その通信も高速になるというわけです。素晴らしい!
何が辛いのか
これまで感じた旨味を書いて素晴らしい!というスタンスでいましたが、もちろん辛い部分もあるわけです。
gRPCは型安全で堅牢であることの裏返しで、変更に対しての柔軟性が低く、コストがかかるという特徴があります。自分が実装する中で困った例を挙げて説明します。
(1) 型を変更したいケース
最初、以下のように定義していたとします。
message UserServiceAddRequest {
int32 id = 1;
string name = 2;
}
message UserServiceAddResponse {
int32 id = 1;
}
そして開発中にidをuuidで定義することになったため、整数型として扱うのではなく、文字列で扱うようにしました。
message UserServiceAddRequest {
string id = 1;
string name = 2;
}
message UserServiceAddResponse {
string id = 1;
}
。。。実はこれ破壊的変更なんです。Protocol Buffersは当てているタグ番号(Field Number)でやりとりするデータをMappingします。そのため、型の不整合が起きてしまい、Deserializeが失敗します。そのため、スキーマ変更をするたびに再度、クライアント側も生成をさせる必要があるのです。
(2) フィールドを追加したいケース
同じく、最初に以下のように定義していたとします
message UserServiceAddRequest {
int32 id = 1;
string name = 2;
}
message UserServiceAddResponse {
int32 id = 1;
}
ここで新たにフィールドを追加することにします。
message UserServiceAddRequest {
int32 id = 1;
string name = 2;
}
message UserServiceAddResponse {
int32 id = 1;
string name = 2;
}
これでもし、クライアント側のコード生成をサボってしまうと通信エラーにはなりませんが、クライアント側のコードにGetName()が存在しないため、アプリケーションレイヤーで無視されることになるのです。
このようなスキーマ変更を安全に行うために、deprecatedとreservedを使って、タグ番号(Field Number)を使いまわさずに変更をするたびにその番号を使い捨てるというやり方があるのですが、長くなるのでここでは説明は控えておきます。(気になった人は調べてみてください。需要がありそうだったらまた記事にするかもしれません。)
と、このようにとにかくスキーマ変更がめちゃくちゃめんどくさいのです。
コードを自動で生成してくれるため、楽に感じるかもしれませんが、実際に実装していくとそう甘い話ではないことに気づくかと思います。騙される前にしっかり知っておいてもらいたいです。
自動生成された構造体(struct)は通信のやりとりのためのメタデータ(state、sizeCacheなど)を含んでいたり、全てのフィールドがパブリックであったりと、そのままドメインロジックで扱うには問題がありすぎます。
そのため、gRPCの型 → アプリケーションレイヤーの型のmapper(adapter)が必要になってくるわけです。ちなみに、ここでフィールドが抜けてしまっていると空文字で送られてしまいます。
func (e *entity) GetUser(...) (*pb.UserResponse, error) {
return &pb.UserResponse{
Id: e.ID,
Name: e.Name,
Email: e.Email,
}, nil
}
さらにさらに、Enumを扱うことになると以下のような処理が必要になります。
// アプリ側のEnumを、gRPCのEnumに変換する関数
func ToProtoStatus(s e.status) pb.UserStatus {
switch s {
case domain.StatusActive:
return pb.UserStatus_USER_STATUS_ACTIVE
case domain.StatusInactive:
return pb.UserStatus_USER_STATUS_INACTIVE
default:
return pb.UserStatus_USER_STATUS_UNKNOWN
}
}
ここで、Enumに新しい値を追加した時、このswitch文の更新を忘れると...もうお分かりだと思いますが、UserStatus_USER_STATUS_UNKNOWNが返ってしまうことになってしまいます。
話が戻りますが、変更に対する影響範囲も大きいのです。
何に向いているのか
gRPCを検討する方々の為にどのようなケースで使うと旨味を感じられるのか書いてみます。
(1)マイクロサービス間の通信を行いたい時
共通の.protoファイルさえあれば、各言語用のコードを自動生成して繋ぎ込みができ、またバイナリでやり取りをすることになるため、
- 大量のデータを高速にやり取りしたい
- 言語横断的に堅牢な型チェックをしたい
といったような要件に対してはgRPCは満たしてくれる気がします。
(2)強い型安全を重視するとき
.protoファイルは単なる設定ファイルではなく、サーバーとクライアントの間で交わされる「Contrct」であるため、OpenAI(Swagger)やドキュメントを書く必要がありません。また、gRPCではクライアントコード自体が型定義に基づいて生成されるため、実行時に型ミスマッチが起こりづらい強い環境を作ることができるため、強い型安全を重視するには最適だと思います。
(3)双方向通信・リアルタイム更新
サーバー ⇄ クライアントの双方向ストリーミングを実現できるため、リアルタイム通知や大量データの継続的処理を行う際には有効かと思います。
最後に
長くなりましたが、ざっとgRPCについてつらつらと書いてみました。これをきっかけに個人開発、チーム開発での技術選定の1つとして選択肢に入れてもらえると幸いです。
以上しょちぇむでした。謝謝。