はじめに
この記事は スタンバイ 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になります。

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のページと連携することでみることができます。
実装に時間をかけたくなかったので、今回はClineを用いております。
各ファイルの責務
main.go: handlerやdynamodbClientの作成
handler.go: リクエストの受け取り、dynamodbの操作等(こちらは本来リポジトリなどでやったほうがいい)
job.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)
}
}
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)
}
}
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
サービス定義を行っていきます。
// 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
...
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()
}
...
...
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形式)
- Before:
-
pb.UnimplementedJobServiceServerの追加
将来このprotoに新しくdeleteなどのインタフェースを追加した時に、goではインタフェースに対するメソッドを持っていない場合はコンパイルエラーになってしまうのでpb.UnimplementedJobServiceServerを埋め込みます。
- JSON処理の削除
-
json.NewDecoderやjson.NewEncoderといったJSONの読み書き処理は不要になるため削除します(gRPCフレームワークが裏側でProtobufのシリアライズを行います)。
-
- データモデルの変換(マッピング)
- DynamoDBから取得できるモデルを
internal.Job型(DBモデル)からpb.Job型(APIモデル)に変更
- DynamoDBから取得できるモデルを
次にmainの変更を行う
-
サーバーの種類の変更
-
net/http(Webサーバー)の使用をやめ、純粋なTCPリスナー(net.Listen)とgRPCサーバー(grpc.NewServer)を使用する実装に置き換える
-
-
ルーティングの変更
-
http.HandleFunc("/jobs", ...)の代わりにpb.RegisterJobServiceServer(s, h)を呼び出し、作成したハンドラーを「JobService」としてgRPCサーバーに登録します。
-
- Reflection(リフレクション)の有効化
-
reflection.Register(s)を追加し、外部のツール(grpcurlなど)が「このサーバーにはどんなメソッドがあるか」を問い合わせられるようになり、動作確認が非常に楽になります。
-
次に動作確認を行うために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."
}
]
}
うまく帰ってきました!
まとめると
移行に先立って必要なことは下記になります。
- protocolbufferによるサービス定義
- protocコマンドで自動生成
- 生成した構造体やサービスを置き換え
- net.Httpなどをgrpc.NewServerにする
- データのマッピング修正
などが必要になることがわかりました。
ここまでのコード対応は
https://github.com/arata-honda-stb/job-detail-api-go/pull/1/files
にまとまっています。
本当にやるとしたらこれに付け加えて以下のことを考える必要があります。
- クライアント側の呼び出し修正
- 負荷試験(ghzなどを利用)
- おそらく新規APIとして立てる必要がある(修正をマージしてしまうとHttp, gRPCで規格が違うので後戻りできないため)
また、Route53のような荷重ルーティングなどで徐々に切り替えができないのでは
と思うので、各グループへの調整や手法についてもっと検討することは多そうです...
感想
実際に実装を通してみて何をしなければならないのか少し見えてきました!
少しでもこの記事がみなさんの役に立てればと思います。
明日は、僕が敬愛してやまない@taaaogiの記事がでるそうです!
楽しみですね!




