งานอ่าน 15 งาน สองภาษาตอบต่างกันช่องเดียว
หกบทที่ผ่านมาเล่าผลของงานอ่านทีละงาน โดยไม่ค่อยบอกว่าใครเป็นคนอ่าน คำตอบคือมีคนอ่าน สองคนเสมอ ทุกงานถูกวัดสองรอบ รอบหนึ่งด้วย Go และอีกรอบด้วย Java
บทนี้เอาผลทั้งสองชุดมาวางเทียบกันทีละช่อง เพื่อตอบคำถามที่ค้างอยู่ตลอดคอร์สว่า สิ่งที่คุณเห็นในหกบทที่แล้ว เป็นพฤติกรรมของ protobuf หรือเป็นพฤติกรรมของภาษาที่บังเอิญวัด
orderctl คือเครื่องมือประมวลผลออเดอร์ที่สามคอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ ระหว่างนี้ทุกตัวเลขในคอร์สนี้รันเองได้ สั่ง
scripts/wire-contract/up.sh เพื่อดึงเครื่องมือที่ตรึง version ไว้ แล้วตามด้วย
python3 scripts/wire-contract/probe.py
งานอ่านทั้งสิบห้า วางเทียบกันทีละช่อง
หัวข้อที่มีชื่อว่า “งานอ่านทั้งสิบห้า วางเทียบกันทีละช่อง”สิบห้างานอ่าน parse ผ่านทั้งหมด และสองภาษาตอบตรงกันทุกงาน ยกเว้นค่าที่แสดงในงานที่ 11
ทุกแถวในตารางนี้คือ byte ชุดหนึ่งที่ Go เขียนไว้ ส่งให้ผู้อ่านที่ถือ schema ทรงหนึ่ง แล้ววัดสามอย่างคือค่าที่ได้ จำนวน unknown fieldunknown fieldfield ที่ผู้อ่านไม่รู้จัก เพราะ schema ของตัวเองไม่มีหมายเลขนั้น · ผู้อ่านเก็บ byte ดิบไว้เฉยๆ แล้วส่งคืนตอน serialize กลับ และ byte ที่ผู้อ่านคนนั้น serialize กลับออกมา
| # | ผู้อ่านถือ | อ่าน byte ของ | Go ตอบ | Java ตอบ | unknown | serialize กลับ |
|---|---|---|---|---|---|---|
| 1 | v1 | v1 | customer=wira, quantity=7 | customer=wira, quantity=7 | 0 | เท่าเดิม |
| 2 | customer_name | v1 | customer_name=wira, quantity=7 | customer_name=wira, quantity=7 | 0 | เท่าเดิม |
| 3 | v1 | v2 | customer=wira, quantity=7 | customer=wira, quantity=7 | 1 | เท่าเดิม |
| 4 | v2 | v1 | customer=wira, quantity=7 | customer=wira, quantity=7 | 0 | เท่าเดิม |
| 5 | v1 | v1 ที่จำนวนเป็นศูนย์ | customer=wira | customer=wira | 0 | เท่าเดิม |
| 6 | optional | v1 ที่จำนวนเป็นศูนย์ | customer=wira · presence false | customer=wira · presence false | 0 | เท่าเดิม |
| 7 | optional | ของตัวเองที่ตั้งค่าแล้ว | customer=wira, quantity=0 · presence true | customer=wira, quantity=0 · presence true | 0 | เท่าเดิม |
| 8 | string size_name = 2 | v1 | customer=wira | customer=wira | 1 | เท่าเดิม |
| 9 | int32 seats = 2 | v1 | customer=wira, seats=7 | customer=wira, seats=7 | 0 | เท่าเดิม |
| 10 | int64 | v1 | customer=wira, quantity=7 | customer=wira, quantity=7 | 0 | เท่าเดิม |
| 11 | uint32 | v1 ที่จำนวนติดลบ | customer=wira, quantity=4294967295 | customer=wira, quantity=-1 | 0 | ไม่เท่า |
| 12 | sint32 | v1 | customer=wira, quantity=-4 | customer=wira, quantity=-4 | 0 | เท่าเดิม |
| 13 | v1 | sint32 ที่จำนวนเป็นเจ็ด | customer=wira, quantity=14 | customer=wira, quantity=14 | 0 | เท่าเดิม |
| 14 | repeated string customer | v1 | customer=wira, quantity=7 | customer=wira, quantity=7 | 0 | เท่าเดิม |
| 15 | v1 | repeated ที่มีสองชื่อ | customer=somchai, quantity=7 | customer=somchai, quantity=7 | 0 | ไม่เท่า |
สองงานที่มี unknown field คืองานที่ 3 กับงานที่ 8 และทั้งสองภาษาถือ byte ชุดเดียวกันไว้
ในนั้น คือ 1a086272616e63682d62 กับ 1007 ตามลำดับ
สองงานที่ serialize กลับแล้วไม่เท่าเดิมคืองานที่ 11 กับงานที่ 15 และตรงนี้ก็เหมือนกันอีก
Go กับ Java คืน 0a047769726110ffffffff0f และ 0a07736f6d636861691007 ออกมาตัวต่อตัว
ช่องที่คนคาดว่าจะแตกต่างมากที่สุดคือช่องที่ตรงกันหมด ไม่ว่าจะเป็นจำนวน unknown field ที่ผู้อ่านเก็บไว้ หรือ byte ที่มันคายกลับออกมา ทั้งสองอย่างนี้ไม่ขึ้นกับภาษา
ช่องเดียวที่สองภาษาตอบไม่เท่ากัน
หัวข้อที่มีชื่อว่า “ช่องเดียวที่สองภาษาตอบไม่เท่ากัน”งานที่ 11 คือ
uint32อ่าน byte ของint32ที่เป็น −1 Go ตอบ4294967295Java ตอบ-1
ฝั่งเขียนถือ int32 quantity = 2; แล้วใส่ค่า −1 ผลที่ออกมาคือ 17 byte
0a047769726110ffffffffffffffffff01ฝั่งอ่านถือ schema ที่เปลี่ยนช่องเดียวกันเป็น uint32 quantity = 2; ทั้งสองภาษา parse ผ่าน
ทั้งคู่ได้ unknown field เป็นศูนย์ และทั้งคู่ serialize กลับได้ 0a047769726110ffffffff0f
เหมือนกัน
ค่าที่แสดงเท่านั้นที่ไม่ตรงกัน
| runtime | ค่าของ quantity ที่ได้ |
|---|---|
| Go | 4294967295 |
| Java | -1 |
Java ไม่มีชนิดจำนวนเต็ม 32 bit ที่ไม่มีเครื่องหมาย protobuf จึง map uint32 ลงเป็น int
ของ Java แล้วเก็บ bit บนสุดไว้ในตำแหน่งบิตเครื่องหมาย กติกาข้อนี้อยู่ในตารางชนิดข้อมูล
ของ protobuf.dev
bit ทั้ง 32 ตัวจึงเหมือนกันทุกตัวในสองภาษา ต่างกันที่วิธีพิมพ์ออกมาเป็นตัวหนังสือ
Go พิมพ์ uint32 แบบไม่มีเครื่องหมาย Java พิมพ์ int แบบมีเครื่องหมาย
byte ที่วิ่งเข้ามาชุดเดียวกัน byte ที่คายกลับออกไปชุดเดียวกัน จำนวน unknown field เท่ากัน สิ่งที่ต่างคือชนิดที่ภาษาปลายทางมีให้ใช้ ถ้า Java มีชนิด 32 bit ที่ไม่มี เครื่องหมาย ตารางข้างบนจะไม่มีแถวไหนต่างกันเลย
รายละเอียดว่าทำไม −1 ถึงกิน 10 byte และทำไม serialize กลับแล้วมันหด อยู่ที่ บทที่ 5
byte คือสัญญา ส่วนค่าที่คุณเห็นคือการแสดงผลของภาษานั้น
หัวข้อที่มีชื่อว่า “byte คือสัญญา ส่วนค่าที่คุณเห็นคือการแสดงผลของภาษานั้น”เส้นแบ่งของคอร์สนี้อยู่ตรงนี้ ข้ออ้างเรื่องสัญญาต้องพูดถึง byte ไม่ใช่พูดถึงค่าที่ภาษาหนึ่งพิมพ์
งานที่ 11 แยกของสองอย่างที่คนมักรวมเป็นอย่างเดียวออกจากกัน อย่างแรกคือ byte ที่เดินทาง อย่างที่สองคือค่าที่ภาษาปลายทางแสดงให้คุณดู
wire compatibilitywire compatibilityการแก้ schema ที่ทั้งสองฝั่งยังอ่าน byte ของกันได้ตามที่ตั้งใจ · parse ผ่านไม่ใช่หลักฐานของข้อนี้ เพราะค่าที่ไปนั่งผิดช่องก็ parse ผ่านเหมือนกัน เป็นข้ออ้างเกี่ยวกับอย่างแรก ส่วนตัวเลขที่คุณเห็นใน log หรือใน เทสต์ เป็นอย่างที่สอง สองอย่างนี้ตรงกันเกือบตลอด และงานที่ 11 คือจุดที่มันแยกออกจากกัน
ผลที่ตามมามีสองข้อ เอาไปใช้ได้ทันที
- เทสต์ที่เทียบตัวเลขที่พิมพ์ออกมา วัดการแสดงผลของภาษาตัวเอง ไม่ได้วัดสัญญา
- เทสต์ที่เทียบ hex ของ byte ที่ serialize ออกมา วัดสัญญาตรงๆ และให้คำตอบเดียวกันทั้งสองภาษา
protoc เขียน code ให้ แต่ descriptor ไม่ได้เดินทางไปด้วย
หัวข้อที่มีชื่อว่า “protoc เขียน code ให้ แต่ descriptor ไม่ได้เดินทางไปด้วย”protoc อ่าน .proto แล้วเขียน code ให้ภาษาปลายทาง ส่วนของที่ถือชื่อ field ไว้ อยู่ข้าง code ไม่ได้อยู่บนสาย
code generationcode generationขั้นที่ protoc แปล .proto เป็น code ของภาษาปลายทาง · Go กับ Java ได้ generator คนละตัว จึงเป็นจุดที่สองภาษาตอบไม่เท่ากันได้ คือขั้นที่ protoc อ่าน order.proto แล้วเขียน code ของภาษาปลายทาง
ออกมา ฝั่ง Go ต้องลง protoc-gen-go เป็น plugin แยกต่างหากก่อน ส่วนฝั่ง Java ใช้
generator ที่ติดมากับ protoc เอง ส่วน protobuf-java คือ runtime ที่ code ฝั่งนั้นเรียกใช้
descriptordescriptorของที่ถือชื่อ หมายเลข และชนิดของทุก field ไว้ครบ · มันอยู่ข้าง code ที่ generate ออกมา และไม่ได้เดินทางไปกับ message คือของที่ถือชื่อ หมายเลข และชนิดของทุก field ไว้ครบ มันถูกวางไว้ข้าง code ที่ generate ออกมาในแต่ละภาษา ผู้อ่านใช้มันเพื่อแปลหมายเลขบนสายกลับเป็นชื่อที่คนอ่านรู้เรื่อง
descriptor ไม่ได้เดินทางไปกับ message หน้า Encoding ของ protobuf.dev อธิบายว่า
ฝั่งถอดรหัสรู้ชื่อและชนิดของ field ได้จากนิยามใน .proto ของตัวเอง ไม่ใช่จาก byte ที่รับมา
มันเดินทางได้ก็ต่อเมื่อมีคนตั้งใจส่งมันเป็น message อีกใบต่างหาก ซึ่งคอร์สนี้ไม่แตะ
flowchart LR P["order.proto ใบเดียว"] --> C["protoc"] C --> G["code กับ descriptor ฝั่ง Go"] C --> J["code กับ descriptor ฝั่ง Java"] G --> W["byte บนสาย"] J --> W W --> RG["ค่าที่ Go แสดง"] W --> RJ["ค่าที่ Java แสดง"]
คำบรรยายภาพ: schema ใบเดียวผ่าน protoc ออกมาเป็นของสองชุด สองชุดนั้นเขียนและอ่าน byte ชุดเดียวกัน ส่วนค่าที่คนเห็น เป็นสิ่งที่แต่ละภาษาแสดงจาก byte ชุดนั้นอีกทีหนึ่ง
นี่คือเหตุผลที่บทที่ 1 วัดได้ว่าเปลี่ยนชื่อ field แล้ว byte ไม่ขยับ ชื่ออยู่ใน descriptor ของแต่ละฝั่ง ไม่ได้อยู่ในของที่ส่ง
กฎที่เอาไปใช้ได้ วัดฝั่งที่คุณไม่ได้เขียน
หัวข้อที่มีชื่อว่า “กฎที่เอาไปใช้ได้ วัดฝั่งที่คุณไม่ได้เขียน”ข้ออ้างที่วัดจาก runtime เดียว เป็นข้ออ้างเรื่อง runtime นั้น ไม่ใช่ข้ออ้างเรื่อง format
เทสต์ที่เขียนง่ายที่สุดคือเทสต์แบบนี้ เขียนออกมาแล้วอ่านกลับเข้าไปในภาษาเดียวกัน มันผ่านเสมอ และมันไม่ได้แตะฝั่งที่จะพัง
b, _ := proto.Marshal(&orderv1.Order{Customer: "wira", Quantity: 7})
var got orderv1.Order_ = proto.Unmarshal(b, &got)// ผ่านทุกครั้ง · แต่ฝั่งที่ deploy จริงคือ Java และเทสต์นี้ไม่เคยเรียกมันเลยทางแก้ไม่ใช่การเขียนเทสต์ให้เยอะขึ้นในภาษาเดิม แต่คือการย้ายเส้นที่วัด ให้ฝั่งเขียนกับ ฝั่งอ่านเป็นคนละ runtime กัน เหมือนที่ของจริงเป็น
ทำได้สามท่า เรียงจากถูกที่สุดขึ้นไป
- ตรึง hex ของ byte ที่ผู้เขียนสร้าง ไว้เป็นค่าคาดหวังในเทสต์ แล้วให้ฝั่งอ่านยืนยันกับ hex ชุดนั้น
- ให้ฝั่งอ่านจริงอ่าน byte ชุดนั้น แล้วยืนยันทั้งค่าที่ได้ จำนวน unknown field และ hex ที่ serialize กลับ
- ทำข้อสองกับทุกทรงของ schema ที่คุณตั้งใจจะ deploy ไม่ใช่เฉพาะทรงที่แก้ล่าสุด
สิ่งที่การวัดชุดนี้ยังไม่ได้พิสูจน์
หัวข้อที่มีชื่อว่า “สิ่งที่การวัดชุดนี้ยังไม่ได้พิสูจน์”ตารางข้างบนเป็นหลักฐานเรื่อง Go กับ Java ที่ version ซึ่งตรึงไว้ คือ protoc 36.1
protoc-gen-go v1.36.12 และ protobuf-java 4.36.1 เท่านั้น
มันไม่ได้พิสูจน์ว่าทุกภาษาตอบเหมือนกัน ภาษาที่ไม่มีชนิดจำนวนเต็มแบบไม่มีเครื่องหมาย มีข้อจำกัดข้อเดียวกับ Java ส่วนมันจะพิมพ์ค่าออกมาเป็นอะไร คอร์สนี้ไม่ได้วัด และภาษาที่ตัวเลขทุกตัวเป็นทศนิยม 64 bit มีคำถามของตัวเองอีกชุด ซึ่งคอร์สนี้ไม่ได้วัดเหมือนกัน
ท่าที่ปลอดภัยคือเขียนว่าอะไรถูกวัด แล้วหยุดตรงนั้น ประโยคที่บอกว่าทุก runtime ทำแบบนี้ ต้องมีบรรทัดใน protobuf.dev รองรับ ไม่ใช่มีแค่ผลจากสองภาษารองรับ
หกบทแรกกับบทนี้มีจุดร่วมอยู่อย่างหนึ่ง กับดักทุกข้อเงียบสนิท ไม่มี error ไม่มี parse ที่พัง และไม่มีเทสต์ฝั่งไหนแดง บทที่ 8 ถามคำถามสุดท้ายว่า มีอะไรที่เครื่องมือจับได้ก่อนของจะขึ้นบ้าง
-
Protocol Buffers — Encoding (เข้าถึง 2026-09-04) — ที่มาของกฎว่าฝั่งถอดรหัสรู้ชื่อและชนิดของ field จากนิยามของตัวเอง ไม่ใช่จาก byte ที่รับมา
-
Protocol Buffers — Language Guide (proto 3) · Scalar Value Types (เข้าถึง 2026-09-04) — ที่มาของการ map
uint32ลงเป็นintของ Java และของข้อสังเกตว่า Java เก็บจำนวนเต็มแบบไม่มีเครื่องหมายไว้ในชนิดที่มีเครื่องหมาย -
Protocol Buffer Basics: Go (เข้าถึง 2026-09-04) — ที่มาของขั้นตอนที่ protoc เรียก
protoc-gen-goเขียน code ฝั่ง Go -
Protocol Buffer Basics: Java (เข้าถึง 2026-09-04) — ที่มาของขั้นตอนเดียวกันฝั่ง Java ซึ่งสั่ง protoc ตัวเดียวจบ ไม่ต้องลง plugin แยกเหมือนฝั่ง Go
-
ตัวเลขทุกตัวในบทนี้มาจาก
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
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3จากงานอ่านทั้ง 15 งาน Go กับ Java ตอบต่างกันกี่ช่อง