ข้าม​ไป​ยัง​เนื้อหา

ผู้​อ่าน​รุ่น​เก่า​ถือ field ที่​มัน​ไม่รู้​จัก ติดมือ​ไป​ด้วย​ครบ​ทุก byte

ทีม​หนึ่ง​เพิ่ม field ใหม่​เข้า schema แล้ว​ขึ้น production วัน​จันทร์ อีก​ทีม​ยัง​ถือ schema ทรง​เก่า​อยู่ และ​จะ​ขึ้น​ของ​ตัว​เอง​อีก​สอง​สัปดาห์​ข้าง​หน้า ระหว่าง​นั้น byte ที่​มี​ของ​ใหม่​ปน​อยู่ วิ่ง​เข้าไป​หา​ผู้​อ่าน​ที่​ไม่​เคย​ได้ยิน​ชื่อ​ช่อง​นั้น​เลย

คำ​ตอบ​ที่​คน​ส่วน​ใหญ่​เดา​คือ​ผู้​อ่าน​ทิ้ง​ของ​ที่​ไม่รู้​จัก บท​นี้​วัด​จริง​แล้ว​ได้​คำ​ตอบ​ตรง​ข้าม ผู้​อ่าน​ถือ​มัน​ไว้​ครบ​ทุก byte แล้ว​ส่ง​คืน​ออก​ไป​ตอน​เขียน​กลับ

📦 orderctl — ของ​ชิ้น​เดียวกัน​กับ​สาม​คอร์ส​ภาษา​ใน​เสา​นี้

orderctl คือ​เครื่อง​มือ​ประมวล​ผล​ออ​เด​อร์​ที่​สาม​คอร์ส​ภาษา​ใน​เสา​นี้​สร้าง​ซ้ำ​ที​ละ​ภาษา repo ตัวอย่าง กำลัง​จัด​ทำ ระหว่าง​นี้​ทุก​ตัวเลข​ใน​คอร์ส​นี้​รัน​เอง​ได้ สั่ง scripts/wire-contract/up.sh เพื่อ​ดึง​เครื่อง​มือ​ที่​ตรึง version ไว้ แล้ว​ตาม​ด้วย python3 scripts/wire-contract/probe.py

ทรง v2 ต่าง​จาก​ทรง​ตั้ง​ต้น​ที่ string branch = 3 อย่าง​เดียว และ​ขึ้น​สาย​เป็น 18 byte

ทรง​ตั้ง​ต้น​ของ​คอร์ส​นี้​มี​สอง​ช่อง ทรง v2 เติม​ช่อง​ที่​สาม​ต่อ​ท้าย โดย​ไม่​แตะ​ชื่อ ไม่​แตะ​หมายเลข และ​ไม่​แตะ​ชนิด​ของ​สอง​ช่อง​เดิม

order.proto ทรง 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–80a04776972611007ทั้ง​ใบ​ของ​ทรง​ตั้ง​ต้น ไม่​ขยับ​สัก​ตัว
91atag ของ field หมายเลข 3 ชนิด​บอก​ความ​ยาว​นำ​หน้า
1008ความ​ยาว​ของ​ค่า คือ 8 byte
11–186272616e63682d62ตัว​อักษร​ของ branch-b

1a ตรวจ​ได้​ด้วย​สูตร​เดิม​จาก​บท​ที่ 1 คือ (3 << 3) | 2 ได้ 26 ซึ่ง​เขียน​เป็น hex คือ 1a ของ​ใหม่​จึง​ไป​ต่อ​ท้าย และ​ของ​เดิม​อยู่​ที่​เดิม​ทุก byte

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 นั้น ค่าที่​วัด​ได้​จาก​งาน​อ่าน​นี้​มี​สี่​ช่อง

ช่อง​ที่​วัดค่าที่​ผู้​อ่าน​ทรง​ตั้ง​ต้น​ได้
parseok
customerwira
quantity7
จำนวน unknown1

byte ที่​ค้าง​อยู่​ใน​ที่​เก็บ​นั้น​เทียบ​ได้ที​ละ​ตัว มัน​คือ 1a086272616e63682d62 ซึ่ง​เท่ากับ byte ที่ 9 ถึง 18 ของ message ที่​รับ​เข้า​มา ไม่​ขาด​และ​ไม่​เกิน

นี่​คือ ความ​เข้า​กัน​ได้​กับ​ของ​ที่​ใหม่​กว่าforward compatibilityผู้​อ่าน​รุ่น​เก่า​อ่าน byte ที่​ผู้​เขียน​รุ่น​ใหม่​ส่ง​มา​ได้ · วัด​แล้ว​ว่า​ผู้​อ่าน v1 อ่าน​ของ v2 ได้​ค่า​ครบ ถือ unknown ไว้​หนึ่ง​หมายเลข แล้ว​คืน byte ชุด​เดิม​ทุก​ตัว ใน​รูป​ที่​วัด​ได้ คือ​ผู้​อ่าน​รุ่น​เก่า​อ่าน byte ของ​ผู้​เขียน​รุ่น​ใหม่​แล้ว​ได้​ค่าที่​ตัว​เอง​ต้องการ​ครบ

ผู้​อ่าน​ที่​ถือ​ทรง​ตั้ง​ต้น​เขียน​กลับ​ได้ 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 ตอบ คือ parse ผ่าน customer เป็น wira quantity เป็น 7 และ unknown เท่ากับ 1 · byte ที่​เขียน​กลับ​ก็​เป็น​ชุด​เดิม

ตัวเลข​ทุก​ตัว​ใน​สอง​หัวข้อ​บน​ถูก​วัด​สอง​ครั้ง ครั้ง​หนึ่ง​ด้วย runtime ของ Java อีก​ครั้ง​ด้วย runtime ของ Go บน byte ชุด​เดียวกัน ผลอ​อก​มา​ไม่​ต่าง​กัน​สัก​ช่อง

เรื่อง​นี้​สำคัญ​กว่า​ที่​มัน​ดู ข้อ​อ้าง​ที่​วัด​จาก runtime เดียว เป็น​ข้อ​อ้าง​เรื่อง runtime นั้น ไม่ใช่​เรื่อง​ของ​รูป​แบบ การ​ได้​ค่า​เดียวกัน​จาก​สอง runtime ที่​คนละ​ทีม​เขียน คือ​หลัก​ฐาน​คนละ​ชั้น​กัน

ระวัง​ไว้​ว่า​นี่​ยัง​ไม่​ได้​แปล​ว่า​สอง​ภาษา​ตอบ​เหมือน​กัน​ทุก​งาน มี​งาน​อ่าน​หนึ่ง​งาน​ใน​คอร์ส​นี้​ที่​สอง​ฝั่ง ตอบ​ไม่​เท่า​กัน และ​มัน​รอ​อยู่​ที่ บท​ที่ 5

ผู้​อ่าน v2 อ่าน byte แปด​ตัว​ของ​ทรง​ตั้ง​ต้น ได้​ค่า​ครบ​สอง​ช่อง และ​ถือ unknown ไว้ 0 หมายเลข

สลับ​ด้าน​กัน คราว​นี้​ผู้​อ่าน​ถือ v2 ซึ่ง​มี​สาม​ช่อง ส่วน byte ที่​วิ่ง​มา​เป็น​ของ​ทรง​ตั้ง​ต้น ซึ่ง​มี​แค่ 0a04776972611007

ช่อง​ที่​วัดค่าที่​ผู้​อ่าน v2 ได้
parseok
customerwira
quantity7
จำนวน unknown0

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 แล้วได้อะไร