Things you don’t know about Protocol Buffers

ช่วงนี้นั่งงม Protocol Buffers ลึกๆ แล้วพบว่า documentation มันไม่ค่อยมีเขียนเท่าไร หลายๆ คนก็คงน่าจะเคยใช้อย่างมากก็ gRPC เลยอยากมาเล่าให้ฟังหน่อยว่า Protobuf ทำอะไรได้อีกบ้าง

บทความนี้จะพูดถึงเฉพาะ Protobuf 3 เท่านั้น ส่วน 2 ผมไม่ได้ใช้นานแล้ว

REST API

ลองทำ API แล้วคืนเป็น Protobuf ดู พบว่าทำงานง่ายขึ้นมาก

  • Protobuf มี schema ชัดเจน และ validate มาแล้ว ไม่ต้องนั่งเขียน JSON Schema บน Swagger อีกรอบ (ตรงไม่ตรงก็ไม่รู้อีกต่างหาก)
  • ไม่เสียเวลาเขียน struct หรือ type definition มา parse บน client side ใช้ codegen ออกมาจาก proto definition ได้เลย
  • ยังใช้ Django + Django REST Framework Serializer ได้อยู่

แต่ข้อเสียคือ

  • HTTP Library ส่วนมาก parse JSON จาก response ได้เลย พอเป็น Protobuf แล้วต้องเขียน logic ในการ parse เอง
  • บน JavaScript frontend ถ้าใช้ Protobuf API ต้อง ship parser ไปด้วยทำให้เปลือง bundle size

JSON

จากข้อข้างบน ถ้าอยากจะ ship Protobuf แต่ไม่อยากเปลือง bundle size วิธีหนึ่งที่ทำได้คือ Protobuf จะมี JSON representation เราก็อาจจะให้รับส่งเป็น JSON แทนได้

วิธีนี้คิดว่าได้ข้อดีข้างบนมาหมดทุกข้อเลย

วิธี encode protobuf เป็น JSON อย่า parse ออกมาแล้วโยนใส่ JSON encoder ปกติ แต่ Protobuf จะมี function ให้ เช่น google.protobuf.json_format.MessageToJson ใน Python หรือ google.golang.org/protobuf/encoding/protojson.Marshal ใน Go เวลาถอดรหัสก็ต้องใช้ function ของ protobuf เช่นกัน

JSON encoding ของ Protobuf จะไม่เหมือนกับ data structure ตรงๆ นิดนึงคือ

  • Key จะกลายเป็น lowerCamelCase เสมอ เช่น field ชื่อ user_name จะกลายเป็น userName
  • ถ้าไม่ได้แก้ settings อะไร default value ของแต่ละ field จะหายไป เช่นถ้ามี field string username = 1; อยู่มีค่าเป็น string เปล่า มันจะไม่ออกมาใน JSON (ถ้าอยากให้ parse สะดวกอาจจะต้องแก้ config ให้มันส่งออกมาด้วย)
  • enum จะแสดงเป็นชื่อ enum field แต่จะแก้ settings ให้ส่งเป็นตัวเลขแทนก็ได้ (default value ของ enum คือ member ตัวแรก)
  • bytes กลายเป็น base64 string

Fun fact: เวลาแปลง protobuf เป็น JSON ใน Go ไม่ได้ใช้ encoding/json แต่มันจะ build string ออกมาเลย (แต่ตอนถอดกลับใช้ยัง encoding/json นะ)

Null value

จากข้อข้างบนอาจจะสงสัยว่าทำไม default value ไม่ถูก encode ออกมา

คำตอบคือ Protobuf ไม่มี null value และนี่น่าจะเป็นสิ่งที่ผมพลาดเยอะที่สุด

กรณีที่เราเซต field ใดๆ เป็น null นั้น protobuf จะถือว่าใช้ default value ของ field นั้นๆ ได้แก่

  • string คือ string เปล่า
  • bool คือ false
  • ตัวเลขต่างๆ คือ 0
  • enum คือ สมาชิกตัวแรกของ enum
  • repeated คือ empty list
  • เฉพาะ field ที่มี type เป็น message เท่านั้นจะมี null value ได้

ที่เป็นแบบนี้เพราะ Protobuf จะไม่ encode field ที่มีค่าเป็น default ส่งไป

Well Known Type

Docs Protobuf เอา WKT ไปแอบลึกมากจนอาจจะไม่เคยรู้เลยว่ามีสิ่งนี้ด้วย

Well known type คือ message ต่างๆ ที่ติดมากับ Protobuf ได้แก่

  • google.protobuf.Duration เก็บระยะเวลา
  • google.protobuf.Timestamp เก็บเวลา
  • google.protobuf.Empty ไม่เก็บอะไรเลย

ดังนั้นเวลาจะ encode เวลาควรจะใช้ WKT เสมอ เนื่องจากว่าใน library ภาษาต่างๆ มักจะมี function ที่ถอดรหัสเข้าออกเป็น native value ของภาษานั้นๆ ให้อยู่แล้ว เช่น ToDatetime ใน Python หรือ AsTime ใน Go วิธีนี้ทำให้เราไม่ต้องนั่งจำว่า time บน service นี้ encode เป็นอะไร (ISO8601? POSIX? Custom format?)

เวลาใช้ WKT ต้อง import มาจาก google/protobuf/timestamp.proto (หรือไฟล์อื่นๆ ตามที่จะใช้) ซึ่งไม่มี document ไว้…

Wrapped type

จากข้อข้างบนเราบอกว่า Protobuf ไม่มี null value แต่ message เป็น null ได้ ดังนั้นถ้าอยากจะส่ง nullable เราก็แค่ยก field นั้นไปทำเป็น message ใหม่แล้วเซตเป็น null

ซึ่ง Protobuf ก็คิดมาแล้ว ใน WKT จะมี type ต่างๆ ที่ครอบมาให้แล้ว

  • google.protobuf.BoolValue
  • google.protobuf.BytesValue
  • google.protobuf.DoubleValue
  • google.protobuf.FloatValue
  • google.protobuf.Int32Value
  • google.protobuf.Int64Value
  • google.protobuf.StringValue
  • google.protobuf.UInt32Value
  • google.protobuf.UInt64Value

ทั้งหมดจะมี field เดียวคือ value และเมื่อ encode เป็น JSON แล้วมันจะหายไปกลายเป็นค่าที่เก็บไว้ตรงๆ (นี่แหละที่ถ้าทำ type เองจะทำไม่ได้)

FileDescriptorSet

เวลาใช้งาน protobuf เราจะใช้ protoc เพื่อทำ code generation ซึ่งต้องลง protoc-gen-* ตามภาษาที่ใช้งานด้วย

แล้ว protoc generate อะไรได้มั้ย?

คำตอบคือมัน generate FileDescriptorSet ได้

FileDescriptorSet เป็น well known type อันนึง มันคือการ .proto ให้เป็น protobuf แถมเราสามารถบอกให้ protoc include ไฟล์ทั้งหมดที่เรา include ต่อๆ กันมาได้ด้วย ทำให้ FileDescriptorSet นั้นจบในตัว

FileDescriptorSet นี้ไม่อยู่ใน protobuf documentation ด้วยนะ!!

Any

WKT อีก type หนึ่งที่น่าสนใจคือ Any ซึ่งน่าเสียดายที่ถึงมันจะอยู่ใน documentation แต่ library ต่างๆ ยังไม่พร้อมใช้เท่าไรนัก

Any ใช้เก็บ message อะไรก็ได้ โดยมันจะมี 2 field คือ

  • type_url เก็บ URL ของ type นั้น เช่น type.googleapis.com/com.mycompany.TypeName
  • value เป็น bytes คือข้อมูลที่ต้องการเก็บ encode เป็น protobuf

เวลา decode แล้ว library ที่รองรับจะสามารถอ่าน type ต้นฉบับได้เลยโดยไม่ต้อง decode เอง นอกไปจากนี้จะสังเกตว่า type_url นั้นเป็น URL เวลาเจอ type แปลกประหลาด Protobuf client library จะเข้าไปใน URL นั้นเพื่อโหลด type definition ให้อัตโนมัติ ซึ่งฟีเจอร์นี้โม้ไว้เฉยๆ ยังไม่ได้ทำ

เวลา encode Any เป็น JSON มันจะกลายเป็น {"@type": "type.googleapis.com/com.mycompany.TypeName"} แล้วเอาค่าใน value ที่เก็บไว้เข้ามา merge เลยไม่ได้ทำเป็น field ย่อย ใครที่ใช้ Stackdriver Logging น่าจะเห็น output ที่มี @type field บ่อย

จากด้านบน ถ้าเราทำ message ที่มี Any กับ FileDescriptorSet ฝังรวมกันเราจะได้ Self describing message แต่ปัจจุบันเนื่องจาก library ไม่รองรับกันเท่าไรจึง decode ยากมากๆ บางภาษาอาจจะ decode แล้วพังเลย

Reflection

แล้ว FileDescriptorSet ทำอะไรได้อีก? Protobuf ในบางภาษาจะมี reflection API ทำให้เราสามารถ create Protobuf message ได้ใน runtime (ภาษา dynamic เช่น JavaScript อาจจะไม่ต้องรองรับเพราะไม่ต้องใช้ codegen) เช่น dynamicpb

Lots of potential

เขียนมาทั้งหมดนี้แล้วจะเห็นธีมคล้ายๆ กันว่าถึง Protobuf จะออกมา 12 ปีแล้ว และ Proto3 ออกมา 4 ปีแล้วแต่ Protobuf ยังดูมี potential อีกมากที่ยังพัฒนาไม่เสร็จ