ทุกสิ่งเปลี่ยนแปลงและไม่มีอะไรหยุดนิ่ง

Heraclitus of Ephesus, as quoted by Plato in Cratylus (360 BCE)

แอปพลิเคชันย่อมเปลี่ยนแปลงไปตามกาลเวลา คุณสมบัติใหม่ ๆ ถูกเพิ่มหรือปรับเปลี่ยนเมื่อมีการเปิดตัวผลิตภัณฑ์ใหม่ ความต้องการของผู้ใช้งานเริ่มชัดเจนขึ้น หรือสถานการณ์ทางธุรกิจเปลี่ยนแปลงไป ใน บทที่ 2 เราได้แนะนำแนวคิดเรื่อง ความสามารถในการปรับเปลี่ยน (evolvability): เราควรมุ่งมั่นสร้างระบบที่ปรับตัวตามการเปลี่ยนแปลงได้ง่าย (ดู "Evolvability: Making Change Easy" )

ในกรณีส่วนใหญ่ การเปลี่ยนแปลงคุณสมบัติของแอปพลิเคชันมักต้องมีการเปลี่ยนแปลงข้อมูลที่มันจัดเก็บด้วย บางครั้งจำเป็นต้องเพิ่มฟิลด์หรือประเภทเรคคอร์ดใหม่ หรือต้องนำเสนอข้อมูลที่มีอยู่ด้วยวิธีใหม่

โมเดลข้อมูลที่เราพูดถึงใน บทที่ 3 มีวิธีการจัดการกับการเปลี่ยนแปลงที่แตกต่างกัน ฐานข้อมูลเชิงสัมพันธ์ (relational database) มักถือว่าข้อมูลทั้งหมดในฐานข้อมูลเป็นไปตาม schema เดียว แม้ schema นั้นจะเปลี่ยนได้ (ผ่าน schema migration เช่นการใช้คำสั่ง ALTER ) แต่ ณ เวลาใดเวลาหนึ่งก็จะมี schema ที่ใช้งานอยู่เพียงตัวเดียว ในทางกลับกัน ฐานข้อมูลแบบ schema-on-read (หรือ "schemaless") ไม่บังคับ schema จึงเก็บข้อมูลทั้งรูปแบบเก่าและใหม่ที่เขียนไว้คนละเวลาปนกันได้ (ดู "Schema flexibility in the document model" )

เมื่อรูปแบบข้อมูลหรือ schema เปลี่ยน มักต้องแก้โค้ดของแอปพลิเคชันตามไปด้วย (เช่น คุณเพิ่มฟิลด์ใหม่ลงในเรคคอร์ด แล้วโค้ดเริ่มอ่านและเขียนฟิลด์นั้น) แต่ในแอปพลิเคชันขนาดใหญ่ การเปลี่ยนโค้ดมักเกิดขึ้นทันทีทุกที่ไม่ได้ ด้วยเหตุผลหลายประการ เช่น:

  • สำหรับแอปพลิเคชันฝั่ง server คุณอาจอยากทำ rolling upgrade (หรือที่เรียกว่า staged rollout ) คือ deploy เวอร์ชันใหม่ทีละไม่กี่โหนด คอยดูว่าทำงานราบรื่นหรือไม่ แล้วค่อย ๆ ขยายไปยังโหนดทั้งหมด วิธีนี้ทำให้ deploy เวอร์ชันใหม่ได้โดยไม่ต้องหยุดให้บริการ จึงปล่อยเวอร์ชันได้บ่อยขึ้น และระบบปรับเปลี่ยนได้ง่ายขึ้น

  • สำหรับแอปพลิเคชันฝั่ง client คุณต้องพึ่งผู้ใช้ ซึ่งอาจไม่ติดตั้งอัปเดตไปอีกพักใหญ่

นั่นหมายความว่าโค้ดเวอร์ชันเก่าและใหม่ รวมถึงรูปแบบข้อมูลเก่าและใหม่ อาจอยู่ในระบบพร้อมกัน เพื่อให้ระบบทำงานต่อได้อย่างราบรื่น คุณต้องรักษาความเข้ากันได้ทั้งสองทิศทาง:

Backward compatibility (ความเข้ากันได้ย้อนหลัง)

โค้ดเวอร์ชันใหม่ต้องอ่านข้อมูลที่โค้ดเวอร์ชันเก่าเขียนไว้ได้

Forward compatibility (ความเข้ากันได้ไปข้างหน้า)

โค้ดเวอร์ชันเก่าต้องอ่านข้อมูลที่โค้ดเวอร์ชันใหม่เขียนไว้ได้

ในบริบทของ API ถ้าอยากให้ client เก่าเรียก service ใหม่ได้สำเร็จ คุณต้องมี backward compatibility ใน request และ forward compatibility ใน response ส่วน client ใหม่ที่จะเรียก service เก่า ต้องมี forward compatibility ใน request และ backward compatibility ใน response

โดยปกติ backward compatibility ไม่ใช่เรื่องยาก ในฐานะผู้เขียนโค้ดเวอร์ชันใหม่ คุณรู้อยู่แล้วว่าโค้ดเวอร์ชันเก่าเขียนข้อมูลในรูปแบบไหน จึงจัดการได้ตรง ๆ (ถ้าจำเป็นก็แค่เก็บโค้ดเก่าไว้อ่านข้อมูลเก่า) ส่วน forward compatibility ยุ่งยากกว่า เพราะโค้ดเก่าต้องรู้จักข้ามส่วนที่โค้ดเวอร์ชันใหม่เพิ่มเข้ามา

forward compatibility ยังมีความท้าทายอีกข้อ ดัง รูปที่ 5-1 สมมติว่าคุณเพิ่มฟิลด์ใหม่ใน schema ของเรคคอร์ด แล้วโค้ดเวอร์ชันใหม่สร้างเรคคอร์ดที่มีฟิลด์นั้นและเก็บลงฐานข้อมูล ต่อมาโค้ดเวอร์ชันเก่า (ที่ยังไม่รู้จักฟิลด์ใหม่) อ่านเรคคอร์ดนั้น อัปเดต แล้วเขียนกลับ โดยปกติพฤติกรรมที่ต้องการคือให้โค้ดเก่าเก็บฟิลด์ใหม่ไว้เหมือนเดิม แม้จะตีความมันไม่ได้ก็ตาม แต่ถ้าเรคคอร์ดถูกถอดรหัสเป็น model object ที่ไม่ได้เก็บฟิลด์ที่ไม่รู้จักไว้ ข้อมูลอาจสูญหายได้ ดังที่เห็นในรูป

ในบทนี้เราจะดูรูปแบบการเข้ารหัสข้อมูลหลายแบบ ทั้ง JSON, XML, Protocol Buffers และ Avro โดยเน้นว่าแต่ละแบบรับมือกับการเปลี่ยนแปลง schema อย่างไร และรองรับระบบที่ต้องมีทั้งข้อมูลและโค้ดเก่า-ใหม่อยู่ร่วมกันอย่างไร จากนั้นจะดูว่ารูปแบบเหล่านี้ถูกใช้ในการเก็บข้อมูลและการสื่อสารอย่างไร ทั้งในฐานข้อมูล web service, REST API, Remote Procedure Call (RPC), workflow engine และระบบ event-driven เช่น actor และ message queue

Diagram illustrating data loss when an old version of code updates a data structure with an unknown field, resulting in the loss of the "photoURL" field during a read and write operation with a newer record schema.

รูปที่ 5-1. เมื่อแอปพลิเคชันเวอร์ชันเก่าอัปเดตข้อมูลที่แอปพลิเคชันเวอร์ชันใหม่เขียนไว้ ข้อมูลอาจสูญหายได้ถ้าไม่ระวัง

Formats for Encoding Data (รูปแบบการเข้ารหัสข้อมูล)

โปรแกรม โดยทั่วไปทำงานกับข้อมูลใน (อย่างน้อย) สองรูปแบบ:

  • ในหน่วยความจำ ข้อมูลถูกเก็บใน object, struct, list, array, hash table, tree และอื่น ๆ โครงสร้างข้อมูลเหล่านี้ถูกปรับให้ CPU เข้าถึงและจัดการได้อย่างมีประสิทธิภาพ (โดยทั่วไปใช้ pointer)

  • เมื่อต้องเขียนข้อมูลลงไฟล์หรือส่งผ่านเครือข่าย คุณต้องเข้ารหัสข้อมูลเป็นลำดับไบต์ที่ครบในตัวเอง (เช่น เอกสาร JSON) เพราะ pointer ไม่มีความหมายสำหรับ process อื่น ลำดับไบต์แบบนี้จึงมักต่างจากโครงสร้างข้อมูลในหน่วยความจำมาก

เราจึงต้องแปลงระหว่างสองรูปแบบนี้ การแปลงจากข้อมูลในหน่วยความจำเป็นลำดับไบต์เรียกว่า การเข้ารหัส (encoding หรือเรียกว่า serialization หรือ marshaling ) และการแปลงย้อนกลับ เรียกว่า การถอดรหัส (decoding หรือ parsing , deserialization หรือ unmarshaling )

Terminology clash (คำศัพท์ที่ซ้ำกัน)

คำว่า serialization น่าเสียดายที่ถูกใช้ในบริบทของ transaction (ดู บทที่ 8 ) ด้วยความหมายที่ต่างออกไปโดยสิ้นเชิง เพื่อไม่ให้คำสับสนกัน หนังสือเล่มนี้จึงใช้ encoding แม้ serialization อาจจะพบบ่อยกว่าก็ตาม

บางครั้งก็ไม่ต้องเข้ารหัส/ถอดรหัส เช่น เมื่อฐานข้อมูลทำงานโดยตรงกับข้อมูลที่ถูกบีบอัดซึ่งโหลดมาจากดิสก์ ดังที่กล่าวถึงใน "Query Execution: Compilation and Vectorization" และยังมีรูปแบบข้อมูล zero-copy ที่ออกแบบให้ใช้ได้ทั้งใน runtime และบนดิสก์/เครือข่ายโดยไม่ต้องมีขั้นตอนแปลงแยกต่างหาก เช่น Cap'n Proto และ FlatBuffers

อย่างไรก็ตาม ระบบส่วนใหญ่ยังต้องแปลงระหว่าง object ในหน่วยความจำกับลำดับไบต์แบบแบน เพราะเป็นปัญหาที่พบบ่อย จึงมี library และรูปแบบการเข้ารหัสให้เลือกมากมาย มาดูภาพรวมคร่าว ๆ กัน

Language-Specific Formats (รูปแบบเฉพาะภาษา)

ภาษาโปรแกรมหลายภาษามีตัวเข้ารหัส object ในหน่วยความจำเป็นลำดับไบต์ติดมาให้ เช่น Java มี java.io.Serializable , Python มี pickle , และ Ruby มี Marshal และยังมี library ของบุคคลที่สามอีกมาก เช่น Kryo สำหรับ Java

library เหล่านี้สะดวก เพราะบันทึกและกู้คืน object ในหน่วยความจำได้โดยเขียนโค้ดเพิ่มน้อยมาก แต่ก็มีปัญหาสำคัญหลายข้อ:

  • การเข้ารหัสมักผูกกับภาษาโปรแกรมภาษาใดภาษาหนึ่ง ทำให้อ่านข้อมูลด้วยภาษาอื่นได้ยาก ถ้าคุณเก็บหรือส่งข้อมูลด้วยรูปแบบนี้ ก็เท่ากับผูกตัวเองกับภาษาปัจจุบันไปอีกนาน และขัดขวางการเชื่อมระบบของคุณกับระบบขององค์กรอื่น (ซึ่งอาจใช้คนละภาษา)

  • การกู้คืนข้อมูลกลับเป็น object ประเภทเดิม ทำให้ตัวถอดรหัสต้องสร้าง instance ของคลาสใดก็ได้ ซึ่งมักเป็นต้นเหตุของปัญหาด้านความปลอดภัย [ 1 ]; ถ้าผู้โจมตีทำให้แอปพลิเคชันของคุณถอดรหัสลำดับไบต์ใดก็ได้ พวกเขาก็สร้าง instance ของคลาสใดก็ได้ ซึ่งมักเปิดทางให้ทำสิ่งเลวร้าย เช่น รันโค้ดตามอำเภอใจจากระยะไกล [ 2 , 3 ]

  • library เหล่านี้มักมองข้ามเรื่อง versioning ของข้อมูล เพราะถูกออกแบบมาให้เข้ารหัสได้เร็วและง่าย จึงมักละเลยปัญหายุ่งยากเรื่อง forward และ backward compatibility [ 4 ]

  • ประสิทธิภาพ (เวลา CPU ที่ใช้เข้ารหัสหรือถอดรหัส และขนาดของข้อมูลที่เข้ารหัสแล้ว) ก็มักถูกมองข้ามเช่นกัน เช่น serialization ในตัวของ Java ขึ้นชื่อว่าช้าและได้ข้อมูลที่บวมเกินจำเป็น [ 5 ]

ด้วยเหตุนี้ โดยทั่วไปจึงไม่ควรใช้ตัวเข้ารหัสที่ติดมากับภาษาโปรแกรมของคุณ ยกเว้นเพื่อวัตถุประสงค์ชั่วคราวมาก ๆ เท่านั้น

JSON, XML, and Binary Variants (JSON, XML และรูปแบบไบนารีอื่น ๆ)

ถ้าต้องการการเข้ารหัสมาตรฐานที่ภาษาโปรแกรมหลายภาษาเขียนและอ่านได้ JSON และ XML เป็นตัวเลือกที่เห็นชัด: เป็นที่รู้จักกว้างขวางและมีเครื่องมือรองรับแพร่หลาย CSV ก็เป็นอีกรูปแบบยอดนิยมที่ไม่ขึ้นกับภาษา แต่รองรับเฉพาะข้อมูลแบบตารางที่ไม่มีโครงสร้างซ้อนกัน

JSON, XML และ CSV เป็นรูปแบบข้อความ (textual format) คนจึงอ่านออกในระดับหนึ่ง แม้ไวยากรณ์ของมันมักเป็นประเด็นถกเถียงกัน นอกจากเรื่องไวยากรณ์ที่เป็นปัญหาผิวเผินแล้ว ก็ยังมีปัญหาอื่นอีกมาก:

  • XML มักถูกวิจารณ์ว่า verbose และซับซ้อนเกินจำเป็น [ 6 ]

  • การเข้ารหัสตัวเลขมีความคลุมเครืออยู่มาก ใน XML และ CSV คุณแยกตัวเลขออกจากสตริงที่ประกอบด้วยตัวเลขไม่ได้ (เว้นแต่อ้างอิง schema ภายนอก) JSON แยกสตริงกับตัวเลขได้ แต่ไม่แยกจำนวนเต็มกับจำนวนจุดลอยตัว และไม่ระบุความแม่นยำ

    ปัญหานี้เกิดกับตัวเลขขนาดใหญ่ เช่น จำนวนเต็มที่มากกว่า 2 53 แทนค่าอย่างแม่นยำด้วยเลขทศนิยมแบบ double-precision ตามมาตรฐาน IEEE 754 ไม่ได้ ตัวเลขเหล่านี้จึงคลาดเคลื่อนเมื่อ parse ในภาษาที่ใช้เลขทศนิยม เช่น JavaScript [ 7 ] ตัวอย่างของตัวเลขที่ใหญ่กว่า 2 53 พบบน X ซึ่งใช้ตัวเลข 64 บิตระบุแต่ละโพสต์ JSON ที่ API คืนมาจึงใส่ ID ของโพสต์ไว้สองครั้ง ครั้งหนึ่งเป็น JSON number และอีกครั้งเป็น decimal string เพื่อไม่ให้แอปพลิเคชัน JavaScript parse ตัวเลขผิด [ 8 ]

  • JSON และ XML รองรับ Unicode string (ข้อความที่คนอ่านได้) ได้ดี แต่ไม่รองรับ binary string (ลำดับไบต์ที่ไม่มีการเข้ารหัสอักขระ) ซึ่งเป็นความสามารถที่มีประโยชน์ คนจึงแก้ข้อจำกัดนี้ด้วยการเข้ารหัสข้อมูลไบนารีเป็นข้อความด้วย Base64 แล้วใช้ schema บอกว่าค่านั้นต้องตีความเป็นข้อมูลที่เข้ารหัสด้วย Base64 วิธีนี้ใช้ได้ผล แต่ค่อนข้างเป็นการแก้ขัด และทำให้ข้อมูลใหญ่ขึ้นราวหนึ่งในสาม

  • XML Schema และ JSON Schema ทรงพลัง จึงค่อนข้างซับซ้อนทั้งตอนเรียนรู้และตอนนำไปใช้ เนื่องจากการตีความข้อมูลให้ถูกต้อง (เช่น ตัวเลขและ binary string) ขึ้นกับข้อมูลใน schema แอปพลิเคชันที่ไม่ใช้ XML/JSON Schema อาจต้องเขียน logic เข้ารหัส/ถอดรหัสที่เหมาะสมลงในโค้ดโดยตรงแทน

  • CSV ไม่มี schema ดังนั้นแอปพลิเคชันต้องเป็นผู้กำหนดความหมายของแต่ละแถวและคอลัมน์เอง ถ้าแอปพลิเคชันเปลี่ยนไปจนเพิ่มแถวหรือคอลัมน์ใหม่ คุณก็ต้องจัดการการเปลี่ยนนั้นเอง CSV ยังเป็นรูปแบบที่ค่อนข้างคลุมเครือ (จะเกิดอะไรขึ้นถ้าค่ามีเครื่องหมายจุลภาคหรืออักขระขึ้นบรรทัดใหม่?) แม้กฎการ escape จะมีการระบุอย่างเป็นทางการแล้ว [ 9 ] แต่ parser บางตัวก็ implement ไม่ถูกต้อง

แม้จะมีข้อบกพร่องเหล่านี้ JSON, XML และ CSV ก็เพียงพอสำหรับงานหลายอย่าง และน่าจะยังได้รับความนิยมต่อไป โดยเฉพาะในฐานะรูปแบบแลกเปลี่ยนข้อมูล (ส่งข้อมูลจากองค์กรหนึ่งไปอีกองค์กรหนึ่ง) ในกรณีเหล่านี้ ขอแค่ทุกฝ่ายตกลงรูปแบบเดียวกันได้ ก็มักไม่สำคัญว่ารูปแบบนั้นจะสวยหรือมีประสิทธิภาพแค่ไหน การทำให้องค์กรต่าง ๆ ตกลงกันได้ใน สิ่งใดสิ่งหนึ่ง เป็นเรื่องที่ยากเกินกว่าข้อกังวลอื่น ๆ ส่วนใหญ่

JSON Schema

JSON Schema ถูกใช้อย่างแพร่หลายเพื่อสร้างโมเดลข้อมูลที่แลกเปลี่ยนระหว่างระบบหรือเขียนลง storage คุณจะพบ JSON Schema ใน web service (ดู "Web services" ) ซึ่งเป็นส่วนหนึ่งของข้อกำหนด web service ของ OpenAPI ใน schema registry เช่น Confluent's Schema Registry และ Red Hat's Apicurio Registry และในฐานข้อมูล (เช่น PostgreSQL's pg_jsonschema validator extension และ MongoDB's $jsonSchema validator syntax)

ข้อกำหนด JSON Schema มีความสามารถมากมาย schema ประกอบด้วยชนิดข้อมูลพื้นฐานมาตรฐาน เช่น string , number , integer , object , array , boolean และ null แต่ JSON Schema ยังมีข้อกำหนด validation แยกต่างหาก ที่ให้นักพัฒนาเพิ่มข้อจำกัดให้ฟิลด์ได้ เช่น ฟิลด์ port อาจมีค่าต่ำสุดเป็น 1 และสูงสุดเป็น 65,535

JSON Schema จะใช้ content model แบบเปิด (open) หรือปิด (closed) ก็ได้ แบบเปิดอนุญาตให้มีฟิลด์ใดก็ได้ที่ไม่ได้ประกาศใน schema โดยมีชนิดข้อมูลอะไรก็ได้ ส่วนแบบปิดอนุญาตเฉพาะฟิลด์ที่ประกาศไว้อย่างชัดเจน ใน JSON Schema แบบเปิดจะเปิดใช้เมื่อ additionalProperties ถูกตั้งค่าเป็น true ซึ่งเป็นค่าเริ่มต้น JSON Schema จึงมักเป็นการนิยามสิ่งที่ ไม่ได้รับอนุญาต (คือค่าที่ไม่ถูกต้องในฟิลด์ที่ประกาศไว้) มากกว่าจะนิยามสิ่งที่ ได้รับอนุญาต

content model แบบเปิดทรงพลัง แต่ก็อาจซับซ้อน เช่น สมมติว่าคุณต้องการนิยาม map จากจำนวนเต็ม (เช่น ID) ไปยังสตริง JSON ไม่มีชนิด map หรือ dictionary ที่ใช้จำนวนเต็มเป็นคีย์ได้ เพราะ object ใน JSON ใช้สตริงเป็นคีย์เสมอ เพื่อให้ได้ตามต้องการ คุณใช้ JSON Schema จำกัดชนิดนี้ให้คีย์มีแต่ตัวเลขและค่าเป็นสตริงได้ โดยใช้ patternProperties และ additionalProperties ดังที่แสดงใน ตัวอย่างที่ 5-1

ตัวอย่างที่ 5-1. JSON Schema ที่คีย์เป็นจำนวนเต็มและค่าเป็นสตริง
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "patternProperties": {
    "^[0-9]+$": {
      "type": "string"
    }
  },
  "additionalProperties": false
}

นอกจาก content model แบบเปิด/ปิดและ validator แล้ว JSON Schema ยังรองรับ conditional if/else schema logic, named type, การอ้างอิง schema ระยะไกล และอื่น ๆ อีกมาก ทำให้เป็นภาษา schema ที่ทรงพลังมาก แต่ก็ทำให้นิยามมีขนาดใหญ่และยุ่งยาก การ resolve schema ระยะไกล การวิเคราะห์กฎแบบมีเงื่อนไข หรือการพัฒนา schema ให้เข้ากันได้ทั้งไปข้างหน้าและย้อนหลังอาจเป็นเรื่องท้าทาย [ 10 , 11 ] ข้อกังวลทำนองเดียวกันนี้ใช้กับ XML Schema [ 12 ]

Binary encodings (การเข้ารหัสแบบไบนารี)

JSON กะทัดรัดกว่า XML แต่ทั้งคู่ยังกินพื้นที่มากเมื่อเทียบกับรูปแบบไบนารี ข้อสังเกตนี้ทำให้เกิดการเข้ารหัสแบบไบนารีมากมายสำหรับ JSON (MessagePack, CBOR, BSON, BJSON, UBJSON, BISON, Hessian และ Smile เป็นต้น) และสำหรับ XML (เช่น WBXML และ Fast Infoset) บางกลุ่มเฉพาะนำรูปแบบเหล่านี้ไปใช้ เพราะกะทัดรัดกว่าและบางครั้ง parse ได้เร็วกว่า แต่ไม่มีรูปแบบใดแพร่หลายเท่า JSON และ XML แบบข้อความ [ 13 ]

บางรูปแบบขยายชุดชนิดข้อมูล (เช่น แยกจำนวนเต็มกับจำนวนจุดลอยตัว หรือเพิ่มการรองรับ binary string) แต่นอกนั้นยังคงโมเดลข้อมูลของ JSON/XML ไว้เหมือนเดิม โดยเฉพาะอย่างยิ่ง เมื่อไม่มี schema รูปแบบเหล่านี้จึงต้องเก็บชื่อฟิลด์ทั้งหมดของ object ไว้ในข้อมูลที่เข้ารหัส กล่าวคือ ในการเข้ารหัสแบบไบนารีของเอกสาร JSON ใน ตัวอย่างที่ 5-2 ต้องมีสตริง userName , favoriteNumber และ interests อยู่ที่ใดที่หนึ่ง

ตัวอย่างที่ 5-2. เรคคอร์ดที่เราจะนำไปเข้ารหัสด้วยรูปแบบไบนารีหลายแบบในบทนี้
{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}

ลองดู MessagePack ซึ่งเป็นการเข้ารหัสแบบไบนารีของ JSON เป็นตัวอย่าง รูปที่ 5-2 แสดงลำดับไบต์ที่ได้เมื่อเข้ารหัสเอกสาร JSON ใน ตัวอย่างที่ 5-2 ด้วย MessagePack

A MessagePack encoding diagram showing a record compressed into 66 bytes, detailing the object structure with strings and data entries such as userName, favoriteNumber, and interests.

รูปที่ 5-2. เรคคอร์ดของเรา ( ตัวอย่างที่ 5-2 ) เมื่อเข้ารหัสด้วย MessagePack

ไบต์แรก ๆ มีความหมายดังนี้:

  1. ไบต์แรก 0x83 บอกว่าสิ่งที่ตามมาคือ object (4 บิตสูงสุด = 0x80 ) ที่มีสามฟิลด์ (4 บิตต่ำสุด = 0x03 ). (ถ้าสงสัยว่า object ที่มีมากกว่า 15 ฟิลด์ ซึ่งจำนวนฟิลด์ใส่ใน 4 บิตไม่พอ จะเป็นอย่างไร ก็จะใช้ตัวบ่งชี้ชนิดอีกตัว และเข้ารหัสจำนวนฟิลด์ด้วยสองหรือสี่ไบต์)

  2. ไบต์ที่สอง 0xa8 บอกว่าสิ่งที่ตามมาคือสตริง (4 บิตสูงสุด = 0xa0 ) ที่ยาวแปดไบต์ (4 บิตต่ำสุด = 0x08 )

  3. แปดไบต์ถัดไปคือชื่อฟิลด์ userName ใน ASCII เนื่องจากบอกความยาวไว้ก่อนแล้ว จึงไม่ต้องมี marker บอกว่าสตริงจบตรงไหน (หรือต้อง escape อะไรเลย)

  4. เจ็ดไบต์ถัดไปเข้ารหัสค่าสตริงยาว 6 ตัวอักษร Martin ด้วยคำนำหน้า 0xa6 และต่อไปเรื่อย ๆ

ผลการเข้ารหัสยาว 66 ไบต์ ซึ่งน้อยกว่า JSON แบบข้อความที่ยาว 81 ไบต์ (เมื่อตัดช่องว่างออก) เพียงเล็กน้อย การเข้ารหัสไบนารีของ JSON ทุกแบบก็เป็นลักษณะนี้ จึงไม่ชัดเจนว่าพื้นที่ที่ลดลงนิดเดียว (และ parse ที่อาจเร็วขึ้นเล็กน้อย) คุ้มกับการที่คนอ่านไม่ออกหรือไม่

ในหัวข้อถัดไปเราจะเห็นว่าทำได้ดีกว่านี้มาก โดยเข้ารหัสเรคคอร์ดเดียวกันได้ในราวครึ่งหนึ่งของจำนวนไบต์นี้

Protocol Buffers

Protocol Buffers (protobuf) คือ library เข้ารหัสแบบไบนารีที่ Google พัฒนาขึ้น คล้ายกับ Apache Thrift ซึ่งเดิมพัฒนาโดย Facebook [ 14 ] เนื้อหาส่วนใหญ่ในหัวข้อนี้ที่พูดถึง Protocol Buffers จึงใช้ได้กับ Thrift เช่นกัน

Protocol Buffers ต้องมี schema สำหรับข้อมูลทุกชิ้นที่เข้ารหัส ในการเข้ารหัสข้อมูลใน ตัวอย่างที่ 5-2 คุณจะอธิบาย schema ด้วย Interface Definition Language (IDL) ของ Protocol Buffers ดังนี้:

syntax = "proto3";

message Person {
    string user_name = 1;
    int64 favorite_number = 2;
    repeated string interests = 3;
}

Protocol Buffers มาพร้อมเครื่องมือสร้างโค้ด (code generator) ที่รับ schema definition อย่างข้างต้น แล้วสร้างคลาสที่ implement schema นั้นในภาษาโปรแกรมต่าง ๆ โค้ดแอปพลิเคชันเรียกใช้โค้ดที่สร้างขึ้นนี้เพื่อเข้ารหัสหรือถอดรหัสเรคคอร์ดตาม schema ได้ ภาษา schema ของมันเรียบง่ายมากเมื่อเทียบกับ JSON Schema คือกำหนดเพียงฟิลด์ของแต่ละเรคคอร์ดกับชนิดของฟิลด์ แต่ไม่รองรับข้อจำกัดอื่น ๆ เกี่ยวกับค่าที่เป็นไปได้ของฟิลด์

การเข้ารหัส ตัวอย่างที่ 5-2 ด้วย Protocol Buffers ใช้ 33 ไบต์ ดังที่แสดงใน รูปที่ 5-3 [ 15 ]. เช่นเดียวกับ รูปที่ 5-2 แต่ละฟิลด์มีตัวบอกชนิด (ว่าเป็น string, integer ฯลฯ) และมีตัวบอกความยาวเมื่อจำเป็น (เช่น ความยาวของสตริง) สตริงในข้อมูล ( Martin , daydreaming , hacking ) ถูกเข้ารหัสเป็น ASCII (หรือจะให้ตรงคือ UTF-8) เช่นเดิม

Diagram illustrating an example record encoded using Protocol Buffers, showing a 33-byte sequence breakdown with field tags, data types, and lengths for strings like "Martin," "daydreaming," and "hacking."

รูปที่ 5-3. เรคคอร์ดของเราเมื่อเข้ารหัสด้วย Protocol Buffers

แตกต่างจาก รูปที่ 5-2 ตัวอย่างนี้ไม่มีชื่อฟิลด์ ( userName , favoriteNumber , interests ) แต่ข้อมูลที่เข้ารหัสมี Field Tags ซึ่งเป็นตัวเลข ( 1 , 2 และ 3 ) ตามที่ปรากฏใน schema definition field tag เป็นเหมือนนามแฝงของฟิลด์ คือวิธีที่กระชับในการบอกว่ากำลังพูดถึงฟิลด์ไหน โดยไม่ต้องเขียนชื่อฟิลด์ออกมาทั้งหมด

จะเห็นว่า Protocol Buffers ประหยัดพื้นที่ได้มากขึ้นอีก โดยบรรจุชนิดของฟิลด์และหมายเลข tag ไว้ในไบต์เดียว และใช้ variable-length integer (จำนวนเต็มความยาวผันแปร) ตัวเลข 1337 เข้ารหัสได้ในสองไบต์ โดยบิตบนสุดของแต่ละไบต์บอกว่ายังมีไบต์ถัดไปอีกหรือไม่ (7 บิตต่ำสุดจะถูกเก็บไว้ในไบต์แรก เพื่อให้ประกอบจำนวนเต็มกลับคืนได้ง่ายตอนอ่านไบต์) นั่นหมายความว่าตัวเลขตั้งแต่ –64 ถึง 63 เข้ารหัสในหนึ่งไบต์ ตัวเลขตั้งแต่ –8,192 ถึง 8,191 เข้ารหัสในสองไบต์ และต่อ ๆ ไป ตัวเลขที่ใหญ่ขึ้นก็ใช้จำนวนไบต์มากขึ้น

Protocol Buffers ไม่มีชนิดข้อมูล list หรือ array โดยเฉพาะ แต่ repeated modifier บนฟิลด์ interests บอกว่าฟิลด์นั้นเก็บค่าได้หลายค่า ในการเข้ารหัสแบบไบนารี สมาชิกของ list แสดงด้วย field tag เดิมที่ปรากฏซ้ำในเรคคอร์ดเดียวกัน

Field tags and schema evolution (field tag และการพัฒนา schema)

ก่อนหน้านี้เราบอกว่า schema ย่อมต้องเปลี่ยนไปตามกาลเวลาอย่างหลีกเลี่ยงไม่ได้ เราเรียกสิ่งนี้ว่า Schema Evolution . แล้ว Protocol Buffers รับมือกับการเปลี่ยน schema โดยยังรักษา backward และ forward compatibility ได้อย่างไร?

จากตัวอย่างจะเห็นว่าเรคคอร์ดที่เข้ารหัสก็คือฟิลด์ที่เข้ารหัสแล้วต่อกัน แต่ละฟิลด์ถูกระบุด้วยหมายเลข tag (ตัวเลข 1 , 2 , 3 ใน schema ตัวอย่าง) และมีตัวบอกชนิดข้อมูล (เช่น string หรือ integer) ถ้าไม่ได้ตั้งค่าฟิลด์ใด ฟิลด์นั้นก็จะไม่ปรากฏในเรคคอร์ดที่เข้ารหัส จึงเห็นได้ว่า field tag สำคัญมากต่อความหมายของข้อมูลที่เข้ารหัส คุณเปลี่ยนชื่อฟิลด์ใน schema ได้ เพราะข้อมูลที่เข้ารหัสไม่เคยอ้างถึงชื่อฟิลด์ แต่เปลี่ยน tag ของฟิลด์ไม่ได้ เพราะจะทำให้ข้อมูลที่เข้ารหัสไว้แล้วทั้งหมด ใช้ไม่ได้

คุณเพิ่มฟิลด์ใหม่ลงใน schema ได้ โดยให้หมายเลข tag ใหม่กับฟิลด์นั้น ถ้าโค้ดเก่า (ที่ไม่รู้จักหมายเลข tag ใหม่) พยายามอ่านข้อมูลที่โค้ดใหม่เขียน ซึ่งมีฟิลด์ใหม่ที่ใช้หมายเลข tag ที่มันไม่รู้จัก มันก็ข้ามฟิลด์นั้นไปได้ ตัวบอกชนิดข้อมูลช่วยให้ parser รู้ว่าต้องข้ามกี่ไบต์ และยังเก็บฟิลด์ที่ไม่รู้จักไว้ได้ ซึ่งหลีกเลี่ยงปัญหาใน รูปที่ 5-1 วิธีนี้รักษา forward compatibility ไว้ได้: โค้ดเก่าอ่านเรคคอร์ดที่โค้ดใหม่เขียนได้

แล้ว backward compatibility ล่ะ? ตราบที่แต่ละฟิลด์มีหมายเลข tag ที่ไม่ซ้ำกัน โค้ดใหม่ก็อ่านข้อมูลเก่าได้เสมอ เพราะหมายเลข tag ยังมีความหมายเหมือนเดิม ถ้า schema ใหม่เพิ่มฟิลด์เข้ามา แล้วคุณอ่านข้อมูลเก่าที่ยังไม่มีฟิลด์นั้น ฟิลด์จะถูกเติมด้วยค่าเริ่มต้น (default value) เช่น สตริงว่างถ้าฟิลด์เป็น string หรือ 0 ถ้าเป็นตัวเลข

การลบฟิลด์คล้ายกับการเพิ่มฟิลด์ แต่ backward และ forward compatibility สลับบทบาทกัน คุณใช้หมายเลข tag เดิมซ้ำไม่ได้ เพราะอาจยังมีข้อมูลที่เขียนไว้ที่ไหนสักแห่งซึ่งใช้หมายเลข tag เก่านั้นอยู่ และโค้ดใหม่ต้องข้ามฟิลด์นั้นไป หมายเลข tag ที่เคยใช้แล้วสามารถ reserve ไว้ใน schema definition เพื่อไม่ให้ถูกลืม

แล้วการเปลี่ยนชนิดข้อมูลของฟิลด์ล่ะ? ทำได้กับบางชนิด (ดูรายละเอียดในเอกสาร) แต่มีความเสี่ยงที่ค่าจะถูกตัดทอน เช่น สมมติว่าคุณเปลี่ยนจำนวนเต็ม 32 บิตเป็น 64 บิต โค้ดใหม่อ่านข้อมูลที่โค้ดเก่าเขียนได้ง่าย เพราะ parser เติมบิตที่ขาดด้วย 0 ได้ แต่ถ้าโค้ดเก่าอ่านข้อมูลที่โค้ดใหม่เขียน โค้ดเก่ายังใช้ตัวแปร 32 บิตเก็บค่าอยู่ ถ้าค่าที่ถอดรหัสได้เป็น 64 บิตไม่พอดีกับ 32 บิต ค่านั้นก็จะถูกตัดทอน

Avro

Apache Avro เป็นรูปแบบการเข้ารหัสแบบไบนารีอีกแบบ ที่ต่างจาก Protocol Buffers ในจุดที่น่าสนใจ เริ่มต้นในปี 2009 เป็นโปรเจกต์ย่อยของ Hadoop เพราะ Protocol Buffers ไม่เหมาะกับ use case ของ Hadoop [ 16 ]

Avro ก็ใช้ schema ระบุโครงสร้างของข้อมูลที่เข้ารหัสเช่นกัน และมีภาษา schema สองแบบ: แบบหนึ่ง (Avro IDL) ออกแบบมาให้คนแก้ไข และอีกแบบ (อิงตาม JSON) ที่เครื่องอ่านได้ง่ายกว่า เช่นเดียวกับ Protocol Buffers ภาษา schema ระบุเพียงฟิลด์กับชนิดของฟิลด์ และไม่รองรับกฎ validation ที่ซับซ้อนอย่างใน JSON Schema

เมื่อเขียนด้วย Avro IDL schema ตัวอย่างของเราจะหน้าตาประมาณนี้:

record Person {
    string               userName;
    union { null, long } favoriteNumber = null;
    array<string>        interests;
}

schema เดียวกันนี้ในรูปแบบ JSON เทียบเท่าคือ:

{
    "type": "record",
    "name": "Person",
    "fields": [
        {"name": "userName",       "type": "string"},
        {"name": "favoriteNumber", "type": ["null", "long"], "default": null},
        {"name": "interests",      "type": {"type": "array", "items": "string"}}
    ]
}

สังเกตว่า schema ไม่มีหมายเลข tag ถ้าเราเข้ารหัสเรคคอร์ดของเรา ( ตัวอย่างที่ 5-2 ) ด้วย schema นี้ ผลการเข้ารหัสแบบไบนารีของ Avro ยาวเพียง 32 ไบต์ ซึ่งกะทัดรัดที่สุดในบรรดาการเข้ารหัสทั้งหมดที่เราเห็นมา รายละเอียดของลำดับไบต์ที่เข้ารหัสแสดงใน รูปที่ 5-4

ถ้าลองดูลำดับไบต์ จะเห็นว่าไม่มีอะไรระบุฟิลด์หรือชนิดข้อมูลเลย การเข้ารหัสเป็นแค่ค่าที่ต่อกันไป สตริงคือตัวบอกความยาวตามด้วยไบต์ UTF-8 แต่ไม่มีอะไรในข้อมูลที่เข้ารหัสบอกว่ามันเป็นสตริง มันอาจเป็นจำนวนเต็มหรืออย่างอื่นก็ได้ จำนวนเต็มเข้ารหัสด้วย variable-length encoding

ในการ parse ข้อมูลไบนารี คุณต้องไล่ไปตามฟิลด์ตามลำดับที่ปรากฏใน schema และใช้ schema บอกชนิดข้อมูลของแต่ละฟิลด์ นั่นหมายความว่าจะถอดรหัสข้อมูลไบนารีได้ถูกต้องก็ต่อเมื่อโค้ดที่อ่านใช้ schema เดียวกันทุกประการ กับโค้ดที่เขียนข้อมูล ถ้า schema ของ reader และ writer ไม่ตรงกันแม้เพียงนิดเดียว ข้อมูลที่ถอดรหัสก็จะผิด

Diagram demonstrating how a byte sequence is encoded and decoded in Avro, showing a breakdown of strings, integers, and array elements according to their variable-length encoding format.

รูปที่ 5-4. เรคคอร์ดของเราเมื่อเข้ารหัสด้วย Avro

แล้ว Avro รองรับ schema evolution อย่างไร?

The writer's schema and the reader's schema (writer's schema และ reader's schema)

เมื่อ แอปพลิเคชันต้องการเข้ารหัสข้อมูล (เพื่อเขียนลงไฟล์หรือฐานข้อมูล ส่งผ่านเครือข่าย ฯลฯ) มันจะใช้ schema เวอร์ชันที่มันรู้จัก เช่น schema ที่คอมไพล์ไว้ในแอปพลิเคชัน สิ่งนี้เรียกว่า Writer's Schema

ในการถอดรหัสข้อมูล (อ่านจากไฟล์หรือฐานข้อมูล รับจากเครือข่าย ฯลฯ) แอปพลิเคชันใช้ schema สองแบบ: writer's schema ซึ่งเป็น schema เดียวกับที่ใช้เข้ารหัส และ Reader's Schema ซึ่งอาจต่างออกไป ดังที่แสดงใน รูปที่ 5-5 reader's schema กำหนดฟิลด์ของแต่ละเรคคอร์ดที่โค้ดแอปพลิเคชันคาดหวัง และชนิดของฟิลด์

ถ้า reader's schema กับ writer's schema เหมือนกัน การถอดรหัสก็ง่าย ถ้าต่างกัน Avro จะ resolve ความต่างโดยเทียบสอง schema แล้วแปลงข้อมูลจาก writer's schema ไปเป็น reader's schema

Diagram comparing Protocol Buffers and Avro encoding and decoding processes, highlighting Avro's use of both writer's and reader's schemas for decoding.

รูปที่ 5-5. ใน Protocol Buffers ตัวเข้ารหัสและตัวถอดรหัสใช้ schema คนละเวอร์ชันกันได้ ส่วนใน Avro การถอดรหัสใช้ schema สองแบบ: writer's schema ต้องเหมือนกับ schema ที่ใช้เข้ารหัส แต่ reader's schema จะเป็นเวอร์ชันเก่าหรือใหม่กว่าก็ได้

ข้อกำหนดของ Avro [ 17 , 18 ] ระบุไว้ชัดเจนว่าการ resolve นี้ทำงานอย่างไร ดังที่แสดงใน รูปที่ 5-6 ไม่เป็นปัญหาถ้า writer's schema กับ reader's schema เรียงฟิลด์ต่างกัน เพราะการ resolve จะจับคู่ฟิลด์ตามชื่อ ถ้าโค้ดที่อ่านพบฟิลด์ที่มีใน writer's schema แต่ไม่มีใน reader's schema ฟิลด์นั้นจะถูกข้าม ถ้าโค้ดที่อ่านคาดหวังฟิลด์หนึ่ง แต่ writer's schema ไม่มีฟิลด์ชื่อนั้น ฟิลด์จะถูกเติมด้วยค่าเริ่มต้นที่ประกาศไว้ใน reader's schema

Diagram showing the resolution of differences between a writer's schema and a reader's schema for a Person record, highlighting matched and unmatched fields.

รูปที่ 5-6. Avro reader กำลัง resolve ความต่างระหว่าง writer's schema และ reader's schema

Schema evolution rules (กฎของ schema evolution)

ใน Avro forward compatibility หมายความว่า writer ใช้ schema เวอร์ชันที่ใหม่กว่า reader ได้ ส่วน backward compatibility หมายความว่า writer ใช้ schema เวอร์ชันที่เก่ากว่า reader ได้

เพื่อรักษาความเข้ากันได้ คุณเพิ่มหรือลบได้เฉพาะฟิลด์ที่มีค่าเริ่มต้น (default value) เท่านั้น (เช่นฟิลด์ favoriteNumber ใน Avro schema ของเรา) เช่น สมมติว่าคุณเพิ่มฟิลด์ที่มีค่าเริ่มต้น ฟิลด์ใหม่นี้มีใน schema ใหม่แต่ไม่มีใน schema เก่า เมื่อ reader ที่ใช้ schema ใหม่อ่านเรคคอร์ดที่เขียนด้วย schema เก่า ก็จะเติมค่าเริ่มต้นให้ฟิลด์ที่ขาดไป

ถ้าคุณเพิ่มฟิลด์ที่ไม่มีค่าเริ่มต้น reader ใหม่จะอ่านข้อมูลที่ writer เก่าเขียนไม่ได้ จึงทำลาย backward compatibility ถ้าคุณลบฟิลด์ที่ไม่มีค่าเริ่มต้น reader เก่าจะอ่านข้อมูลที่ writer ใหม่เขียนไม่ได้ จึงทำลาย forward compatibility

ในภาษาโปรแกรมบางภาษา null เป็นค่าเริ่มต้นที่ใช้ได้กับตัวแปรทุกตัว แต่ใน Avro ไม่เป็นเช่นนั้น: ถ้าต้องการให้ฟิลด์เป็น null ได้ คุณต้องใช้ union type ตัวอย่างเช่น union { null, long, string } field; ระบุว่า field เป็นตัวเลข สตริง หรือ null ได้ คุณสามารถใช้ null เป็นค่าเริ่มต้นได้ก็ต่อเมื่อเป็น branch แรกของ union เท่านั้น วิธีนี้ verbose กว่าการให้ทุกอย่างเป็น nullable โดยค่าเริ่มต้นอยู่บ้าง แต่ช่วยป้องกัน bug ได้ เพราะบอกชัดเจนว่าอะไรเป็น null ได้ [ 19 ]

เปลี่ยนชนิดข้อมูลของฟิลด์ได้ ตราบที่ Avro แปลงชนิดนั้นได้ การเปลี่ยนชื่อฟิลด์ก็ทำได้แต่ค่อนข้างยุ่งยาก reader's schema มี alias สำหรับชื่อฟิลด์ได้ จึงจับคู่ชื่อฟิลด์ใน writer's schema เก่ากับ alias ได้ นั่นหมายความว่าการเปลี่ยนชื่อฟิลด์เป็น backward compatible แต่ไม่ใช่ forward compatible ในทำนองเดียวกัน การเพิ่ม branch ให้ union type ก็เป็น backward compatible แต่ไม่ใช่ forward compatible

But what is the writer's schema? (แล้ว writer's schema คืออะไร?)

เรายังข้ามคำถามสำคัญไปข้อหนึ่ง: reader รู้ได้อย่างไรว่าข้อมูลชิ้นหนึ่งถูกเข้ารหัสด้วย schema ใด? เราใส่ schema ทั้งหมดไว้ในทุกเรคคอร์ดไม่ได้ เพราะ schema อาจใหญ่กว่าข้อมูลที่เข้ารหัสมาก ซึ่งจะทำให้พื้นที่ที่ประหยัดได้จากการเข้ารหัสแบบไบนารีหายไปหมด

คำตอบขึ้นอยู่กับบริบทที่ใช้ Avro ตัวอย่างเช่น:

Large file with lots of records (ไฟล์ขนาดใหญ่ที่มีเรคคอร์ดจำนวนมาก)

การใช้ Avro ที่พบบ่อยคือเก็บไฟล์ขนาดใหญ่ที่มีเรคคอร์ดหลายล้านรายการ ซึ่งเข้ารหัสด้วย schema เดียวกันทั้งหมด (เราจะพูดถึงสถานการณ์แบบนี้ใน บทที่ 11 ) ในกรณีนี้ writer ของไฟล์ใส่ schema ไว้ครั้งเดียวที่ต้นไฟล์ได้ Avro กำหนดรูปแบบไฟล์ (object container file) ไว้สำหรับทำเช่นนี้

Database with individually written records (ฐานข้อมูลที่มีเรคคอร์ดที่ถูกเขียนทีละรายการ)

ในฐานข้อมูล เรคคอร์ดต่าง ๆ อาจถูกเขียนต่างเวลากันด้วย schema ต่างกัน คุณจึงสมมติไม่ได้ว่าทุกเรคคอร์ดใช้ schema เดียวกัน วิธีที่ง่ายที่สุดคือใส่หมายเลขเวอร์ชันไว้ต้นทุกเรคคอร์ดที่เข้ารหัส แล้วเก็บรายการ schema เวอร์ชันต่าง ๆ ไว้ในฐานข้อมูล reader จะดึงเรคคอร์ด แยกหมายเลขเวอร์ชันออกมา จากนั้นดึง writer's schema ของเวอร์ชันนั้นจากฐานข้อมูล แล้วใช้ schema นั้นถอดรหัสส่วนที่เหลือของเรคคอร์ด ตัวอย่างเช่น Schema Registry ของ Confluent สำหรับ Apache Kafka [ 20 ] และ Espresso ของ LinkedIn [ 21 ] ทำงานในลักษณะนี้

Sending records over a network connection (การส่งเรคคอร์ดผ่านการเชื่อมต่อเครือข่าย)

เมื่อสอง process สื่อสารกันผ่านการเชื่อมต่อเครือข่ายแบบสองทิศทาง ก็เจรจาเวอร์ชันของ schema ตอนตั้งค่าการเชื่อมต่อ แล้วใช้ schema นั้นตลอดอายุการเชื่อมต่อ RPC protocol ของ Avro (ดู "Dataflow Through Services: REST and RPC" ) ทำงานในลักษณะนี้

ฐานข้อมูลของ schema เวอร์ชันต่าง ๆ มีประโยชน์ไม่ว่ากรณีใด เพราะทำหน้าที่เป็นเอกสาร และช่วยให้คุณตรวจสอบความเข้ากันได้ของ schema ได้ [ 22 ] จะใช้จำนวนเต็มที่เพิ่มทีละหนึ่ง หรือ hash ของ schema เป็นหมายเลขเวอร์ชันก็ได้

Dynamically generated schemas (schema ที่สร้างแบบไดนามิก)

ข้อดีอย่างหนึ่งของ Avro เมื่อเทียบกับ Protocol Buffers คือ schema ไม่มีหมายเลข tag แต่ทำไมเรื่องนี้ถึงสำคัญ? การเก็บตัวเลขไม่กี่ตัวไว้ใน schema มีปัญหาอะไร?

ความต่างคือ Avro เหมาะกับ schema ที่สร้างแบบไดนามิก มากกว่า เช่น สมมติว่าคุณมีฐานข้อมูลเชิงสัมพันธ์ที่อยากจะ dump เนื้อหาลงไฟล์ และอยากใช้รูปแบบไบนารีเพื่อเลี่ยงปัญหาของรูปแบบข้อความ (JSON, CSV, XML) ที่กล่าวถึงก่อนหน้านี้ ถ้าใช้ Avro คุณสร้าง Avro schema (ในรูปแบบ JSON ที่เราเห็นก่อนหน้านี้) จาก relational schema ได้อย่างง่ายดาย แล้วเข้ารหัสเนื้อหาของฐานข้อมูลด้วย schema นั้น และ dump ทั้งหมดลงใน Avro object container file [ 23 ] คุณสร้าง record schema สำหรับแต่ละตารางในฐานข้อมูล โดยแต่ละคอลัมน์จะกลายเป็นฟิลด์ในเรคคอร์ดนั้น และชื่อคอลัมน์ในฐานข้อมูลจะ map ไปเป็นชื่อฟิลด์ใน Avro

ทีนี้ ถ้า schema ของฐานข้อมูลเปลี่ยน (เช่น ตารางเพิ่มคอลัมน์หนึ่งและลบอีกคอลัมน์หนึ่ง) คุณก็แค่สร้าง Avro schema ใหม่จาก schema ฐานข้อมูลที่อัปเดตแล้ว และ export ข้อมูลด้วย Avro schema ใหม่นั้น กระบวนการ export ไม่ต้องสนใจการเปลี่ยน schema เพราะสร้าง schema ใหม่ได้ทุกครั้งที่ทำงาน ใครก็ตามที่อ่านไฟล์ข้อมูลใหม่จะเห็นว่าฟิลด์ของเรคคอร์ดเปลี่ยนไป แต่เพราะฟิลด์ถูกระบุด้วยชื่อ writer's schema ที่อัปเดตแล้วจึงยังจับคู่กับ reader's schema เก่าได้

ในทางกลับกัน ถ้าใช้ Protocol Buffers เพื่อจุดประสงค์นี้ field tag มักต้องกำหนดด้วยมือ ทุกครั้งที่ schema ของฐานข้อมูลเปลี่ยน ผู้ดูแลระบบต้องอัปเดต mapping จากชื่อคอลัมน์ไปเป็น field tag เอง (อาจทำให้อัตโนมัติได้ แต่ตัวสร้าง schema ต้องระวังมากไม่ให้ใช้ field tag ที่เคยใช้ไปแล้ว) schema ที่สร้างแบบไดนามิกเช่นนี้ไม่ใช่เป้าหมายในการออกแบบของ Protocol Buffers แต่เป็นเป้าหมายของ Avro

The Merits of Schemas (ข้อดีของ schema)

อย่างที่เห็น Protocol Buffers และ Avro ต่างใช้ schema อธิบายรูปแบบการเข้ารหัสแบบไบนารี ภาษา schema ของทั้งสองง่ายกว่า XML Schema หรือ JSON Schema มาก ซึ่งรองรับกฎ validation ที่ละเอียดกว่า (เช่น "ค่าสตริงของฟิลด์นี้ต้องตรงกับ regular expression นี้" หรือ "ค่าจำนวนเต็มของฟิลด์นี้ต้องอยู่ระหว่าง 0 ถึง 100") และเพราะ Protocol Buffers กับ Avro implement และใช้งานง่าย จึงมีภาษาโปรแกรมรองรับค่อนข้างหลากหลาย

แนวคิดเบื้องหลังการเข้ารหัสเหล่านี้ไม่ใช่เรื่องใหม่ เช่น มีหลายอย่างเหมือนกับ ASN.1 ซึ่งเป็นภาษา schema definition ที่ถูกกำหนดเป็นมาตรฐานครั้งแรกในปี 1984 [ 24 , 25 ] ใช้นิยาม network protocol ต่าง ๆ และการเข้ารหัสแบบไบนารีของมัน (DER) ก็ยังใช้เข้ารหัส SSL certificate (X.509) เป็นต้น [ 26 ] ASN.1 รองรับ schema evolution ด้วยหมายเลข tag คล้ายกับ Protocol Buffers [ 27 ] แต่ซับซ้อนมากและมีเอกสารไม่ดี ดังนั้น ASN.1 จึงอาจไม่ใช่ตัวเลือกที่ดีสำหรับแอปพลิเคชันใหม่

ระบบข้อมูลหลายระบบก็ใช้การเข้ารหัสแบบไบนารีของตัวเองสำหรับข้อมูลของมันเช่นกัน เช่น ฐานข้อมูลเชิงสัมพันธ์ส่วนใหญ่มี network protocol ที่ใช้ส่ง query ไปยังฐานข้อมูลและรับ response กลับมา โดยทั่วไป protocol เหล่านี้เฉพาะกับฐานข้อมูลแต่ละตัว และผู้พัฒนาฐานข้อมูลจะมี driver (เช่นผ่าน ODBC หรือ JDBC API) ที่ถอดรหัส response จาก network protocol ของฐานข้อมูลเป็นโครงสร้างข้อมูลในหน่วยความจำ

จะเห็นได้ว่าแม้รูปแบบข้อมูลข้อความอย่าง JSON, XML และ CSV จะแพร่หลาย แต่การเข้ารหัสแบบไบนารีที่อิงตาม schema ก็เป็นทางเลือกที่ใช้ได้จริงเช่นกัน และมีข้อดีหลายประการ:

  • กะทัดรัดกว่า "binary JSON" หลายรูปแบบมาก เพราะตัดชื่อฟิลด์ออกจากข้อมูลที่เข้ารหัสได้

  • schema เป็นเอกสารที่มีค่า และเพราะต้องใช้ schema ในการถอดรหัส คุณจึงมั่นใจได้ว่ามันทันสมัยอยู่เสมอ (ส่วนเอกสารที่เขียนดูแลด้วยมืออาจคลาดเคลื่อนจากความเป็นจริงได้ง่าย)

  • การเก็บฐานข้อมูลของ schema ไว้ช่วยให้ตรวจสอบ forward และ backward compatibility ของการเปลี่ยน schema ได้ก่อน deploy อะไรก็ตาม

  • สำหรับผู้ใช้ภาษาแบบ statically typed การสร้างโค้ดจาก schema มีประโยชน์ เพราะทำให้ตรวจสอบชนิดข้อมูลตอนคอมไพล์ได้

โดยสรุป schema evolution ให้ความยืดหยุ่นแบบเดียวกับฐานข้อมูล JSON แบบ schemaless/schema-on-read (ดู "Schema flexibility in the document model" ) แต่ให้หลักประกันเกี่ยวกับข้อมูลและเครื่องมือที่ดีกว่า อย่างไรก็ตาม ควรเก็บจำนวนรูปแบบ schema ที่ใช้งานพร้อมกัน ให้น้อยที่สุดเพื่อให้ดูแลระบบได้ง่าย

Modes of Dataflow (รูปแบบการไหลของข้อมูล)

ตอนต้นบทเราบอกว่า เมื่อใดที่ต้องส่งข้อมูลไปยัง process อื่นที่ไม่ได้ใช้หน่วยความจำร่วมกัน เช่น ส่งผ่านเครือข่ายหรือเขียนลงไฟล์ คุณต้องเข้ารหัสข้อมูลเป็นลำดับไบต์ จากนั้นเราก็พูดถึงรูปแบบการเข้ารหัสต่าง ๆ ที่ใช้ทำเช่นนี้

เรายังพูดถึง forward และ backward compatibility ซึ่งสำคัญต่อ evolvability (ทำให้เปลี่ยนแปลงได้ง่ายขึ้น เพราะอัปเกรดแต่ละส่วนของระบบได้อย่างอิสระ ไม่ต้องเปลี่ยนทุกอย่างพร้อมกัน) compatibility คือความสัมพันธ์ระหว่าง process หนึ่งที่เข้ารหัสข้อมูลกับอีก process หนึ่งที่ถอดรหัส

แนวคิดนี้ค่อนข้างเป็นนามธรรม เพราะข้อมูลไหลจาก process หนึ่งไปอีก process หนึ่งได้หลายวิธี ใครเป็นคนเข้ารหัส และใครเป็นคนถอดรหัส? ในส่วนที่เหลือของบทนี้ เราจะดูวิธีที่พบบ่อยที่สุดบางวิธีที่ข้อมูลไหลระหว่าง process ได้แก่ ผ่านฐานข้อมูล ผ่านการเรียก service, ผ่าน workflow engine และผ่าน message แบบอะซิงโครนัส

Dataflow Through Databases (การไหลของข้อมูลผ่านฐานข้อมูล)

ในฐานข้อมูล process ที่เขียนจะเข้ารหัสข้อมูล และ process ที่อ่านจะถอดรหัส อาจมี process เดียวที่เข้าถึงฐานข้อมูล ซึ่งในกรณีนี้ reader ก็เป็นเพียงเวอร์ชันใหม่กว่าของ process เดิม ในสถานการณ์นี้ คุณมองการเก็บบางสิ่งลงฐานข้อมูลได้ว่าเป็น การส่งข้อความถึงตัวคุณในอนาคต . ตรงนี้จำเป็นต้องมี backward compatibility อย่างชัดเจน ไม่เช่นนั้นตัวคุณในอนาคตจะถอดรหัสสิ่งที่เขียนไว้ก่อนหน้านี้ไม่ได้

โดยทั่วไป หลาย process เข้าถึงฐานข้อมูลพร้อมกันเป็นเรื่องปกติ process เหล่านี้อาจเป็นแอปพลิเคชันหรือ service คนละตัว หรือเป็นแค่หลาย instance ของ service เดียวกัน (ที่รันแบบขนานเพื่อ scalability หรือ fault tolerance) ไม่ว่ากรณีใด ในสภาพแวดล้อมแบบนี้ มีโอกาสที่บาง process ที่เข้าถึงฐานข้อมูลจะรันโค้ดใหม่กว่า และบาง process รันโค้ดเก่ากว่า เช่น เพราะกำลัง rolling upgrade เวอร์ชันใหม่อยู่ ทำให้บาง instance อัปเดตแล้วแต่บางตัวยังไม่ได้อัปเดต

นั่นหมายความว่าค่าในฐานข้อมูลอาจถูกเขียนโดยโค้ดเวอร์ชัน ใหม่กว่า และต่อมาถูกอ่านโดยโค้ดเวอร์ชัน เก่ากว่า ที่ยังทำงานอยู่ ฐานข้อมูลจึงมักต้องมี forward compatibility ด้วยเช่นกัน

Different values written at different times (ค่าที่เขียนต่างเวลากัน)

โดยทั่วไปฐานข้อมูลอนุญาตให้อัปเดตค่าใดก็ได้ตลอดเวลา ภายในฐานข้อมูลเดียว คุณอาจมีบางค่าที่เพิ่งเขียนเมื่อห้ามิลลิวินาทีก่อน และค่าอื่นที่เขียนไว้เมื่อห้าปีก่อน

เมื่อ deploy แอปพลิเคชันเวอร์ชันใหม่ (อย่างน้อยฝั่ง server) คุณอาจแทนที่เวอร์ชันเก่าทั้งหมดด้วยเวอร์ชันใหม่ได้ภายในไม่กี่นาที แต่เนื้อหาในฐานข้อมูลไม่เป็นเช่นนั้น ข้อมูลอายุห้าปียังคงอยู่ในการเข้ารหัสดั้งเดิม เว้นแต่คุณจะเขียนทับมันไปแล้วตั้งแต่นั้น ข้อสังเกตนี้บางครั้งสรุปสั้น ๆ ว่า ข้อมูลมีอายุยืนกว่าโค้ด (data outlives code)

แม้ว่าการเขียน ( Migrating ) ข้อมูลไปเป็น schema ใหม่ทำได้แน่นอน แต่มีต้นทุนสูงสำหรับชุดข้อมูลขนาดใหญ่ ฐานข้อมูลส่วนใหญ่จึงเลื่อนการทำนี้ออกไป โดยทำแบบอะซิงโครนัสเท่าที่ทำได้ (best-effort) เช่น LSM-tree storage engine (ดู "Log-Structured Storage" ) จะเขียนข้อมูลใหม่ด้วยรูปแบบล่าสุดระหว่าง compaction ฐานข้อมูลเชิงสัมพันธ์ส่วนใหญ่ก็ให้เปลี่ยน schema แบบง่าย ๆ ได้ เช่น เพิ่มคอลัมน์ใหม่ที่มีค่าเริ่มต้น null โดยไม่ต้องเขียนข้อมูลที่มีอยู่ใหม่ เมื่ออ่านแถวเก่า ฐานข้อมูลจะเติม null สำหรับคอลัมน์ที่ไม่มีในข้อมูลที่เข้ารหัสบนดิสก์ schema evolution จึงทำให้ทั้งฐานข้อมูลดูเหมือนถูกเข้ารหัสด้วย schema เดียว แม้ storage เบื้องหลังอาจมีเรคคอร์ดที่เข้ารหัสด้วย schema เวอร์ชันเก่าหลายรุ่นก็ตาม

การเปลี่ยน schema ที่ซับซ้อนกว่านั้น เช่น เปลี่ยน attribute ที่เก็บค่าเดียวให้เก็บได้หลายค่า หรือย้ายข้อมูลบางส่วนไปไว้ในตารางแยก ยังต้องเขียนข้อมูลใหม่ ซึ่งมักต้องทำในระดับแอปพลิเคชัน [ 28 ] การรักษา forward และ backward compatibility ระหว่างการย้ายข้อมูลลักษณะนี้ยังเป็นปัญหาวิจัยที่เปิดอยู่ [ 29 ]

Archival storage (การจัดเก็บถาวร)

บางครั้งคุณอาจ snapshot ฐานข้อมูลเป็นระยะ เช่น เพื่อสำรองข้อมูล หรือเพื่อโหลดเข้า data warehouse (ดู "Data Warehousing" ) ในกรณีนี้ data dump มักถูกเข้ารหัสด้วย schema ล่าสุด แม้ข้อมูลที่เข้ารหัสอยู่ในฐานข้อมูลต้นทางจะมี schema หลายเวอร์ชันจากหลายยุคปนกันอยู่ก็ตาม ในเมื่อคุณกำลังคัดลอกข้อมูลอยู่แล้ว ก็ควรเข้ารหัสสำเนาให้เป็นรูปแบบเดียวกันหมด

เนื่องจาก data dump เขียนเพียงครั้งเดียวและหลังจากนั้นไม่เปลี่ยนแปลง รูปแบบอย่าง Avro object container file จึงเหมาะสม และยังเป็นโอกาสดีที่จะเข้ารหัสข้อมูลในรูปแบบเชิงคอลัมน์ที่เหมาะกับการวิเคราะห์ เช่น Parquet (ดู "Column compression" )

ใน บทที่ 11 เราจะพูดถึงการใช้ข้อมูลที่เก็บถาวรเพิ่มเติม

Dataflow Through Services: REST and RPC (การไหลของข้อมูลผ่าน service: REST และ RPC)

เมื่อมี process ที่ต้องสื่อสารกันผ่านเครือข่าย คุณจัดการสื่อสารนั้นได้หลายวิธี รูปแบบที่พบบ่อยที่สุดคือมีสองบทบาท: client และ server server เปิด API ให้ใช้ผ่านเครือข่าย และ client เชื่อมต่อกับ server เพื่อส่ง request ไปยัง API นั้นได้ API ที่ server เปิดให้ใช้นี้เรียกว่า service

เว็บทำงานแบบนี้: client (web browser) ส่ง request ไปยัง web server โดยส่ง GET request เพื่อดาวน์โหลด HTML, CSS, JavaScript, รูปภาพ ฯลฯ และส่ง POST request เพื่อส่งข้อมูลไปยัง server API ประกอบด้วยชุด protocol และรูปแบบข้อมูลมาตรฐาน (HTTP, URL, SSL/TLS, HTML ฯลฯ) เพราะ web browser, web server และผู้สร้างเว็บไซต์ส่วนใหญ่ตกลงยึดมาตรฐานเหล่านี้ร่วมกัน คุณจึงใช้ web browser ตัวไหนก็ได้เข้าเว็บไซต์ไหนก็ได้ (อย่างน้อยในทางทฤษฎี!)

web browser ไม่ใช่ client ประเภทเดียว เช่น native app ที่รันบนอุปกรณ์เคลื่อนที่และเดสก์ท็อปก็มักคุยกับ server และแอปพลิเคชัน JavaScript ฝั่ง client ที่รันใน web browser ก็ส่ง HTTP request ได้เช่นกัน ในกรณีนี้ response ของ server มักไม่ใช่ HTML ที่แสดงให้คนดู แต่เป็นข้อมูลที่เข้ารหัสไว้ให้โค้ดฝั่ง client ประมวลผลต่อได้สะดวก (ส่วนใหญ่เป็น JSON) แม้ HTTP อาจใช้เป็น transport protocol แต่ API ที่สร้างอยู่บนนั้นเป็นแบบเฉพาะของแอปพลิเคชัน และ client กับ server ต้องตกลงรายละเอียดของ API นั้นร่วมกัน

ในบางแง่ service ก็คล้ายฐานข้อมูล: ปกติแล้ว client ใช้มันส่งและ query ข้อมูลได้ แต่ฐานข้อมูลให้ query อะไรก็ได้ด้วยภาษา query ที่เราพูดถึงใน บทที่ 3 ส่วน service เปิด API เฉพาะของแอปพลิเคชันที่รับได้เฉพาะ input และให้ output ที่ business logic (โค้ดแอปพลิเคชัน) ของ service กำหนดไว้ล่วงหน้า [ 30 ] ข้อจำกัดนี้ทำให้เกิดระดับหนึ่งของ encapsulation: service กำหนดข้อจำกัดอย่างละเอียดได้ว่า client ทำอะไรได้และทำอะไรไม่ได้

เป้าหมายหลักในการออกแบบ service-oriented/microservices architecture คือทำให้แอปพลิเคชันเปลี่ยนแปลงและดูแลรักษาได้ง่ายขึ้น ด้วยการให้ service deploy และพัฒนาได้อย่างอิสระ หลักการทั่วไปคือแต่ละ service ควรเป็นของทีมเดียว และทีมนั้นควรปล่อยเวอร์ชันใหม่ของ service ได้บ่อย โดยไม่ต้องประสานงานกับทีมอื่น ดังนั้นเราควรคาดว่า server และ client ทั้งเวอร์ชันเก่าและใหม่จะทำงานพร้อมกัน และการเข้ารหัสข้อมูลที่ server กับ client ใช้ต้องเข้ากันได้ในทุกเวอร์ชันของ service API ตราบที่ API ยังเข้ากันได้ ทีมก็ปรับเปลี่ยนระบบของตัวเองได้อย่างอิสระ คุณสมบัตินี้ทำให้นักพัฒนา migrate ข้อมูลภายในของ service หรือแม้แต่ทั้งระบบได้ง่ายขึ้นมาก

Web services

เมื่อ ใช้ HTTP เป็น protocol พื้นฐานสำหรับสื่อสารกับ service จะเรียกว่า web service web service ใช้กันทั่วไปเมื่อสร้าง service-oriented หรือ microservices architecture (ที่กล่าวถึงก่อนหน้านี้ใน "Microservices and Serverless" ) ชื่อนี้อาจเรียกไม่ค่อยตรงนัก เพราะ web service ไม่ได้ใช้เฉพาะบนเว็บ แต่ใช้ในหลายบริบท เช่น:

  • แอปพลิเคชัน client ที่รันบนอุปกรณ์ของผู้ใช้ (เช่น native app บนมือถือ หรือ JavaScript web app ใน browser) ส่ง request ไปยัง service ผ่าน HTTP โดยทั่วไป request เหล่านี้ผ่านอินเทอร์เน็ตสาธารณะ

  • service หนึ่งส่ง request ไปยังอีก service ขององค์กรเดียวกัน ซึ่งมักอยู่ใน private network เดียวกัน เป็นส่วนหนึ่งของ service-oriented/microservices architecture

  • service หนึ่งส่ง request ไปยัง service ขององค์กรอื่น โดยปกติผ่านอินเทอร์เน็ต ใช้แลกเปลี่ยนข้อมูลระหว่างระบบ backend ขององค์กร กรณีนี้รวมถึง public API ที่ online service เปิดให้ใช้ เช่น ระบบประมวลผลบัตรเครดิต หรือ OAuth สำหรับเข้าถึงข้อมูลผู้ใช้ร่วมกัน

แนวคิดการออกแบบ service ที่นิยมที่สุดคือ REST ซึ่งสร้างบนหลักการของ HTTP [ 31 , 32 ] REST เน้นรูปแบบข้อมูลที่เรียบง่าย ใช้ URL ระบุ resource และใช้ความสามารถของ HTTP สำหรับควบคุม cache, authentication และทำ content type negotiation API ที่ออกแบบตามหลักการของ REST เรียกว่า RESTful

โค้ด ที่จะเรียก web service API ต้องรู้ว่าจะ query HTTP endpoint ไหน ต้องส่งข้อมูลรูปแบบใด และคาดหวังอะไรใน response แม้ service จะใช้หลักการออกแบบ RESTful แต่ client ก็ยังต้องหารายละเอียดเหล่านี้เอง นักพัฒนา service จึงมักใช้ IDL เพื่อนิยามและทำเอกสาร API endpoint กับ data model ของ service และปรับให้ทันสมัยอยู่เสมอ นักพัฒนาคนอื่นใช้ service definition นั้นดูว่าจะ query service ได้อย่างไร IDL ของ service ที่นิยมที่สุดสองแบบคือ OpenAPI (หรือเรียกว่า Swagger [ 33 ]) ใช้กับ web service ที่ส่งและรับ JSON และ Protocol Buffers ใช้กับ gRPC service

นักพัฒนามักเขียน OpenAPI service definition เป็น JSON หรือ YAML (ดู ตัวอย่างที่ 5-3 ) service definition ให้นักพัฒนากำหนด service endpoint, เอกสาร, เวอร์ชัน, data model และอื่น ๆ อีกมาก ส่วน Protocol Buffers service definition ใช้ IDL ที่เราเห็นใน "Protocol Buffers"

ตัวอย่างที่ 5-3. OpenAPI service definition ใน YAML
openapi: 3.0.0
info:
  title: Ping, Pong
  version: 1.0.0
servers:
  - url: http://localhost:8080
paths:
  /ping:
    get:
      summary: Given a ping, returns a pong message
      responses:
        '200':
          description: A pong
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Pong!

แม้จะใช้แนวคิดการออกแบบและ IDL แล้ว นักพัฒนาก็ยังต้องเขียนโค้ดที่ implement API call ของ service ตัวเอง service framework เช่น Spring Boot, FastAPI หรือ gRPC มักถูกใช้เพื่อลดงานส่วนนี้ service framework ช่วยให้นักพัฒนาโฟกัสที่การเขียน business logic ของแต่ละ API endpoint ส่วนโค้ดของ framework จัดการ routing, metrics, caching, authentication และอื่น ๆ ตัวอย่างที่ 5-4 แสดงการ implement ด้วย Python ของ service ที่กำหนดใน ตัวอย่างที่ 5-3

ตัวอย่างที่ 5-4. FastAPI service ที่ implement definition จาก ตัวอย่างที่ 5-3
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Ping, Pong", version="1.0.0")

class PongResponse(BaseModel):
    message: str = "Pong!"

@app.get("/ping", response_model=PongResponse,
         summary="Given a ping, returns a pong message")
async def ping():
    return PongResponse()

framework จำนวนมากเชื่อม service definition กับโค้ดฝั่ง server เข้าด้วยกัน ในบางกรณี เช่น Python FastAPI ที่ได้รับความนิยม เขียน server เป็นโค้ดแล้ว IDL ถูกสร้างให้อัตโนมัติ ในกรณีอื่น เช่น gRPC เขียน service definition ก่อน แล้วสร้างโครงโค้ด server ขึ้นมา ทั้งสองวิธีช่วยให้นักพัฒนาสร้าง client library และ SDK ในภาษาต่าง ๆ จาก service definition ได้ นอกจากสร้างโค้ดแล้ว เครื่องมือ IDL อย่าง Swagger ยังสร้างเอกสาร ตรวจสอบความเข้ากันได้ของการเปลี่ยน schema และมี GUI ให้นักพัฒนา query และทดสอบ service

The problems with remote procedure calls (ปัญหาของ remote procedure call)

Web service เป็นเพียงร่างล่าสุดของเทคโนโลยีสายยาวสำหรับส่ง API request ผ่านเครือข่าย ซึ่งหลายตัวถูกโหมกระแสอย่างมากแต่มีปัญหาร้ายแรง Enterprise JavaBeans (EJB) และ Java's Remote Method Invocation (RMI) ใช้ได้เฉพาะ Java ส่วน Distributed Component Object Model (DCOM) ใช้ได้เฉพาะแพลตฟอร์ม Microsoft Common Object Request Broker Architecture (CORBA) ซับซ้อนเกินไปและไม่มี backward หรือ forward compatibility [ 34 ] SOAP และ WS-* web services framework ตั้งใจให้ทำงานร่วมกันข้ามผู้ขายได้ แต่ก็ประสบปัญหาเรื่องความซับซ้อนและความเข้ากันได้ [ 35 , 36 , 37 ]

ทั้งหมดนี้อิงจากแนวคิดของ Remote Procedure Calls (RPC) ซึ่งเสนอครั้งแรกในทศวรรษ 1970 [ 38 ] โมเดล RPC พยายามทำให้ request ไปยัง network service ระยะไกลดูเหมือนการเรียกฟังก์ชันหรือ method ใน process เดียวกัน (abstraction นี้เรียกว่า Location Transparency ) แม้ตอนแรกจะดูสะดวก แต่แนวทางนี้มีข้อบกพร่องพื้นฐาน [ 39 , 40 ] network request ต่างจากการเรียกฟังก์ชันในเครื่องมาก ด้วยเหตุผลหลายประการ:

  • การเรียกฟังก์ชันในเครื่องคาดเดาได้ คือสำเร็จหรือล้มเหลวตามพารามิเตอร์ที่คุณควบคุมได้ แต่ network request คาดเดาไม่ได้ เพราะมีเหตุผลที่คุณควบคุมไม่ได้เลย request หรือ response อาจสูญหายเพราะปัญหาเครือข่าย หรือเครื่องระยะไกลอาจช้าหรือไม่พร้อมให้บริการ ปัญหาเครือข่ายเป็นเรื่องปกติ แอปพลิเคชันจึงต้องคาดการณ์ไว้ล่วงหน้า (เช่น retry request ที่ล้มเหลว)

  • การเรียกฟังก์ชันในเครื่องจะคืนผลลัพธ์ โยน exception หรือไม่คืนเลย (เพราะติด infinite loop หรือ process crash) ส่วน network request มีผลลัพธ์ที่เป็นไปได้อีกแบบ: อาจคืนค่ากลับมาโดยไม่มีผลลัพธ์ เพราะ timeout . ในกรณีนั้นคุณไม่รู้ว่าเกิดอะไรขึ้น ถ้าไม่ได้รับ response จาก service ระยะไกล คุณก็ไม่มีทางรู้ว่า request ส่งถึงหรือไม่ (เราจะพูดถึงปัญหานี้ละเอียดขึ้นใน บทที่ 9 )

  • ถ้าคุณ retry network request ที่ล้มเหลว อาจเป็นไปได้ว่า request ก่อนหน้าส่งถึงจริง และมีเพียง response ที่สูญหาย ในกรณีนั้น การ retry จะทำให้การกระทำนั้นถูกทำหลายครั้ง เว้นแต่คุณจะสร้างกลไก deduplication ( Idempotence ) ไว้ใน protocol [ 41 ] การเรียกฟังก์ชันในเครื่องไม่มีปัญหานี้ (เราจะพูดถึง idempotence ละเอียดขึ้นใน บทที่ 12 )

  • การเรียกฟังก์ชันในเครื่องแต่ละครั้งใช้เวลาใกล้เคียงกัน แต่ network request ช้ากว่ามาก และ latency ก็แปรปรวนมาก: ในช่วงที่ทุกอย่างดี อาจเสร็จในไม่ถึงมิลลิวินาที แต่เมื่อเครือข่ายแออัดหรือ service ระยะไกลโอเวอร์โหลด อาจใช้เวลาหลายวินาทีกับงานเดียวกันทุกประการ

  • เมื่อเรียกฟังก์ชันในเครื่อง คุณส่ง reference (pointer) ไปยัง object ในหน่วยความจำได้อย่างมีประสิทธิภาพ แต่เมื่อส่ง network request พารามิเตอร์ทั้งหมดต้องถูกเข้ารหัสเป็นลำดับไบต์ที่ส่งผ่านเครือข่ายได้ ถ้าพารามิเตอร์เป็น immutable primitive เช่นตัวเลขหรือสตริงสั้น ๆ ก็ไม่เป็นไร แต่จะกลายเป็นปัญหาอย่างรวดเร็วเมื่อมีข้อมูลจำนวนมากหรือเป็น mutable object

  • client และ service อาจ implement ด้วยภาษาโปรแกรมต่างกัน RPC framework จึงต้องแปลชนิดข้อมูลจากภาษาหนึ่งไปอีกภาษาหนึ่ง ซึ่งอาจออกมาน่าเกลียด เพราะไม่ใช่ทุกภาษาที่มีชนิดข้อมูลเหมือนกัน ลองนึกถึงปัญหาของ JavaScript กับตัวเลขที่มากกว่า 2 53 ตัวอย่างเช่น (ดู "JSON, XML, and Binary Variants" ) ปัญหานี้ไม่มีใน process เดียวที่เขียนด้วยภาษาเดียว

ปัจจัยทั้งหมดนี้หมายความว่าไม่มีประโยชน์ที่จะพยายามทำให้ service ระยะไกลดูเหมือน object ในเครื่องของภาษาที่คุณใช้ เพราะมันเป็นของคนละอย่างโดยพื้นฐาน ส่วนหนึ่งของเสน่ห์ของ REST คือมองการถ่ายโอนสถานะผ่านเครือข่ายเป็นกระบวนการที่ต่างจากการเรียกฟังก์ชัน

Load balancers, service discovery, and service meshes

service ทั้งหมดสื่อสารผ่านเครือข่าย client จึงต้องรู้ที่อยู่ของ service ที่จะเชื่อมต่อ ซึ่งเป็นปัญหาที่เรียกว่า service discovery วิธีที่ง่ายที่สุดคือกำหนดค่าให้ client เชื่อมต่อไปยัง IP address และ port ที่ service รันอยู่ วิธีนี้ใช้ได้ แต่ถ้า server ออฟไลน์ ถูกย้ายไปเครื่องใหม่ หรือโอเวอร์โหลด ก็ต้องกำหนดค่า client ใหม่ด้วยมือ

เพื่อให้มี availability และ scalability ที่สูงขึ้น โดยปกติจะรัน service หลาย instance บนเครื่องจำนวนมาก โดย instance ใดก็ได้รับ request ที่เข้ามาได้ การกระจาย request ไปยัง instance เหล่านี้เรียกว่า load balancing [ 42 ] มีวิธีทำ load balancing และ service discovery ให้เลือกมากมาย:

Hardware load balancer (load balancer แบบฮาร์ดแวร์)

อุปกรณ์ เฉพาะทางเหล่านี้ติดตั้งใน datacenter ให้ client เชื่อมต่อไปยัง host และ port เดียว แล้ว route การเชื่อมต่อที่เข้ามาไปยัง server ตัวใดตัวหนึ่งที่รัน service อยู่ load balancer เหล่านี้ตรวจจับความล้มเหลวของเครือข่ายเมื่อเชื่อมต่อกับ downstream server และเปลี่ยนเส้นทาง traffic ไปยัง server อื่น

Software load balancer (เช่น NGINX และ HAProxy)

ทำงานคล้ายกับ hardware load balancer แต่ไม่ต้องใช้อุปกรณ์พิเศษ เป็นแอปพลิเคชันที่ติดตั้งบนเครื่องทั่วไปได้

The Domain Name Service (DNS)

นี่คือวิธีที่ชื่อโดเมน ถูกแก้ไขบนอินเทอร์เน็ตเมื่อคุณเปิดหน้าเว็บ มันสนับสนุน Load Balancing โดยอนุญาตให้หลาย IP Address เชื่อมโยงกับชื่อโดเมนเดียว Clients สามารถกำหนดค่าให้เชื่อมต่อกับ Service ผ่านชื่อโดเมนแทน IP Address และเลเยอร์เครือข่ายของ Client จะเลือก IP Address ที่จะใช้เมื่อทำการเชื่อมต่อ ข้อเสียประการหนึ่งของวิธีนี้คือ DNS ถูกออกแบบมาให้เผยแพร่การเปลี่ยนแปลงในช่วงเวลาที่ยาวนานและแคชรายการ DNS หาก Servers ถูกเริ่ม, หยุด, หรือย้ายบ่อยครั้ง Clients อาจเห็น IP Address ที่ล้าสมัยซึ่งไม่มี Server ทำงานอยู่

Service discovery system (ระบบ service discovery)

ระบบ เหล่านี้ใช้ registry ส่วนกลาง เช่น etcd หรือ Apache ZooKeeper แทน DNS เพื่อติดตามว่า service endpoint ใดพร้อมใช้งานบ้าง (เราจะกลับมาที่ระบบเหล่านี้ใน "Coordination Services" ). เมื่อ instance ใหม่ของ service เริ่มทำงาน มันจะลงทะเบียนตัวเองกับระบบ service discovery โดยประกาศ host และ port ที่กำลังฟังอยู่ พร้อม metadata ที่เกี่ยวข้อง เช่น ข้อมูลว่าเป็นเจ้าของ shard ใด (ดู บทที่ 7 ), datacenter ที่อยู่ และอื่น ๆ จากนั้น service จะส่ง heartbeat ไปยังระบบ discovery เป็นระยะเพื่อบอกว่ายังพร้อมใช้งานอยู่

เมื่อ client ต้องการเชื่อมต่อกับ service มันจะ query ระบบ discovery ก่อนเพื่อรับรายการ endpoint ที่พร้อมใช้งาน แล้วเชื่อมต่อกับ endpoint โดยตรง เมื่อเทียบกับ DNS แล้ว service discovery รองรับสภาพแวดล้อมที่เปลี่ยนแปลงเร็วกว่ามาก ซึ่ง service instance เปลี่ยนบ่อย ระบบ discovery ยังให้ metadata เกี่ยวกับ service ที่ client กำลังเชื่อมต่อ ช่วยให้ client ตัดสินใจเรื่อง load balancing ได้ฉลาดขึ้น

Service mesh

รูปแบบ การทำ load balancing ที่ซับซ้อนนี้รวม software load balancer กับ service discovery เข้าด้วยกัน ต่างจาก software load balancer แบบดั้งเดิมที่รันบนเครื่องแยกต่างหาก service mesh load balancer มักถูก deploy เป็น in-process client library หรือเป็น process หรือ "sidecar" container ทั้งฝั่ง client และ server แอปพลิเคชันฝั่ง client เชื่อมต่อกับ load balancer ของ service ในเครื่องตัวเอง ซึ่งเชื่อมต่อต่อไปยัง load balancer ของฝั่ง server จากนั้นการเชื่อมต่อจะถูก route ไปยัง server process ในเครื่อง

แม้จะซับซ้อน แต่โทโพโลยีนี้มีข้อดี เพราะ client และ server route ผ่านการเชื่อมต่อในเครื่องทั้งหมด การเข้ารหัสการเชื่อมต่อจึงจัดการได้ทั้งหมดที่ระดับ load balancer ทำให้ client และ server ไม่ต้องยุ่งกับ SSL certificate และ TLS ระบบ mesh ยังมี observability ที่ซับซ้อน เช่น ติดตามได้แบบ real-time ว่า service ใดกำลังเรียกกัน ตรวจจับความล้มเหลว ติดตาม traffic load และอื่น ๆ

วิธีไหนเหมาะสมขึ้นอยู่กับความต้องการขององค์กร ผู้ที่ทำงานในสภาพแวดล้อม service ที่เปลี่ยนแปลงเร็วและมี orchestrator อย่าง Kubernetes มักเลือก service mesh อย่าง Istio หรือ Linkerd โครงสร้างพื้นฐานเฉพาะทาง เช่น ฐานข้อมูลหรือระบบ messaging อาจต้องมี load balancer ที่สร้างเฉพาะของตัวเอง ส่วนการ deploy ที่เรียบง่ายกว่าควรใช้ software load balancer

Data encoding and evolution for RPC (การเข้ารหัสข้อมูลและ evolution ของ RPC)

เพื่อให้ปรับเปลี่ยนระบบได้ง่าย สิ่งสำคัญคือ RPC client และ server ต้องเปลี่ยนแปลงและ deploy ได้อย่างอิสระ เมื่อเทียบกับข้อมูลที่ไหลผ่านฐานข้อมูล (ดังที่อธิบายใน "Dataflow Through Databases" ) กรณี dataflow ผ่าน service ตั้งสมมติฐานให้ง่ายขึ้นได้: สมเหตุสมผลที่จะสมมติว่า server ทั้งหมดจะอัปเดตก่อน และ client ทั้งหมดอัปเดตทีหลัง ดังนั้นคุณต้องการ backward compatibility เฉพาะใน request และ forward compatibility ใน response

backward และ forward compatibility ของ RPC scheme ขึ้นกับการเข้ารหัสที่มันใช้:

  • gRPC (Protocol Buffers) และ Avro RPC พัฒนาต่อได้ตามกฎความเข้ากันได้ของรูปแบบการเข้ารหัสที่เกี่ยวข้อง

  • RESTful API ส่วนใหญ่มักใช้ JSON สำหรับ response และใช้ JSON หรือ request parameter แบบ URI-encoded/form-encoded สำหรับ request การเพิ่ม request parameter ที่เป็น optional และการเพิ่มฟิลด์ใหม่ใน response object มักถือว่าเป็นการเปลี่ยนที่ยังเข้ากันได้

การรักษาความเข้ากันได้ของ service ยากกว่า เพราะ RPC มักใช้สื่อสารข้ามขอบเขตขององค์กร ผู้ให้บริการ service จึงมักควบคุม client ไม่ได้และบังคับให้อัปเกรดไม่ได้ ความเข้ากันได้จึงต้องคงอยู่เป็นเวลานาน อาจไม่มีกำหนด ถ้าจำเป็นต้องเปลี่ยนในแบบที่ทำลายความเข้ากันได้ ผู้ให้บริการมักต้องดูแล service API หลายเวอร์ชันควบคู่กัน

ไม่มีข้อตกลงร่วมกันว่า API versioning ควรทำงานอย่างไร (เช่น client จะระบุได้อย่างไรว่าต้องการใช้ API เวอร์ชันใด [ 43 ]) สำหรับ RESTful API วิธีที่พบบ่อยคือใส่หมายเลขเวอร์ชันใน URL หรือใน HTTP Accept header สำหรับ service ที่ใช้ API key ระบุ client โดยเฉพาะ อีกทางเลือกหนึ่งคือเก็บ API เวอร์ชันที่ client ร้องขอไว้ที่ server และให้อัปเดตเวอร์ชันที่เลือกนี้ผ่าน administrative interface แยกต่างหาก [ 44 ]

Durable Execution and Workflows

โดยนิยามแล้ว สถาปัตยกรรมแบบ service-based มีหลาย service ที่รับผิดชอบส่วนต่าง ๆ ของแอปพลิเคชัน ลองพิจารณาแอปพลิเคชันประมวลผลการชำระเงินที่เรียกเก็บเงินจากบัตรเครดิตและฝากเงินเข้าบัญชีธนาคาร ระบบนี้น่าจะมี service แยกกันสำหรับตรวจจับการทุจริต เชื่อมต่อบัตรเครดิต เชื่อมต่อธนาคาร และอื่น ๆ

การประมวลผลการชำระเงินหนึ่งครั้งในตัวอย่างของเราต้องเรียก service หลายตัว service ประมวลผลการชำระเงินอาจเรียก fraud detection service เพื่อตรวจสอบการทุจริต เรียก credit card service เพื่อหักเงินบัตรเครดิต และเรียก banking service เพื่อฝากเงินที่หักมา ดังที่แสดงใน รูปที่ 5-7 . เราเรียกลำดับขั้นตอนนี้ว่า workflow และแต่ละขั้นตอนเรียกว่า task . workflow มักนิยามเป็น graph ของ task โดยอาจเขียนนิยามของ workflow ด้วยภาษาโปรแกรมทั่วไป ภาษาเฉพาะโดเมน (DSL) หรือภาษา markup เช่น Business Process Execution Language (BPEL) [ 45 ]

Tasks, activities, and functions

workflow engine แต่ละตัวเรียก task ต่างกัน เช่น Temporal ใช้คำว่า activity ส่วนบางที่เรียก task ว่า durable functions แม้ชื่อจะต่างกัน แต่แนวคิดเหมือนกัน

Diagram illustrating a payment processing workflow using BPMN, showing steps for computing fraud risk, crediting a bank account, and potential transaction outcomes.

รูปที่ 5-7. workflow ที่แสดงด้วย Business Process Model and Notation (BPMN) ซึ่งเป็นสัญลักษณ์แบบกราฟิก

Workflows ถูก Run หรือ Execute โดย workflow engine . workflow engine เป็นผู้กำหนดว่าจะรันแต่ละ task เมื่อใดและบนเครื่องใด จะทำอย่างไรถ้า task ล้มเหลว (เช่น เครื่อง crash ขณะที่ task กำลังทำงาน) อนุญาตให้ task ทำงานขนานกันได้กี่ task และอื่น ๆ

workflow engine มักประกอบด้วย orchestrator และ executor: orchestrator มีหน้าที่จัดตารางเวลาให้ task ถูกรัน และ executor มีหน้าที่รัน task การรันเริ่มเมื่อ workflow ถูก trigger orchestrator จะ trigger workflow เองถ้าผู้ใช้กำหนดตารางเวลาตามเวลา เช่น รันทุกชั่วโมง แหล่งภายนอก เช่น web service หรือแม้แต่คน ก็ trigger การรัน workflow ได้เช่นกัน เมื่อถูก trigger แล้ว executor จะถูกเรียกให้รัน task

workflow engine มีหลายประเภทเพื่อรองรับ use case ที่หลากหลาย บางตัว เช่น Airflow, Dagster และ Prefect เชื่อมกับ data system และ orchestrate งาน ETL บางตัว เช่น Camunda และ Orkes มีสัญลักษณ์แบบกราฟิกสำหรับ workflow (เช่น BPMN ที่ใช้ใน รูปที่ 5-7 ) เพื่อให้คนที่ไม่ใช่วิศวกรนิยามและรัน workflow ได้ง่ายขึ้น และบางตัว เช่น Temporal และ Restate ให้บริการ durable execution

durable execution framework กลายเป็นวิธียอดนิยมในการสร้างสถาปัตยกรรมแบบ service-based ที่ต้องการ transactionality ในตัวอย่างการชำระเงินของเรา เราต้องการประมวลผลการชำระเงินแต่ละครั้งเพียงครั้งเดียว (exactly once) ความล้มเหลวระหว่างที่ workflow กำลังรันอาจทำให้เรียกเก็บเงินจากบัตรเครดิตแล้ว แต่ไม่มีการฝากเข้าบัญชีธนาคารที่สอดคล้องกัน ในสถาปัตยกรรมแบบ service-based เราห่อ task สองอย่างไว้ใน database transaction เดียวไม่ได้ ยิ่งไปกว่านั้น เราอาจกำลังโต้ตอบกับ payment gateway ของบุคคลที่สามซึ่งเราควบคุมได้จำกัด

durable execution framework เป็นวิธีที่ให้ exactly-once semantics สำหรับ workflow ถ้า task ล้มเหลว framework จะรัน task ใหม่ แต่จะข้าม RPC call หรือการเปลี่ยนแปลงสถานะที่ task ทำสำเร็จไปแล้วก่อนล้มเหลว มันจะทำเหมือนเรียกไปแล้ว แต่คืนผลลัพธ์จากการเรียกครั้งก่อนแทน ที่ทำได้เพราะ durable execution framework บันทึก RPC และการเปลี่ยนแปลงสถานะทั้งหมดลง durable storage เช่น write-ahead log [ 46 , 47 ] ตัวอย่างที่ 5-5 แสดงนิยามของ workflow ที่รองรับ durable execution ด้วย Temporal

ตัวอย่างที่ 5-5. ส่วนหนึ่งของนิยาม Temporal workflow สำหรับ workflow การชำระเงินใน รูปที่ 5-7
@workflow.defn
class PaymentWorkflow:
    @workflow.run
    async def run(self, payment: PaymentRequest) -> PaymentResult:
        is_fraud = await workflow.execute_activity(
            check_fraud,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        if is_fraud:
            return PaymentResultFraudulent
        credit_card_response = await workflow.execute_activity(
            debit_credit_card,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        # ...

framework อย่าง Temporal ก็มีความท้าทายไม่น้อย service ภายนอก เช่น payment gateway ของบุคคลที่สามในตัวอย่างของเรา ยังต้องมี idempotent API นักพัฒนาต้องจำใช้ unique ID กับ API เหล่านี้เพื่อป้องกันการรันซ้ำ [ 48 ] และเพราะ durable execution framework บันทึกแต่ละ RPC call ตามลำดับ จึงคาดหวังว่าการรันครั้งต่อ ๆ ไปจะเรียก RPC เดียวกันในลำดับเดียวกัน ทำให้การเปลี่ยนโค้ดเปราะบาง คุณอาจก่อให้เกิดพฤติกรรมที่ไม่กำหนดได้เพียงแค่เรียงลำดับ function call ใหม่ [ 49 ] แทนที่จะแก้โค้ดของ workflow ที่มีอยู่ การ deploy โค้ดเวอร์ชันใหม่แยกต่างหากปลอดภัยกว่า เพื่อให้การรันซ้ำของ workflow invocation ที่มีอยู่ยังใช้เวอร์ชันเก่าต่อไป และมีเพียง invocation ใหม่เท่านั้นที่ใช้โค้ดใหม่ [ 50 ]

ในทำนองเดียวกัน เพราะ durable execution framework คาดหวังว่าการ replay โค้ดจะเป็น deterministic (input เดียวกันให้ output เดียวกัน) โค้ดที่ไม่ deterministic เช่น การเรียก random number generator หรือ system clock จึงเป็นปัญหา [ 49 ] framework มักมี implementation แบบ deterministic ของฟังก์ชัน library เหล่านี้ แต่นักพัฒนาต้องจำใช้ บาง framework ยังมีเครื่องมือ static analysis เช่น Temporal's workflow check เพื่อตรวจว่ามีพฤติกรรมที่ไม่ deterministic หลุดเข้ามาหรือไม่

Note (หมายเหตุ)

การทำให้โค้ด deterministic เป็นแนวคิดที่ทรงพลังแต่ทำให้ robust ได้ยาก เราจะกลับมาที่หัวข้อนี้ใน บทที่ 9

Event-Driven Architectures

ใน ส่วนสุดท้ายนี้ เราจะดูคร่าว ๆ ถึง event-driven architecture ซึ่งเป็นอีกวิธีหนึ่งที่ข้อมูลที่เข้ารหัสไหลจาก process หนึ่งไปอีก process หนึ่งได้ ในบริบทนี้ request จะถูกเรียกว่า event หรือ message . ต่างจาก RPC ตรงที่ผู้ส่งมักไม่ต้องรอให้ผู้รับประมวลผล event และ event มักไม่ได้ส่งไปยังผู้รับผ่านการเชื่อมต่อเครือข่ายโดยตรง แต่ผ่านตัวกลางที่เรียกว่า message broker (หรือเรียกว่า event broker , message queue หรือ message-oriented middleware ) ซึ่งเก็บ message ไว้ชั่วคราว [ 51 ]

การใช้ message broker มีข้อดีหลายอย่างเมื่อเทียบกับ RPC โดยตรง:

  • ทำหน้าที่เป็น buffer ได้ถ้าผู้รับไม่พร้อมใช้งานหรือโอเวอร์โหลด ช่วยเพิ่มความน่าเชื่อถือของระบบ

  • ส่ง message ซ้ำไปยัง process ที่ crash ได้โดยอัตโนมัติ ป้องกันไม่ให้ message สูญหาย

  • ลดความจำเป็นของ service discovery เพราะผู้ส่งไม่ต้องเชื่อมต่อโดยตรงกับ IP address ของผู้รับ

  • ส่ง message เดียวกันไปยังผู้รับหลายรายได้

  • แยกผู้ส่งออกจากผู้รับในเชิงตรรกะ (ผู้ส่งแค่ publish message โดยไม่สนว่าใครจะ consume)

การสื่อสารผ่าน Message Broker เป็นแบบ asynchronous : ผู้ส่งไม่รอให้ message ถูกส่งถึง แต่ส่งแล้วก็ปล่อยไป (fire and forget) อย่างไรก็ตาม ก็ implement โมเดลแบบ RPC ที่เป็น synchronous ได้ โดยให้ผู้ส่งรอ response บนช่องทางแยกต่างหาก

Message brokers

ในอดีต วงการ message broker ถูกครอบงำโดยซอฟต์แวร์ enterprise เชิงพาณิชย์จากบริษัทอย่าง TIBCO, IBM WebSphere และ webMethods ก่อนที่ implementation แบบ open source เช่น RabbitMQ, ActiveMQ, HornetQ, NATS, Redpanda และ Apache Kafka จะได้รับความนิยม ระยะหลัง cloud service เช่น Amazon Kinesis, Azure Service Bus และ Google Cloud Pub/Sub ก็ถูกใช้มากขึ้น เราจะเปรียบเทียบเหล่านี้ละเอียดขึ้นใน บทที่ 12

รายละเอียดของ delivery semantics ต่างกันไปตาม implementation และ configuration แต่โดยทั่วไป รูปแบบการกระจาย message ที่ใช้บ่อยที่สุดมีสองแบบ:

  • process หนึ่งเพิ่ม message ลงใน queue ที่มีชื่อ และ consumer ของ queue นั้นจะได้รับ message ถ้ามี consumer หลายตัว จะมีเพียงตัวใดตัวหนึ่งที่ได้รับ message

  • process หนึ่ง publish message ไปยัง topic ที่มีชื่อ และ broker จะส่ง message นั้นไปยัง subscriber ทุกตัวของ topic นั้น ถ้ามี subscriber หลายตัว ทุกตัวจะได้รับ message

message broker มักไม่บังคับ data model ใดโดยเฉพาะ message เป็นเพียงลำดับไบต์พร้อม metadata คุณจึงใช้รูปแบบการเข้ารหัสใดก็ได้ วิธีที่พบบ่อยคือใช้ Protocol Buffers, Avro หรือ JSON และ deploy schema registry ควบคู่กับ message broker เพื่อเก็บ schema เวอร์ชันที่ใช้ได้ทั้งหมดและตรวจสอบความเข้ากันได้ [ 20 , 22 ] AsyncAPI ซึ่งเป็นตัวเทียบเท่า OpenAPI สำหรับ messaging ก็ใช้ระบุ schema ของ message ได้เช่นกัน

message broker ต่างกันที่ durability (ความคงทน) ของ message หลายตัวเขียน message ลงดิสก์เพื่อไม่ให้สูญหายถ้า message broker crash หรือต้องรีสตาร์ท ต่างจากฐานข้อมูล message broker หลายตัวจะลบ message ให้อัตโนมัติหลังจากที่ consumer รับไปแล้ว แต่บาง broker ตั้งค่าให้เก็บ message ไว้ได้ไม่จำกัดเวลา ซึ่งคุณจะต้องใช้ถ้าต้องการทำ event sourcing (ดู "Event Sourcing and CQRS" )

ถ้า consumer publish message ไปยัง topic อีกครั้ง คุณอาจต้องระวังรักษาฟิลด์ที่ไม่รู้จักไว้ เพื่อป้องกันปัญหาที่อธิบายไปก่อนหน้านี้ในบริบทของฐานข้อมูล ( รูปที่ 5-1 )

Distributed actor frameworks

actor model คือโมเดลการเขียนโปรแกรมสำหรับ concurrency (การทำงานพร้อมกัน) ใน process เดียว แทนที่จะจัดการ thread โดยตรง (พร้อมปัญหาเรื่อง race condition, locking และ deadlock) logic จะถูก encapsulate ไว้ใน actor . โดยทั่วไปแต่ละ actor แทน client หรือ entity หนึ่งราย อาจมี local state (ที่ไม่แชร์กับ actor อื่น) และสื่อสารกับ actor อื่นด้วยการส่งและรับ message แบบ asynchronous การส่ง message ไม่มีการรับประกัน: ในสถานการณ์ error บางแบบ message จะสูญหาย เพราะแต่ละ actor ประมวลผลทีละหนึ่ง message จึงไม่ต้องกังวลเรื่อง thread และ framework จัดตารางเวลาให้แต่ละ actor ได้อย่างอิสระ

ใน distributed actor framework เช่น Akka, Orleans [ 52 ] และ Erlang/OTP โมเดลการเขียนโปรแกรมนี้ถูกใช้เพื่อ scale แอปพลิเคชันข้ามหลายโหนด กลไกส่งข้อความแบบเดียวกันใช้ได้ไม่ว่าผู้ส่งและผู้รับจะอยู่บนโหนดเดียวกันหรือคนละโหนด ถ้าอยู่คนละโหนด message จะถูกเข้ารหัสเป็นลำดับไบต์ ส่งผ่านเครือข่าย และถอดรหัสที่อีกฝั่งโดยอัตโนมัติ ผู้ใช้ไม่ต้องจัดการเอง

location transparency ทำงานได้ดีกว่าใน actor model เมื่อเทียบกับ RPC เพราะ actor model สมมติอยู่แล้วว่า message อาจสูญหายได้ แม้ใน process เดียว แม้ latency บนเครือข่ายอาจสูงกว่าใน process เดียว แต่เมื่อใช้ actor model ความไม่สอดคล้องกันโดยพื้นฐานระหว่างการสื่อสารในเครื่องกับระยะไกลก็น้อยลง

distributed actor framework โดยพื้นฐานแล้วรวม message broker กับ actor programming model เข้าไว้ใน framework เดียว อย่างไรก็ตาม ถ้าคุณต้องการ rolling upgrade แอปพลิเคชันแบบ actor-based คุณก็ยังต้องกังวลเรื่อง forward และ backward compatibility เพราะ message อาจถูกส่งจากโหนดที่รันเวอร์ชันใหม่ไปยังโหนดที่รันเวอร์ชันเก่า และในทางกลับกัน ซึ่งทำได้โดยใช้การเข้ารหัสแบบใดแบบหนึ่งที่กล่าวถึงในบทนี้

Summary (สรุป)

ในบทนี้เราได้ดูหลายวิธีในการแปลงโครงสร้างข้อมูลเป็นไบต์บนเครือข่ายหรือบนดิสก์ เราเห็นว่ารายละเอียดของการเข้ารหัสเหล่านี้ส่งผลไม่เพียงต่อประสิทธิภาพ แต่ที่สำคัญกว่านั้นคือต่อสถาปัตยกรรมของแอปพลิเคชัน และต่อทางเลือกในการพัฒนาแอปพลิเคชันเหล่านั้น

โดยเฉพาะอย่างยิ่ง service จำนวนมากต้องรองรับ rolling upgrade ซึ่ง deploy service เวอร์ชันใหม่ทีละไม่กี่โหนด แทนที่จะทำทั้งหมดพร้อมกัน rolling upgrade ทำให้ปล่อย service เวอร์ชันใหม่ได้โดยไม่มี downtime (จึงสนับสนุนให้ปล่อยเวอร์ชันเล็ก ๆ บ่อย ๆ แทนการปล่อยเวอร์ชันใหญ่นาน ๆ ครั้ง) และทำให้การ deploy เสี่ยงน้อยลง (ตรวจพบและ rollback เวอร์ชันที่มีข้อบกพร่องได้ก่อนจะกระทบผู้ใช้จำนวนมาก) คุณสมบัติเหล่านี้เป็นประโยชน์อย่างมากต่อ evolvability ซึ่งก็คือความง่ายในการเปลี่ยนแปลงแอปพลิเคชัน

ระหว่าง rolling upgrade หรือด้วยเหตุผลอื่น เราต้องสมมติว่าโหนดต่าง ๆ กำลังรันโค้ดแอปพลิเคชันคนละเวอร์ชัน ดังนั้นข้อมูลทั้งหมดที่ไหลอยู่ในระบบจึงต้องถูกเข้ารหัสในแบบที่ให้ backward compatibility (โค้ดใหม่อ่านข้อมูลเก่าได้) และ forward compatibility (โค้ดเก่าอ่านข้อมูลใหม่ได้)

เราพูดถึงรูปแบบการเข้ารหัสข้อมูลหลายแบบและคุณสมบัติด้านความเข้ากันได้ของแต่ละแบบ:

  • การเข้ารหัสเฉพาะภาษา (language-specific encoding) ถูกจำกัดอยู่กับภาษาโปรแกรมภาษาเดียว และมักไม่ให้ forward และ backward compatibility

  • รูปแบบข้อความอย่าง JSON, XML และ CSV แพร่หลาย และความเข้ากันได้ขึ้นอยู่กับวิธีที่คุณใช้ มีภาษา schema ให้เลือกใช้ ซึ่งบางครั้งมีประโยชน์และบางครั้งก็เป็นอุปสรรค รูปแบบเหล่านี้ค่อนข้างคลุมเครือเรื่องชนิดข้อมูล คุณจึงต้องระวังเรื่องอย่างตัวเลขและ binary string

  • รูปแบบไบนารีที่ขับเคลื่อนด้วย schema อย่าง Protocol Buffers และ Avro เข้ารหัสได้กะทัดรัดและมีประสิทธิภาพ พร้อม semantics ของ forward และ backward compatibility ที่ชัดเจน schema มีประโยชน์ทั้งในฐานะเอกสารและการสร้างโค้ดในภาษาแบบ statically typed อย่างไรก็ตาม รูปแบบเหล่านี้มีข้อเสียคือต้องถอดรหัสข้อมูลก่อนคนจึงจะอ่านได้

เรายังพูดถึงรูปแบบการไหลของข้อมูลหลายแบบ ซึ่งแสดงสถานการณ์ต่าง ๆ ที่การเข้ารหัสข้อมูลมีความสำคัญ:

Databases (ฐานข้อมูล)

process ที่เขียนลงฐานข้อมูลจะเข้ารหัสข้อมูล และ process ที่อ่านจากฐานข้อมูลจะถอดรหัส

RPC and REST APIs

client เข้ารหัส request, server ถอดรหัส request แล้วเข้ารหัส response และสุดท้าย client ถอดรหัส response

Event-driven architectures (ใช้ message broker หรือ actor)

โหนดสื่อสารกันโดยส่ง message ซึ่งผู้ส่งเข้ารหัสและผู้รับถอดรหัส

สรุปได้ว่า ด้วยความระมัดระวังเพียงเล็กน้อย ก็ทำ backward/forward compatibility และ rolling upgrade ได้ไม่ยากนัก ขอให้แอปพลิเคชันของคุณพัฒนาได้รวดเร็วและ deploy ได้บ่อยครั้ง