移行前の課題
題材は、社内の複数サービスから呼ばれる UserService。当初は素朴な JSON/REST で実装されていたが、
利用サービスが増えるにつれて次の課題が顕在化した。
- 往復コスト — 1画面の描画で複数サービスが UserService を何度も叩き、JSON のシリアライズ/デシリアライズが積み上がる
- ペイロード肥大 — フィールド名を毎回送る JSON が、件数の多い一覧で無視できないサイズに
- 契約のドリフト — レスポンス形をドキュメント(OpenAPI)と別管理しており、実装とズレて事故が起きた
そこで「型を .proto に一本化し、内部通信を gRPC 化する」方針を立てた。以下、同じ機能を両者でどう書くか対比していく。
① .proto を起こす
移行の起点は契約の定義だ。既存 JSON のレスポンス形を .proto のメッセージへ写し取る。
syntax = "proto3";
package user.v1;
option go_package = "example.com/gen/user/v1;userv1";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message GetUserRequest { int64 id = 1; }
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
message ListUsersRequest {
int32 page_size = 1; // 1ページ件数
string page_token = 2; // 次ページのカーソル
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
💡 コード生成
protoc(または buf generate)で .proto から Go のサーバーインターフェースとクライアントスタブが生成される。以降の Go コードに出てくる pb.User や pb.UserServiceServer はこの生成物だ。型はここから来るので、手書きの構造体定義が不要になる。
② GetUser — REST 版 vs gRPC 版
1件取得を並べる。左が既存の REST(net/http)、右が gRPC のサービス実装だ。
// GET /users/{id}
func (s *Server) GetUser(
w http.ResponseWriter,
r *http.Request,
) {
id, err := strconv.ParseInt(
chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "bad id",
http.StatusBadRequest) // 400
return
}
u, err := s.repo.Find(r.Context(), id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found",
http.StatusNotFound) // 404
return
}
if err != nil {
http.Error(w, "internal",
http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type",
"application/json")
json.NewEncoder(w).Encode(toJSON(u))
}
// rpc GetUser(GetUserRequest) returns (User)
func (s *Server) GetUser(
ctx context.Context,
req *pb.GetUserRequest,
) (*pb.User, error) {
// 引数は型付き。手動パース不要
if req.GetId() <= 0 {
return nil, status.Error(
codes.InvalidArgument, "bad id")
}
u, err := s.repo.Find(ctx, req.GetId())
if errors.Is(err, ErrNotFound) {
return nil, status.Error(
codes.NotFound, "not found")
}
if err != nil {
return nil, status.Error(
codes.Internal, "internal")
}
// 戻り値も型付き。JSON 化は不要
return &pb.User{
Id: u.ID,
Name: u.Name,
Email: u.Email,
}, nil
}
対比から見える本質的な差は次の通り。
| 論点 | REST 版 | gRPC 版 |
|---|---|---|
| 入力 | URL/クエリを手動でパース | 型付き req をそのまま使用 |
| 出力 | json.Encode で明示的に直列化 | 型付き構造体を return するだけ |
| エラー | HTTP ステータス+文字列 | codes.* + status |
| ルーティング | 自前でパスを登録 | 生成コードが束ねる |
③ ListUsers — REST 版 vs gRPC 版
ページング付き一覧。ペイロードと型の差がより顕著になる。
// GET /users?page_size=20&page_token=...
func (s *Server) ListUsers(
w http.ResponseWriter,
r *http.Request,
) {
q := r.URL.Query()
size, _ := strconv.Atoi(
q.Get("page_size"))
token := q.Get("page_token")
users, next, err := s.repo.List(
r.Context(), size, token)
if err != nil {
http.Error(w, "internal",
http.StatusInternalServerError)
return
}
resp := listResp{
Users: toJSONList(users),
NextPageToken: next,
}
w.Header().Set("Content-Type",
"application/json")
json.NewEncoder(w).Encode(resp)
}
// rpc ListUsers(...) returns (ListUsersResponse)
func (s *Server) ListUsers(
ctx context.Context,
req *pb.ListUsersRequest,
) (*pb.ListUsersResponse, error) {
users, next, err := s.repo.List(
ctx,
int(req.GetPageSize()),
req.GetPageToken(),
)
if err != nil {
return nil, status.Error(
codes.Internal, "internal")
}
out := make([]*pb.User, 0, len(users))
for _, u := range users {
out = append(out, &pb.User{
Id: u.ID, Name: u.Name,
Email: u.Email,
})
}
return &pb.ListUsersResponse{
Users: out,
NextPageToken: next,
}, nil
}
💡 一覧で効くペイロード差
REST/JSON は1件ごとに "id" "name" "email" というキー名を繰り返し送る。件数が多いほどこの冗長さが効く。protobuf はフィールド番号で表すため一覧レスポンスが小さくなり、デシリアライズも速い——これが「サービス間の一覧 API を gRPC 化したい」動機の核心だ。
④ クライアント呼び出しの対比
呼び出す側も大きく変わる。REST は HTTP を組み立てて JSON をデコードするが、gRPC はローカル関数のように呼べる。
resp, err := http.Get(
"http://users/users/42")
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode == 404 {
return ErrNotFound
}
var u UserJSON
json.NewDecoder(resp.Body).
Decode(&u)
// u を使う
u, err := client.GetUser(ctx,
&pb.GetUserRequest{Id: 42})
if err != nil {
if status.Code(err) ==
codes.NotFound {
return ErrNotFound
}
return err
}
// u (*pb.User) をそのまま使う
⑤ 段階的移行の戦略
一気に切り替えるのは危険だ。既存の REST を止めずに gRPC を並走させ、徐々に寄せる。
| 手法 | 内容 |
|---|---|
| gRPC-Gateway 併用 | 1つの .proto から gRPC と REST/JSON の両方を提供。既存の REST クライアントを生かしたまま、内部は gRPC へ寄せられる |
| ストラングラーパターン | 新しい呼び出し経路から gRPC に切り替え、古い REST 経路を機能ごとに「絞め殺す」ように段階的に廃止する |
| トラフィックの段階切替 | 呼び出し元を1サービスずつ gRPC に移し、メトリクスで問題がないことを確認しながら比率を上げる |
| 契約の二重実装 | 移行期は同じリポジトリ層の上に REST ハンドラと gRPC サービスの薄い2枚を載せ、ロジックは共有する |
# フェーズ1:REST 単独(現状)
[caller] --REST--> [UserService(REST)]
# フェーズ2:gRPC を並走(ロジックは共有)
[caller A] --REST--> [UserService] <--gRPC-- [caller B(移行済)]
|
共有リポジトリ層
# フェーズ3:REST を gRPC-Gateway 経由に縮退
[外部/旧] --REST--> [gRPC-Gateway] --gRPC--> [UserService(gRPC)]
[内部] ------------- gRPC -------------> [UserService(gRPC)]
⑥ ハマりどころ
| つまずき | 対処 |
|---|---|
| ブラウザから叩けない | 外部公開層は gRPC-Web+プロキシ、または gRPC-Gateway で REST を残す |
| L4 LB で負荷が偏る | HTTP/2 多重化のため、リクエスト単位の L7 LB / サービスメッシュ(Envoy)を使う |
| デバッグ手段が変わる | curl の代わりに grpcurl、サーバーリフレクションを有効化 |
| 監視ダッシュボードが空に | HTTP ステータス前提の監視を gRPC status / メトリクスに作り替える |
| フィールド番号の事故 | 削除フィールドは reserved で凍結、番号を再利用しない |
| チームの学習コスト | .proto 運用・コード生成・CI 組み込みを先に整備してから移行を始める |
⚠️ 移行は「速くする」より「壊さない」が主目的
性能改善を急いで一斉切替すると、LB・監視・デバッグの未整備が一気に表面化する。並走 → 計測 → 段階切替の順を守り、各フェーズでロールバック可能にしておくことが安全な移行の鍵だ。
✅ 次の章では…
最終回 PART 05 では、ここまでの全観点を実務で使える設計観点チェックリストに再構成し、「優劣ではなく適材適所・併用が現実的」という結論をまとめる。