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

int32 เป็น int64 ได้​ฟรี แต่ int32 เป็น uint32 เขียน​ตัวเลข​ใหม่

บท​ที่ 4 ถาม​ว่า​เกิด​อะไร​ขึ้น​เมื่อ​หมายเลข​ของ field ถูก​เอา​กลับ​มา​ใช้​ซ้ำ บท​นี้​ไม่​แตะ​หมายเลข​เลย หมายเลข​เท่า​เดิม ชื่อ​เท่า​เดิม สิ่ง​เดียว​ที่​เปลี่ยน​คือ​ชนิด​ของ​ช่อง quantity

การ​เปลี่ยน​ชนิด​สี่​แบบ​ใน​บท​นี้​ผ่าน protoc ทุก​แบบ และ parse ผ่าน​ทุก​แบบ แต่​มี​แบบ​เดียว​ที่ ผู้​อ่าน​ได้​ค่า​เดิม​กลับ​มา​ตรง​กัน​ทั้ง​สอง​ภาษา และ​ยัง​ส่ง byte ชุด​เดิม​ต่อ​ออก​ไป​ได้ อีก​สาม​แบบ​เสีย​อย่าง​ใด​อย่าง​หนึ่ง​ใน​สอง​อย่าง​นั้น​ไป โดย​ไม่มี​อะไร​แดง

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

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

ขยาย quantity จาก 32 bit เป็น 64 bit แล้ว Go ยัง​เขียน​ออก​มา 8 byte ชุด​เดิม

ทรง​ตั้ง​ต้น​ของ​คอร์ส​นี้​ประกาศ quantity เป็น int32 ทรง​ใหม่​เปลี่ยน​คำ​เดียว

ขยาย​ชนิด​ของ​ช่อง​เดียว — เฉพาะ​ส่วน message
message Order {
string customer = 1;
int64 quantity = 2;
}

Go ใส่ customer เป็น wira และ quantity เป็น 7 เหมือน​เดิม แล้ว​เขียน​ออก​มา​ได้ 8 byte คือ 0a04776972611007 ซึ่ง​เท่ากับ​ของ​ทรง int32 ทุก​ตัว​อักษร

เหตุผล​อยู่​ที่ wire type ทั้ง int32 และ int64 ใช้ wire type 0 คือ varint เหมือน​กัน tag จึง​เป็น 10 ตัว​เดียวกัน และ​ค่า 7 ก็​ลง​ได้​ใน byte เดียว​ทั้ง​สอง​ชนิด

ฝั่ง​อ่าน​ตรง​กัน ผู้​อ่าน​ที่​ถือ int64 อ่าน byte ที่​ทรง int32 เขียน​ไว้ ได้ customer เป็น wira และ quantity เป็น 7 ไม่มี unknown field สัก​หมายเลข และ serialize กลับ​ได้ byte ชุด​เดิม​ทุก​ตัว ทั้ง​ใน Go และ​ใน Java

protobuf.dev ระบุ​ว่า int32 int64 uint32 uint64 และ bool เป็น​ชนิด​ที่​เข้า​กัน​ได้​บน​สาย แต่​หน้า​นั้น​วาง​รายการ​นี้​ไว้​ใน​กลุ่ม​ที่​มัน​เรียก​ว่า​ปลอดภัย​แบบ​มี​เงื่อนไข และ​เขียน​เงื่อนไข​ต่อ​ท้าย​ไว้​ว่า ถ้า​ค่าที่​อ่าน​มา​ไม่​พอดี​กับ​ชนิด​ปลาย​ทาง ผล​ที่​ได้​เท่ากับ​การ cast ค่า​นั้น​เข้า​ชนิด​ปลาย​ทาง

ข้อ​นี้​เป็น​กฎ​ของ format ไม่ใช่​แค่​ผล​ของ​ตัวอย่าง​นี้ ทรง​ที่​เพิ่ง​วัด​ไป​คือ​ด้าน​ที่​เงื่อนไข​ไม่​ทำงาน เพราะ 7 พอดี​กับ​ทั้ง int32 และ int64 สอง​หัวข้อ​ถัด​ไป​คือ​ด้าน​ที่​เงื่อนไข​ทำงาน

quantity = -1 ทำให้​ทั้ง​ใบ​กลาย​เป็น 17 byte เพราะ varint ช่อง​เดียว​ยาว 10 byte

เปลี่ยน​กลับ​มา​เป็น​ทรง​ตั้ง​ต้น​ที่ quantity เป็น int32 แล้ว​ใส่​ค่า​ติดลบ​แทน 7 Go เขียน​ออก​มา​ได้ 17 byte

0a047769726110ffffffffffffffffff01

แกะ​ที​ละ​กลุ่ม​แล้ว​เทียบ​กับ​ใบ 8 byte ของ​บท​ที่ 1 จะ​เห็น​ว่า​หก​ตัว​แรก​กับ​ตัว​ที่​เจ็ด​เหมือน​กัน​หมด ที่​พอง​คือ​กลุ่ม​สุดท้าย​กลุ่ม​เดียว

byteค่าความ​หมาย
10atag ของ field หมายเลข 1
204ความ​ยาว​ของ​ชื่อ คือ 4 byte
3–677 69 72 61ตัว​อักษร w i r a
710tag ของ field หมายเลข 2 ชนิด varint
8–17ff เก้า​ตัว แล้ว​ปิด​ท้าย​ด้วย 01ค่า −1 ที่​ถูก​ขยาย​เป็น 64 bit แล้ว

การ​ขยาย​เครื่องหมายsign extensionการ​ขยาย​จำนวน​ติดลบ​ให้​เต็ม 64 bit ก่อน​เข้า​รหัส​เป็น varint · int32 ที่​เป็น −1 จึง​กิน​สิบ byte และ​ทั้ง​ใบ​เป็น​สิบ​เจ็ด byteคือ​สิ่ง​ที่​ทำให้​ค่า​นี้​พอง protobuf เข้า​รหัส​จำนวนเต็ม​ติดลบ ด้วย​การ​ขยาย​มัน​ให้​เต็ม 64 bit ก่อน แล้ว​ค่อย​ส่ง​ผลลัพธ์​นั้น​เข้า varint

−1 ที่​เต็ม 64 bit คือ bit หนึ่ง​ทั้ง 64 ตัว varint หั่น​ที​ละ 7 bit จึง​ได้​เก้า​กลุ่ม​เต็ม คือ ff เก้า​ตัว เหลือ bit สุดท้าย​อีก​หนึ่ง​ตัว​เป็นก​ลุ่ม​ที่​สิบ คือ 01

protobuf.dev ระบุ​กฎ​นี้​ไว้​ตรงๆ ว่า​จำนวน​ติดลบ​ใน int32 และ int64 ยาว​สิบ byte เสมอ เพราะ​มัน​ถูก​มอง​เป็น​จำนวน​บวก​ก้อน​ใหญ่​มาก นี่​คือ​กฎ ไม่ใช่​ผล​เฉพาะ​ของ​ค่า −1

byte 17 ตัว​ชุด​เดิม​ไม่​ถูก​แตะ​เลย แต่ Go ตอบ 4294967295 ส่วน Java ตอบ -1

ทรง​ถัด​ไป​เปลี่ยน quantity จาก int32 เป็น uint32 แล้ว​ให้​ผู้​อ่าน​ทรง​นี้​อ่าน byte 17 ตัว​ชุด​เดิม ฝั่ง​เขียน​ไม่​ได้​เขียน​ใหม่ และ​ไม่มี​ใคร​แปลง byte ระหว่าง​ทาง

ทั้ง​สอง runtime parse ผ่าน ทั้ง​คู่​ได้ customer เป็น wira และ​ทั้ง​คู่​ไม่มี unknown field สัก​หมายเลข ช่อง​ที่​ต่าง​กัน​มี​ช่อง​เดียว

runtimeค่าที่ quantity อ่าน​ได้
Go4294967295
Java-1

Go มี​ชนิด uint32 จริง มัน​จึง​พิมพ์​ค่าที่ bit ทั้ง 32 ตัว​เป็น​หนึ่ง ออก​มา​เป็น 4294967295 ซึ่ง​คือ 2 ยก​กำลัง 32 ลบ​หนึ่ง

Java ไม่มี​ชนิด 32 bit ที่​ไม่มี​เครื่องหมาย​ให้​ใช้​เลย code generation จึง​คืน​ค่า​เป็น int ธรรมดา bit ทั้ง 32 ตัว​ตรง​กับ​ของ Go ทุก​ตัว แต่ int ของ Java อ่าน bit บน​สุด​เป็น​เครื่องหมาย มัน​จึง​พิมพ์​ออก​มา​เป็น -1

นี่​ไม่ใช่​บั๊ก​ของ runtime ไหน​เลย ทั้ง​คู่​ถือ bit ชุด​เดียวกัน ต่าง​กัน​แค่​ตอน​แปลง bit เป็น​ตัวเลข​ให้​คน​อ่าน คน​ที่​เขียน test เทียบ​ค่าที่​พิมพ์​ออก​มา​ข้าม​สอง​ภาษา จะ​เห็น​สอง​ภาษา​นี้ ไม่​ตรง​กัน ทั้ง​ที่ byte ตรง​กัน

ผู้​อ่าน uint32 เขียน​ค่า​เดิม​ออก​ไป​ใหม่​ได้ 12 byte เพราะ varint หด​จาก 10 byte เหลือ 5 byte

จนถึง​ตรง​นี้​ยัง​ไม่มี​อะไร​เสีย​หาย byte ยัง​เป็น​ชุด​เดิม เสีย​หาย​เกิด​ตอน​ผู้​อ่าน​ทรง uint32 serialize สิ่ง​ที่​มัน​อ่าน​มา ส่ง​ต่อ​ออก​ไป

0a047769726110ffffffff0f

byte ชุด​ใหม่​นี้​ยาว 12 byte ไม่ใช่ 17 byte กลุ่ม​สุดท้าย​เหลือ ff สี่​ตัว​แล้ว​ปิด​ท้าย​ด้วย 0f คือ varint 5 byte แทนที่​จะ​เป็น 10 byte และ Go กับ Java คืน byte ชุด​นี้​ออก​มา​เห​มือ​นกันเป๊ะ

เหตุผล​คือ​ผู้​อ่าน​ทรง​นี้​ถือ​ค่า​เป็น​จำนวน 32 bit ที่​ไม่มี​เครื่องหมาย มัน​จึง​ไม่มี​อะไร​ให้​ขยาย เป็น 64 bit varint หั่น 32 bit เป็น​สี่​กลุ่ม​เต็ม​กับ​อีก​กลุ่ม​ที่​มี 4 bit จึง​ได้ 5 byte

ใบbyteกลุ่ม​สุดท้าย
ที่ Go เขียน​ออก​มา​จาก​ทรง int3217ff เก้า​ตัว แล้ว 01
ที่​ผู้​อ่าน​ทรง uint32 ส่ง​ต่อ12ff สี่​ตัว แล้ว 0f

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

flowchart TD
  A["ทรง int32 ใส่ค่า -1"] --> B["ขยายเป็น 64 bit ก่อนเข้ารหัส"]
  B --> C["varint 10 byte · ff เก้าตัวแล้ว 01"]
  C --> D["ผู้อ่าน uint32 ใน Go ได้ 4294967295"]
  C --> E["ผู้อ่าน uint32 ใน Java ได้ -1"]
  D --> F["serialize กลับ · varint 5 byte · ทั้งใบ 12 byte"]
  E --> F

คำ​บรรยาย​ภาพ: byte ชุด​เดียว​เข้าไป​ใน​ผู้​อ่าน​สอง​ภาษา สอง​ภาษา​พิมพ์​ค่า​ออก​มา​ไม่​ตรง​กัน แต่​พอ serialize กลับ ทั้ง​คู่​คืน byte ชุด​เดียวกัน ซึ่ง​สั้น​กว่า​ชุด​ที่​รับ​เข้า​มา

ทรง sint32 เขียน​เลข 7 ออก​มา​ได้ 8 byte เท่า​เดิม แต่ byte สุดท้าย​เป็น 0e ไม่ใช่ 07

int32 แพง​กับ​จำนวน​ติดลบ เพราะ​ทุก​ค่าที่​ติดลบ​ยาว​สิบ byte ทาง​แก้​ของ protobuf คือ​ชนิด sint32 และ sint64 ซึ่ง​ใช้​การ​เข้า​รหัส​คนละ​แบบ

เปลี่ยน​ชนิด​เพื่อ​ให้​จำนวน​ติดลบ​ไม่​แพง — เฉพาะ​ส่วน message
message Order {
string customer = 1;
sint32 quantity = 2;
}

การ​เข้า​รหัส​แบบ​สลับ​ฟันปลาzigzag encodingการ​เข้า​รหัส​ของ sint32 และ sint64 ที่​ย้าย​เลข​ติดลบ​ไป​อยู่​ใกล้​ศูนย์​ก่อน​เข้า varint · เลข 7 จึง​ขึ้น​สาย​เป็น 0eย้าย​จำนวน​ติดลบ​มา​อยู่​ใกล้​ศูนย์​ก่อน แล้ว​ค่อย​ส่ง ผลลัพธ์​เข้า varint ค่า 0 ไป​เป็น 0 ค่า −1 ไป​เป็น 1 ค่า 1 ไป​เป็น 2 ค่า −2 ไป​เป็น 3 ต่อ​ไป เรื่อยๆ สลับ​กัน​ไป​มา

ผล​คือ​ค่า​ติดลบ​ตัว​เล็ก​ใช้ byte น้อย​เท่า​ค่า​บวก​ตัว​เล็ก แต่​ราคา​คือ​ค่า​บวก​ทุก​ตัว​ถูก​คูณ​สอง เลข 7 จึง​กลาย​เป็น 14 ซึ่ง​เขียน​เป็น hex ได้ 0e

Go ใส่ quantity เป็น 7 ใน​ทรง​นี้ แล้ว​เขียน​ออก​มา​ได้ 8 byte คือ 0a0477697261100e เท่ากับ​ใบ​ของ​ทรง int32 ทุก​ตัว ยกเว้น byte สุดท้าย​ตัว​เดียว

ทรง​ของ​ผู้​เขียนbytehex
int32 quantity = 2; ใส่ 780a04776972611007
sint32 quantity = 2; ใส่ 780a0477697261100e

tag เท่า​กัน คือ 10 ทั้ง​คู่ เพราะ sint32 ก็​ใช้ wire type 0 เหมือน​กัน ความ​ยาว​ของ​ใบ​ก็​เท่า​กัน ผู้​อ่าน​จึง​ไม่มี​ทาง​รู้​จาก byte ว่า​ตัวเลข​ที่มา​ถึง​ผ่าน zigzag มา​แล้ว​หรือ​ยัง

ผู้​อ่าน sint32 อ่าน byte ของ int32 ได้ -4 ส่วน​ผู้​อ่าน int32 อ่าน byte ของ sint32 ได้ 14

sint32 ไม่​ได้​เข้า​กัน​ได้​กับ int32 บน​สาย protobuf.dev จัด​ให้ sint32 และ sint64 เข้า​กัน​ได้​กับ​กันเอง​เท่านั้น ไม่​เข้า​กับ​ชนิด​จำนวนเต็ม​ตัว​อื่น

สิ่ง​ที่​วัด​ได้​คือ​ทั้ง​สอง​ทาง​ไม่มี​ใคร​บ่น​สัก​ทาง

❌ version ดิบ — ทีม​หนึ่ง​เปลี่ยน​ชนิด อีก​ทีม​ไม่​ได้​เปลี่ยน
// ฝั่งเขียนถือทรงนี้
message Order { string customer = 1; int32 quantity = 2; }
// ฝั่งอ่านถือทรงนี้ เพราะเพิ่งแก้ให้จำนวนติดลบถูกลง
message Order { string customer = 1; sint32 quantity = 2; }
ผู้​อ่าน​ถืออ่าน byte ของhex ที่​รับ​เข้า​มาค่าที่​ได้unknownserialize กลับ
sint32int32 ใส่ 70a04776972611007-40เท่า​เดิม
int32sint32 ใส่ 70a0477697261100e140เท่า​เดิม

ทาง​แรก byte สุดท้าย​คือ 07 ผู้​อ่าน sint32 ถอด zigzag กลับ ได้ -4 ทาง​ที่​สอง byte สุดท้าย คือ 0e ผู้​อ่าน int32 อ่าน​ตรงๆ ไม่​ถอด​อะไร ได้ 14

Go และ Java ตอบ​ตรง​กัน​ทุก​ช่อง​ใน​ตาราง​นี้ ทั้ง​สอง​ภาษา​ให้ -4 ใน​ทาง​แรก และ​ให้ 14 ใน​ทาง​ที่​สอง ข้อ​นี้​จึง​เป็น​พฤติกรรม​ของ format ไม่ใช่​ของ runtime ใด

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

14 เป็น​จำนวน​จาน​ที่​ดู​สม​เหตุ​สม​ผล ส่วน -4 ดู​ไม่​สม​เหตุ​สม​ผล​แต่​ก็​ไม่​ทำให้​อะไร​พัง ไม่มี test ฝั่ง​ไหน​ที่​ดู​แต่​ผล​ของ​ตัว​เอง​แล้ว​จะ​สะดุด นี่​คือ​ความ​หมาย​ของ​คำ​ว่า wire compatibility ที่ parse ผ่าน​ไม่ใช่​หลัก​ฐาน

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

การ​เปลี่ยน​ชนิด​ของ quantityค่าที่​ผู้​อ่าน​ได้round trip
int32 เป็น int647 ซึ่ง​คือ​ค่า​เดิมเท่า​เดิม
int32 ที่​เป็น −1 อ่าน​ด้วย uint32Go 4294967295 · Java -1ไม่​เท่า​เดิม
int32 อ่าน​ด้วย sint32-4เท่า​เดิม
sint32 อ่าน​ด้วย int3214เท่า​เดิม

ทั้ง​สี่​แถว​นี้ parse ผ่าน ทั้ง​สี่​แถว​ไม่มี unknown field สัก​หมายเลข และ​สาม​ใน​สี่​แถว​ส่ง byte ชุด​เดิม​ต่อ​ออก​ไป​ได้ ผู้​อ่าน​ทั้ง​สี่​ทรง​จึง​ดู​ปกติ​จาก​ภายนอก​เท่า​กัน​หมด

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

ชนิด​ของ​ช่อง​ไม่​ได้​เดิน​ทาง​ไป​กับ byte แบบ​เดียว​กับ​ที่​ชื่อ​ของ​ช่อง​ไม่​ได้​เดิน​ทาง​ไป

บท​ที่ 1 บอก​ว่า​ชื่อ​ของ field ไม่​ได้​อยู่​บน​สาย บท​นี้​เพิ่ม​ข้อ​ที่​สอง คือ​ชนิด​ของ field ก็​ไม่​ได้​อยู่​บน​สาย​เหมือน​กัน ที่​อยู่​บน​สาย​คือ​หมายเลข​กับ wire type

wire type บอก​ได้​แค่​ว่า​ค่า​นี้​เป็น varint หรือ​เป็น​ของ​ที่​บอก​ความ​ยาว​นำ​หน้า มัน​ไม่​ได้​บอก​ว่า varint ตัว​นี้​ผ่าน zigzag มา​หรือ​ยัง และ​ไม่​ได้​บอก​ว่า​ใคร​ควร​อ่าน​มัน​เป็น​จำนวน​ที่​มี​เครื่องหมาย หรือ​ไม่มี

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Protocol Buffers — Encoding (เข้า​ถึง 2026-09-04) — ที่มา​ของ​กฎ​ว่า​จำนวน​ติดลบ​ยาว​สิบ byte เสมอ และ​ของ​สูตร zigzag

  • Protocol Buffers — Language Guide (proto 3) (เข้า​ถึง 2026-09-04) — ที่มา​ของ​รายการ​ชนิด​ที่​เข้า​กัน​ได้​บน​สาย ของ​เงื่อนไข​เรื่อง cast ที่​ห้อย​ท้าย​รายการ​นั้น​ไว้ และ​ของ​ข้อ​ที่​ว่า sint32 กับ sint64 เข้า​กัน​ได้​กับ​กันเอง​เท่านั้น

  • Protocol Buffers — Java Generated Code (เข้า​ถึง 2026-09-04) — ที่มา​ของ​ข้อ​ที่​ว่า uint32 ถูก generate ออก​มา​เป็น int ของ Java

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

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

ข้อ 1 / 3

ทรง `int32` ใส่ `quantity = -1` แล้ว Go เขียนออกมาได้ 17 byte ทำไมช่องเดียวถึงกิน 10 byte