3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go + grpcでREST APIの移行を実装しながら考えてみた

Last updated at Posted at 2025-12-20

はじめに

この記事は スタンバイ Advent Calendar 2025 の21日目の記事です。
昨日の記事は @n-gondo123 さんの「dbt macro のユニットテストについて考えてみた」でした。

ごあいさつ

株式会社スタンバイで求人取り込み部分のエンジニアをやっております、本田と申します!
私のグループでは、求人取り込みを行うシステムのリプレイスやFlinkを用いて大量の求人のストリーム処理などを行うことでコスト削減などに取り組んでいます!(一昨年のコスト削減記事正規表現の違い紹介記事)

求人取り込みを行うシステムの一環で、求人の一覧ページや詳細ページなどの情報を返すAPIがあります。
スタンバイではBFFのAPIがあり、様々なマイクロサービスAPIから情報取得を行っており、この求人の一覧情報を返すAPIもそのうちの一つとなっています。(一昨年のコスト削減記事の求人詳細API部分)

今後システムの機能が増えるなどにつれてBFFのAPIのレイテンシや将来性を考えた際にマイクロサービス等のgrpc変更を達成するとより低レイテンシ、高スループット(RESTとgRPCの比較)を提供できる可能性があり、このAPIをgrpcに移行すると考えた時に、どのようなことを考えれば良いかを考えてまとめようと思い、アドベントカレンダーに参加させていただきました!

現状のAPIの概要

アーキテクチャ図

簡単なアーキテクチャ図が下記になります。
データソースにDynamodbを使っており、Postのみを受け付けるWebAPIになります。
スクリーンショット 2025-12-09 13.27.48.png

IN-OUT

IN-OUTについては、簡易的ですがこのようなAPIで考えます。

下記のように/jobsに対して求人IDの配列を要素としたJSONを投げてやると

curl -X POST http://localhost:8080/jobs -H "Content-Type: application/json" -d '{"jobIds": ["job_a", "job_b"]}'
{
  "job": [
    {
      "jobId": "job_a",
      "jobTitle": "Software Engineer",
      "jobContent": "Develop awesome software."
    },
    {
      "jobId": "job_b",
      "jobTitle": "Product Manager",
      "jobContent": "Manage product roadmap."
    }
  ]
}

このようにjobId, jobTitle, jobContentを要素と持つ求人情報の配列を返すものとします。

コードで表現してみる。

Goで実装してみました。今回はgrpcにどう移行するかbeforeという形になります。
コードは「arata-honda-stb/job-detail-api-go」にて全て載っています。
localstackにてDynamodblocalをエミュレートし、それに初期データを登録してgoのWebApiで取得するといった簡易的なものになっております。

下記のようなデータが入っているのが、localstackのページと連携することでみることができます。

image.png

実装に時間をかけたくなかったので、今回はClineを用いております。

各ファイルの責務

main.go: handlerやdynamodbClientの作成
handler.go: リクエストの受け取り、dynamodbの操作等(こちらは本来リポジトリなどでやったほうがいい)
job.go: モデル部分
main.go
package main

func main() {
	// AWS設定の読み込み
	// LocalStack対応: AWS_ENDPOINT_URLがある場合はダミー認証情報を設定
	var opts []func(*config.LoadOptions) error
	if os.Getenv("AWS_ENDPOINT_URL") != "" {
		opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("dummy", "dummy", "")))
		if os.Getenv("AWS_REGION") == "" {
			opts = append(opts, config.WithRegion("ap-northeast-1"))
		}
	}

	cfg, err := config.LoadDefaultConfig(context.TODO(), opts...)
	if err != nil {
		log.Fatalf("unable to load SDK config, %v", err)
	}

	// DynamoDBクライアントの作成
	// LocalStack対応: AWS_ENDPOINT_URL環境変数があればBaseEndpointとして設定
	svc := dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
		if endpoint := os.Getenv("AWS_ENDPOINT_URL"); endpoint != "" {
			o.BaseEndpoint = aws.String(endpoint)
		}
	})

	tableName := os.Getenv("TABLE_NAME")
	if tableName == "" {
		tableName = "Jobs"
	}

	h := &internal.Handler{
		Client:    svc,
		TableName: tableName,
	}

	// ルーティング設定
	http.HandleFunc("/jobs", h.GetJobs)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	log.Printf("Server starting on port %s...", port)
	if err := http.ListenAndServe(":"+port, nil); err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

handler.go
package internal

type JobRequest struct {
	JobIDs []string `json:"jobIds"`
}

type JobResponse struct {
	Jobs []Job `json:"job"`
}

type Handler struct {
	Client    *dynamodb.Client
	TableName string
}

func (h *Handler) GetJobs(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req JobRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	if len(req.JobIDs) == 0 {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(JobResponse{Jobs: []Job{}})
		return
	}

	// DynamoDB BatchGetItem Keys作成
	// 重複を除去するなどしたほうが良いが、簡易実装とする
	keys := make([]map[string]types.AttributeValue, 0, len(req.JobIDs))
	seen := make(map[string]bool)

	for _, id := range req.JobIDs {
		if _, exists := seen[id]; !exists {
			seen[id] = true
			keys = append(keys, map[string]types.AttributeValue{
				"jobId": &types.AttributeValueMemberS{Value: id},
			})
		}
	}

	// BatchGetItemの制限(最大100件)があるため、実際の運用では分割が必要だが
	// 簡易実装のため、ここではそのままリクエストする
	input := &dynamodb.BatchGetItemInput{
		RequestItems: map[string]types.KeysAndAttributes{
			h.TableName: {
				Keys: keys,
			},
		},
	}

	result, err := h.Client.BatchGetItem(context.TODO(), input)
	if err != nil {
		log.Printf("failed to get items: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var jobs []Job
	if items, ok := result.Responses[h.TableName]; ok {
		if err := attributevalue.UnmarshalListOfMaps(items, &jobs); err != nil {
			log.Printf("failed to unmarshal items: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if jobs == nil {
		jobs = []Job{}
	}

	resp := JobResponse{
		Jobs: jobs,
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(resp); err != nil {
		log.Printf("failed to encode response: %v", err)
	}
}

job.go
package internal

type Job struct {
	JobID      string `json:"jobId" dynamodbav:"jobId"`
	JobTitle   string `json:"jobTitle" dynamodbav:"jobTitle"`
	JobContent string `json:"jobContent" dynamodbav:"jobContent"`
}

起動して、叩いてみる
server側

$ AWS_ENDPOINT_URL=http://localhost:4566 go run cmd/main.go
2025/12/09 10:30:05 Server starting on port 8080...

client側

$ curl -X POST http://localhost:8080/jobs -H "Content-Type: application/json" -d '{"jobIds": ["job_a", "job_b"]}'|jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   216  100   186  100    30   9756   1573 --:--:-- --:--:-- --:--:-- 11368
{
  "job": [
    {
      "jobId": "job_a",
      "jobTitle": "Software Engineer",
      "jobContent": "Develop awesome software."
    },
    {
      "jobId": "job_b",
      "jobTitle": "Product Manager",
      "jobContent": "Manage product roadmap."
    }
  ]
}

まず何をどうして移行すればいいのか

まず、gRPCのコアコンセプトを理解する必要があります。
公式のページを読みます。

抜粋すると

  • サービス定義

gRPCはサービスを定義し、パラメータと戻り値の型を指定してリモートから呼び出せるメソッドを指定するという考え方に基づいています。デフォルトでは、gRPCはプロトコルバッファを使用します。サービスインターフェースとペイロードメッセージの構造を記述するためのインターフェース定義言語(IDL)として使用されます。

つまり、まずはプロトコルバッファを使用して、リクエスト、レスポンスなどを定義するところからスタートかなと思いました。

.protoのファイルをつくる

https://protobuf.dev/programming-guides/proto3/
に従い.protoを作っていきます。

ベストプラクティスを読む

まずは、ベストプラクティスの非推奨を確認します。

基本的には、クライアントとサーバーがまったく同時に定義の更新が行われることがないので、破壊的な変更はしてはいけない考え方なのかなと読み取りました。
いくつか抜粋します。

  • Do Reserve Tag Numbers for Deleted Fields(フィールド削除のためにTag番号を予約しろ)

When you delete a field that’s no longer used, reserve its tag number so that no one accidentally re-uses it in the future. Just reserved 2, 3; is enough. No type required (lets you trim dependencies!). You can also reserve names to avoid recycling now-deleted field names: reserved "foo", "bar";.

使用されなくなったフィールドを削除するときは、将来誤って再利用しないように、そのタグ番号(string job_id = 1 <- この番号;)を予約してください。型は必要ありません。また、削除されたフィールド名を再利用しないように名前を予約することもできます (reserved "foo", "bar";)。

message Foo {
  reserved 2, 15, 9 to 11;
}
  • Don’t Change the Type of a Field(フィールドの型を変更するな)
  • Don’t Add a Required Field(必須フィールドを追加するな)
  • Don’t Make a Message with Lots of Fields(たくさんのフィールドを持つメッセージを作るな)

Don’t make a message with “lots” (think: hundreds) of fields. In C++ every field adds roughly 65 bits to the in-memory object size whether it’s populated or not (8 bytes for the pointer and, if the field is declared as optional, another bit in a bitfield that keeps track of whether the field is set). When your proto grows too large, the generated code may not even compile (for example, in Java there is a hard limit on the size of a method ).

C++などフィールドごとに65ビットのメモリサイズを確保するので、デカすぎるメッセージはコンパイルできない可能性がでてくる(もちろんJavaのメソッドサイズなども)

  • Don’t Change the Default Value of a Field(フィールドのデフォルト値を変更するな)

クライアントとサーバーの間で言語違いがあった場合、これらのデフォルト値は異なる結果を招く可能性があります。

また、protoファイルの置き場については下記で言及されていました
https://protobuf.dev/programming-guides/proto3/

Prefer not to put .proto files in the same directory as other language sources. Consider creating a subpackage proto for .proto files, under the root package for your project.

.proto ファイルを他の言語ソースと同じディレクトリに置かないことをお勧めします。プロジェクトのルート パッケージの下に、.proto ファイルのサブパッケージ proto を作成することを検討してください。

なので、まずは.protoのディレクトリを別ディレクトリでおこうと思います。

ディレクトリをつくって、サービス定義をしてみる

現状

$ tree
.
├── cmd
│   └── main.go
├── docker
│   ├── docker-compose.yml
│   └── init
│       └── 01_create_table.sh
├── go.mod
├── go.sum
├── internal
│   ├── handler.go
│   └── job.go
└── README.md

ここにprotoディレクトリを掘ってjobの構造体をprotoで定義してみます。

$ tree
.
├── cmd
│   └── main.go
├── docker
│   ├── docker-compose.yml
│   └── init
│       └── 01_create_table.sh
├── go.mod
├── go.sum
├── internal
│   ├── handler.go
│   └── job.go
├── proto <- NEW
└── README.md

次に、go言語に自動生成するprotoのライブラリをインストールします

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && protoc --version
go: downloading google.golang.org/protobuf v1.36.10
go: downloading google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0
go: downloading google.golang.org/grpc v1.77.0
libprotoc 28.3

サービス定義を行っていきます。

job.proto
// protoのバージョンの宣言
syntax = "proto3";

// packageの宣言
package job;

// protoファイルから自動生成させるGoのコードの置き先(この場合だとpkg/grpcディレクトリ下にできる
option go_package = "job-detail-api-go/pkg/grpc";

// サービスの定義
service JobService {
  // サービスが持つメソッドの定義
  // RPCがメソッド単位なので、メソッドしか定義しない
  rpc GetJobs (GetJobsRequest) returns (GetJobsResponse);
}

// 型の定義
message Job {
  string job_id = 1;
  string job_title = 2;
  string job_content = 3;
}

message GetJobsRequest {
  repeated string job_ids = 1;
}

message GetJobsResponse {
  repeated Job jobs = 1;
}

次に導入したprotocコマンドでファイルの自動生成を行います。
https://protobuf.dev/programming-guides/proto3/

protoc --go_out=. --go_opt=module=job-detail-api-go --go-grpc_out=. --go-grpc_opt=module=job-detail-api-go proto/job.proto

--go_out generates Go code in DST_DIR. See the Go generated code reference for more.
Goのコードの生成先のディレクトリを指定します。

--go_opt=paths ファイルの出力パスを自動調整するオプション。このオプションでモジュール名にすると、モジュール名を除いた残りのパスで生成してくれる。(protoにはjob-detail-api-go/pkg/grpcとかいたので、pkg/grpc下に生成される

早速作っていきます。

$ tree
.
├── cmd
│   └── main.go
├── docker
│   ├── docker-compose.yml
│   └── init
│       └── 01_create_table.sh
├── go.mod
├── go.sum
├── internal
│   ├── handler.go
│   └── job.go
├── pkg
│   └── grpc
│       ├── job_grpc.pb.go <- NEW
│       └── job.pb.go <- NEW
├── proto
│   └── job.proto
└── README.md
job_grpc.pb.go
...
func (c *jobServiceClient) GetJobs(ctx context.Context, in *GetJobsRequest, opts ...grpc.CallOption) (*GetJobsResponse, error) {
	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
	out := new(GetJobsResponse)
	err := c.cc.Invoke(ctx, JobService_GetJobs_FullMethodName, in, out, cOpts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

// JobServiceServer is the server API for JobService service.
// All implementations must embed UnimplementedJobServiceServer
// for forward compatibility.
type JobServiceServer interface {
	GetJobs(context.Context, *GetJobsRequest) (*GetJobsResponse, error)
	mustEmbedUnimplementedJobServiceServer()
}
...
job.pb.go
...
type Job struct {
	state         protoimpl.MessageState `protogen:"open.v1"`
	JobId         string                 `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
	JobTitle      string                 `protobuf:"bytes,2,opt,name=job_title,json=jobTitle,proto3" json:"job_title,omitempty"`
	JobContent    string                 `protobuf:"bytes,3,opt,name=job_content,json=jobContent,proto3" json:"job_content,omitempty"`
	unknownFields protoimpl.UnknownFields
	sizeCache     protoimpl.SizeCache
}
...
type GetJobsRequest struct {
	state         protoimpl.MessageState `protogen:"open.v1"`
	JobIds        []string               `protobuf:"bytes,1,rep,name=job_ids,json=jobIds,proto3" json:"job_ids,omitempty"`
	unknownFields protoimpl.UnknownFields
	sizeCache     protoimpl.SizeCache
}

このように自動でリクエストや構造体などが生成されました。
そしたら、まずは構造体とかの変更を行います。

  • handler内のメソッド定義の変更
    まず、ハンドラーの中で受け取るメソッドの変更があります。生成したメソッドがcontextを受け取ってやり取りを行うため、シグネチャの変更を行います。

    • Before: GetJobs(w http.ResponseWriter, r *http.Request) (HTTP形式)
    • After: GetJobs(ctx context.Context, req *pb.GetJobsRequest) (*pb.GetJobsResponse, error) (gRPC形式)
  • pb.UnimplementedJobServiceServerの追加
    将来このprotoに新しくdeleteなどのインタフェースを追加した時に、goではインタフェースに対するメソッドを持っていない場合はコンパイルエラーになってしまうのでpb.UnimplementedJobServiceServer を埋め込みます。

image.png

  • JSON処理の削除
    • json.NewDecoderjson.NewEncoder といったJSONの読み書き処理は不要になるため削除します(gRPCフレームワークが裏側でProtobufのシリアライズを行います)。
  • データモデルの変換(マッピング)
    • DynamoDBから取得できるモデルをinternal.Job 型(DBモデル)から pb.Job 型(APIモデル)に変更

image.png

次にmainの変更を行う

  • サーバーの種類の変更

    • net/http(Webサーバー)の使用をやめ、純粋なTCPリスナー(net.Listen)とgRPCサーバー(grpc.NewServer)を使用する実装に置き換える
  • ルーティングの変更

    • http.HandleFunc("/jobs", ...)の代わりに pb.RegisterJobServiceServer(s, h) を呼び出し、作成したハンドラーを「JobService」としてgRPCサーバーに登録します。

image.png

  • Reflection(リフレクション)の有効化
    • reflection.Register(s) を追加し、外部のツール(grpcurlなど)が「このサーバーにはどんなメソッドがあるか」を問い合わせられるようになり、動作確認が非常に楽になります。

image.png

次に動作確認を行うためにgrpcurlをインストールします

$ go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
go: downloading github.com/fullstorydev/grpcurl v1.9.3
go: downloading google.golang.org/grpc v1.61.0
go: downloading github.com/jhump/protoreflect v1.17.0
go: downloading github.com/bufbuild/protocompile v0.14.1
go: downloading google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17
go: downloading github.com/envoyproxy/go-control-plane v0.11.1
go: downloading google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17
go: downloading github.com/cespare/xxhash/v2 v2.2.0
go: downloading github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101
go: downloading google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17
go: downloading github.com/envoyproxy/protoc-gen-validate v1.0.2
go: downloading golang.org/x/oauth2 v0.14.0
go: downloading cloud.google.com/go/compute/metadata v0.2.3
go: downloading cloud.google.com/go/compute v1.23.3

サーバー起動

$ AWS_ENDPOINT_URL=http://localhost:4566 go run cmd/main.go                                                         
2025/12/09 19:49:33 gRPC server starting on port 8080...

grpcurlで先ほど指定したリフレクション越しにlistを確認してみる

$ grpcurl -plaintext localhost:8080 list job.JobService
job.JobService.GetJobs

実際に送信してみる

$ grpcurl -plaintext -d '{"job_ids": ["job_a", "job_b"]}' localhost:8080 job.JobService/GetJobs
{
  "jobs": [
    {
      "jobId": "job_a",
      "jobTitle": "Software Engineer",
      "jobContent": "Develop awesome software."
    },
    {
      "jobId": "job_b",
      "jobTitle": "Product Manager",
      "jobContent": "Manage product roadmap."
    }
  ]
}

うまく帰ってきました!

まとめると

移行に先立って必要なことは下記になります。

  1. protocolbufferによるサービス定義
  2. protocコマンドで自動生成
  3. 生成した構造体やサービスを置き換え
  4. net.Httpなどをgrpc.NewServerにする
  5. データのマッピング修正

などが必要になることがわかりました。

ここまでのコード対応は
https://github.com/arata-honda-stb/job-detail-api-go/pull/1/files
にまとまっています。

本当にやるとしたらこれに付け加えて以下のことを考える必要があります。

  • クライアント側の呼び出し修正
  • 負荷試験(ghzなどを利用)
  • おそらく新規APIとして立てる必要がある(修正をマージしてしまうとHttp, gRPCで規格が違うので後戻りできないため)

また、Route53のような荷重ルーティングなどで徐々に切り替えができないのでは :thinking: と思うので、各グループへの調整や手法についてもっと検討することは多そうです...

感想

実際に実装を通してみて何をしなければならないのか少し見えてきました!
少しでもこの記事がみなさんの役に立てればと思います。

明日は、僕が敬愛してやまない@taaaogiの記事がでるそうです!
楽しみですね!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?