ผู้อ่านรุ่นเก่าถือ field ที่มันไม่รู้จัก ติดมือไปด้วยครบทุก byte
ทีมหนึ่งเพิ่ม field ใหม่เข้า schema แล้วขึ้น production วันจันทร์ อีกทีมยังถือ schema ทรงเก่าอยู่ และจะขึ้นของตัวเองอีกสองสัปดาห์ข้างหน้า ระหว่างนั้น byte ที่มีของใหม่ปนอยู่ วิ่งเข้าไปหาผู้อ่านที่ไม่เคยได้ยินชื่อช่องนั้นเลย
คำตอบที่คนส่วนใหญ่เดาคือผู้อ่านทิ้งของที่ไม่รู้จัก บทนี้วัดจริงแล้วได้คำตอบตรงข้าม ผู้อ่านถือมันไว้ครบทุก byte แล้วส่งคืนออกไปตอนเขียนกลับ
orderctl คือเครื่องมือประมวลผลออเดอร์ที่สามคอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ ระหว่างนี้ทุกตัวเลขในคอร์สนี้รันเองได้ สั่ง
scripts/wire-contract/up.sh เพื่อดึงเครื่องมือที่ตรึง version ไว้ แล้วตามด้วย
python3 scripts/wire-contract/probe.py
v2 เพิ่มช่องเดียวท้ายสุด แล้ววัดใหม่
หัวข้อที่มีชื่อว่า “v2 เพิ่มช่องเดียวท้ายสุด แล้ววัดใหม่”ทรง v2 ต่างจากทรงตั้งต้นที่
string branch = 3อย่างเดียว และขึ้นสายเป็น 18 byte
ทรงตั้งต้นของคอร์สนี้มีสองช่อง ทรง v2 เติมช่องที่สามต่อท้าย โดยไม่แตะชื่อ ไม่แตะหมายเลข และไม่แตะชนิดของสองช่องเดิม
syntax = "proto3";
package order.v2;
message Order { string customer = 1; int32 quantity = 2; string branch = 3;}Go ใส่ค่าเดิมสองค่า คือ customer เป็น wira และ quantity เป็น 7 แล้วเติม branch
เป็น branch-b ผลที่ได้คือ 18 byte
0a047769726110071a086272616e63682d62แปด byte แรกของชุดนี้คือ message ของทรงตั้งต้นทั้งใบ เทียบกับ hex ของบทที่ 1
ซึ่งเป็น 0a04776972611007 ได้ตรงกันทุกตัวอักษร
| byte | ค่า | ความหมาย |
|---|---|---|
| 1–8 | 0a04776972611007 | ทั้งใบของทรงตั้งต้น ไม่ขยับสักตัว |
| 9 | 1a | tag ของ field หมายเลข 3 ชนิดบอกความยาวนำหน้า |
| 10 | 08 | ความยาวของค่า คือ 8 byte |
| 11–18 | 6272616e63682d62 | ตัวอักษรของ branch-b |
1a ตรวจได้ด้วยสูตรเดิมจากบทที่ 1 คือ (3 << 3) | 2 ได้ 26 ซึ่งเขียนเป็น hex คือ 1a
ของใหม่จึงไปต่อท้าย และของเดิมอยู่ที่เดิมทุก byte
ผู้อ่านที่ถือทรงตั้งต้น อ่าน byte ของ v2
หัวข้อที่มีชื่อว่า “ผู้อ่านที่ถือทรงตั้งต้น อ่าน byte ของ v2”Java ที่ถือทรงตั้งต้น parse ผ่าน ได้
customerเป็นwiraและquantityเป็น7แล้วรายงานว่ามี unknown อยู่ 1 หมายเลข
ผู้อ่านฝั่งนี้ไม่มีหมายเลข 3 ใน schema ของตัวเอง มันจึงไม่มีช่องให้วางค่านั้น แต่มันอ่าน tag ออก และรู้จากสาม bit ท้ายว่าค่าถัดไปมีรูปร่างแบบไหน จึงรู้ว่าต้องข้ามไปกี่ byte
นี่คือเหตุผลที่ parse ไม่พัง ผู้อ่านไม่จำเป็นต้องรู้ความหมายของช่อง เพื่อจะรู้ขอบเขตของมัน รูปร่างของ รูปแบบบนสายwire formatรูปแบบของ byte ที่ protobuf ใช้จริงบนสาย · แต่ละ field คือ tag แล้วตามด้วยค่า และไม่มีชื่อ field เดินทางไปด้วยสักตัว บอกขอบเขตไว้แล้ว
ของที่มันข้ามไม่ได้ถูกทิ้ง มันถูกเก็บไว้เป็น byte ดิบในที่เก็บ field ที่ไม่รู้จักunknown fieldfield ที่ผู้อ่านไม่รู้จัก เพราะ schema ของตัวเองไม่มีหมายเลขนั้น · ผู้อ่านเก็บ byte ดิบไว้เฉยๆ แล้วส่งคืนตอน serialize กลับ ของ message นั้น ค่าที่วัดได้จากงานอ่านนี้มีสี่ช่อง
| ช่องที่วัด | ค่าที่ผู้อ่านทรงตั้งต้นได้ |
|---|---|
| parse | ok |
customer | wira |
quantity | 7 |
| จำนวน unknown | 1 |
byte ที่ค้างอยู่ในที่เก็บนั้นเทียบได้ทีละตัว มันคือ 1a086272616e63682d62
ซึ่งเท่ากับ byte ที่ 9 ถึง 18 ของ message ที่รับเข้ามา ไม่ขาดและไม่เกิน
นี่คือ ความเข้ากันได้กับของที่ใหม่กว่าforward compatibilityผู้อ่านรุ่นเก่าอ่าน byte ที่ผู้เขียนรุ่นใหม่ส่งมาได้ · วัดแล้วว่าผู้อ่าน v1 อ่านของ v2 ได้ค่าครบ ถือ unknown ไว้หนึ่งหมายเลข แล้วคืน byte ชุดเดิมทุกตัว ในรูปที่วัดได้ คือผู้อ่านรุ่นเก่าอ่าน byte ของผู้เขียนรุ่นใหม่แล้วได้ค่าที่ตัวเองต้องการครบ
serialize กลับออกไป แล้วเทียบทีละตัว
หัวข้อที่มีชื่อว่า “serialize กลับออกไป แล้วเทียบทีละตัว”ผู้อ่านที่ถือทรงตั้งต้นเขียนกลับได้
0a047769726110071a086272616e63682d62ซึ่งเท่ากับ byte ที่รับเข้ามาทุกตัว
งานอ่านอย่างเดียวยังไม่ครบเรื่อง เพราะบริการจริงมักอ่านเข้ามา แก้ช่องที่ตัวเองสนใจ แล้วส่งต่อออกไปอีกทอด ขาที่สองนี้คือที่ที่ของหายกันจริง
ให้ผู้อ่านตัวเดิมสั่งเขียนกลับทันทีหลัง parse แล้วเทียบผลกับ byte ที่รับเข้ามา การอ่านเข้าแล้วเขียนกลับround tripการอ่าน byte เข้ามาแล้ว serialize กลับออกไป · เท่าเดิมทุก byte ในเกือบทุกงาน ยกเว้นสองงานที่เหลือสิบสอง byte และสิบเอ็ด byte รอบนี้ให้ผลเท่าเดิมทุก byte
| ขา | hex |
|---|---|
| รับเข้ามา | 0a047769726110071a086272616e63682d62 |
| เขียนกลับ | 0a047769726110071a086272616e63682d62 |
ช่องที่ผู้อ่านเรียกชื่อไม่ได้ ยังเดินทางต่อไปถึงปลายทางถัดไปได้ครบ ผู้อ่านทำหน้าที่เป็นทางผ่าน ของมัน ไม่ใช่ปลายทาง
เอกสารของ protobuf ระบุกฎข้อนี้ไว้ตรงๆ ว่าผู้อ่าน proto3 เก็บ unknown field ไว้ แล้วคืนมันตอน serialize มันจึงเป็นกฎของรูปแบบ ไม่ใช่ผลที่บังเอิญออกมาแบบนี้ในตัวอย่างเดียว
flowchart LR W["ผู้เขียน v2 ส่ง 18 byte"] --> P["ผู้อ่านทรงตั้งต้น parse"] P --> K["ช่องที่รู้จัก customer และ quantity"] P --> U["ที่เก็บ unknown หมายเลข 3 หนึ่งรายการ"] K --> S["serialize กลับ"] U --> S S --> O["18 byte ชุดเดิมทุกตัว"]
คำบรรยายภาพ: ผู้อ่านแยก byte ที่รับเข้ามาเป็นสองกอง กองที่ schema ของตัวเองรู้จัก กับกองที่ไม่รู้จักและถูกเก็บดิบไว้ แล้วตอนเขียนกลับ มันเอาสองกองมาต่อกันใหม่จนได้ byte ชุดเดิม
Go ตอบเหมือน Java ทุกช่องในงานอ่านนี้
หัวข้อที่มีชื่อว่า “Go ตอบเหมือน Java ทุกช่องในงานอ่านนี้”ทั้งสี่ช่องที่ Go ตอบ เท่ากับที่ Java ตอบ คือ parse ผ่าน
customerเป็นwiraquantityเป็น7และ unknown เท่ากับ 1 · byte ที่เขียนกลับก็เป็นชุดเดิม
ตัวเลขทุกตัวในสองหัวข้อบนถูกวัดสองครั้ง ครั้งหนึ่งด้วย runtime ของ Java อีกครั้งด้วย runtime ของ Go บน byte ชุดเดียวกัน ผลออกมาไม่ต่างกันสักช่อง
เรื่องนี้สำคัญกว่าที่มันดู ข้ออ้างที่วัดจาก runtime เดียว เป็นข้ออ้างเรื่อง runtime นั้น ไม่ใช่เรื่องของรูปแบบ การได้ค่าเดียวกันจากสอง runtime ที่คนละทีมเขียน คือหลักฐานคนละชั้นกัน
ระวังไว้ว่านี่ยังไม่ได้แปลว่าสองภาษาตอบเหมือนกันทุกงาน มีงานอ่านหนึ่งงานในคอร์สนี้ที่สองฝั่ง ตอบไม่เท่ากัน และมันรออยู่ที่ บทที่ 5
ทางกลับ คือผู้อ่านทรงใหม่อ่านของเก่า
หัวข้อที่มีชื่อว่า “ทางกลับ คือผู้อ่านทรงใหม่อ่านของเก่า”ผู้อ่าน v2 อ่าน byte แปดตัวของทรงตั้งต้น ได้ค่าครบสองช่อง และถือ unknown ไว้ 0 หมายเลข
สลับด้านกัน คราวนี้ผู้อ่านถือ v2 ซึ่งมีสามช่อง ส่วน byte ที่วิ่งมาเป็นของทรงตั้งต้น
ซึ่งมีแค่ 0a04776972611007
| ช่องที่วัด | ค่าที่ผู้อ่าน v2 ได้ |
|---|---|
| parse | ok |
customer | wira |
quantity | 7 |
| จำนวน unknown | 0 |
branch ไม่มีค่า และไม่มีอะไรผิดปกติให้สงสัย เพราะไม่มี byte ส่วนเกินเข้ามาเลย
ผู้อ่านจึงไม่มีอะไรต้องเก็บ
ผลของทางกลับนี้คือความเงียบสองแบบที่หน้าตาเหมือนกัน คือ branch ที่ผู้ส่งไม่ได้ตั้งค่า
กับ branch ที่ผู้ส่งไม่รู้จัก บทที่ 2 อธิบายไว้แล้วว่าทำไมผู้อ่าน proto3 ธรรมดาแยกสองแบบนี้ไม่ออก
อ่านย้อนได้ที่ บทที่ 2
ราคาของกฎข้อนี้
หัวข้อที่มีชื่อว่า “ราคาของกฎข้อนี้”กฎที่วัดได้ในบทนี้คือสิ่งที่ทำให้ขึ้น production ทีละฝั่งเป็นไปได้
ถ้าผู้อ่านทิ้งของที่ไม่รู้จัก สองทีมจะต้องขึ้นของพร้อมกันทุกครั้งที่ schema โต และทุกบริการที่อยู่กลางทางจะกลายเป็นจุดที่ข้อมูลหายเงียบๆ
เพราะผู้อ่านถือของนั้นไว้และคืนมันครบ สองทีมจึงขึ้นของคนละวันได้ ฝั่งที่โตก่อนส่งของใหม่ไป ฝั่งที่ยังเก่าอ่านของที่ตัวเองต้องการได้ และส่งของใหม่ต่อไปโดยไม่ต้องรู้ว่ามันคืออะไร
ราคาของกฎนี้คือมันคืน byte ให้ครบ แต่ไม่ได้กันค่าไม่ให้ไปนั่งผิดช่อง บทที่ 4 วัดการเอาหมายเลขเดิมกลับมาใช้ และพบว่าทั้งสองแบบ parse ผ่านเหมือนกัน แบบหนึ่งยังเหลือร่องรอยไว้ใน unknown field ส่วนอีกแบบไม่เหลืออะไรเลย อ่านต่อที่ บทที่ 4
-
Protocol Buffers — Language Guide (proto 3) (เข้าถึง 2026-09-04) — ที่มาของกฎว่าผู้อ่านเก็บ unknown field ไว้ แล้วคืนมันตอน serialize
-
Protocol Buffers — Encoding (เข้าถึง 2026-09-04) — ที่มาของสูตร tag ที่ใช้ตรวจ
1aและของกฎว่า wire type บอกขอบเขตของค่าถัดไปให้ผู้อ่านที่ไม่รู้จักช่องนั้น -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/wire-contract/probe.pyซึ่งให้ Go เขียน byte แล้วให้ทั้ง Go และ Java อ่านกลับ ต้องสั่งscripts/wire-contract/up.shก่อนหนึ่งครั้ง ซึ่งดึงเครื่องมือสามตัวที่ตรึง version ไว้- protoc 36.1
- protoc-gen-go v1.36.12
- protobuf-java 4.36.1
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ผู้อ่านที่ถือ schema ทรงตั้งต้น อ่าน byte 18 ตัวของ v2 แล้วได้อะไร