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

หมายเลข​ที่​คุณ​เอา​กลับ​มา​ใช้​ซ้ำ และ​ไม่มี​อะไร​แดง​สัก​ที่

ทีม​หนึ่ง​ลบ int32 quantity = 2; ออก​จาก schema เพราะ​ไม่มี​ใคร​ใช้​ช่อง​นั้น​แล้ว การ​ลบ​ผ่าน review ผ่าน build และ​ผ่าน test ทุก​ตัว

ผ่าน​ไป​หลาย​เดือน อีก​คน​เปิด order.proto ขึ้น​มา​เพื่อ​เพิ่ม​ช่อง​ใหม่ เขา​เห็น​หมายเลข 1 ถูก​ใช้​อยู่ และ​ไม่​เห็น​หมายเลข 2 ที่ไหน​เลย เขา​จึง​เลือก 2

บท​นี้​วัด​ว่า byte ชุด​เก่า​ที่​ยัง​วิ่ง​อยู่​ใน​ระบบ เกิด​อะไร​ขึ้น​กับ​มัน คำ​ตอบ​มี​สอง​ทรง ทั้ง​สอง​ทรง parse ผ่าน และ​ทรง​ที่​เงียบ​กว่า​คือ​ทรง​ที่​แย่​กว่า

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

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

ออ​เด​อร์​ใบ​เดิม​ที่ Go เขียน​ไว้​ยัง​เป็น byte แปด​ตัว​ชุด​เดิม และ​การ​ลบ field ไม่​ได้​แตะ​มัน​เลย

schema ตั้ง​ต้น​ของ​คอร์ส​นี้​มี​สอง​ช่อง และ Go เขียน​ออ​เด​อร์​หนึ่ง​ใบ​ออก​มา​เป็น 8 byte

ทรง​ตั้ง​ต้น — เฉพาะ​ส่วน message
message Order {
string customer = 1;
int32 quantity = 2;
}
0a04776972611007

byte สอง​ตัว​ท้าย​คือ 1007 ตัว 10 เป็นtagtagค่าที่​อยู่​หัว​ของ​แต่ละ​กลุ่ม​บน​สาย ซึ่ง​รวม​หมายเลข​กับ wire type ไว้​ด้วย​กัน · สูตร​ของ​มัน​คือ (หมายเลข << 3) | wire type · ตัว​มัน​เอง​เป็น varint หมายเลข 1 ถึง 15 จึง​ยาว byte เดียว ส่วน​หมายเลข 16 ยาว​สอง byte · ยิ่ง​หมายเลข​ใหญ่ tag ยิ่ง​ยาวของหมายเลข fieldfield numberหมายเลข​ประจำ field ที่​ประกาศ​ไว้​ใน schema · เป็น​สิ่ง​เดียว​ที่​ทั้ง​สอง​ฝั่ง​ใช้​จับ​คู่​ค่า​เข้า​กับ​ช่อง เปลี่ยน​ชื่อ​จึง​ฟรี แต่​เปลี่ยน​หมายเลข​คือ​เปลี่ยน​สัญญา 2 ชนิด varintvarintการ​เข้า​รหัส​จำนวนเต็ม​ที่​ใช้ byte เท่า​ที่​ค่า​นั้น​ต้องการ · เลข 7 กิน byte เดียว ส่วน int32 ที่​เป็น −1 กิน​สิบ byte และ 07 เป็น​ค่า 7 นี่​คือ​จำนวน​จาน​ขอ​งอ​อเด​อร์​ใบ​นั้น

การ​ลบ quantity ออก​จาก schema เปลี่ยน​แค่​ของ​ที่​จะ​เขียน​ออก​ไป​ใน​อนาคต ส่วน byte ที่​เขียน​ไป​แล้ว ยัง​นอน​อยู่​ใน queue ใน log และ​ใน​แถว​ของ​ฐาน​ข้อมูล​ตาม​เดิม

ผู้​อ่าน​ทรง​นี้ parse ผ่าน ช่อง​ใหม่​ไม่มี​ค่า และ​ค่า​เดิม​ไป​นอน​อยู่​ใน unknown field หนึ่ง​ช่อง

คน​ที่​หยิบ​หมายเลข 2 ไป​ใช้ เลือก​ให้​มัน​เป็น​ข้อความ ชื่อ size_name

❌ version ดิบ — เอา​หมายเลข 2 กลับ​มา​ใช้​กับ string
message Order {
string customer = 1;
string size_name = 2;
}

ให้​ผู้​อ่าน​ทรง​นี้​อ่าน byte แปด​ตัว​ข้าง​บน ผล​ที่​วัด​ได้​จาก Go และ​จาก Java ตรง​กัน​ทุก​ช่อง

สิ่ง​ที่​วัดค่าที่​ได้
parseok
customerwira
size_nameไม่มี​ค่า
จำนวน unknown fieldunknown fieldfield ที่​ผู้​อ่าน​ไม่รู้​จัก เพราะ schema ของ​ตัว​เอง​ไม่มี​หมายเลข​นั้น · ผู้​อ่าน​เก็บ byte ดิบ​ไว้​เฉยๆ แล้ว​ส่ง​คืน​ตอน serialize กลับ1
byte ที่​ค้าง​อยู่​ใน unknown1007
serialize กลับ0a04776972611007

ไม่มี exception ไม่มี​รหัส​ออก​ที่​ไม่ใช่​ศูนย์ และ​ไม่มี​ข้อความ​จาก​การ parse กลับ​มา​สัก​บรรทัด ผู้​อ่าน​ได้ customer ครบ ส่วน​ช่อง size_name เป็น​ข้อความ​ว่าง​เห​มือ​นอ​อเด​อร์​ที่​ไม่​ได้​กรอก​ขนาด​มา

ที่​เกิด​ขึ้น​กับ​ตัว 1007 คือ​มัน​ถูก​เก็บ​ไว้​ทั้ง​ชุด​โดย​ไม่​ถูก​ตีความ แล้ว​ถูก​ส่ง​คืน​ตอน serialize กลับ byte ที่​ออก​ไป​จึง​เท่ากับ byte ที่​รับ​เข้า​มา​ทุก​ตัว​อักษร

ผู้​อ่าน​จับ​คู่​ทั้ง​หมายเลข​และชนิด​บน​สายwire typeสาม​บิต​ท้าย​ของ tag ที่​บอก​ว่า​ค่า​ถัด​ไป​มี​รูป​ร่าง​แบบ​ไหน · wire type 0 คือ varint และ wire type 2 คือ length-delimited ของ​ที่มา​ถึง​บอก​ว่า​หมายเลข 2 เป็น varint แต่ schema ของ​ผู้​อ่าน​ประกาศ​หมายเลข 2 ไว้​เป็น​แบบ​บอก​ความ​ยาว​นำ​หน้า ตัวอย่าง​นี้​วัด​ได้​ว่า​ทั้ง​สอง runtime พา​ค่า​นั้น​ไป​ไว้​ที่ unknown field

ผู้​อ่าน​ทรง​นี้ parse ผ่าน อ่าน seats ได้ 7 และ​ไม่มี unknown field สัก​ช่อง

เปลี่ยน​แค่​ชนิด​ของ​ช่อง​ใหม่ จาก​ข้อความ​เป็น​จำนวนเต็ม หมายเลข​ยัง​เป็น 2 เท่า​เดิม

❌ version ดิบ — เอา​หมายเลข 2 กลับ​มา​ใช้​กับ int32 ตัว​อื่น
message Order {
string customer = 1;
int32 seats = 2;
}

ให้​ผู้​อ่าน​ทรง​นี้​อ่าน byte ชุด​เดิม ผล​ที่​วัด​ได้​จาก Go และ​จาก Java ตรง​กัน​ทุก​ช่อง​อีก​ครั้ง

สิ่ง​ที่​วัดค่าที่​ได้
parseok
customerwira
seats7
จำนวน unknown field0
byte ที่​ค้าง​อยู่​ใน unknown(ไม่มี)
serialize กลับ0a04776972611007

จำนวน​จาน​เจ็ด​จาน​เดิน​เข้าไป​นั่ง​ใน​ช่อง​ที่​นั่ง แล้วก​ลาย​เป็น​เจ็ด​ที่​นั่ง ค่า​นั้น​ถูก​ทุก​อย่าง ยกเว้น​ความ​หมาย

flowchart TD
  W["Go เขียนด้วยทรงตั้งต้น · 0a04776972611007"]
  W --> A["ผู้อ่านที่ถือ string size_name = 2"]
  W --> B["ผู้อ่านที่ถือ int32 seats = 2"]
  A --> A1["customer = wira · size_name ไม่มีค่า"]
  A --> A2["unknown 1 ช่อง · 1007"]
  B --> B1["customer = wira · seats = 7"]
  B --> B2["unknown 0 ช่อง"]

คำ​บรรยาย​ภาพ: byte ชุด​เดียวกัน​เดิน​เข้าหา​ผู้​อ่าน​สอง​ทรง ทรง​ซ้าย​เก็บ​ค่า​เดิม​ไว้​ที่ unknown field ส่วน​ทรง​ขวา​รับ​ค่า​เดิม​เข้า​ช่อง​ใหม่​ตรงๆ โดย​ไม่​เหลือ​อะไร​ไว้​ที่ unknown เลย

ทรง​แรก​ยัง​เหลือ​ร่อง​รอย​หนึ่ง​ช่อง​บน​สาย ส่วน​ทรง​ที่​สอง​ไม่​เหลือ​อะไร​ให้​ใคร​สงสัย

วาง​สอง​ทรง​เทียบ​กัน​ที​ละ​ช่อง ช่อง​ที่​ต่าง​กัน​มี​ช่อง​เดียว

สิ่ง​ที่​วัดstring size_name = 2int32 seats = 2
parseokok
จำนวน unknown field10
byte ที่​ค้าง​อยู่​ใน unknown1007(ไม่มี)
ช่อง​ใหม่​ได้​ค่า​อะไรไม่มี​ค่า7
serialize กลับ0a047769726110070a04776972611007

ทรง​แรก​ยัง​มี​ที่​ให้​ยืน ใคร​ที่​เปิด​ดู​จำนวน unknown field ของ message ที่​รับ​เข้า​มา จะ​เห็น​เลข 1 แล้ว​เห็น byte 1007 ที่​ตัว​เอง​อ่าน​ไม่​ออก นั่น​คือ​หลัก​ฐาน​ว่า​มี​ของ​ที่​ไม่​ได้​ตกลง​กัน​ไว้​เดิน​มา​ด้วย

ทรง​ที่​สอง​ไม่มี​หลัก​ฐาน​อะไร​เลย unknown field เป็น 0 ทุก​ช่อง​มี​ค่า​ครบ และ​ไม่มี​อะไร​แยก​ออ​เด​อร์​ใบ​นี้ ออก​จา​กอ​อเด​อร์​ที่​ฝั่ง​ใหม่​เขียน​เอง​อย่าง​ถูก​ต้อง

สังเกต​แถว​สุดท้าย​ของ​ตาราง​ให้​ดี ทั้ง​สอง​ทรง serialize กลับ​ได้ 0a04776972611007 เท่า​เดิม การ​เทียบ byte ขา​เข้า​กับ byte ขา​ออก​จึง​แยก​สอง​ทรง​นี้​ไม่​ออก และ​แยก​ทรง​ที่​ผิด​ออก​จาก​ทรง​ที่​ถูก​ไม่​ได้​ด้วย

นี่​คือ​ความ​หมาย​ของความ​เข้า​กัน​ได้​บน​สายwire compatibilityการ​แก้ schema ที่​ทั้ง​สอง​ฝั่ง​ยัง​อ่าน byte ของ​กัน​ได้​ตาม​ที่​ตั้งใจ · parse ผ่าน​ไม่ใช่​หลัก​ฐาน​ของ​ข้อ​นี้ เพราะ​ค่าที่​ไป​นั่ง​ผิด​ช่อง​ก็ parse ผ่าน​เหมือน​กัน ที่​คอร์ส​นี้​ใช้ parse ผ่าน​ไม่ใช่ หลัก​ฐาน​ของ​มัน เพราะ​ค่าที่​ไป​นั่ง​ผิด​ช่อง​ก็ parse ผ่าน​เหมือน​กัน

สิ่ง​ที่​ทรง​ที่​สอง​ทำ ไม่มี​ทาง​กลับ

ค่า 7 ใน​ช่อง seats เป็น​ตัวเลข​ที่​ถูก​ต้อง​ตาม​ชนิด ตาม​ช่วง และ​ตาม schema ระบบ​ปลาย​ทาง​ที่​รับ ตัวเลข​นี้​ไป​คำนวณ​ต่อ ไม่มี​ข้อมูล​อะไร​เหลือ​ให้​ย้อน​กลับ​ไป​รู้​ว่า​เดิม​มัน​คือ​จำนวน​จาน

reserved คือ​ทาง​เดียว​ที่​ย้าย​คำถาม​นี้​ไป​ให้​เครื่อง​มือ

หัวข้อ​ที่​มีชื่อ​ว่า “reserved คือ​ทาง​เดียว​ที่​ย้าย​คำถาม​นี้​ไป​ให้​เครื่อง​มือ”

ประกาศ reserved ไว้​ตั้งแต่​ตอน​ลบ แล้ว protoc คืน​รหัส​ออก 1 ให้​คน​ที่​หยิบ​หมายเลข​เดิม​กลับ​มา​ใช้

ทุก​อย่าง​ข้าง​บน​เกิด​ขึ้น​เพราะ​คน​ที่​เปิด order.proto ใน​วัน​นั้น ไม่มี​ทาง​รู้​ว่า​หมายเลข 2 เคย​มี​เจ้าของ สิ่ง​ที่​เขา​เห็น​คือ​ช่อง​ว่าง reservedreservedคำ​ประกาศ​ที่​กัน​หมายเลข​หรือ​ชื่อ​ไว้ ไม่​ให้​ใคร​เอา​กลับ​มา​ใช้​อีก · protoc คืน​รหัส​ออก 1 และ​บอก​หมายเลข​ว่าง​ถัด​ไป​ให้​ด้วย คือ​คำ​ประกาศ​ที่​เปลี่ยน​ช่อง​ว่าง​นั้น​ให้​เป็น​ป้าย​ห้าม

reserved ที่​ประกาศ​ไว้​ตอน​ลบ · เจอ​กับ​คน​ที่​หยิบ​หมายเลข 2 กลับ​มา​ใช้
message Order {
string customer = 1;
reserved 2;
reserved "quantity";
int32 seats = 2;
}

protoc ไม่​ยอม compile ทรง​นี้ มัน​คืน​รหัส​ออก 1 แล้ว​พิมพ์​ออก​มา​ว่า

order.proto:7:12: Field "seats" uses reserved number 2. order.proto:7:12: Suggested field numbers for order.reserved.Order: 3

reserved 2; กัน​หมายเลข​ไว้ ส่วน reserved "quantity"; กัน​ชื่อ​ไว้​ด้วย เพื่อ​ไม่​ให้​ใคร​เอา​ชื่อ​เดิม ไป​แปะ​บน​หมายเลข​อื่น​แล้ว​ทำให้​คน​อ่าน​สอง​ฝั่ง​เข้าใจ​ไม่​ตรง​กัน

นี่​คือ​กับ​ดัก​ข้อ​เดียว​ใน​คอร์ส​นี้​ที่​มี​เครื่อง​มือ​เตือน และ​มัน​เตือน​ตั้งแต่​ตอน compile ก่อน​ของ​จะ​ขึ้น​ระบบ คำ​ตอบ​ของ protoc ทั้ง​สอง​แบบ​อยู่​เต็มๆ ที่ บท​ที่ 8

ชื่อ field เปลี่ยน​ได้​ฟรี​เพราะ​ไม่​เคย​อยู่​บน​สาย ส่วน​หมายเลข​อยู่​บน​สาย​จริง จึง​เปลี่ยน​ความ​หมาย​ไม่​ได้

บท​ที่ 1 บอก​ว่า​ชื่อ field เปลี่ยน​ได้​ฟรี เพราะ​มัน​ไม่​เคย​อยู่​บน​สาย บท​นี้​คือ​ด้าน​ที่​ต้อง​จ่าย​ของ​กฎ เดียวกัน หมายเลข​อยู่​บน​สาย​จริง มัน​จึง​เปลี่ยน​ความ​หมาย​ไม่​ได้​เลย​สัก​ครั้ง

ราคา​นี้​ต่าง​จาก​ราคา​ของ unknown field ใน บท​ที่ 3 ตรง​ที่​บท​นั้น​เป็น​ด้าน​ที่​ได้​เปล่า ผู้​อ่าน​รุ่น​เก่า​ถือ​ของ​ที่​ไม่รู้​จัก​ไว้​แล้ว​ส่ง​คืน​ครบ ส่วน​บท​นี้​ทรง​ที่​สอง​ไม่​เหลือ unknown field ไว้ สัก​ช่อง ร่อง​รอย​ที่​บท​นั้น​ได้​มา​ฟรี จึง​ไม่มี​ให้​ดู​เลย

สังเกต​ว่า​ตลอด​ทั้ง​บท หมายเลข​ไม่​เคย​ขยับ​ไป​ไหน​เลย มัน​เป็น 2 ตั้งแต่​ต้น​จน​จบ แล้ว​สัญญา​ก็​ยัง​พัง​ได้ คำถาม​ถัด​ไป​จึง​เป็น​ด้าน​กลับ คือ​หมายเลข​อยู่​ที่​เดิม​แต่ชนิดเปลี่ยน แล้ว​เกิด​อะไร​ขึ้น คำ​ตอบ​อยู่​ที่ บท​ที่ 5


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Protocol Buffers — Language Guide (proto 3) (เข้า​ถึง 2026-09-04) — ที่มา​ของ reserved ทั้ง​แบบ​หมายเลข​และ​แบบ​ชื่อ และ​ของ​กฎ​ว่า​หมายเลข​ที่​ลบ​แล้ว ห้าม​เอา​กลับ​มา​ใช้

  • Protocol Buffers — Encoding (เข้า​ถึง 2026-09-04) — ที่มา​ของ​สูตร tag และ​ของ​เรื่อง unknown field

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก 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

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

ผู้อ่านที่ถือ `string size_name = 2;` อ่าน byte `0a04776972611007` ซึ่งเขียนตอนหมายเลข 2 ยังเป็น `int32 quantity` ผลที่วัดได้คืออะไร