Day 051 — gRPC/Protocol Buffers実装(続き): Wire Format理解——varint符号化とタグ構造から見る「なぜPBは速いのか」

2026-09-17 🟡 上級者 / Phase 3 概念理解 gRPC/Protocol Buffers実装

📚 背景知識(読んでから問題へ)

Day 048〜050では、net/rpcを使って本物のgRPCの挙動(コード生成・デッドライン伝播・Server streaming)を疑似体験してきました。しかし、これまで一度も「実際にワイヤ(ネットワーク)の上を流れるバイト列がどういう形をしているか」には触れていません。今日はその核心であるProtocol Buffers(以下PB)のWire Formatを扱います。

PBがJSONに比べて小さく速い理由は、しばしば「バイナリだから」の一言で片付けられますが、それは正確ではありません。真の理由は次の2点です。

  1. スキーマがワイヤに乗らない: JSONは{"a":150}のようにキー名そのものを毎回送りますが、PBは.protoで定義したフィールド番号(例: a = 1)だけを送ります。フィールド名は送受信者が事前に共有している.protoにしかなく、ワイヤ上には一切現れません。
  2. 数値がvarint(可変長整数)で符号化される: 実務のデータでは、IDやカウンタ、enumの値など「小さい数値」が圧倒的に多く出現します。PBは数値を固定長4/8バイトではなく、値が小さいほどバイト数も小さくなるvarintという可変長エンコーディングで表現します。

この2つの設計判断——「スキーマは帯域外(out-of-band)で共有する」「小さい値を優先的に短く符号化する」——は、Protocol Buffersの公式エンコーディング仕様に明記されています(出典: Protocol Buffers - Encoding | protobuf.dev、参照日: 2026-09-17)。今日はこの仕様書が示す具体例を、外部ライブラリを一切使わず標準ライブラリのみでGoに実装し、本物のprotocが吐くバイト列と一致することを手元で検証します。

📌
今日のテーマの位置づけ: これまでの「サービス定義」「デッドライン伝播」「ストリーミング」はいずれもgRPC/PBのAPI層の話でした。今日はその一段下、バイト表現そのものまで潜って、なぜPBが実務で「速い」と言われるのかを数字で裏付けます。

📝 問題

  1. varintのエンコード規則を実装する: uint64の値を受け取り、Protocol Buffers形式のvarintバイト列を返すencodeVarint(x uint64) []byteを実装してください。規則: 値を7bitずつ区切り、下位バイトから順に格納する。最後のバイト以外は最上位ビット(MSB)を1にして「まだ続きがある」ことを示し、最後のバイトはMSBを0にする。
  2. varintのデコードを実装する: エンコードの逆操作として、バイト列からuint64の値と消費バイト数を復元するdecodeVarint(buf []byte) (uint64, int)を実装してください。
  3. タグの計算とフィールドエンコードを実装する: PBのフィールドはタグ = (フィールド番号 << 3) | ワイヤタイプという式でタグを計算し、タグ(varint) + 値(そのワイヤタイプに応じた符号化)という順で並べます。ワイヤタイプ0はVarintを意味します。この式に基づきencodeTag(fieldNumber, wireType int) uint64と、それを使って1フィールド分をまるごとエンコードするencodeVarintField(fieldNumber int, value uint64) []byteを実装してください。
  4. 公式ドキュメントの例と一致することを検証する: Protocol Buffers公式ドキュメントには「message Test1 { optional int32 a = 1; }でa = 150のとき、ワイヤ上のバイト列は08 96 01になる」という具体例が示されています。あなたの実装でencodeVarintField(1, 150)を呼び出し、結果が[]byte{0x08, 0x96, 0x01}と一致することをコード上で検証してください(panicまたはgo testのいずれかで、一致しなければ失敗する形にする)。
  5. 概念確認(言葉で説明): 上記の実装を踏まえ、「なぜ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 解説

1
varintの符号化規則を数式ではなくビット操作として理解する
150を2進数で書くと10010110(8bit)です。これを7bitずつ下位から区切ると0010110(下位7bit)と1(残りの上位1bit)に分かれます。下位側のグループは「まだ続きがある」ので0x80を立てて10010110(= 0x96)に、上位側のグループは「これが最後」なので0x80を立てずに00000001(= 0x01)になります。これが実行結果の96 01の正体です。
2
タグも「ただのvarintでエンコードされた数値」だと理解する
タグ(1 << 3) | 0 = 8は0x80未満なので、1バイトの0x08としてそのままエンコードされます。もしフィールド番号が16以上になると(16 << 3 = 128 = 0x80)、タグ自体も2バイト以上のvarintになります。つまり「フィールド番号を小さく保つ」ことは、実はワイヤサイズに直結する設計上の選択です。.protoで頻出フィールドに小さい番号を割り当てる慣習は、この仕組みへの理解から来ています。
3
デコードでshiftを使う理由
decodeVarintでx |= uint64(b&0x7f) << shiftとしているのは、2バイト目以降の7bitは「値の上位側」を表すためです。1バイト目の7bitはそのまま(shift=0)、2バイト目の7bitは1バイト目の7bitより上位の桁を表すのでshift=7だけ左シフトしてから足し合わせます。
4
大きい値でvarintが不利になることを実測する
2^63-1(=符号なし64bit整数の最大値に近い値)をエンコードすると9バイトになりました。これは固定長uint64の8バイトより1バイト多い結果です。つまりvarintは「小さい値を短くする代わりに、大きい値では固定長よりわずかに不利になる」というトレードオフの上に成り立っています。PBの設計者は、実務データの大半が小さい値であるという経験則に基づき、このトレードオフを許容しました。

💡 設計思想・なぜこう書くのか

📌
明示的な契約を優先する発想: Protocol Buffersのワイヤフォーマットは、Goの設計思想と地続きの発想で読み解けます。Goがif err != nilという明示的なエラーハンドリングを選んだのと同様に、PBも「フィールド名という自己記述的な情報をワイヤに含める」という便利さを捨て、「送受信者が.protoという契約を事前に共有している」という明示的な前提の上に成り立っています。両者とも「実行時の暗黙の解決」よりも「事前に交わされた明示的な契約」を優先するという一貫した設計思想が見て取れます。
📌
データの分布に最適化する発想: varintという「値の大きさに応じてサイズが変わる」符号化は、Goのinterface{}が値の型に応じてitableとdataポインタを動的に持つのと同じ発想——「一様な固定サイズよりも、実際のデータの分布に最適化する」という考え方の一種です。ただしGoの言語仕様自体にvarintのような可変長プリミティブはなく、これはPB・gRPC固有の設計判断であることには注意してください。

🌐 他言語との比較

フォーマット/エコシステム数値のエンコード方式Protocol Buffersとの違い
JSON数値を10進数の文字列として送る(例: 150は3バイトの文字'1' '5' '0')人間が読めるが、パース時に文字列→数値変換のコストがかかり、桁数が増えるほどバイト数も線形に増える。フィールド名もキーとして毎回送るため、繰り返し送信するAPIレスポンスでは冗長性が大きい
MessagePackTimestamp等は固定長、小さい整数は「型タグに値を埋め込む」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、文字列・埋め込みメッセージ用)の符号化を扱う予定。

🎯 自己評価

自分の回答

気づき・メモ