📚 背景知識(読んでから問題へ)
Day 047までのジェネリクス応用で「型集合」というコンパイル時の抽象化を扱いましたが、今日のテーマはプロセス境界を越える通信、すなわちサービス定義からコードを生成するという別種の抽象化です。
gRPCは「.protoファイルにサービスとメッセージを1回書けば、Go・Java・Python・TypeScriptなど複数言語のクライアント/サーバーコードを自動生成できる」という契約駆動(contract-first)の思想に基づくRPCフレームワークです。典型的な.proto定義は次のようになります。
syntax = "proto3";
package greeter;
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
このファイルに対してprotoc(Protocol Buffersコンパイラ)と2つのプラグインを実行すると、以下のコードが自動生成されます。
protoc-gen-go:HelloRequest・HelloReplyに対応するGoの構造体(各フィールドへのgetterメソッド、proto.Marshal/proto.Unmarshalで使われるバイナリシリアライズ用のメタデータ付き)protoc-gen-go-grpc:GreeterServerインターフェース(開発者が実装すべきメソッド群)、GreeterClientインターフェース(クライアントが呼び出せるメソッド群)、RegisterGreeterServer関数、NewGreeterClientコンストラクタ
つまり開発者が手で書くのは「.protoファイル」と「GreeterServerインターフェースの実装(ビジネスロジック)」だけで、シリアライズ・デシリアライズ・HTTP/2フレーミング・言語間のスタブコードはすべて生成されます。
protocコマンド(Go本体とは別配布のバイナリ)と、外部モジュールgoogle.golang.org/grpc・google.golang.org/protobufが必須で、これらなしではgo run main.go単体でgRPCコードは動きません。そこで今日は、標準ライブラリのnet/rpcパッケージを「protocなしで体験できるgRPCの縮小モデル」として使い、生成されるコードの役割分担・RPCメソッドのシグネチャ規約という「考え方」を手を動かして学びます。実際のプロジェクトでgRPCを使う際は、実務での使いどころの項で述べる公式ツールチェーンに進んでください。net/rpcはGo標準ライブラリに含まれる、コード生成なしのシンプルなRPCパッケージです。gRPCほどの機能(HTTP/2多重化・双方向ストリーミング・多言語対応)はありませんが、「サービスを定義する」「メソッドのシグネチャに規約がある」「クライアントはメソッド名を文字列で呼び出す」という、RPCフレームワーク共通の骨格を体験するには十分です。net/rpcでRPCとして公開されるメソッドには、以下の規約があります。
- メソッドはエクスポートされている(大文字始まり)
- 引数は2つ:
(args *ArgsType, reply *ReplyType) error - 第2引数(
reply)はポインタ型でなければならない - 戻り値は
error型1つだけ
この規約はまさに、gRPCでprotoc-gen-go-grpcが生成するGreeterServerインターフェースのメソッドシグネチャ(SayHello(ctx context.Context, req *HelloRequest) (*HelloReply, error))と同じ発想——「引数と戻り値の型を固定した規約に沿わせることで、フレームワーク側がリフレクションや生成コードを使って任意のメソッドを一様に呼び出せるようにする」——の縮小版です。
📝 問題
- 上記の
.proto定義(Greeterサービス、SayHelloメソッド)に対してprotoc+protoc-gen-go+protoc-gen-go-grpcを実行した場合、生成される主要な要素(メッセージ構造体・サーバー側インターフェース・クライアント側インターフェース・登録関数)をそれぞれ1〜2文で説明してください。 - 標準ライブラリの
net/rpcだけを使い、Greeterサービス相当の疑似gRPC体験を実装してください。要件:HelloRequest{Name string}/HelloReply{Message string}という、.protoのmessageに相当する構造体を定義するnet/rpcの規約に従うSayHelloメソッドを実装する(Nameが空文字列の場合はエラーを返す)main関数内でサーバーとクライアントの両方を1プロセス内に立ち上げ、client.Call("Greeter.SayHello", ...)で呼び出して結果を表示する- 空文字列を渡した場合にエラーが返ることも確認する
- 以下のコードは(2)と似た実装ですが、
go run main.goを実行するとサーバーが正しく起動しません。何が問題か指摘し、修正してください。type Greeter struct{} type HelloRequest struct { Name string } type HelloReply struct { Message string } // 引数の型に注目 func (g *Greeter) SayHello(req HelloRequest, reply HelloReply) error { reply.Message = "Hello, " + req.Name + "!" return nil } net/rpcと実際のgRPCの違いを3点(トランスポート層・シリアライズ形式・多言語対応)挙げ、実務で「まずnet/rpcのような軽量な手段で十分な場面」と「gRPCの導入コスト(.proto管理・コード生成パイプライン・外部依存)を払ってでも採用すべき場面」をそれぞれ1つ具体的に述べてください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
net/rpcのサーバー登録はrpc.Register(receiver)で行います。登録時にGoランタイムはリフレクション(reflectパッケージ)を使い、receiverが持つメソッドの中から「エクスポートされている」「引数が2つでともにエクスポート可能な型」「第2引数がポインタ」「戻り値がerrorのみ」という条件を満たすものだけを、呼び出し可能なRPCメソッドとして登録します。条件を満たさないメソッドは黙って除外されるか、Registerがエラーを返します。
ヒント2 — アプローチ
1プロセス内でサーバーとクライアントを両方動かすには、net.Listen("tcp", "127.0.0.1:0")のようにポート0を指定してOSに空いているポートを自動選択させ、listener.Addr().String()で実際に割り当てられたアドレスを取得してクライアント側のrpc.Dialに渡します。サーバー側はgo func() { for { conn, _ := listener.Accept(); go rpc.ServeConn(conn) } }()のように別goroutineで待ち受けます。
ヒント3 — コード骨格
package main
import (
"fmt"
"log"
"net"
"net/rpc"
)
type Greeter struct{}
type HelloRequest struct{ Name string }
type HelloReply struct{ Message string }
func (g *Greeter) SayHello(req *HelloRequest, reply *HelloReply) error {
// TODO: req.Name が空文字列ならエラーを返す
// TODO: reply.Message を組み立てる
return nil
}
func main() {
rpc.Register(new(Greeter))
listener, _ := net.Listen("tcp", "127.0.0.1:0")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go rpc.ServeConn(conn)
}
}()
client, _ := rpc.Dial("tcp", listener.Addr().String())
var reply HelloReply
// TODO: client.Call("Greeter.SayHello", ...) を呼ぶ
fmt.Println(reply.Message)
}
✅ 模範解答
(1) protocが生成する主要な要素:
- メッセージ構造体(
protoc-gen-goが生成):HelloRequest・HelloReplyという、.protoのmessage定義に対応するGo構造体。各フィールドへのGetName()のようなgetterメソッドと、proto.Marshal/proto.Unmarshalによるバイナリシリアライズに必要な内部メタデータを持つ - サーバー側インターフェース(
protoc-gen-go-grpcが生成):GreeterServerインターフェース。SayHello(context.Context, *HelloRequest) (*HelloReply, error)のようなメソッドシグネチャが定義され、開発者はこれを実装した構造体を用意する - クライアント側インターフェース(同上):
GreeterClientインターフェースと、それを実装する非公開の構造体・NewGreeterClient(conn)コンストラクタ。呼び出し側はこのインターフェース越しにメソッドをまるでローカル関数のように呼び出せる - 登録関数(同上):
RegisterGreeterServer(s *grpc.Server, srv GreeterServer)。grpc.Serverにサービス実装を紐付け、gRPCのリクエストルーティングテーブルに登録する
(2) net/rpcによる疑似gRPC体験(完全なコード):
package main
import (
"fmt"
"log"
"net"
"net/rpc"
)
// Greeter は「.proto の service Greeter { rpc SayHello(...) }」から
// protoc-gen-go-grpc が生成する GreeterServer インターフェースに相当する、
// 手書きの疑似スタブ。
type Greeter struct{}
// HelloRequest / HelloReply は protoc-gen-go が
// message HelloRequest { string name = 1; } から生成する構造体に相当する。
type HelloRequest struct {
Name string
}
type HelloReply struct {
Message string
}
// SayHello は net/rpc が要求する規約
// 「エクスポートされたメソッド、引数は (args *ArgsType, reply *ReplyType) error」
// を満たす。実際のgRPCではこのメソッドは
// func (g *Greeter) SayHello(ctx context.Context, req *HelloRequest) (*HelloReply, error)
// という、context.Contextを第1引数に取る形で書く。
func (g *Greeter) SayHello(req *HelloRequest, reply *HelloReply) error {
if req.Name == "" {
return fmt.Errorf("name is required")
}
reply.Message = "Hello, " + req.Name + "!"
return nil
}
func main() {
// --- サーバー側 ---
if err := rpc.Register(new(Greeter)); err != nil {
log.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0") // ポート0 = OSに空きポートを割り当てさせる
if err != nil {
log.Fatal(err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return // listener.Close() された後はここで抜ける
}
go rpc.ServeConn(conn)
}
}()
// --- クライアント側 ---
client, err := rpc.Dial("tcp", listener.Addr().String())
if err != nil {
log.Fatal(err)
}
defer client.Close()
var reply HelloReply
if err := client.Call("Greeter.SayHello", &HelloRequest{Name: "Gopher"}, &reply); err != nil {
log.Fatal(err)
}
fmt.Println(reply.Message)
// 空文字列を渡すとサーバー側のバリデーションでエラーになることを確認する
var errReply HelloReply
err = client.Call("Greeter.SayHello", &HelloRequest{Name: ""}, &errReply)
fmt.Println("エラーケース:", err)
}
実行結果(実測。go1.21以降で確認済み):
Hello, Gopher!
エラーケース: name is required
(3) バグの指摘と修正:
問題のコードはSayHelloの引数が(req HelloRequest, reply HelloReply) errorとなっており、第2引数replyがポインタ型ではありません。net/rpcの規約では「第2引数はポインタでなければならない」(サーバー側が呼び出し結果をこの引数越しに書き込んで返すため、値渡しでは呼び出し元に結果が伝わらない)ため、rpc.Registerはこのメソッドを呼び出し可能なRPCメソッドとして登録できません。
$ go run main.go
2026/09/14 12:00:00 rpc.Register: type Greeter has no exported methods of suitable type
2026/09/14 12:00:00 rpc.Register: type Greeter has no exported methods of suitable type
exit status 1
(実測。go1.26で確認済み。メッセージが2回出るのは、net/rpc内部の登録処理が一度log.Printlnでログ出力し、続けてRegisterが返したerrorをこのコード自身がlog.Fatal(err)でも出力しているためです。Goのバージョンによって行数や文言は多少異なる場合がありますが、「no exported methods of suitable type」(この型には適切なシグネチャのエクスポート済みメソッドが1つもない)という趣旨のメッセージが出る点は共通です。)
修正は、第1引数・第2引数をともにポインタ型に揃えることです。
func (g *Greeter) SayHello(req *HelloRequest, reply *HelloReply) error {
reply.Message = "Hello, " + req.Name + "!"
return nil
}
(4) net/rpcと実際のgRPCの違い(3点)と使い分け:
| 観点 | net/rpc | gRPC |
|---|---|---|
| トランスポート層 | 生のTCPコネクション(net.Conn)を直接使う。多重化なし、1接続1リクエストずつ処理するのが基本 | HTTP/2を使用し、1つのTCPコネクション上で複数のリクエスト/レスポンスを多重化でき、双方向ストリーミングにも対応する |
| シリアライズ形式 | デフォルトはencoding/gob(Go専用のバイナリ形式)。JSON-RPCに切り替えることもできるが標準はGoバイナリ | Protocol Buffersによる、言語非依存かつスキーマ付きのコンパクトなバイナリ形式 |
| 多言語対応 | Go専用(gobはGoの型情報に依存するため他言語クライアントからは事実上使えない) | .protoから任意の対応言語向けにクライアント/サーバーコードを生成できる |
net/rpcのような軽量な手段で十分な場面: 社内の小規模なGo製バッチジョブ群同士が、同じGoプロセス群の中だけで単純なジョブ委譲を行うようなケース。将来的に他言語クライアントを想定しておらず、.proto管理やコード生成パイプラインを整備するコストに見合わない小規模なツールに向く- gRPC導入コストを払ってでも採用すべき場面: マイクロサービス間通信で、Goで書かれたサービスとJava/Pythonで書かれたサービスが混在し、かつ高頻度・低レイテンシな通信が要求される場合。
.protoをサービス間の契約として一元管理でき、双方向ストリーミングやデッドライン伝播などgRPC標準機能が実務要件に直結する
🪜 Step-by-Step 解説
.protoが「契約」として機能する仕組みを理解する.protoファイルはコードではなく、あくまで言語非依存のインターフェース定義です。protocはこのファイルを解析し、指定したプラグイン(protoc-gen-goなど)に「このサービス・メッセージにはこういうフィールド・メソッドがある」という情報を渡し、各プラグインが対象言語のコードを機械的に吐き出します。1つの.protoから複数言語のコードが生成されるため、「サーバーはGo、クライアントはPython」のような異言語間通信でも型の不一致が起きません。
net/rpcの登録規約はリフレクションで検査されるrpc.Register(new(Greeter))が呼ばれると、net/rpcパッケージは内部でreflect.TypeOf(receiver)を使い、Greeter型が持つすべてのメソッドを走査します。「エクスポートされているか」「引数の数と型」「戻り値の型」という条件を1つずつ検査し、条件を満たすメソッドだけを内部のメソッドテーブルに登録します。これはgRPCの生成コードがgrpc.ServiceDescという構造体にメソッド名と実体を紐付けるテーブルを持つのと同じ発想——「文字列によるメソッド名解決」を実現する仕組みです。
client.Callが行っていることclient.Call("Greeter.SayHello", &HelloRequest{...}, &reply)は、"Greeter.SayHello"という文字列をサーバーに送り、サーバー側は自分の持つメソッドテーブルからこの文字列に一致するメソッドを検索して呼び出します。引数はGoの標準エンコーディング(encoding/gob)でシリアライズされ、TCPコネクション越しに送信されます。gRPCではこの「文字列によるメソッド解決」の部分がHTTP/2のパスルーティング(例: /greeter.Greeter/SayHello)に置き換わり、シリアライズがProtocol Buffersに置き換わりますが、「名前でメソッドを解決し、構造化データをやり取りする」という骨格は同じです。
(3)のバグは、Goの基本である「値渡し」の性質に起因します。
reply HelloReply(値渡し)にした場合、メソッド内でreply.Messageを書き換えても、それは呼び出し元が持つHelloReplyのコピーには反映されません。net/rpcはこの構造的な制約を理解した上で、「結果を呼び出し元に伝えるための唯一の手段」としてポインタ型のreply引数を要求し、その規約を満たさないメソッドは実行時に検査して弾く、という設計になっています。
💡 設計思想・なぜこう書くのか
net/rpcも実際のgRPCも、「特定のシグネチャ規約に従うメソッドだけを、文字列名で動的に解決して呼び出す」という同じ骨格を持ちます。net/rpcはこれを実行時のリフレクションで実現し、gRPCはコンパイル時のコード生成(protoc)で実現します。後者の方が実行時オーバーヘッドが小さく型安全性も高い一方、コード生成パイプラインの整備という別のコストを払っています。「動的検査 vs 静的生成」という、Goのエコシステム全体に繰り返し現れるトレードオフの一例です。net/rpcは実務のマイクロサービス間通信でgRPCの代わりに採用されることはほとんどありません(Go専用・多重化なし・ストリーミング非対応という制約が大きすぎるため)。今日学んだのは「RPCフレームワークが解決している一般的な問題(名前解決・シリアライズ・シグネチャ規約)の構造」であり、実務で本物のgRPCを使う際はprotoc・protoc-gen-go・protoc-gen-go-grpc・google.golang.org/grpcという外部ツールチェーンの導入が別途必要になる点を混同しないようにしてください。🌐 他言語との比較
| 言語/エコシステム | 同等の概念 | Goとの違い |
|---|---|---|
| Java | gRPC-Java + Protocol Buffers Gradle/Mavenプラグイン | ビルドツール(Gradle/Maven)のプラグインがprotoc実行とコード生成をビルドライフサイクルに統合しており、./gradlew buildだけで生成コードが最新化される。Goではprotocコマンドとgoプラグインを別途手動またはMakefile/go generateで呼び出す構成が一般的で、ビルドツール統合の度合いはJavaエコシステムの方が成熟している |
| Python | grpcio + grpcio-tools(python -m grpc_tools.protoc) | Pythonは動的型付けのため、生成されるメッセージクラスも実行時にフィールドアクセスが検証される。Goは生成された構造体がコンパイル時に型チェックされるため、フィールド名のtypoやメッセージ型の取り違えをコンパイル時に検出できる点で安全性が高い |
| TypeScript | grpc-web + protoc-gen-grpc-web(ブラウザから直接gRPCを呼べないためのプロキシ層) | ブラウザはHTTP/2の生フレームを直接扱えないため、TypeScript/JavaScriptからgRPCを使うにはgrpc-webという追加のプロトコル変換層(Envoyなどのプロキシ)が必要になる。Goサーバー同士やGoクライアントは素のgRPC(HTTP/2)で直接通信できるため、この追加層が不要という違いがある |
🏆 実務での使いどころ
- マイクロサービス間の内部通信: 複数の言語で書かれたサービス群が混在する組織で、
.protoをサービス間契約の「単一の真実源」として管理し、破壊的変更をコードレビューの中で検出する - ストリーミングが必要なAPI: リアルタイムのログ配信・進捗通知など、双方向ストリーミングが必要な場面ではgRPCのストリーミングRPCが素のREST/JSONより自然に設計できる
- 公式ツールチェーンへの接続: 実際に手を動かす場合は
brew install protobuf(または各OSのパッケージマネージャ)でprotoc本体を、go install google.golang.org/protobuf/cmd/protoc-gen-go@latestとgo install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latestでGo用プラグインを導入し、google.golang.org/grpcモジュールをgo.modに追加するところから始める
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
net/rpcとnet/rpc/jsonrpc・gRPCを同じもの、あるいはnet/rpcが内部でgRPCを使っていると誤解する | パッケージ名が似ている(どちらも「RPC」という言葉を含む)ため | net/rpcは標準ライブラリ完結の独立した仕組みで、Protocol Buffers・HTTP/2・google.golang.org/grpcとは無関係。gRPCを使うには別途外部モジュールとprotocが必要 |
reply引数をポインタにし忘れ、コンパイルは通るのにRPC呼び出し時に原因不明のエラーになる | Goの値渡し/ポインタ渡しの区別に慣れていないと、「コンパイルが通る=正しい」と思い込みやすい | net/rpcの規約違反はコンパイルエラーではなく実行時(rpc.Registerまたは呼び出し時)に検出される。コンパイラは検査してくれないため、規約を意識して書く必要がある |
.protoファイルさえ書けばコードが自動的に最新化されると思い込み、protocの再実行を忘れる | 他言語のORMなどで見られる「マイグレーションファイルを保存すれば自動適用される」ような仕組みと混同する | .protoの変更を反映するにはprotocを明示的に再実行してコード生成をやり直す必要がある。CI/CDパイプラインに生成コードの最新性チェックを組み込むのが実務での定石 |
🚀 次のステップ
- 発展: 実際に
protocとgoogle.golang.org/grpcをインストールできる環境があれば、今日の.proto定義から本物のGreeterServer/GreeterClientを生成し、net/rpc版と比べて「引数にcontext.Contextが増える」「ストリーミングRPCが追加できる」といった違いを直接観察してみましょう - 次回予告: ミドルウェア設計パターン —
http.Handlerのチェーン設計を扱います