Day 052 — gRPC/Protocol Buffers実装(続き): Length-delimited(Wire Type 2)で見る string・埋め込みメッセージの共通構造

2026-09-18 🟡 上級者 / Phase 3 実装 gRPC/Protocol Buffers実装

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

Day 051では、Wire Type 0(Varint)を使い、タグ + 値という最小構成でPBのバイナリ表現を実装しました。しかし実務の.protoはint32やboolだけでは成り立ちません。string・bytes・埋め込みメッセージ(他のmessageをフィールドとして持つ構造)が必ず登場します。今日はこれらすべてを支えるWire Type 2(Length-delimited、長さ接頭辞付き)を扱います。

公式ドキュメント「Protocol Buffers - Encoding」の"Length-Delimited Records"節は、この仕組みを次のように説明しています(出典: protobuf.dev/programming-guides/encoding、"Length-Delimited Records"節、2026-09-18にWebFetchで実際に取得し記載内容を確認)。

  • message Test2 { optional string b = 2; } で b = "testing" のとき、ワイヤ上のバイト列は 12 07 [74 65 73 74 69 6e 67] になる。12はタグ(フィールド2・ワイヤタイプ2)、07は長さ(7バイト)、残りは"testing"のUTF-8バイト列。
  • message Test3 { optional Test1 c = 3; } で c = { a: 150 } のとき、ワイヤ上のバイト列は 1a 03 [08 96 01] になる。1aはタグ(フィールド3・ワイヤタイプ2)、03は長さ、08 96 01はDay051で検証済みのTest1{a:150}のバイト列そのもの。
📌
今日のテーマの位置づけ: stringもembedded messageも、PBのワイヤ上では「長さ + 生バイト列」という全く同じ構造で表現されます。埋め込みメッセージのエンコード結果は、それ自体が独立したPBメッセージのバイト列であり、それをただ「長さを添えて別フィールドの値として埋め込む」だけで実現されています。これはPBの再帰的な設計——「メッセージのエンコード結果は、それ自体を別のメッセージに埋め込める1つのバイト列にすぎない」——を象徴する事実です。

📝 問題

  1. Length-delimitedのエンコードを実装する: フィールド番号と生バイト列data []byteを受け取り、タグ(varint) + 長さ(varint) + dataという順でエンコードするencodeLengthDelimited(fieldNumber int, data []byte) []byteを実装してください(ワイヤタイプは2)。
  2. Length-delimitedのデコードを実装する: タグを読み終えた直後のバイト列から、長さvarintとそのバイト数ぶんのdataを復元するdecodeLengthDelimited(buf []byte) (data []byte, n int)を実装してください(nは消費バイト数=長さvarintのバイト数+dataのバイト数)。
  3. string フィールドを検証する: encodeLengthDelimited(2, []byte("testing"))を呼び出し、結果が公式ドキュメントの例[]byte{0x12, 0x07, 0x74, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67}と一致することを検証してください。
  4. 埋め込みメッセージを検証する: Day051で実装したencodeVarintField(1, 150)(Test1{a:150}のバイト列)をdataとしてencodeLengthDelimited(3, ...)に渡し、結果が公式ドキュメントの例[]byte{0x1a, 0x03, 0x08, 0x96, 0x01}と一致することを検証してください。さらに、そのバイト列をdecodeLengthDelimitedで取り出したあと、中身をもう一度decodeVarintでデコードしてfieldNumber=1, value=150を復元できることを確認してください(=入れ子のデコード)。
  5. 概念確認(言葉で説明): 埋め込みメッセージには、XMLの</tag>やJSONの}のような「終端を示す記号」が一切なく、長さプレフィックスだけで区切られています。この設計により、PBのパーサは埋め込みメッセージを読む際に何をしなければならないか(ストリーミングで1バイトずつ処理していく方式と比較して)を説明してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Day051で実装したencodeVarintFieldは「タグ + 値そのもの(varint)」という2要素構成でした。今日のencodeLengthDelimitedはその間に「値の長さ」をもう1つ挟むだけです。「タグ + 長さ + 生データ」の3要素構成だと考えてください。

ヒント2 — アプローチ

エンコードはencodeVarint(encodeTag(fieldNumber, 2))でタグ部分を作り、そこにencodeVarint(uint64(len(data)))で長さ部分をappendし、最後にdata自体をappendすれば完成します。デコードは逆に、まずdecodeVarintで長さと「長さ部分が何バイトだったか」を得てから、buf[長さ部分のバイト数 : 長さ部分のバイト数+長さ]でデータ本体をスライスします。

ヒント3 — コード骨格
func encodeLengthDelimited(fieldNumber int, data []byte) []byte {
	buf := encodeVarint(encodeTag(fieldNumber, /* TODO: wireType */))
	buf = append(buf, encodeVarint(uint64(len(data)))...)
	buf = append(buf, /* TODO */...)
	return buf
}

func decodeLengthDelimited(buf []byte) (data []byte, n int) {
	length, lenBytes := decodeVarint(buf)
	data = buf[lenBytes : /* TODO: 終端インデックス */]
	return data, /* TODO: 消費バイト数の合計 */
}

✅ 模範解答

package main

import "fmt"

// --- Day051からの持ち込み: varintとタグの基本操作(今日の実装が依存する) ---

const (
	wireTypeVarint = 0
	wireTypeLen    = 2 // 可変長: string, bytes, embedded message, packed repeated
)

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
}

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)
}

func encodeTag(fieldNumber, wireType int) uint64 {
	return uint64(fieldNumber)<<3 | uint64(wireType)
}

func encodeVarintField(fieldNumber int, value uint64) []byte {
	buf := encodeVarint(encodeTag(fieldNumber, wireTypeVarint))
	return append(buf, encodeVarint(value)...)
}

// --- 今日の新実装: Wire Type 2 (Length-delimited) ---

// encodeLengthDelimited はフィールド番号と生バイト列から、
// タグ(varint) + 長さ(varint) + 生データ、というLength-delimitedフィールドをエンコードする。
// string / bytes / embedded message はすべてこの同じ形式に還元される。
func encodeLengthDelimited(fieldNumber int, data []byte) []byte {
	buf := encodeVarint(encodeTag(fieldNumber, wireTypeLen))
	buf = append(buf, encodeVarint(uint64(len(data)))...)
	buf = append(buf, data...)
	return buf
}

// decodeLengthDelimited はタグを読み終えた直後のbufから
// 長さvarint + そのバイト数ぶんのdataを取り出し、消費バイト数(n)とともに返す。
func decodeLengthDelimited(buf []byte) (data []byte, n int) {
	length, lenBytes := decodeVarint(buf)
	data = buf[lenBytes : lenBytes+int(length)]
	return data, lenBytes + int(length)
}

func mustEqual(name string, got, want []byte) {
	if len(got) != len(want) {
		panic(fmt.Sprintf("%s: length mismatch got=%d want=%d", name, len(got), len(want)))
	}
	for i := range want {
		if got[i] != want[i] {
			panic(fmt.Sprintf("%s: byte mismatch at %d: got %x want %x", name, i, got[i], want[i]))
		}
	}
	fmt.Printf("OK: %s は公式ドキュメントの例と一致した (% x)\n", name, got)
}

func main() {
	// --- 検証1: string フィールド ---
	// message Test2 { optional string b = 2; }  b = "testing"
	// 出典: protobuf.dev/programming-guides/encoding の "Length-Delimited Records" 節
	// (2026-09-18 にWebFetchで取得し記載内容を確認)
	gotStr := encodeLengthDelimited(2, []byte("testing"))
	wantStr := []byte{0x12, 0x07, 0x74, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67}
	mustEqual(`Test2.b="testing"`, gotStr, wantStr)

	// ラウンドトリップ確認
	tag, n := decodeVarint(gotStr)
	fieldNumber, wireType := tag>>3, tag&0x7
	data, _ := decodeLengthDelimited(gotStr[n:])
	fmt.Printf("decoded: fieldNumber=%d wireType=%d data=%q\n", fieldNumber, wireType, string(data))

	// --- 検証2: embedded message フィールド ---
	// message Test1 { optional int32 a = 1; }
	// message Test3 { optional Test1 c = 3; }  c = { a: 150 }
	// 出典: 同上 "Length-Delimited Records" 節
	innerTest1 := encodeVarintField(1, 150) // Day051のTest1エンコード結果 = 08 96 01
	gotMsg := encodeLengthDelimited(3, innerTest1)
	wantMsg := []byte{0x1a, 0x03, 0x08, 0x96, 0x01}
	mustEqual("Test3.c={a:150}", gotMsg, wantMsg)

	// 入れ子構造のデコード: 埋め込みメッセージのバイト列をさらにデコードする
	_, n2 := decodeVarint(gotMsg)
	innerBytes, _ := decodeLengthDelimited(gotMsg[n2:])
	innerTag, innerN := decodeVarint(innerBytes)
	innerValue, _ := decodeVarint(innerBytes[innerN:])
	fmt.Printf("nested decoded: outer field=3, inner field=%d inner value=%d\n", innerTag>>3, innerValue)

	// --- 概念確認用の実測メモ ---
	fmt.Println("---")
	fmt.Println("embedded messageのバイト列そのものにはXMLのやJSONの}のような終端記号がなく、")
	fmt.Println("長さプレフィックスだけで区切られている。そのためdecodeLengthDelimitedは")
	fmt.Println("「長さぶんのバイトを一括スライス」する実装になっており、1バイトずつのストリーミングパースはできない。")
}

実行結果(実測。go1.27.1 darwin/arm64で確認):

OK: Test2.b="testing" は公式ドキュメントの例と一致した (12 07 74 65 73 74 69 6e 67)
decoded: fieldNumber=2 wireType=2 data="testing"
OK: Test3.c={a:150} は公式ドキュメントの例と一致した (1a 03 08 96 01)
nested decoded: outer field=3, inner field=1 inner value=150
---
embedded messageのバイト列そのものにはXMLのやJSONの}のような終端記号がなく、
長さプレフィックスだけで区切られている。そのためdecodeLengthDelimitedは
「長さぶんのバイトを一括スライス」する実装になっており、1バイトずつのストリーミングパースはできない。

🪜 Step-by-Step 解説

1
タグの1ビットが構造をどう変えるか
Day051のVarint(ワイヤタイプ0)とWire Type 2の違いは、タグの下位3bitが000か010かだけです。encodeTag関数はDay051のものをそのまま流用でき、変更点は呼び出し時に渡すwireTypeの値だけです。PBの設計では「値の種類(int32かstringか)」を型システムではなくワイヤタイプという3bitの数値だけで区別しており、これがGoのinterface{}が実行時に型情報(itab)を持つのとは異なる、符号化レベルでの型消去であることが分かります。
2
「長さ」を挟むだけでバイト列同士を合成できる
encodeLengthDelimited(3, innerTest1)のinnerTest1は、それ自体が独立して意味を持つ完全なPBバイト列(Test1{a:150}のエンコード結果)です。それを「長さを測って前に付けるだけ」で、別のメッセージのフィールドとして埋め込めています。文字列も埋め込みメッセージも、コードの実装上は同じ関数を1回呼ぶだけで扱える——これが「stringもembedded messageも同じワイヤタイプ」という設計の実利です。
3
デコードでのスライス境界計算
decodeLengthDelimitedでは、まずdecodeVarintで長さの値(length)と、その長さ自体が何バイトのvarintだったか(lenBytes)を同時に得ます。データ本体はbuf[lenBytes : lenBytes+int(length)]という範囲にあります。ここをbuf[lenBytes:]のように終端を省略してしまうと、後続の別フィールドのバイトまで誤って含めてしまうバグになるため、上限を明示することが重要です。
4
入れ子のデコードは「デコードをもう一度呼ぶだけ」
gotMsgからdecodeLengthDelimitedで取り出したinnerBytesは、それ単体でまた1つの完全なPBメッセージのバイト列です。そのためdecodeVarintをもう一度呼ぶだけで、中のTest1をデコードできます。この「デコード関数を再帰的に適用できる」構造こそが、実際のprotobuf生成コードが任意の深さのネストしたmessageを扱える理由です。

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

📌
共通構造への還元: PBがstring・bytes・embedded messageという3つの異なる概念を1つのワイヤタイプ(Length-delimited)にまとめたのは、Goがerrorという1つのインターフェースで「ファイルが見つからない」から「ネットワークタイムアウト」まであらゆる失敗を表現するのと同じ発想です。「型ごとに専用の表現を作る」のではなく、「共通の構造(長さ+データ)に還元できるものは1つの仕組みで扱う」という設計判断がここでも一貫しています。
⚠️
トレードオフ: ストリーミング解析の犠牲: この設計には明確なコストがあります。長さプレフィックス方式はパーサに「データ全体をメモリ上に確保してから初めて中身を解釈できる」という制約を課します。Goのencoding/jsonのDecoderがストリームから逐次的にトークンを読める(json.NewDecoder(r).Token()でSAXパーサのように1トークンずつ処理できる)のとは対照的に、標準のPBパーサは埋め込みメッセージの境界を跨いで1バイトずつ処理するようなインクリメンタルパースには向いていません。これは「シンプルな構造にする」という利点と引き換えに、「ストリーミング解析のしやすさ」を犠牲にしたトレードオフです。

🌐 他言語との比較

フォーマット/エコシステム「入れ子」の表現方法Protocol Buffersとの違い
JSON{と}という開始・終了記号で入れ子を表現する人間には読みやすいが、終了記号がどこにあるか分かるまでバイト列を走査しないと構造が確定しない場合がある。ただしencoding/jsonのDecoder.Token()のようにSAXスタイルで前から逐次パースする実装は広く普及している
XML<tag>と</tag>という開始・終了タグで入れ子を表現するJSONと同様に開始・終了の対応で構造を表す。SAXパーサ(イベント駆動でストリーミング処理)がXMLエコシステムでは標準的に使われ、巨大なXML文書をメモリに全展開せず処理できる
Protocol Buffers長さプレフィックス(今日実装したWire Type 2)で入れ子を表現する終了記号が存在せず「前もって長さが分かっている」ことを前提にする。デコーダは長さぶんのバイトを一括して切り出す必要があり、SAXのような1バイトずつのストリーミングパースには原理的に不向き
Cap'n Proto / FlatBuffers固定幅オフセットでフィールド位置を直接指す(ポインタ方式)PBが「長さを読んでからデータをコピー/解釈する」のに対し、Cap'n Proto/FlatBuffersはデコード処理そのものを省略し、受信したバイト列に直接オフセットでアクセスできる(ゼロコピー)。PBより高速だが、スキーマ進化の柔軟性やツールの成熟度で異なるトレードオフを持つ(出典: Cap'n Proto公式サイトの設計思想の説明、2026-09-18時点の一般的な認識として記載。詳細な最新仕様は同サイトで要確認)

🏆 実務での使いどころ

  • gRPCのMessageフィールド全般: 実務の.protoで最も頻繁に使うのがstring・bytes・そしてネストしたmessageです。今日実装した1つの関数が、実際のgRPC通信で流れるフィールドの大部分をカバーしていることになります。
  • repeated フィールドのpacked encoding: repeated int32のような繰り返しフィールドも、(PBのバージョンや設定によって)Wire Type 2を使い「複数のvarintをまとめて1つのLength-delimitedフィールドとして送る」packed encodingという最適化が使われます。今日のdecodeLengthDelimitedで取り出したdataに対し、decodeVarintをループで繰り返し適用すれば複数の値を復元できる、という発展につながります。
  • ストリーミングgRPCでのメモリ設計判断: 今日学んだ「埋め込みメッセージは長さぶん丸ごとバッファする必要がある」という制約は、巨大な埋め込みメッセージ(例: 大きなバイナリファイルをbytesフィールドに詰めるような設計)を避け、代わりにgRPCのストリーミングRPC(Day050で扱ったServer streaming)で小さいメッセージを何度も送る設計にする、という実務判断の裏付けになります。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
stringとembedded messageはワイヤ上で全く別の形式だと思い込む.proto上の型名が違うので、符号化方式も別物だろうと推測してしまう今日確認した通り、両方ともタグ(wireType=2) + 長さ + 生バイト列という完全に同じ構造で表現される。違いは「その生バイト列をUTF-8文字列として解釈するか、別のPBメッセージとして再帰的にデコードするか」というアプリケーション側の解釈だけ
decodeLengthDelimitedを「バイト列全体を毎回コピーするもの」と誤解し、大きなペイロードで無駄なアロケーションが起きると心配しすぎるGoのスライスの挙動を正確に理解していない今日の実装buf[lenBytes:lenBytes+int(length)]はスライス操作であり、元の配列を指す新しいスライスヘッダーを作るだけでバイトのコピーは発生しない(Day005で学んだスライスの内部構造の応用)。ただし元のbuf全体がGCされずに保持され続ける副作用には注意が必要
埋め込みメッセージのバイト列に対して1バイトずつのストリーミングパースができると思い込むJSON/XMLのSAXパーサに慣れていると、PBも同様にできるはずだと推測してしまう今日の設計思想で述べた通り、長さプレフィックス方式は「長さが分かってから初めてデータ本体を切り出せる」構造であり、標準的なPBデコーダは埋め込みメッセージ単位でバッファを必要とする。巨大な単一メッセージのストリーミング処理にはgRPCのストリーミングRPC(複数の小さいメッセージに分割して送る)で対応するのがPBのエコシステムにおける実務上の解決策

🚀 次のステップ

  • 発展: repeated int32のpacked encoding(複数のvarintを1つのWire Type 2フィールドにまとめる方式)を、今日実装したdecodeLengthDelimitedとdecodeVarintのループ適用で実装してみましょう。
  • 次回予告: 自己評価が「完全理解」で安定すればテーマ15「ミドルウェア設計パターン」(http.Handlerのチェーン設計)へ。継続する場合は同テーマでWire Type 1(64-bit固定長: double/fixed64)・Wire Type 5(32-bit固定長: float/fixed32)を扱う予定。

🎯 自己評価

自分の回答

気づき・メモ