📚 背景知識(読んでから問題へ)
Day 048〜050では、net/rpcを使って本物のgRPCの挙動(コード生成・デッドライン伝播・Server streaming)を疑似体験してきました。しかし、これまで一度も「実際にワイヤ(ネットワーク)の上を流れるバイト列がどういう形をしているか」には触れていません。今日はその核心であるProtocol Buffers(以下PB)のWire Formatを扱います。
PBがJSONに比べて小さく速い理由は、しばしば「バイナリだから」の一言で片付けられますが、それは正確ではありません。真の理由は次の2点です。
- スキーマがワイヤに乗らない: JSONは
{"a":150}のようにキー名そのものを毎回送りますが、PBは.protoで定義したフィールド番号(例:a = 1)だけを送ります。フィールド名は送受信者が事前に共有している.protoにしかなく、ワイヤ上には一切現れません。 - 数値がvarint(可変長整数)で符号化される: 実務のデータでは、IDやカウンタ、enumの値など「小さい数値」が圧倒的に多く出現します。PBは数値を固定長4/8バイトではなく、値が小さいほどバイト数も小さくなるvarintという可変長エンコーディングで表現します。
この2つの設計判断——「スキーマは帯域外(out-of-band)で共有する」「小さい値を優先的に短く符号化する」——は、Protocol Buffersの公式エンコーディング仕様に明記されています(出典: Protocol Buffers - Encoding | protobuf.dev、参照日: 2026-09-17)。今日はこの仕様書が示す具体例を、外部ライブラリを一切使わず標準ライブラリのみでGoに実装し、本物のprotocが吐くバイト列と一致することを手元で検証します。
📝 問題
- varintのエンコード規則を実装する:
uint64の値を受け取り、Protocol Buffers形式のvarintバイト列を返すencodeVarint(x uint64) []byteを実装してください。規則: 値を7bitずつ区切り、下位バイトから順に格納する。最後のバイト以外は最上位ビット(MSB)を1にして「まだ続きがある」ことを示し、最後のバイトはMSBを0にする。 - varintのデコードを実装する: エンコードの逆操作として、バイト列から
uint64の値と消費バイト数を復元するdecodeVarint(buf []byte) (uint64, int)を実装してください。 - タグの計算とフィールドエンコードを実装する: PBのフィールドは
タグ = (フィールド番号 << 3) | ワイヤタイプという式でタグを計算し、タグ(varint) + 値(そのワイヤタイプに応じた符号化)という順で並べます。ワイヤタイプ0はVarintを意味します。この式に基づきencodeTag(fieldNumber, wireType int) uint64と、それを使って1フィールド分をまるごとエンコードするencodeVarintField(fieldNumber int, value uint64) []byteを実装してください。 - 公式ドキュメントの例と一致することを検証する: Protocol Buffers公式ドキュメントには「
message Test1 { optional int32 a = 1; }でa = 150のとき、ワイヤ上のバイト列は08 96 01になる」という具体例が示されています。あなたの実装でencodeVarintField(1, 150)を呼び出し、結果が[]byte{0x08, 0x96, 0x01}と一致することをコード上で検証してください(panicまたはgo testのいずれかで、一致しなければ失敗する形にする)。 - 概念確認(言葉で説明): 上記の実装を踏まえ、「なぜvarintは小さい値ほど有利で、大きい値では固定長エンコーディングより不利になりうるか」を、具体的な数値例(例:
1と2^63-1をそれぞれvarintでエンコードした場合の必要バイト数)を挙げて説明してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
タグは(フィールド番号 << 3) | ワイヤタイプという式で計算されます。フィールド番号1・ワイヤタイプ0(Varint)なら、タグの値そのものは(1 << 3) | 0 = 8です。まずこの「タグ自体もvarintとして符号化される数値の一種にすぎない」という点を押さえてください。次に、varintの規則(7bitずつ・最上位ビットが継続フラグ)を思い出しましょう。
ヒント2 — アプローチ
encodeVarintは「値が0x80(=128)以上の間はループを続ける」という条件で書けます。ループの各回で、下位7bitに継続フラグ0x80を立てたバイトを出力バイト列に追加し、値を7bit右シフトします。ループを抜けたら、残った値(すでに0x80未満)をそのまま最後のバイトとして追加します。decodeVarintはこの逆で、各バイトの下位7bitをshiftだけ左シフトしながら足し合わせ、MSBが0になったバイトで打ち切ります。
ヒント3 — コード骨格
func encodeVarint(x uint64) []byte {
var buf []byte
for x >= 0x80 {
buf = append(buf, byte(x& /* TODO: 下位7bitのマスク */)|0x80)
x >>= 7
}
buf = append(buf, byte(x))
return buf
}
func decodeVarint(buf []byte) (uint64, int) {
var x uint64
var shift uint
for i, b := range buf {
x |= uint64(b&0x7f) << shift
if b&0x80 == 0 {
return x, /* TODO: 消費バイト数 */
}
shift += 7
}
return x, len(buf)
}
✅ 模範解答
package main
import "fmt"
// encodeVarint はGoのuint64をProtocol Buffers形式のvarintバイト列にエンコードする。
// 各バイトの最上位ビット(MSB)が「続きがあるか」を示すcontinuationビット。
// 下位7ビットがpayload。値の下位ビットから順に7bitずつ切り出して詰めていく。
func encodeVarint(x uint64) []byte {
var buf []byte
for x >= 0x80 {
buf = append(buf, byte(x&0x7f)|0x80)
x >>= 7
}
buf = append(buf, byte(x))
return buf
}
// decodeVarint はvarintバイト列をuint64にデコードし、消費したバイト数を返す。
func decodeVarint(buf []byte) (uint64, int) {
var x uint64
var shift uint
for i, b := range buf {
x |= uint64(b&0x7f) << shift
if b&0x80 == 0 {
return x, i + 1
}
shift += 7
}
return x, len(buf)
}
// wireTypeVarint はProtocol Buffersのワイヤタイプ0(Varint系: int32/int64/uint32/uint64/bool/enum)を表す。
const wireTypeVarint = 0
// encodeTag はフィールド番号とワイヤタイプからProtocol Buffersのタグ値を計算する。
// タグ = (フィールド番号 << 3) | ワイヤタイプ
func encodeTag(fieldNumber int, wireType int) uint64 {
return uint64(fieldNumber)<<3 | uint64(wireType)
}
// encodeVarintField は「フィールド番号 + Varint値」を1つのPBフィールドとしてエンコードする。
// ワイヤ上の並び順は [タグ(varint)] [値(varint)]。
func encodeVarintField(fieldNumber int, value uint64) []byte {
buf := encodeVarint(encodeTag(fieldNumber, wireTypeVarint))
buf = append(buf, encodeVarint(value)...)
return buf
}
func main() {
// Protocol Buffers公式ドキュメントの例:
// message Test1 { optional int32 a = 1; }
// a = 150 のとき、ワイヤ上のバイト列は 08 96 01 になる。
// 出典: https://protobuf.dev/programming-guides/encoding/#simple (参照日: 2026-09-17)
got := encodeVarintField(1 /* field number */, 150 /* value */)
want := []byte{0x08, 0x96, 0x01}
fmt.Printf("encoded: % x\n", got)
fmt.Printf("want: % x\n", want)
if len(got) != len(want) {
panic("length mismatch")
}
for i := range want {
if got[i] != want[i] {
panic(fmt.Sprintf("byte mismatch at %d: got %x want %x", i, got[i], want[i]))
}
}
fmt.Println("OK: 公式ドキュメントの例と一致した")
// ラウンドトリップ確認: タグをデコードしてフィールド番号とワイヤタイプを復元する
tag, n := decodeVarint(got)
fieldNumber := tag >> 3
wireType := tag & 0x7
value, _ := decodeVarint(got[n:])
fmt.Printf("decoded: fieldNumber=%d wireType=%d value=%d\n", fieldNumber, wireType, value)
// バイト数比較の実測: 小さい値(1) と 大きい値(2^63-1) でvarintが必要とするバイト数
small := encodeVarint(1)
large := encodeVarint(1<<63 - 1)
fmt.Printf("varint(1) = % x (%dバイト)\n", small, len(small))
fmt.Printf("varint(2^63-1) = % x (%dバイト)\n", large, len(large))
fmt.Printf("固定長(uint64)なら常に8バイト固定\n")
// 同じ情報 {"a":150} をJSONで表現した場合とのバイト数比較
jsonBytes := len(`{"a":150}`)
fmt.Printf("Protobuf(a=150): %dバイト, JSON(a=150): %dバイト\n", len(got), jsonBytes)
}
実行結果(実測。go1.27.1 darwin/arm64で確認):
encoded: 08 96 01
want: 08 96 01
OK: 公式ドキュメントの例と一致した
decoded: fieldNumber=1 wireType=0 value=150
varint(1) = 01 (1バイト)
varint(2^63-1) = ff ff ff ff ff ff ff ff 7f (9バイト)
固定長(uint64)なら常に8バイト固定
Protobuf(a=150): 3バイト, JSON(a=150): 9バイト
🪜 Step-by-Step 解説
150を2進数で書くと10010110(8bit)です。これを7bitずつ下位から区切ると0010110(下位7bit)と1(残りの上位1bit)に分かれます。下位側のグループは「まだ続きがある」ので0x80を立てて10010110(= 0x96)に、上位側のグループは「これが最後」なので0x80を立てずに00000001(= 0x01)になります。これが実行結果の96 01の正体です。
タグ
(1 << 3) | 0 = 8は0x80未満なので、1バイトの0x08としてそのままエンコードされます。もしフィールド番号が16以上になると(16 << 3 = 128 = 0x80)、タグ自体も2バイト以上のvarintになります。つまり「フィールド番号を小さく保つ」ことは、実はワイヤサイズに直結する設計上の選択です。.protoで頻出フィールドに小さい番号を割り当てる慣習は、この仕組みへの理解から来ています。
decodeVarintでx |= uint64(b&0x7f) << shiftとしているのは、2バイト目以降の7bitは「値の上位側」を表すためです。1バイト目の7bitはそのまま(shift=0)、2バイト目の7bitは1バイト目の7bitより上位の桁を表すのでshift=7だけ左シフトしてから足し合わせます。
2^63-1(=符号なし64bit整数の最大値に近い値)をエンコードすると9バイトになりました。これは固定長uint64の8バイトより1バイト多い結果です。つまりvarintは「小さい値を短くする代わりに、大きい値では固定長よりわずかに不利になる」というトレードオフの上に成り立っています。PBの設計者は、実務データの大半が小さい値であるという経験則に基づき、このトレードオフを許容しました。
💡 設計思想・なぜこう書くのか
if err != nilという明示的なエラーハンドリングを選んだのと同様に、PBも「フィールド名という自己記述的な情報をワイヤに含める」という便利さを捨て、「送受信者が.protoという契約を事前に共有している」という明示的な前提の上に成り立っています。両者とも「実行時の暗黙の解決」よりも「事前に交わされた明示的な契約」を優先するという一貫した設計思想が見て取れます。interface{}が値の型に応じてitableとdataポインタを動的に持つのと同じ発想——「一様な固定サイズよりも、実際のデータの分布に最適化する」という考え方の一種です。ただしGoの言語仕様自体にvarintのような可変長プリミティブはなく、これはPB・gRPC固有の設計判断であることには注意してください。🌐 他言語との比較
| フォーマット/エコシステム | 数値のエンコード方式 | Protocol Buffersとの違い |
|---|---|---|
| JSON | 数値を10進数の文字列として送る(例: 150は3バイトの文字'1' '5' '0') | 人間が読めるが、パース時に文字列→数値変換のコストがかかり、桁数が増えるほどバイト数も線形に増える。フィールド名もキーとして毎回送るため、繰り返し送信するAPIレスポンスでは冗長性が大きい |
| MessagePack | Timestamp等は固定長、小さい整数は「型タグに値を埋め込む」1バイト特殊形式(positive fixint)を持つ | PBのvarintのような「継続ビットで任意長にする」方式ではなく、値の範囲ごとに専用の型タグ(0x00-0x7f, 0xcc, 0xcd...)を切り替える方式。可変長にする発想はPBと共通するが実装アプローチが異なる |
| Apache Thrift(Compact Protocol) | PBのvarintとほぼ同一のzigzag varint方式を採用 | Facebookが開発したThriftのCompact ProtocolはPBのwire formatを強く参考にしており、フィールドIDのデルタエンコーディングなど類似点が多い。「スキーマを帯域外で共有し、数値は可変長にする」という設計思想がPBだけの特殊解ではなく、複数の独立した実装で収斂した合理的な解であることを示す一例 |
| Javaのprotobuf生成コード | CodedOutputStream.writeInt32(fieldNumber, value)が内部で同じvarintロジックを実装 | Javaでは生成されたBuilderクラス越しにこのロジックが完全に隠蔽され、開発者がバイトレベルを意識することはまずない。今日のGo実装はその「隠蔽された中身」を自分で書いた形になる |
🏆 実務での使いどころ
- gRPCによるマイクロサービス間通信: gRPC/PBはGoogle社内は元より、Kubernetes(APIサーバーとetcd間、kubelet通信の一部)、CockroachDB、Envoy Proxyなど、大規模分散システムで広く採用されています(各プロジェクトの公開ドキュメント・ソースコードで確認可能)。今日学んだvarintの特性により、頻繁に交換される小さいカウンタ・タイムスタンプのデルタ・enum値は、JSONに比べて大幅に小さいペイロードで送受信されます。
- フィールド番号の設計判断: 実務で
.protoを設計する際、「将来頻繁に使うフィールドには1〜15の番号を割り当てる」という慣習があります。これはタグが1バイトで収まる番号の上限(フィールド番号1〜15かつワイヤタイプ0〜7の場合、(15<<3)|7=127が1バイトvarintの上限)を踏まえた、今日学んだ仕組みに基づく実務上の最適化です。 - モバイル・IoTなど帯域制約のある環境: パケット数課金や低帯域回線を使うモバイルアプリ・IoTデバイスでは、JSONからPBへの移行だけでペイロードサイズを数分の一に削減できるケースがあり、実際に多くのモバイルAPIでPB(またはFlatBuffers等の類似技術)が採用される動機になっています。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「Protocol Buffersは常にJSONよりバイト数が小さい」と一般化してしまう | ベンチマーク記事の一部だけを見て、条件を確認せずに一般化する | 今日の実測でもa=150は3バイト対9バイトと大差がついたが、これは数値主体のペイロードだから。長い自由記述の文字列フィールドが主体のペイロードでは差は縮まる。フィールド構成に依存することを常に意識する |
| 負の数もそのままvarintにすれば得だと思い込む | varintが「小さい値ほど有利」という説明だけを覚え、符号の扱いを考えていない | 負の数をint64のまま素朴にvarintエンコードすると、2の補数表現により上位ビットがすべて1で埋まるため常に10バイトになる(今日の2^63-1の9バイトよりさらに悪い)。PBはこの問題に対し、sint32/sint64という型でzigzagエンコーディング(符号付き整数を0,-1,1,-2,2,...の順に符号なし整数へマッピングする)を別途用意している |
| フィールド名を変更すると後方互換性が壊れると思い込む | JSONのキー感覚で「名前が識別子」だと考えてしまう | 今日確認した通り、ワイヤ上にはフィールド番号しか現れずフィールド名は一切乗らない。PBの後方互換性はフィールド番号で決まり、.proto上の名前は自由に変更してよい(番号を変える・使い回すことが破壊的変更になる) |
🚀 次のステップ
- 発展:
sint32/sint64で使われるzigzagエンコーディング((n << 1) ^ (n >> 31)のような変換式)を実装し、負の数を含むデータでvarintのバイト数がどう変化するか実測してみましょう。 - 次回予告: 自己評価が「完全理解」で安定すればテーマ15「ミドルウェア設計パターン」(
http.Handlerのチェーン設計)へ。継続する場合は同テーマでlength-delimited(ワイヤタイプ2、文字列・埋め込みメッセージ用)の符号化を扱う予定。