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

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

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

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

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

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

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

  • สำหรับแอปพลิเคชันฝั่ง 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 Services, REST APIs, Remote Procedure Calls (RPC), Workflow Engines และระบบ Event-Driven เช่น Actors และ Message Queues

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 ไม่มีความหมายสำหรับกระบวนการอื่น การแสดงแบบลำดับไบต์นี้จึงมักดูแตกต่างอย่างมากจากโครงสร้างข้อมูลที่ใช้ในหน่วยความจำ

ดังนั้นเราจึงจำเป็นต้องมีการแปลงระหว่างสองรูปแบบนี้ การแปลงจากการแสดงในหน่วยความจำไปเป็นลำดับไบต์เรียกว่า การเข้ารหัส (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 ]

  • การจัดการเวอร์ชันของข้อมูล (Versioning) มักเป็นสิ่งที่ถูกมองข้ามใน Library เหล่านี้ เนื่องจากพวกมันถูกออกแบบมาเพื่อการเข้ารหัสข้อมูลที่รวดเร็วและง่ายดาย จึงมักละเลยปัญหาที่ยุ่งยากเกี่ยวกับ 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 ดังนั้นตัวเลขดังกล่าวจะไม่ถูกต้องเมื่อถูกแยกวิเคราะห์ในภาษาที่ใช้เลขทศนิยม เช่น JavaScript [ 7 ]. ตัวอย่างของตัวเลขที่ใหญ่กว่า 2 53 เกิดขึ้นบน X ซึ่งใช้ตัวเลข 64 บิตเพื่อระบุแต่ละโพสต์ JSON ที่คืนจาก API จะรวม ID ของโพสต์สองครั้ง ครั้งหนึ่งเป็น JSON number และอีกครั้งเป็น Decimal String เพื่อแก้ไขปัญหาการแยกวิเคราะห์ตัวเลขที่ไม่ถูกต้องโดยแอปพลิเคชัน JavaScript [ 8 ]

  • JSON และ XML รองรับสตริง Unicode (ข้อความที่มนุษย์อ่านได้) ได้ดี แต่ไม่รองรับ Binary String (ลำดับของไบต์โดยไม่มีการเข้ารหัสอักขระ) Binary String เป็นคุณสมบัติที่มีประโยชน์ ดังนั้นผู้คนจึงแก้ไขข้อจำกัดนี้ด้วยการเข้ารหัสข้อมูลไบนารีเป็นข้อความโดยใช้ Base64 จากนั้น Schema จะถูกใช้เพื่อระบุว่าควรตีความค่านั้นว่าเป็นข้อมูลที่เข้ารหัสด้วย Base64 วิธีนี้ใช้ได้ผล แต่ค่อนข้างเป็นทางลัดและเพิ่มขนาดข้อมูลประมาณหนึ่งในสาม

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

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

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

JSON Schema (JSON Schema)

JSON Schema ถูกนำมาใช้อย่างแพร่หลายในฐานะวิธีการสร้างแบบจำลองข้อมูลเมื่อมีการแลกเปลี่ยนระหว่างระบบหรือเขียนลงในที่จัดเก็บ คุณจะพบ JSON Schemas ใน Web Services (ดู "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 แบบเปิดหรือปิดก็ได้ Content Model แบบเปิดอนุญาตให้ฟิลด์ใดก็ได้ที่ไม่ได้กำหนดใน Schema สามารถมีอยู่ด้วยชนิดข้อมูลใดก็ได้ ในขณะที่ Content Model แบบปิดอนุญาตเฉพาะฟิลด์ที่ถูกกำหนดไว้อย่างชัดเจนเท่านั้น Content Model แบบเปิดใน 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 ที่มีคีย์เป็นจำนวนเต็มและค่าเป็นสตริง ```php { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "patternProperties": { "^[0-9]+$": { "type": "string" } }, "additionalProperties": false } ``` นอกเหนือจาก Content Model แบบเปิดและปิดและ Validators แล้ว JSON Schema ยังสนับสนุน Conditional `if/else` schema logic, Named Types, การอ้างอิงไปยัง Schema ระยะไกล และอื่น ๆ อีกมากมาย ทั้งหมดนี้ทำให้เป็นภาษา Schema ที่ทรงพลังมาก คุณสมบัติเหล่านี้ยังทำให้คำจำกัดความมีขนาดใหญ่และยุ่งยาก การแก้ไข Schema ระยะไกล การใช้เหตุผลเกี่ยวกับกฎแบบมีเงื่อนไข หรือการพัฒนา Schema ในลักษณะที่เข้ากันได้แบบไปข้างหน้าหรือย้อนหลังอาจเป็นสิ่งที่ท้าทาย [ 10 , 11 ] ความกังวลที่คล้ายกันนี้ใช้กับ XML Schema [ 12 ]

### Binary encodings (การเข้ารหัสแบบไบนารี) JSON มีขนาดกะทัดรัดน้อยกว่า XML แต่ทั้งคู่ยังคงใช้พื้นที่มากเมื่อเทียบกับรูปแบบไบนารี การสังเกตนี้ทำให้เกิดการพัฒนาการเข้ารหัสแบบไบนารีมากมายสำหรับ JSON (MessagePack, CBOR, BSON, BJSON, UBJSON, BISON, Hessian และ Smile เป็นต้น) และ XML (WBXML และ Fast Infoset เป็นตัวอย่าง) รูปแบบเหล่านี้ถูกนำไปใช้ในหลายกลุ่มเฉพาะ เนื่องจากมันมีขนาดกะทัดรัดกว่าและบางครั้งก็แยกวิเคราะห์ได้เร็วกว่า แต่ไม่มีรูปแบบใดที่ถูกนำไปใช้อย่างกว้างขวางเท่า 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.](/images/book/designing-data-intensive-applications-2nd/993b449d-fcbe-44ee-8bad-684c24ee2dd6.png) ###### รูปที่ 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 ไบต์ ซึ่งน้อยกว่า 81 ไบต์ของ JSON ในรูปแบบข้อความ (เมื่อตัดช่องว่างออก) เพียงเล็กน้อย การเข้ารหัสแบบไบนารีของ JSON ทั้งหมดมีความคล้ายคลึงกันในเรื่องนี้ ยังไม่ชัดเจนว่าการลดพื้นที่เพียงเล็กน้อย (และการแยกวิเคราะห์ที่อาจเร็วขึ้นเล็กน้อย) นั้นคุ้มค่ากับการสูญเสียความสามารถในการอ่านโดยมนุษย์หรือไม่ ในหัวข้อถัดไปเราจะเห็นว่าเราสามารถทำได้ดีกว่านี้มาก โดยเข้ารหัสเรคคอร์ดเดียวกันในจำนวนไบต์ที่น้อยเพียงครึ่งเดียว

## Protocol Buffers (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 encoder ต้องใช้ 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."](/images/book/designing-data-intensive-applications-2nd/b1144374-aa8a-465d-aa7b-b96d63c64310.png) ###### รูปที่ 5-3. เรคคอร์ดของเราที่เข้ารหัสด้วย Protocol Buffers แตกต่างจาก รูปที่ 5-2 ตัวอย่างนี้ไม่มีชื่อฟิลด์ ( `userName` , `favoriteNumber` , `interests` ) แต่ข้อมูลที่เข้ารหัสกลับประกอบด้วย _Field Tags_ ซึ่งเป็นตัวเลข ( `1` , `2` และ `3` ) ซึ่งเป็นตัวเลขที่ปรากฏใน Schema Definition Field Tags เป็นเหมือนนามแฝงสำหรับฟิลด์—มันเป็นวิธีที่กระชับในการระบุฟิลด์ที่เรากำลังพูดถึง โดยไม่ต้องสะกดชื่อฟิลด์ออกมา อย่างที่คุณเห็น Protocol Buffers ประหยัดพื้นที่ได้มากยิ่งขึ้นโดยการบรรจุประเภทฟิลด์และหมายเลข Tag ไว้ในไบต์เดียว มันใช้จำนวนเต็มความยาวผันแปรได้ (Variable-Length Integers): ตัวเลข 1337 ถูกเข้ารหัสในสองไบต์ โดยบิตบนสุดของแต่ละไบต์ใช้เพื่อระบุว่ายังมีไบต์ที่เหลืออีกหรือไม่ (7 บิตที่มีนัยสำคัญน้อยที่สุดจะถูกเก็บไว้ในไบต์แรกเพื่อให้การสร้างจำนวนเต็มใหม่ทำได้ง่ายเมื่ออ่านไบต์) ซึ่งหมายความว่าตัวเลขตั้งแต่ –64 ถึง 63 ถูกเข้ารหัสในหนึ่งไบต์ ตัวเลขตั้งแต่ –8,192 ถึง 8,191 ถูกเข้ารหัสในสองไบต์ ฯลฯ ตัวเลขที่ใหญ่ขึ้นใช้จำนวนไบต์มากขึ้น Protocol Buffers ไม่มีประเภทข้อมูล List หรือ Array ที่ชัดเจน แต่ `repeated` modifier บนฟิลด์ `interests` บ่งชี้ว่าฟิลด์นั้นประกอบด้วยรายการของค่ามากกว่าค่าเดียว ในการเข้ารหัสแบบไบนารี สมาชิกของ List จะถูกแสดงเพียงแค่การปรากฏซ้ำของ Field Tag เดียวกันภายในเรคคอร์ดเดียวกัน ### Field tags and schema evolution (Field Tags และวิวัฒนาการของ Schema) ก่อนหน้านี้เรากล่าวว่า Schema ย่อมต้องเปลี่ยนแปลงไปตามกาลเวลา เราเรียกสิ่งนี้ว่า _Schema Evolution_ . Protocol Buffers จัดการกับการเปลี่ยนแปลง Schema ในขณะที่ยังคง backward และ forward compatibility ได้อย่างไร? อย่างที่คุณเห็นจากตัวอย่าง เรคคอร์ดที่ถูกเข้ารหัสก็แค่การต่อกันของฟิลด์ที่ถูกเข้ารหัสของมัน แต่ละฟิลด์ถูกระบุด้วยหมายเลข Tag (ตัวเลข `1` , `2` , `3` ใน Schema ตัวอย่าง) และมีคำอธิบายประเภทข้อมูล (เช่น string หรือ integer) หากไม่ได้ตั้งค่าฟิลด์ใด ฟิลด์นั้นก็จะถูกละเว้นจากเรคคอร์ดที่เข้ารหัส จากนี้คุณจะเห็นว่า Field Tags มีความสำคัญอย่างยิ่งต่อความหมายของข้อมูลที่เข้ารหัส คุณสามารถเปลี่ยนชื่อฟิลด์ใน 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 ที่เคยใช้ในอดีตสามารถถูก Reserved ไว้ใน Schema Definition เพื่อให้แน่ใจว่าจะไม่ถูกลืม แล้วการเปลี่ยนประเภทข้อมูลของฟิลด์ล่ะ? เป็นไปได้กับบางประเภท—ตรวจสอบเอกสารสำหรับรายละเอียด—แต่มีความเสี่ยงที่ค่าจะถูกตัดทอน ตัวอย่างเช่น สมมติว่าคุณเปลี่ยนจำนวนเต็ม 32 บิตเป็นจำนวนเต็ม 64 บิต โค้ดใหม่อ่านข้อมูลที่เขียนโดยโค้ดเก่าได้อย่างง่ายดาย เพราะ Parser สามารถเติมบิตที่ขาดหายไปด้วย 0 ได้ อย่างไรก็ตาม หากโค้ดเก่าอ่านข้อมูลที่เขียนโดยโค้ดใหม่ โค้ดเก่ายังคงใช้ตัวแปร 32 บิตเพื่อเก็บค่า หากค่าที่ถอดรหัสเป็น 64 บิตไม่พอดีกับ 32 บิต มันก็จะถูกตัดทอน ## Avro (Avro) _Apache Avro_ เป็นรูปแบบการเข้ารหัสแบบไบนารีอีกแบบหนึ่ง ที่มีความแตกต่างที่น่าสนใจจาก Protocol Buffers มันเริ่มต้นในปี 2009 ในฐานะโปรเจกต์ย่อยของ Hadoop อันเป็นผลมาจาก Protocol Buffers ไม่เหมาะสมกับ Use Case ของ Hadoop [ 16 ] Avro ยังใช้ Schema เพื่อระบุโครงสร้างของข้อมูลที่กำลังถูกเข้ารหัส มันมีภาษา Schema สองแบบ: แบบหนึ่ง (Avro IDL) ออกแบบสำหรับการแก้ไขโดยมนุษย์ และอีกแบบหนึ่ง (อิงตาม JSON) ที่เครื่องอ่านได้ง่ายกว่า เช่นเดียวกับ Protocol Buffers ภาษา Schema ระบุเฉพาะฟิลด์และประเภทของมัน และไม่สนับสนุนกฎการตรวจสอบความถูกต้องที่ซับซ้อนอย่างใน JSON Schema เมื่อเขียนด้วย Avro IDL Schema ตัวอย่างของเราอาจมีลักษณะดังนี้: ```sql record Person { string userName; union { null, long } favoriteNumber = null; array<string> interests; } ``` การแสดง Schema นั้นในรูปแบบ JSON ที่เทียบเท่ามีดังนี้: ```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 ในการแยกวิเคราะห์ข้อมูลไบนารี คุณต้องไล่ตามฟิลด์ตามลำดับที่ปรากฏใน 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.](/images/book/designing-data-intensive-applications-2nd/c959ab40-b268-4525-a2a4-5e22858628c1.png) ###### รูปที่ 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 จะแก้ไขความแตกต่างโดยการเปรียบเทียบทั้งสองและแปลข้อมูลจาก 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.](/images/book/designing-data-intensive-applications-2nd/e33b6651-7eaa-44ea-8c43-0f0344b52467.png) ###### รูปที่ 5-5. ใน Protocol Buffers การเข้ารหัสและถอดรหัสสามารถใช้ Schema คนละเวอร์ชันกัน ใน Avro การถอดรหัสใช้ Schema สองแบบ: Writer's Schema ต้องเหมือนกับ Schema ที่ใช้สำหรับการเข้ารหัส แต่ Reader's Schema สามารถเป็นเวอร์ชันที่เก่าหรือใหม่กว่าได้ ข้อกำหนดของ Avro [ 17 , 18 ] กำหนดอย่างชัดเจนว่าการแก้ไขนี้ทำงานอย่างไร ดังที่แสดงใน รูปที่ 5-6 ไม่มีปัญหาใด ๆ หาก Writer's Schema และ Reader's Schema มีฟิลด์ในลำดับที่แตกต่างกัน เพราะการแก้ไข Schema จะจับคู่ฟิลด์ตามชื่อฟิลด์ หากโค้ดที่อ่านข้อมูลพบฟิลด์ที่ปรากฏใน 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.](/images/book/designing-data-intensive-applications-2nd/e2b97ae9-95ee-4f59-a6ac-d91cc4a4a142.png) ###### รูปที่ 5-6. Avro Reader กำลังแก้ไขความแตกต่างระหว่าง 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 โดยค่าเริ่มต้นเล็กน้อย แต่มันช่วยป้องกัน Bugs โดยการระบุอย่างชัดเจนว่าสิ่งใดสามารถและไม่สามารถเป็น `null` ได้ [ 19 ] การเปลี่ยนประเภทข้อมูลของฟิลด์เป็นไปได้ โดยที่ Avro สามารถแปลงประเภทได้ การเปลี่ยนชื่อฟิลด์ก็เป็นไปได้แต่ค่อนข้างยุ่งยาก Reader's Schema สามารถมีนามแฝง (Aliases) สำหรับชื่อฟิลด์ได้ ดังนั้นมันสามารถจับคู่ชื่อฟิลด์ของ Writer's Schema เก่ากับนามแฝงได้ ซึ่งหมายความว่าการเปลี่ยนชื่อฟิลด์นั้น 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 Files) เพื่อทำสิ่งนี้ 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 (การส่งเรคคอร์ดผ่านการเชื่อมต่อเครือข่าย) เมื่อสองกระบวนการสื่อสารผ่านการเชื่อมต่อเครือข่ายแบบสองทิศทาง พวกมันสามารถเจรจา 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 ที่เราเห็นก่อนหน้านี้) จาก Schema เชิงสัมพันธ์ได้อย่างง่ายดาย และเข้ารหัสเนื้อหาของฐานข้อมูลโดยใช้ Schema นั้น แล้ว Dump ทั้งหมดลงใน Avro Object Container File [ 23 ] คุณสามารถสร้าง Record Schema สำหรับแต่ละตารางฐานข้อมูล และแต่ละคอลัมน์จะกลายเป็นฟิลด์ในเรคคอร์ดนั้น ชื่อคอลัมน์ในฐานข้อมูลจะ Map ไปยังชื่อฟิลด์ใน Avro ทีนี้ หาก Schema ของฐานข้อมูลเปลี่ยนแปลง (เช่น ตารางมีการเพิ่มคอลัมน์หนึ่งและลบคอลัมน์หนึ่ง) คุณก็แค่สร้าง Avro Schema ใหม่จาก Schema ฐานข้อมูลที่อัปเดตแล้ว และส่งออกข้อมูลใน Avro Schema ใหม่ กระบวนการส่งออกข้อมูลไม่จำเป็นต้องใส่ใจกับการเปลี่ยนแปลง Schema—มันสามารถทำการแปลง Schema ทุกครั้งที่มันทำงาน ใครก็ตามที่อ่านไฟล์ข้อมูลใหม่จะเห็นว่าฟิลด์ของเรคคอร์ดเปลี่ยนไป แต่เนื่องจากฟิลด์ถูกระบุด้วยชื่อ Writer's Schema ที่อัปเดตแล้วก็ยังสามารถจับคู่กับ Reader's Schema เก่าได้ ในทางตรงกันข้าม หากคุณใช้ Protocol Buffers เพื่อจุดประสงค์นี้ Field Tags มักจะต้องถูกกำหนดด้วยมือ ทุกครั้งที่ Schema ของฐานข้อมูลเปลี่ยนแปลง ผู้ดูแลระบบจะต้องอัปเดตการ Mapping จากชื่อคอลัมน์ฐานข้อมูลไปยัง Field Tags ด้วยตนเอง (มันอาจเป็นไปได้ที่จะทำสิ่งนี้โดยอัตโนมัติ แต่ตัวสร้าง Schema จะต้องระมัดระวังอย่างมากในการไม่กำหนด Field Tags ที่เคยใช้มาก่อน) Schema ที่ถูกสร้างแบบไดนามิกประเภทนี้ไม่ใช่เป้าหมายการออกแบบของ Protocol Buffers แต่มันเป็นเป้าหมายของ Avro ## The Merits of Schemas (ข้อดีของ Schema) อย่างที่เราเห็น Protocol Buffers และ Avro ต่างใช้ Schema เพื่ออธิบายรูปแบบการเข้ารหัสแบบไบนารี ภาษา Schema ของพวกมันง่ายกว่า XML Schema หรือ JSON Schema มาก ซึ่งรองรับกฎการตรวจสอบความถูกต้องที่มีรายละเอียดมากกว่า (เช่น "ค่าสตริงของฟิลด์นี้ต้องตรงกับ Regular Expression นี้" หรือ "ค่าจำนวนเต็มของฟิลด์นี้ต้องอยู่ระหว่าง 0 ถึง 100") เนื่องจาก Protocol Buffers และ Avro ง่ายต่อการนำไปใช้และใช้งาน พวกมันจึงได้รับการสนับสนุนจากภาษาโปรแกรมมิ่งที่หลากหลายพอสมควร แนวคิดที่การเข้ารหัสเหล่านี้มีพื้นฐานนั้นไม่ใช่เรื่องใหม่เลย ตัวอย่างเช่น พวกมันมีอะไรหลายอย่างที่เหมือนกับ ASN.1 ซึ่งเป็นภาษา Schema Definition ที่ถูกทำให้เป็นมาตรฐานครั้งแรกในปี 1984 [ 24 , 25 ]. มันถูกใช้เพื่อกำหนด Network Protocol ต่าง ๆ และการเข้ารหัสแบบไบนารี (DER) ยังคงใช้ในการเข้ารหัส SSL Certificates (X.509) เป็นต้น [ 26 ]. ASN.1 รองรับ Schema Evolution โดยใช้หมายเลข Tag คล้ายกับ Protocol Buffers [ 27 ] อย่างไรก็ตาม มันซับซ้อนมากและมีเอกสารที่ไม่ดี ดังนั้น ASN.1 จึงอาจไม่ใช่ตัวเลือกที่ดีสำหรับแอปพลิเคชันใหม่ ระบบข้อมูลหลายระบบยังใช้การเข้ารหัสแบบไบนารีเฉพาะของตัวเองสำหรับข้อมูลของพวกมัน ตัวอย่างเช่น ฐานข้อมูลเชิงสัมพันธ์ส่วนใหญ่มี Network Protocol ที่คุณสามารถส่ง Query ไปยังฐานข้อมูลและรับ Response กลับมาได้ Protocol เหล่านั้นโดยทั่วไปเฉพาะเจาะจงกับฐานข้อมูลแต่ละตัว และผู้จำหน่ายฐานข้อมูลจะให้ Driver (เช่น การใช้ ODBC หรือ JDBC APIs) ที่ถอดรหัส Response จาก Network Protocol ของฐานข้อมูลเป็นโครงสร้างข้อมูลในหน่วยความจำ ดังนั้นเราจะเห็นว่าแม้ว่ารูปแบบข้อมูลข้อความอย่าง JSON, XML และ CSV จะแพร่หลาย แต่การเข้ารหัสแบบไบนารีที่อิงตาม Schema ก็เป็นตัวเลือกที่ใช้งานได้เช่นกัน พวกมันมีคุณสมบัติที่ดีหลายประการ: - พวกมันสามารถกะทัดรัดกว่า "binary JSON" หลายรูปแบบมาก เนื่องจากสามารถละชื่อฟิลด์ออกจากข้อมูลที่เข้ารหัสได้ - Schema เป็นรูปแบบเอกสารที่มีค่า และเนื่องจากต้องใช้ Schema ในการถอดรหัส คุณจึงมั่นใจได้ว่ามันเป็นปัจจุบันอยู่เสมอ (ในขณะที่เอกสารที่ดูแลด้วยตนเองอาจเบี่ยงเบนจากความเป็นจริงได้ง่าย) - การเก็บฐานข้อมูลของ Schema ไว้ช่วยให้คุณตรวจสอบ Forward และ Backward Compatibility ของการเปลี่ยนแปลง Schema ก่อนที่จะปรับใช้สิ่งใด - สำหรับผู้ใช้ภาษาโปรแกรมมิ่งแบบ Static Typed ความสามารถในการสร้างโค้ดจาก Schema มีประโยชน์ เนื่องจากช่วยให้สามารถตรวจสอบชนิดข้อมูลในเวลาคอมไพล์ได้ โดยสรุป Schema Evolution ช่วยให้มีความยืดหยุ่นแบบเดียวกับที่ฐานข้อมูลแบบ Schemaless/Schema-on-read JSON มีให้ (ดู "Schema flexibility in the document model" ) ในขณะที่ยังให้การรับประกันเกี่ยวกับข้อมูลและเครื่องมือที่ดีกว่า อย่างไรก็ตาม ควรเก็บจำนวนรูปแบบ Schema ที่ทำงานพร้อมกัน ให้น้อยที่สุดเพื่อให้การดำเนินงานง่าย

# Modes of Dataflow (รูปแบบการไหลของข้อมูล) ในตอนต้นของบทนี้ เรากล่าวว่าเมื่อใดก็ตามที่คุณต้องการส่งข้อมูลไปยังกระบวนการอื่นที่คุณไม่ได้แชร์หน่วยความจำด้วย—ตัวอย่างเช่น เมื่อคุณต้องการส่งข้อมูลผ่านเครือข่ายหรือเขียนลงไฟล์—คุณจำเป็นต้องเข้ารหัสมันเป็นลำดับของไบต์ จากนั้นเราก็พูดถึงรูปแบบการเข้ารหัสที่หลากหลายสำหรับการทำสิ่งนี้ เราพูดถึง Forward และ Backward Compatibility ซึ่งมีความสำคัญต่อความสามารถในการปรับเปลี่ยน (ทำให้การเปลี่ยนแปลงง่ายขึ้นโดยอนุญาตให้คุณอัปเกรดส่วนต่าง ๆ ของระบบได้อย่างอิสระ แทนที่จะต้องเปลี่ยนแปลงทุกอย่างพร้อมกัน) Compatibility คือความสัมพันธ์ระหว่างกระบวนการหนึ่งที่เข้ารหัสข้อมูลและอีกกระบวนการหนึ่งที่ถอดรหัสมัน นั่นเป็นแนวคิดที่ค่อนข้างเป็นนามธรรมเพราะข้อมูลสามารถไหลจากกระบวนการหนึ่งไปยังอีกกระบวนการหนึ่งได้หลายวิธี ใครเป็นผู้เข้ารหัสข้อมูล และใครเป็นผู้ถอดรหัสมัน? ในส่วนที่เหลือของบทนี้ เราจะสำรวจวิธีการทั่วไปที่สุดบางวิธีที่ข้อมูลไหลระหว่างกระบวนการผ่านฐานข้อมูล การเรียก Service, Workflow Engines และข้อความแบบอะซิงโครนัส ## Dataflow Through Databases (การไหลของข้อมูลผ่านฐานข้อมูล) ในฐานข้อมูล กระบวนการที่ทำการเขียนจะเข้ารหัสข้อมูล และกระบวนการที่ทำการอ่านจะถอดรหัสมัน อาจมีเพียงกระบวนการเดียวที่เข้าถึงฐานข้อมูล ซึ่งในกรณีนี้ผู้อ่านก็เป็นเพียงเวอร์ชันที่ใหม่กว่าของกระบวนการเดียวกัน ในสถานการณ์เช่นนี้ คุณสามารถคิดถึงการจัดเก็บบางสิ่งในฐานข้อมูลว่า _การส่งข้อความถึงตัวคุณในอนาคต_ . Backward Compatibility เป็นสิ่งจำเป็นอย่างชัดเจนที่นี่ มิฉะนั้นตัวคุณในอนาคตจะไม่สามารถถอดรหัสสิ่งที่คุณเขียนไว้ก่อนหน้านี้ได้ โดยทั่วไปแล้ว เป็นเรื่องปกติที่หลายกระบวนการจะเข้าถึงฐานข้อมูลพร้อมกัน กระบวนการเหล่านี้อาจเป็นแอปพลิเคชันหรือ Service ที่แตกต่างกัน หรืออาจเป็นเพียงหลาย Instance ของ Service เดียวกัน (ที่รันแบบขนานเพื่อความสามารถในการปรับขนาดหรือความทนทานต่อข้อผิดพลาด) ไม่ว่าในกรณีใด ในสภาพแวดล้อมเช่นนี้ มีแนวโน้มว่าบางกระบวนการที่เข้าถึงฐานข้อมูลจะรันโค้ดใหม่กว่าและบางกระบวนการจะรันโค้ดเก่ากว่า—ตัวอย่างเช่น เพราะเวอร์ชันใหม่กำลังถูกปรับใช้แบบ Rolling Upgrade ดังนั้นบาง Instance ได้รับการอัปเดตแล้วในขณะที่บางส่วนยังไม่ได้ ซึ่งหมายความว่าค่าในฐานข้อมูลอาจถูกเขียนโดยโค้ดเวอร์ชัน _ใหม่กว่า_ และต่อมาถูกอ่านโดยโค้ดเวอร์ชัน _เก่ากว่า_ ที่ยังคงทำงานอยู่ ดังนั้น Forward Compatibility จึงมักจำเป็นสำหรับฐานข้อมูลเช่นกัน ### Different values written at different times (ค่าที่แตกต่างกันถูกเขียนในเวลาที่ต่างกัน) โดยทั่วไปฐานข้อมูลอนุญาตให้อัปเดตค่าใดก็ได้ตลอดเวลา ภายในฐานข้อมูลเดียว คุณอาจมีค่าบางค่าที่ถูกเขียนเมื่อห้ามิลลิวินาทีก่อน และค่าอื่น ๆ ที่ถูกเขียนเมื่อห้าปีก่อน เมื่อคุณปรับใช้แอปพลิเคชันเวอร์ชันใหม่ (ของแอปพลิเคชันฝั่ง Server อย่างน้อย) คุณอาจแทนที่เวอร์ชันเก่าทั้งหมดด้วยเวอร์ชันใหม่ภายในไม่กี่นาที สิ่งเดียวกันนี้ไม่เป็นความจริงสำหรับเนื้อหาในฐานข้อมูล ข้อมูลที่มีอายุห้าปียังคงอยู่ที่นั่น ในการเข้ารหัสดั้งเดิม เว้นแต่คุณจะเขียนทับมันอย่างชัดเจนตั้งแต่นั้นมา การสังเกตนี้บางครั้งถูกสรุปว่า _ข้อมูลมีอายุยืนกว่าโค้ด_ (data outlives code) แม้ว่าการเขียน ( _Migrating_ ) ข้อมูลใหม่เป็น Schema ใหม่เป็นสิ่งที่เป็นไปได้ แต่มันมีค่าใช้จ่ายสูงสำหรับชุดข้อมูลขนาดใหญ่ ดังนั้นฐานข้อมูลส่วนใหญ่จึงเลื่อนการดำเนินการนี้ ทําแบบอะซิงโครนัสและเท่าที่ทำได้ ตัวอย่างเช่น LSM-tree Storage Engines (ดู "Log-Structured Storage" ) จะเขียนข้อมูลใหม่โดยใช้รูปแบบล่าสุดระหว่างการ Compact ฐานข้อมูลเชิงสัมพันธ์ส่วนใหญ่ยังอนุญาตให้เปลี่ยนแปลง Schema ง่าย ๆ เช่น การเพิ่มคอลัมน์ใหม่ด้วยค่าเริ่มต้น `null` โดยไม่ต้องเขียนข้อมูลที่มีอยู่ใหม่ เมื่ออ่านแถวเก่า ฐานข้อมูลจะเติม `null` สำหรับคอลัมน์ที่ขาดหายไปจากข้อมูลที่เข้ารหัสบนดิสก์ Schema Evolution จึงช่วยให้ฐานข้อมูลทั้งหมดปรากฏราวกับว่ามันถูกเข้ารหัสด้วย Schema เดียว แม้ว่าพื้นที่จัดเก็บเบื้องหลังอาจมีเรคคอร์ดที่ถูกเข้ารหัสด้วย Schema เวอร์ชันประวัติศาสตร์ต่าง ๆ การเปลี่ยนแปลง Schema ที่ซับซ้อนมากขึ้น—เช่น การเปลี่ยน Attribute แบบค่าเดียวเป็นหลายค่า หรือการย้ายข้อมูลบางส่วนไปยังตารางแยก—ยังคงต้องเขียนข้อมูลใหม่ ซึ่งมักจะต้องทำในระดับแอปพลิเคชัน [ 28 ] การรักษา Forward และ Backward Compatibility ในการย้ายข้อมูลดังกล่าวยังคงเป็นปัญหาที่ต้องวิจัย [ 29 ] ### Archival storage (พื้นที่จัดเก็บถาวร) บางทีคุณอาจถ่ายภาพรวม (Snapshot) ของฐานข้อมูลของคุณเป็นครั้งคราว—เช่น เพื่อการสำรองข้อมูล หรือเพื่อโหลดเข้า Data Warehouse (ดู "Data Warehousing" ) ในกรณีนี้ Dump ข้อมูลโดยทั่วไปจะถูกเข้ารหัสโดยใช้ Schema ล่าสุด แม้ว่าการเข้ารหัสดั้งเดิมในฐานข้อมูลต้นทางจะมี Schema หลากหลายเวอร์ชันจากหลายยุคสมัยก็ตาม เนื่องจากคุณกำลังคัดลอกข้อมูลอยู่แล้ว คุณก็ควรเข้ารหัสสำเนาของข้อมูลให้สอดคล้องกัน เนื่องจาก Dump ข้อมูลถูกเขียนเพียงครั้งเดียวและหลังจากนั้นไม่สามารถเปลี่ยนแปลงได้ รูปแบบอย่าง Avro Object Container Files จึงเหมาะสม นี่ยังเป็นโอกาสที่ดีในการเข้ารหัสข้อมูลในรูปแบบเชิงคอลัมน์ที่เป็นมิตรกับการวิเคราะห์ เช่น Parquet (ดู "Column compression" ) ใน บทที่ 11 เราจะพูดถึงเพิ่มเติมเกี่ยวกับการใช้ข้อมูลในพื้นที่จัดเก็บถาวร ## Dataflow Through Services: REST and RPC (การไหลของข้อมูลผ่าน Service: REST และ RPC) เมื่อคุณมีกระบวนการที่ต้องสื่อสารผ่านเครือข่าย คุณสามารถจัดเตรียมการสื่อสารนั้นได้หลายวิธี รูปแบบที่พบบ่อยที่สุดคือการมีสองบทบาท: Client และ Server _Servers_ เปิดเผย API ผ่านเครือข่าย และ _Clients_ สามารถเชื่อมต่อกับ Servers เพื่อทำ Request ไปยัง API นั้น API ที่ Server เปิดเผยเรียกว่า _Service_ เว็บทำงานในลักษณะนี้: Clients (Web Browsers) ทำ Request ไปยัง Web Servers โดยทำ `GET` Request เพื่อดาวน์โหลด HTML, CSS, JavaScript, รูปภาพ ฯลฯ และทำ `POST` Request เพื่อส่งข้อมูลไปยัง Server API ประกอบด้วยชุด Protocol และรูปแบบข้อมูลที่ได้มาตรฐาน (HTTP, URLs, SSL/TLS, HTML ฯลฯ) เนื่องจาก Web Browsers, Web Servers และผู้สร้างเว็บไซต์ส่วนใหญ่เห็นพ้องต้องกันในมาตรฐานเหล่านี้ คุณจึงสามารถใช้ Web Browser ใดก็ได้เพื่อเข้าถึงเว็บไซต์ใดก็ได้ (อย่างน้อยในทางทฤษฎี!) Web Browsers ไม่ใช่ Client ประเภทเดียวเท่านั้น ตัวอย่างเช่น Native Apps ที่รันบนอุปกรณ์เคลื่อนที่และคอมพิวเตอร์เดสก์ท็อปมักจะคุยกับ Servers และแอปพลิเคชัน JavaScript ฝั่ง Client ที่รันภายใน Web Browsers ก็สามารถทำ HTTP Request ได้เช่นกัน ในกรณีนี้ Response ของ Server โดยทั่วไปไม่ใช่ HTML สำหรับแสดงให้มนุษย์เห็น แต่เป็นข้อมูลในการเข้ารหัสที่สะดวกสำหรับการประมวลผลเพิ่มเติมโดยโค้ดแอปพลิเคชันฝั่ง Client (ส่วนใหญ่เป็น JSON) แม้ว่า HTTP อาจถูกใช้เป็น Transport Protocol แต่ API ที่ implement บนนั้นเป็นแบบเฉพาะของแอปพลิเคชัน และ Client และ Server ต้องตกลงกันในรายละเอียดของ API นั้น ในบางแง่ Service ก็คล้ายกับฐานข้อมูล: โดยทั่วไปพวกมันอนุญาตให้ Client ส่งและ Query ข้อมูล อย่างไรก็ตาม ในขณะที่ฐานข้อมูลอนุญาตให้ Query ตามอำเภอใจโดยใช้ภาษา Query ที่เราพูดถึงใน บทที่ 3 นั้น Service เปิดเผย API เฉพาะของแอปพลิเคชันที่อนุญาตเฉพาะ Input และ Output ที่ถูกกำหนดไว้ล่วงหน้าโดย Business Logic (โค้ดแอปพลิเคชัน) ของ Service [ 30 ] ข้อจำกัดนี้ให้ Degree of Encapsulation: Service สามารถกำหนดข้อจำกัดแบบละเอียดว่าอะไรที่ Client ทำได้และทำไม่ได้ เป้าหมายการออกแบบหลักของ Service-Oriented/Microservices Architecture คือการทำให้แอปพลิเคชันเปลี่ยนแปลงและดูแลรักษาได้ง่ายขึ้น โดยทำให้ Service สามารถปรับใช้และพัฒนาได้อย่างอิสระ หลักการทั่วไปคือแต่ละ Service ควรเป็นเจ้าของโดยทีมเดียว และทีมนั้นควรสามารถปล่อยเวอร์ชันใหม่ของ Service ได้บ่อยครั้ง โดยไม่ต้องประสานงานกับทีมอื่น ดังนั้นเราควรคาดหวังว่า Servers และ Clients ทั้งเวอร์ชันเก่าและใหม่จะทำงานพร้อมกัน และการเข้ารหัสข้อมูลที่ใช้โดย Servers และ Clients จะต้องเข้ากันได้ในทุกเวอร์ชันของ Service API ตราบใดที่ API ยังคงเข้ากันได้ ทีมงานก็มีอิสระในการปรับเปลี่ยนระบบของพวกเขาในทางที่ต้องการ คุณสมบัตินี้ทำให้นักพัฒนาสามารถทำ Migration ภายในของข้อมูล Service หรือแม้กระทั่งทั้งระบบได้ง่ายขึ้นมาก ### Web services (Web Services) เมื่อ HTTP ถูกใช้เป็น Protocol พื้นฐานสำหรับการสื่อสารกับ Service มันถูกเรียกว่า _Web Service_ Web Services ถูกใช้โดยทั่วไปเมื่อสร้าง Service-Oriented หรือ Microservices Architecture (ที่กล่าวถึงก่อนหน้านี้ใน "Microservices and Serverless" ) คำนี้ค่อนข้างจะเรียกผิด เพราะ Web Services ไม่ได้ถูกใช้เฉพาะบนเว็บเท่านั้น แต่ในหลายบริบท ตัวอย่างเช่น: - แอปพลิเคชัน 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 Services เช่น ระบบประมวลผลบัตรเครดิต หรือ OAuth สำหรับการเข้าถึงข้อมูลผู้ใช้ร่วมกัน ปรัชญาการออกแบบ Service ที่ได้รับความนิยมมากที่สุดคือ REST ซึ่งสร้างขึ้นบนหลักการของ HTTP [ 31 , 32 ]. REST เน้นรูปแบบข้อมูลที่ง่าย ใช้ URL สำหรับระบุทรัพยากร และใช้คุณสมบัติของ HTTP สำหรับการควบคุม Cache, การรับรองความถูกต้อง และการเจรจาประเภทเนื้อหา API ที่ออกแบบตามหลักการของ REST เรียกว่า _RESTful_ โค้ด ที่ต้องการเรียกใช้ Web Service API ต้องรู้ว่า HTTP Endpoint ใดที่จะ Query และรูปแบบข้อมูลใดที่จะส่งและคาดหวังใน Response แม้ว่า Service จะนำหลักการออกแบบ RESTful มาใช้ Clients ก็ยังคงต้องค้นหารายละเอียดเหล่านี้ นักพัฒนา Service มักใช้ IDL เพื่อกำหนดและจัดทำเอกสาร API Endpoints และ Data Models ของ Service และพัฒนาให้ทันสมัยอยู่เสมอ นักพัฒนาคนอื่นสามารถใช้ Service Definition เพื่อกำหนดวิธีการ Query Service ได้ IDL ของ Service ที่ได้รับความนิยมมากที่สุดสองแบบคือ OpenAPI (หรือที่เรียกว่า Swagger [ 33 ]) ใช้สำหรับ Web Services ที่ส่งและรับ JSON และ Protocol Buffers ใช้สำหรับ gRPC Services นักพัฒนามักเขียน OpenAPI Service Definitions ใน JSON หรือ YAML (ดู ตัวอย่างที่ 5-3 ). Service Definition ช่วยให้นักพัฒนาสามารถกำหนด Service Endpoints, เอกสาร, เวอร์ชัน, Data Models และอื่น ๆ อีกมากมาย Protocol Buffers Service Definitions ใช้ IDL ที่เราเห็นใน "Protocol Buffers" ##### ตัวอย่างที่ 5-3. คำจำกัดความของ OpenAPI Service ใน YAML ```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 ของ Service ของพวกเขา _Service Framework_ เช่น Spring Boot, FastAPI หรือ gRPC มักถูกนำมาใช้เพื่อลดความพยายามนี้ Service Frameworks ช่วยให้นักพัฒนาโฟกัสไปที่การเขียน Business Logic สำหรับแต่ละ API Endpoint ในขณะที่โค้ดของ Framework จัดการ Routing, Metrics, Caching, Authentication และอื่น ๆ ตัวอย่างที่ 5-4 แสดงการ Implement ใน Python ของ Service ที่กำหนดใน ตัวอย่างที่ 5-3 ##### ตัวอย่างที่ 5-4. FastAPI Service ที่ Implement คำจำกัดความจาก ตัวอย่างที่ 5-3 ```sql 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 Definitions และ Server Code เข้าด้วยกัน ในบางกรณี เช่นกับ Python FastAPI Framework ที่ได้รับความนิยม Servers ถูกเขียนในโค้ดและ IDL ถูกสร้างโดยอัตโนมัติ ในกรณีอื่น ๆ เช่นกับ gRPC Service Definition ถูกเขียนก่อน และโครงสร้างโค้ด Server ถูกสร้างขึ้น ทั้งสองวิธีช่วยให้นักพัฒนาสามารถสร้าง Client Libraries และ SDKs ในภาษาต่าง ๆ จาก Service Definition นอกจากการสร้างโค้ดแล้ว เครื่องมือ IDL อย่าง Swagger ยังสามารถสร้างเอกสาร ตรวจสอบความเข้ากันได้ของการเปลี่ยนแปลง Schema และจัดเตรียม GUI สำหรับนักพัฒนาในการ Query และทดสอบ Services ### The problems with remote procedure calls (ปัญหาของ Remote Procedure Calls) Web Services เป็นเพียง Avatar ล่าสุดของเทคโนโลยีมากมายสำหรับการทำ 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) ที่ถูกนำเสนอครั้งแรกในยุค 1970s [ 38 ]. โมเดล RPC พยายามทำให้ Request ไปยัง Service เครือข่ายระยะไกลดูเหมือนกับการเรียกฟังก์ชันหรือ Method ในกระบวนการเดียวกัน (Abstraction นี้เรียกว่า _Location Transparency_ ) แม้ว่าสิ่งนี้จะดูสะดวกในตอนแรก แต่วิธีการนี้มีข้อบกพร่องโดยพื้นฐาน [ 39 , 40 ]. Network Request แตกต่างอย่างมากจากการเรียกฟังก์ชันในเครื่อง ด้วยเหตุผลหลายประการ: - การเรียกฟังก์ชันในเครื่องคาดเดาได้และจะสำเร็จหรือล้มเหลวขึ้นอยู่กับพารามิเตอร์ที่คุณควบคุมได้ Network Request นั้นคาดเดาไม่ได้ ด้วยเหตุผลที่อยู่นอกเหนือการควบคุมของคุณโดยสิ้นเชิง Request หรือ Response อาจสูญหายเนื่องจากปัญหาเครือข่าย หรือเครื่องระยะไกลอาจช้าหรือไม่พร้อมให้บริการ ปัญหาเครือข่ายเป็นเรื่องปกติ ดังนั้นแอปพลิเคชันต้องคาดการณ์ไว้ล่วงหน้า (เช่น โดยการ Retry Request ที่ล้มเหลว) - การเรียกฟังก์ชันในเครื่องจะส่งคืนผลลัพธ์, โยน Exception, หรือไม่ส่งคืนเลย (เพราะเข้าสู่ Infinite Loop หรือกระบวนการ 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 Primitives เช่นตัวเลขหรือสตริงสั้น ๆ แต่มันกลายเป็นปัญหาอย่างรวดเร็วเมื่อมีข้อมูลจำนวนมากและ Mutable Objects - Client และ Service อาจถูก Implement ในภาษาโปรแกรมมิ่งที่แตกต่างกัน ดังนั้น RPC Framework ต้องแปลประเภทข้อมูลจากภาษาหนึ่งไปยังอีกภาษาหนึ่ง ซึ่งอาจจบลงอย่างน่าเกลียด เพราะไม่ใช่ทุกภาษาที่มีประเภทเดียวกัน—ลองนึกถึงปัญหาของ JavaScript กับตัวเลขที่มากกว่า 2 53 ตัวอย่างเช่น (ดู "JSON, XML, and Binary Variants" ) ปัญหานี้ไม่มีอยู่ในกระบวนการเดียวที่เขียนด้วยภาษาเดียว ปัจจัยทั้งหมดเหล่านี้หมายความว่าไม่มีประโยชน์ที่จะพยายามทำให้ Service ระยะไกลดูเหมือน Object ในเครื่องในภาษาโปรแกรมมิ่งของคุณมากเกินไป เพราะมันเป็นสิ่งที่แตกต่างกันโดยพื้นฐาน ส่วนหนึ่งของเสน่ห์ของ REST คือมันถือว่าการถ่ายโอนสถานะผ่านเครือข่ายเป็นกระบวนการที่แตกต่างจากการเรียกฟังก์ชัน ### Load balancers, service discovery, and service meshes (Load Balancers, Service Discovery และ Service Meshes) Services ทั้งหมดสื่อสารผ่านเครือข่าย ด้วยเหตุนี้ Client จึงต้องรู้ที่อยู่ของ Service ที่มันกำลังเชื่อมต่อ—ปัญหาที่เรียกว่า _Service Discovery_ วิธีที่ง่ายที่สุดคือการกำหนดค่าให้ Client เชื่อมต่อไปยัง IP Address และ Port ที่ Service รันอยู่ การกำหนดค่านี้จะใช้ได้ แต่ถ้า Server ออฟไลน์ ถูกย้ายไปยังเครื่องใหม่ หรือโอเวอร์โหลด Client จะต้องถูกกำหนดค่าใหม่ด้วยตนเอง เพื่อให้มีความพร้อมใช้งานและความสามารถในการปรับขนาดที่สูงขึ้น โดยปกติแล้วหลาย Instance ของ Service จะทำงานบนเครื่องจำนวนมาก ซึ่ง Instance ใดก็ได้สามารถจัดการ Request ที่เข้ามาได้ การกระจาย Request ไปยัง Instance เหล่านี้เรียกว่า _Load Balancing_ [ 42 ]. มี Load Balancing และ Service Discovery มากมายให้เลือก: Hardware load balancers (Load Balancers แบบฮาร์ดแวร์) อุปกรณ์ เฉพาะเหล่านี้ถูกติดตั้งใน Datacenters มันอนุญาตให้ Clients เชื่อมต่อไปยัง Host และ Port เดียว และการเชื่อมต่อที่เข้ามาจะถูก Route ไปยัง Server ตัวใดตัวหนึ่งที่รัน Service อยู่ Load Balancers เหล่านี้ตรวจจับความล้มเหลวของเครือข่ายเมื่อเชื่อมต่อกับ Downstream Server และเปลี่ยนเส้นทาง Traffic ไปยัง Servers อื่น Software load balancers (เช่น NGINX และ HAProxy) เหล่านี้ทำงานในลักษณะเดียวกันกับ Hardware Load Balancers แต่แทนที่จะต้องใช้อุปกรณ์พิเศษ พวกมันเป็นแอปพลิเคชันที่สามารถติดตั้งบนเครื่องมาตรฐานได้ 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 systems (ระบบ Service Discovery) ระบบ เหล่านี้ใช้ Registry ส่วนกลาง เช่น etcd หรือ Apache ZooKeeper แทน DNS เพื่อติดตามว่า Service Endpoints ใดบ้างที่พร้อมใช้งาน (เราจะกลับมาที่ระบบเหล่านี้ใน "Coordination Services" ). เมื่อ Instance ใหม่ของ Service เริ่มทำงาน มันจะลงทะเบียนตัวเองกับระบบ Service Discovery โดยประกาศ Host และ Port ที่มันกำลังฟังอยู่ พร้อมกับ Metadata ที่เกี่ยวข้อง เช่น ข้อมูลความเป็นเจ้าของ Shard (ดู บทที่ 7 ), ตำแหน่งที่ตั้ง Datacenter และอื่น ๆ จากนั้น Service จะส่งสัญญาณ Heartbeat ไปยัง Discovery System เป็นระยะเพื่อส่งสัญญาณว่า Service ยังคงพร้อมใช้งาน เมื่อ Client ต้องการเชื่อมต่อกับ Service มันจะ Query Discovery System ก่อนเพื่อรับรายการ Endpoints ที่พร้อมใช้งาน จากนั้นเชื่อมต่อโดยตรงกับ Endpoint เมื่อเทียบกับ DNS Service Discovery รองรับสภาพแวดล้อมที่มีพลวัตสูงกว่ามากซึ่ง Service Instances เปลี่ยนแปลงบ่อยครั้ง Discovery Systems ยังให้ Metadata แก่ Clients เกี่ยวกับ Service ที่พวกเขากำลังเชื่อมต่ออยู่ ซึ่งช่วยให้ Clients สามารถตัดสินใจ Load Balancing ที่ชาญฉลาดขึ้น Service meshes (Service Meshes) รูปแบบ การทำ Load Balancing ที่ซับซ้อนนี้รวม Software Load Balancers และ Service Discovery เข้าด้วยกัน แตกต่างจาก Software Load Balancers แบบดั้งเดิมที่รันบนเครื่องแยกต่างหาก Service Mesh Load Balancer โดยทั่วไปถูกปรับใช้เป็น In-Process Client Library หรือเป็น Process หรือ "Sidecar" Container ทั้งบน Client และ Server แอปพลิเคชัน Client เชื่อมต่อกับ Service Load Balancer ในเครื่องของตัวเอง ซึ่งเชื่อมต่อไปยัง Load Balancer ของ Server จากนั้นการเชื่อมต่อจะถูก Route ไปยัง Local Server Process แม้จะซับซ้อน แต่โทโพโลยีนี้มีข้อดี เนื่องจาก Clients และ Servers ถูก Route ผ่านการเชื่อมต่อในเครื่องทั้งหมด การเข้ารหัสการเชื่อมต่อสามารถจัดการได้ทั้งหมดที่ระดับ Load Balancer สิ่งนี้ป้องกัน Clients และ Servers จากความยุ่งยากของ SSL Certificates และ TLS ระบบ Mesh ยังให้ความสามารถในการสังเกตการณ์ที่ซับซ้อน พวกมันสามารถติดตามว่า Service ใดกำลังเรียกกันแบบ Real-Time, ตรวจจับความล้มเหลว, ติดตาม Traffic Load และอื่น ๆ โซลูชันใดที่เหมาะสมขึ้นอยู่กับความต้องการขององค์กร ผู้ที่ทำงานในสภาพแวดล้อม Service ที่มีพลวัตสูงพร้อม Orchestrator อย่าง Kubernetes มักเลือกใช้ Service Mesh อย่าง Istio หรือ Linkerd โครงสร้างพื้นฐานเฉพาะทาง เช่น ฐานข้อมูลหรือระบบ Messaging อาจต้องใช้ Load Balancers ที่สร้างขึ้นเฉพาะของตัวเอง การปรับใช้ที่เรียบง่ายกว่าควรใช้ Software Load Balancers ### Data encoding and evolution for RPC (การเข้ารหัสข้อมูลและวิวัฒนาการสำหรับ RPC) สำหรับความสามารถในการปรับเปลี่ยน สิ่งสำคัญคือ RPC Clients และ Servers สามารถเปลี่ยนแปลงและปรับใช้ได้อย่างอิสระ เมื่อเทียบกับข้อมูลที่ไหลผ่านฐานข้อมูล (ดังที่อธิบายใน "Dataflow Through Databases" ) เราสามารถตั้งสมมติฐานที่ทำให้ง่ายขึ้นในกรณีของ Dataflow ผ่าน Service: มันสมเหตุสมผลที่จะสมมติว่า Servers ทั้งหมดจะถูกอัปเดตก่อน และ Clients ทั้งหมดในภายหลัง ดังนั้น คุณต้องการ Backward Compatibility เฉพาะใน Request และ Forward Compatibility ใน Response คุณสมบัติ Backward และ Forward Compatibility ของ RPC Scheme มาจากการเข้ารหัสที่มันใช้: - gRPC (Protocol Buffers) และ Avro RPC สามารถถูกพัฒนาได้ตามกฎความเข้ากันได้ของรูปแบบการเข้ารหัสที่เกี่ยวข้อง - RESTful APIs ส่วนใหญ่มักใช้ JSON สำหรับ Response และ JSON หรือ URI-encoded/form-encoded Request Parameters สำหรับ Request การเพิ่ม Optional Request Parameters และการเพิ่มฟิลด์ใหม่ให้กับ Response Objects โดยทั่วไปถือเป็นการเปลี่ยนแปลงที่คงความเข้ากันได้ ความเข้ากันได้ของ Service นั้นยากขึ้นเพราะ RPC มักใช้สำหรับการสื่อสารข้ามขอบเขตขององค์กร ดังนั้นผู้ให้บริการของ Service มักไม่สามารถควบคุม Clients ของมันและไม่สามารถบังคับให้พวกเขาอัปเกรดได้ ดังนั้น Compatibility ต้องคงอยู่เป็นเวลานาน อาจจะไม่มีกำหนด หากจำเป็นต้องมีการเปลี่ยนแปลงที่ทำลาย Compatibility ผู้ให้บริการ Service มักจะต้องดูแล Service API หลายเวอร์ชันควบคู่กันไป ไม่มีข้อตกลงร่วมกันว่า API Versioning ควรทำงานอย่างไร (เช่น Client จะระบุได้อย่างไรว่ามันต้องการใช้ API เวอร์ชันใด [ 43 ]) สำหรับ RESTful APIs วิธีทั่วไปคือการใช้หมายเลขเวอร์ชันใน URL หรือใน HTTP `Accept` Header สำหรับ Service ที่ใช้ API Keys เพื่อระบุ Client โดยเฉพาะ อีกทางเลือกหนึ่งคือการจัดเก็บ API เวอร์ชันที่ Client ร้องขอไว้บน Server และอนุญาตให้การเลือกเวอร์ชันนี้ถูกอัปเดตผ่าน Administrative Interface ที่แยกต่างหาก [ 44 ] ## Durable Execution and Workflows (Durable Execution และ Workflows) ตามคำจำกัดความ สถาปัตยกรรมแบบ Service-Based มีหลาย Service ที่รับผิดชอบส่วนต่าง ๆ ของแอปพลิเคชัน ลองพิจารณาแอปพลิเคชันประมวลผลการชำระเงินที่เรียกเก็บเงินจากบัตรเครดิตและฝากเงินเข้าบัญชีธนาคาร ระบบนี้น่าจะมี Service ที่แตกต่างกันสำหรับการตรวจจับการทุจริต, การรวมระบบบัตรเครดิต, การรวมระบบธนาคาร และอื่น ๆ การประมวลผลการชำระเงินครั้งเดียวในตัวอย่างของเราต้องการการเรียก Service หลายครั้ง Service ประมวลผลการชำระเงินอาจเรียก Fraud Detection Service เพื่อตรวจสอบการทุจริต, เรียก Credit Card Service เพื่อหักเงินบัตรเครดิต, และเรียก Banking Service เพื่อฝากเงินที่ถูกหัก ดังที่แสดงใน รูปที่ 5-7 . เราเรียกลำดับขั้นตอนนี้ว่า _Workflow_ และแต่ละขั้นตอนเรียกว่า _Task_ . Workflows โดยทั่วไปถูกกำหนดเป็น Graph ของ Tasks คำจำกัดความของ Workflow อาจถูกเขียนในภาษาโปรแกรมมิ่งทั่วไป, ภาษาเฉพาะโดเมน (DSL), หรือภาษา Markup เช่น Business Process Execution Language (BPEL) [ 45 ] # Tasks, activities, and functions (Tasks, Activities และ Functions) Workflow Engines ที่แตกต่างกันใช้ชื่อที่แตกต่างกันสำหรับ Tasks ตัวอย่างเช่น Temporal ใช้คำว่า _activity_ บางแห่งอ้างถึง Tasks ว่า _durable functions_ แม้ว่าชื่อจะแตกต่างกัน แต่แนวคิดก็เหมือนกัน ![Diagram illustrating a payment processing workflow using BPMN, showing steps for computing fraud risk, crediting a bank account, and potential transaction outcomes.](/images/book/designing-data-intensive-applications-2nd/ae0542be-fdff-4cd5-86bd-d3d1c357b31f.png) ###### รูปที่ 5-7. Workflow ที่แสดงโดยใช้ Business Process Model and Notation (BPMN) ซึ่งเป็นสัญลักษณ์แบบกราฟิก Workflows ถูก Run หรือ Execute โดย _Workflow Engine_ . Workflow Engines กำหนดว่าเมื่อใดและบนเครื่องใดที่จะรันแต่ละ Task, จะทำอย่างไรถ้า Task ล้มเหลว (เช่น ถ้าเครื่อง Crash ขณะที่ Task กำลังทำงาน), อนุญาตให้ Task ทำงานแบบขนานได้กี่ Task และอื่น ๆ Workflow Engines โดยทั่วไปประกอบด้วย Orchestrator และ Executor: _Orchestrator_ มีหน้าที่จัดตารางเวลาให้ Tasks ถูก Execute และ _Executor_ มีหน้าที่ Execute Tasks การ Execute เริ่มต้นเมื่อ Workflow ถูก Trigger Orchestrator จะ Trigger Workflow เองถ้าผู้ใช้กำหนดตารางเวลาแบบอิงเวลา เช่น การ Execute ทุกชั่วโมง แหล่งข้อมูลภายนอก เช่น Web Service หรือแม้แต่มนุษย์ก็สามารถ Trigger การ Execute Workflow ได้เช่นกัน เมื่อถูก Trigger แล้ว Executors จะถูกเรียกให้รัน Tasks มี Workflow Engines หลายประเภทที่รองรับ Use Case ที่หลากหลาย บางประเภท เช่น Airflow, Dagster และ Prefect, รวมระบบกับ Data Systems และจัด Orchestrate งาน ETL บางประเภท เช่น Camunda และ Orkes, มีสัญลักษณ์แบบกราฟิกสำหรับ Workflows (เช่น BPMN ที่ใช้ใน รูปที่ 5-7 ) เพื่อให้ผู้ที่ไม่ใช่วิศวกรสามารถกำหนดและ Execute Workflows ได้ง่ายขึ้น บางประเภท เช่น Temporal และ Restate, ให้บริการ _Durable Execution_ Durable Execution Frameworks กลายเป็นวิธีที่ได้รับความนิยมในการสร้างสถาปัตยกรรมแบบ Service-Based ที่ต้องการ Transactionality ในตัวอย่างการชำระเงินของเรา เราต้องการประมวลผลการชำระเงินแต่ละครั้งเพียงครั้งเดียว (Exactly Once) ความล้มเหลวในขณะที่ Workflow กำลัง Execute อาจส่งผลให้มีการเรียกเก็บเงินจากบัตรเครดิตแต่ไม่มีการฝากเข้าบัญชีธนาคารที่สอดคล้องกัน ในสถาปัตยกรรมแบบ Service-Based เราไม่สามารถห่อ Task สองอย่างใน Database Transaction ได้ ยิ่งไปกว่านั้น เราอาจกำลังโต้ตอบกับ Payment Gateway ของบุคคลที่สามที่เรามีการควบคุมอย่างจำกัด Durable Execution Frameworks เป็นวิธีการให้ _Exactly-Once Semantics_ สำหรับ Workflows ถ้า Task ล้มเหลว Framework จะ Execute Task ใหม่ แต่จะข้าม RPC Calls หรือการเปลี่ยนแปลงสถานะที่ Task ทำสำเร็จก่อนที่จะล้มเหลว มันจะแกล้งทำการเรียก แต่จะส่งคืนผลลัพธ์จากการเรียกครั้งก่อนแทน สิ่งนี้เป็นไปได้เพราะ Durable Execution Frameworks Log RPC ทั้งหมดและการเปลี่ยนแปลงสถานะทั้งหมดไปยัง 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), ) # ... ``` Frameworks อย่าง Temporal ก็มีความท้าทายไม่น้อย Services ภายนอก เช่น Payment Gateway ของบุคคลที่สามในตัวอย่างของเรา ยังคงต้องให้ Idempotent API นักพัฒนาต้องจำใช้ Unique IDs สำหรับ API เหล่านี้เพื่อป้องกันการ Execute ซ้ำ [ 48 ] และเนื่องจาก Durable Execution Frameworks Log แต่ละ RPC Call ตามลำดับ พวกมันคาดหวังให้การ Execute ครั้งต่อ ๆ ไปทำ RPC Calls เดียวกันในลำดับเดียวกัน ทำให้การเปลี่ยนแปลงโค้ดเปราะบาง คุณอาจสร้างพฤติกรรมที่ไม่ได้กำหนดขึ้นเพียงแค่การเรียงลำดับ Function Calls ใหม่ [ 49 ] แทนที่จะแก้ไขโค้ดของ Workflow ที่มีอยู่ มันปลอดภัยกว่าที่จะปรับใช้โค้ดเวอร์ชันใหม่แยกต่างหาก เพื่อให้การ Execute ใหม่ของ Workflow Invocation ที่มีอยู่ยังคงใช้เวอร์ชันเก่าต่อไป และเฉพาะ Invocation ใหม่เท่านั้นที่ใช้โค้ดใหม่ [ 50 ] ในทำนองเดียวกัน เนื่องจาก Durable Execution Frameworks คาดหวังให้ Playback โค้ดทั้งหมดอย่าง Deterministic (Input เดียวกันให้ Output เดียวกัน) โค้ดที่ไม่เป็น Deterministic เช่น การเรียก Random Number Generator หรือ System Clock จึงเป็นปัญหา [ 49 ] Frameworks มักจะมี Implementations แบบ Deterministic ของ Library Functions ดังกล่าว แต่นักพัฒนาต้องจำใช้มัน บาง Framework ยังมีเครื่องมือ Static Analysis เช่น Temporal's Workflow Check เพื่อตรวจสอบว่ามีพฤติกรรมที่ไม่เป็น Deterministic ถูกนำเข้ามาหรือไม่ ###### Note (หมายเหตุ) การทำให้โค้ดเป็น Deterministic เป็นแนวคิดที่ทรงพลังแต่ทำอย่าง Robust ได้ยาก เราจะกลับมาที่หัวข้อนี้ใน บทที่ 9 ## Event-Driven Architectures (Event-Driven Architectures) ใน ส่วนสุดท้ายนี้ เราจะดูคร่าว ๆ ถึง _Event-Driven Architectures_ ซึ่งเป็นอีกวิธีหนึ่งที่ข้อมูลที่ถูกเข้ารหัสสามารถไหลจากกระบวนการหนึ่งไปยังอีกกระบวนการหนึ่ง ในบริบทนี้ Request จะถูกเรียกว่า _Event_ หรือ _Message_ . แตกต่างจาก RPC ผู้ส่งโดยทั่วไปไม่ต้องรอให้ผู้รับประมวลผล Event นอกจากนี้ Events โดยทั่วไปไม่ได้ถูกส่งไปยังผู้รับผ่านการเชื่อมต่อเครือข่ายโดยตรง แต่จะผ่านตัวกลางที่เรียกว่า _Message Broker_ (หรือเรียกว่า _Event Broker_ , _Message Queue_ หรือ _Message-Oriented Middleware_ ) ซึ่งจะจัดเก็บ Message ไว้ชั่วคราว [ 51 ] การใช้ Message Broker มีข้อดีหลายประการเมื่อเทียบกับ RPC โดยตรง: - มันสามารถทำหน้าที่เป็น Buffer ถ้าผู้รับไม่พร้อมใช้งานหรือโอเวอร์โหลด ช่วยเพิ่มความน่าเชื่อถือของระบบ - มันสามารถส่ง Message ไปยัง Process ที่ Crash ได้โดยอัตโนมัติ ป้องกันไม่ให้ Message สูญหาย - มันช่วยลดความจำเป็นในการ Service Discovery เนื่องจากผู้ส่งไม่ต้องเชื่อมต่อโดยตรงกับ IP Address ของผู้รับ - มันอนุญาตให้ Message เดียวกันถูกส่งไปยังผู้รับหลายราย - มันแยกผู้ส่งออกจากผู้รับในเชิงตรรกะ (ผู้ส่งแค่เผยแพร่ Messages และไม่สนใจว่าใครจะบริโภคมัน) การสื่อสารผ่าน Message Broker เป็นแบบ _Asynchronous_ : ผู้ส่งไม่ต้องรอให้ Message ถูกส่งถึง แต่เพียงส่งแล้วก็ลืมมัน อย่างไรก็ตาม มันเป็นไปได้ที่จะ Implement โมเดลแบบ RPC แบบ Synchronous โดยให้ผู้ส่งรอ Response บนช่องทางแยกต่างหาก ### Message brokers (Message Brokers) ในอดีต ภาพรวมของ Message Brokers ถูกครอบงำโดยซอฟต์แวร์ Enterprise เชิงพาณิชย์จากบริษัท เช่น TIBCO, IBM WebSphere และ webMethods ก่อนที่ Open Source Implementations เช่น RabbitMQ, ActiveMQ, HornetQ, NATS, Redpanda และ Apache Kafka จะได้รับความนิยม ในระยะหลัง Cloud Services เช่น Amazon Kinesis, Azure Service Bus และ Google Cloud Pub/Sub ก็ถูกนำมาใช้มากขึ้น เราจะเปรียบเทียบพวกมันในรายละเอียดเพิ่มเติมใน บทที่ 12 รายละเอียด Delivery Semantics แตกต่างกันไปตาม Implementation และ Configuration แต่โดยทั่วไปแล้ว รูปแบบการกระจาย Message สองแบบมักถูกใช้บ่อยที่สุด: - หนึ่ง Process เพิ่ม Message ไปยัง _Queue_ ที่มีชื่อ และ _Consumer_ ของ Queue จะได้รับ Message นั้น หากมี Consumer หลายตัว หนึ่งในนั้นจะได้รับ Message - หนึ่ง Process เผยแพร่ Message ไปยัง _Topic_ ที่มีชื่อ และ Broker จะส่ง Message นั้นไปยัง _Subscribers_ ทั้งหมดของ Topic นั้น หากมี Subscribers หลายตัว พวกมันทั้งหมดจะได้รับ Message Message Brokers โดยทั่วไปไม่ได้บังคับใช้ Model ข้อมูลใดโดยเฉพาะ Message เป็นเพียงลำดับของไบต์ที่มี Metadata ดังนั้นคุณสามารถใช้รูปแบบการเข้ารหัสใดก็ได้ วิธีการทั่วไปคือการใช้ Protocol Buffers, Avro หรือ JSON และปรับใช้ Schema Registry ควบคู่ไปกับ Message Broker เพื่อจัดเก็บ Schema เวอร์ชันที่ถูกต้องทั้งหมดและตรวจสอบความเข้ากันได้ [ 20 , 22 ] AsyncAPI ซึ่งเป็นสิ่งที่เทียบเท่า OpenAPI สำหรับ Messaging ก็สามารถใช้เพื่อระบุ Schema ของ Messages ได้เช่นกัน Message Brokers แตกต่างกันในเรื่องความคงทน (Durability) ของ Messages หลายตัวเขียน Messages ลงดิสก์เพื่อไม่ให้สูญหายถ้า Message Broker Crash หรือต้องรีสตาร์ท แตกต่างจากฐานข้อมูล Message Brokers หลายตัวลบ Messages โดยอัตโนมัติหลังจากที่ถูกบริโภคแล้ว อย่างไรก็ตาม บาง Broker สามารถกำหนดค่าให้เก็บ Messages ไว้อย่างไม่มีกำหนด ซึ่งคุณจะต้องการถ้าคุณต้องการใช้ Event Sourcing (ดู "Event Sourcing and CQRS" ) ถ้า Consumer เผยแพร่ Messages ไปยัง Topic อีกครั้ง คุณอาจต้องระวังในการรักษาฟิลด์ที่ไม่รู้จักไว้ เพื่อป้องกันปัญหาที่อธิบายไว้ก่อนหน้านี้ในบริบทของฐานข้อมูล ( รูปที่ 5-1 ) ### Distributed actor frameworks (Distributed Actor Frameworks) _Actor Model_ คือ Model การเขียนโปรแกรมสำหรับการทำงานพร้อมกัน (Concurrency) ใน Process เดียว แทนที่จะจัดการกับ Thread โดยตรง (และปัญหาที่เกี่ยวข้องของ Race Conditions, Locking และ Deadlock) Logic จะถูก encapsulate ไว้ใน _Actors_ . แต่ละ Actor โดยทั่วไปแทน Client หรือ Entity หนึ่งราย มันอาจมี Local State (ที่ไม่ได้แชร์กับ Actor อื่น) และสื่อสารกับ Actor อื่นโดยการส่งและรับ Asynchronous Message การจัดส่ง Message ไม่ได้รับการรับประกัน; ในสถานการณ์ Error ที่กำหนด Messages จะสูญหาย เนื่องจากแต่ละ Actor ประมวลผลเพียงหนึ่ง Message ในแต่ละครั้ง มันจึงไม่ต้องกังวลเกี่ยวกับ Threads และแต่ละ Actor สามารถถูกจัดตารางเวลาได้อย่างอิสระโดย Framework ใน _Distributed Actor Frameworks_ เช่น Akka, Orleans [ 52 ] และ Erlang/OTP Model การเขียนโปรแกรมนี้ถูกใช้เพื่อปรับขนาดแอปพลิเคชันข้ามหลายโหนด กลไกการส่งข้อความเดียวกันถูกใช้ ไม่ว่าผู้ส่งและผู้รับจะอยู่บนโหนดเดียวกันหรือต่างโหนด หากพวกมันอยู่บนคนละโหนด Message จะถูกเข้ารหัสเป็นลำดับไบต์อย่างโปร่งใส ส่งผ่านเครือข่าย และถอดรหัสอีกฝั่ง Location Transparency ทำงานได้ดีกว่าใน Actor Model มากกว่าใน RPC เพราะ Actor Model สมมติอยู่แล้วว่า Message อาจสูญหายได้ แม้แต่ใน Process เดียว แม้ว่า Latency บนเครือข่ายอาจสูงกว่าใน Process เดียว แต่ก็มีความไม่สอดคล้องพื้นฐานระหว่างการสื่อสารในเครื่องและระยะไกลน้อยกว่าเมื่อใช้ Actor Model Distributed Actor Framework โดยพื้นฐานแล้วรวม Message Broker และ Actor Programming Model เข้าเป็น Framework เดียว อย่างไรก็ตาม หากคุณต้องการทำ Rolling Upgrades ของแอปพลิเคชันแบบ Actor-Based ของคุณ คุณยังคงต้องกังวลเกี่ยวกับ Forward และ Backward Compatibility เนื่องจาก Messages อาจถูกส่งจากโหนดที่รันเวอร์ชันใหม่ไปยังโหนดที่รันเวอร์ชันเก่า และในทางกลับกัน สิ่งนี้สามารถทำได้โดยใช้หนึ่งในการเข้ารหัสที่กล่าวถึงในบทนี้ # Summary (สรุป) ในบทนี้เราได้ดูหลายวิธีในการเปลี่ยนโครงสร้างข้อมูลให้เป็นไบต์บนเครือข่ายหรือบนดิสก์ เราเห็นว่ารายละเอียดของการเข้ารหัสเหล่านี้ส่งผลกระทบไม่เพียงต่อประสิทธิภาพเท่านั้น แต่ที่สำคัญกว่านั้นคือต่อสถาปัตยกรรมของแอปพลิเคชันและทางเลือกของคุณในการพัฒนาแอปพลิเคชันเหล่านั้น โดยเฉพาะอย่างยิ่ง หลาย Service จำเป็นต้องรองรับ Rolling Upgrades ซึ่งเวอร์ชันใหม่ของ Service จะถูกปรับใช้ทีละสองสามโหนด แทนที่จะเป็นทั้งหมดพร้อมกัน Rolling Upgrades อนุญาตให้ปล่อยเวอร์ชันใหม่ของ Service ได้โดยไม่ต้อง Downtime (จึงส่งเสริมให้ปล่อยเวอร์ชันเล็กบ่อยครั้งแทนที่จะปล่อยเวอร์ชันใหญ่ไม่บ่อย) และทำให้การปรับใช้มีความเสี่ยงน้อยลง (ช่วยให้ตรวจพบและ Rollback การปล่อยที่มีข้อบกพร่องก่อนที่จะส่งผลกระทบต่อผู้ใช้จำนวนมาก) คุณสมบัติเหล่านี้มีประโยชน์อย่างมากต่อ _Evolvability_ ซึ่งคือความง่ายในการเปลี่ยนแปลงแอปพลิเคชัน ในระหว่าง Rolling Upgrades หรือด้วยเหตุผลอื่น ๆ เราต้องถือว่าโหนดต่าง ๆ กำลังรันโค้ดแอปพลิเคชันเวอร์ชันที่แตกต่างกัน ดังนั้นจึงเป็นสิ่งสำคัญที่ข้อมูลทั้งหมดที่ไหลไปรอบ ๆ ระบบจะถูกเข้ารหัสในลักษณะที่ให้ Backward Compatibility (โค้ดใหม่อ่านข้อมูลเก่าได้) และ Forward Compatibility (โค้ดเก่าอ่านข้อมูลใหม่ได้) เราพูดถึงรูปแบบการเข้ารหัสข้อมูลหลายรูปแบบและคุณสมบัติความเข้ากันได้ของพวกมัน: - การเข้ารหัสเฉพาะภาษาโปรแกรมมิ่ง (Language-Specific Encodings) ถูกจำกัดให้ใช้ภาษาโปรแกรมมิ่งภาษาเดียว และมักไม่สามารถให้ Forward และ Backward Compatibility - รูปแบบข้อความอย่าง JSON, XML และ CSV แพร่หลาย และความเข้ากันได้ของพวกมันขึ้นอยู่กับวิธีที่คุณใช้ พวกมันมีภาษา Schema ที่เป็นทางเลือก ซึ่งบางครั้งมีประโยชน์และบางครั้งก็เป็นอุปสรรค รูปแบบเหล่านี้ค่อนข้างคลุมเครือเกี่ยวกับประเภทข้อมูล ดังนั้นคุณต้องระวังกับเรื่องเช่นตัวเลขและ Binary Strings - รูปแบบไบนารีที่ขับเคลื่อนด้วย Schema อย่าง Protocol Buffers และ Avro ช่วยให้การเข้ารหัสมีขนาดกะทัดรัดและมีประสิทธิภาพ พร้อมด้วยความหมายของ Forward และ Backward Compatibility ที่ชัดเจน Schema สามารถมีประโยชน์สำหรับเอกสารและการสร้างโค้ดในภาษาที่มี Static Typing อย่างไรก็ตาม รูปแบบเหล่านี้มีข้อเสียคือข้อมูลต้องถูกถอดรหัสก่อนจึงจะสามารถอ่านโดยมนุษย์ได้ เรายังพูดถึงรูปแบบการไหลของข้อมูลหลายรูปแบบ ซึ่งแสดงให้เห็นสถานการณ์ต่าง ๆ ที่การเข้ารหัสข้อมูลมีความสำคัญ: Databases (ฐานข้อมูล) กระบวนการที่เขียนลงฐานข้อมูลจะเข้ารหัสข้อมูล และกระบวนการที่อ่านจากฐานข้อมูลจะถอดรหัสมัน RPC and REST APIs (RPC และ REST APIs) Client เข้ารหัส Request, Server ถอดรหัส Request และเข้ารหัส Response, และ Client ถอดรหัส Response ในที่สุด Event-driven architectures (Event-Driven Architectures โดยใช้ Message Brokers หรือ Actors) โหนดสื่อสารกันโดยการส่ง Messages ซึ่งถูกเข้ารหัสโดยผู้ส่งและถอดรหัสโดยผู้รับ เราสามารถสรุปได้ว่าด้วยความระมัดระวังเล็กน้อย Backward/Forward Compatibility และ Rolling Upgrades ก็สามารถบรรลุได้ ขอให้แอปพลิเคชันของคุณมีการพัฒนาที่รวดเร็วและการปรับใช้ที่บ่อยครั้ง

Workflow engines โดยทั่วไปประกอบด้วย orchestrator และ executor: _orchestrator_ มีหน้าที่จัดตารางการทำงานของ task ที่ต้องดำเนินการ และ _executor_ มีหน้าที่ดำเนินการ task การทำงานจะเริ่มขึ้นเมื่อ workflow ถูก trigger orchestrator จะ trigger workflow เองถ้าผู้ใช้กำหนดตารางเวลาตามเวลา เช่น การทำงานทุกชั่วโมง แหล่งข้อมูลภายนอก เช่น web service หรือแม้แต่มนุษย์ ก็สามารถ trigger การทำงานของ workflow ได้เช่นกัน เมื่อถูก trigger แล้ว executors จะถูกเรียกให้ทำงาน task มี workflow engines หลายประเภทที่ตอบโจทย์ Use Case ที่หลากหลาย บางตัว เช่น Airflow, Dagster และ Prefect จะทำงานร่วมกับระบบข้อมูลและจัด orchestrate งาน ETL บางตัว เช่น Camunda และ Orkes มี Graphical Notation สำหรับ workflow (เช่น BPMN ที่ใช้ใน Figure 5-7 ) เพื่อให้ผู้ที่ไม่ใช่วิศวกรสามารถกำหนดและดำเนินการ workflow ได้ง่ายขึ้น และบางตัว เช่น Temporal และ Restate ก็มี _durable execution_ . Durable execution frameworks กลายเป็นวิธีที่นิยมในการสร้าง Service-based Architectures ที่ต้องการ Transactionality ในตัวอย่างการชำระเงินของเรา เราต้องการประมวลผลการชำระเงินแต่ละรายการเพียงครั้งเดียว (exactly once) ความล้มเหลวระหว่างที่ workflow กำลังทำงานอาจส่งผลให้มีการเรียกเก็บเงินจากบัตรเครดิตแต่ไม่มีเงินฝากเข้าบัญชีธนาคารที่สอดคล้องกัน ในสถาปัตยกรรมแบบ Service-based เราไม่สามารถแค่ห่อหุ้มสอง Task นี้ใน Database Transaction ได้ นอกจากนี้ เราอาจต้องทำงานร่วมกับ Payment Gateway ของบุคคลที่สามที่เรามีการควบคุมจำกัด Durable execution frameworks เป็นวิธีการให้ _exactly-once semantics_ สำหรับ workflows ถ้า task ล้มเหลว framework จะ re-execute task นั้น แต่จะข้าม RPC calls หรือ state changes ที่ task ทำสำเร็จไปแล้วก่อนที่จะล้มเหลว มันจะแสร้งทำเป็นเรียก แต่จะคืนค่าผลลัพธ์จากการเรียกครั้งก่อนแทน ซึ่งเป็นไปได้เพราะ durable execution frameworks จะ log RPCs และ state changes ทั้งหมดไปยังพื้นที่จัดเก็บแบบ durable เช่น write-ahead log [ 46 , 47 ]. Example 5-5 แสดง workflow definition ที่รองรับ durable execution โดยใช้ Temporal ##### Example 5-5. A Temporal workflow definition fragment for the payment workflow in Figure 5-7 (เศษส่วนของนิยาม Temporal workflow สำหรับ workflow การชำระเงินใน Figure 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), ) # ... ``` Frameworks อย่าง Temporal ก็มีความท้าทายของมันเช่นกัน External Services เช่น third-party payment gateway ในตัวอย่างของเรา ยังคงต้องมี idempotent API นักพัฒนาต้องจำใช้ unique IDs สำหรับ API เหล่านี้เพื่อป้องกันการทำงานซ้ำ [ 48 ] และเนื่องจาก durable execution frameworks log RPC call แต่ละครั้งตามลำดับ พวกมันคาดหวังให้การทำงานครั้งถัดไปเรียก RPC เดียวกันในลำดับเดียวกัน ทำให้การเปลี่ยนแปลงโค้ดเปราะบาง คุณอาจ introduce undefined behavior เพียงแค่เรียงลำดับการเรียก function ใหม่ [ 49 ] แทนที่จะแก้ไขโค้ดของ workflow ที่มีอยู่ การ deploy โค้ดเวอร์ชันใหม่แยกต่างหากจะปลอดภัยกว่า เพื่อให้การ re-execution ของ workflow invocation ที่มีอยู่ยังคงใช้เวอร์ชันเก่า และมีเพียง invocation ใหม่เท่านั้นที่ใช้โค้ดใหม่ [ 50 ] ในทำนองเดียวกัน เนื่องจาก durable execution frameworks คาดหวังให้ replay โค้ดทั้งหมดแบบ deterministically (Input เดียวกันให้ Output เดียวกัน) โค้ดที่ไม่ deterministic เช่น การเรียก random number generators หรือ system clocks จึงเป็นปัญหา [ 49 ] Frameworks มักมี deterministic implementations ของ library functions เหล่านั้นในตัว แต่คุณต้องจำไว้ใช้มัน บางตัวยังมี static analysis tools เช่น Temporal's Workflow Check เพื่อตรวจสอบว่า nondeterministic behavior ถูกนำเข้ามาหรือไม่ ###### Note (หมายเหตุ) การทำให้โค้ดเป็น deterministic เป็นแนวคิดที่ทรงพลังแต่ทำได้อย่าง robust นั้นยาก เราจะกลับมาที่หัวข้อนี้อีกครั้งใน Chapter 9 .

## Event-Driven Architectures (สถาปัตยกรรมที่ขับเคลื่อนด้วยเหตุการณ์) ใน ส่วนสุดท้ายนี้ เราจะดู _event-driven architectures_ อย่างคร่าว ๆ ซึ่งเป็นอีกวิธีหนึ่งที่ข้อมูลที่ถูกเข้ารหัสสามารถไหลจาก process หนึ่งไปยังอีก process หนึ่งได้ ในบริบทนี้ request จะถูกเรียกว่า _event_ หรือ _message_ ซึ่งแตกต่างจาก RPC โดยปกติผู้ส่งจะไม่รอให้ผู้รับประมวลผล event นอกจากนี้ events โดยทั่วไปจะไม่ถูกส่งไปยังผู้รับผ่านการเชื่อมต่อเครือข่ายโดยตรง แต่จะผ่านตัวกลางที่เรียกว่า _message broker_ (หรือเรียกอีกอย่างว่า _event broker_ , _message queue_ หรือ _message-oriented middleware_ ) ซึ่งจะเก็บ message ไว้ชั่วคราว [ 51 ] การใช้ message broker มีข้อดีหลายประการเมื่อเทียบกับ RPC โดยตรง: - มันสามารถทำหน้าที่เป็น buffer ถ้าผู้รับไม่พร้อมหรือโอเวอร์โหลด ทำให้ระบบมีความน่าเชื่อถือมากขึ้น - มันสามารถส่ง messages ซ้ำให้กับ process ที่ crash โดยอัตโนมัติ ป้องกันไม่ให้ messages สูญหาย - มันช่วยไม่ต้องมี service discovery เพราะผู้ส่งไม่จำเป็นต้องเชื่อมต่อโดยตรงกับ IP address ของผู้รับ - มันช่วยให้ message เดียวกันถูกส่งไปยังผู้รับหลายคน - มันแยก sender ออกจาก recipient ในเชิงตรรกะ (sender แค่ publish messages และไม่สนใจว่าใครจะ consume พวกมัน) การสื่อสารผ่าน message broker เป็นแบบ _asynchronous_ : ผู้ส่งไม่ต้องรอให้ message ถูกส่งถึง แต่แค่ส่งมันไปแล้วก็ลืมมันได้ อย่างไรก็ตาม มันเป็นไปได้ที่จะ implement synchronous RPC-like model โดยให้ผู้ส่งรอ response บนช่องทางแยกต่างหาก ### Message brokers (ตัวกลางส่งข้อความ) ในอดีต ภูมิทัศน์ของ message brokers ถูกครอบงำโดยซอฟต์แวร์ enterprise เชิงพาณิชย์จากบริษัทอย่าง TIBCO, IBM WebSphere และ webMethods ก่อนที่ open source implementations อย่าง RabbitMQ, ActiveMQ, HornetQ, NATS, Redpanda และ Apache Kafka จะได้รับความนิยม ในช่วงไม่นานมานี้ cloud services อย่าง Amazon Kinesis, Azure Service Bus และ Google Cloud Pub/Sub ก็เริ่มถูกนำมาใช้มากขึ้น เราจะเปรียบเทียบพวกมันในรายละเอียดเพิ่มเติมใน Chapter 12 . รายละเอียดของ delivery semantics แตกต่างกันไปตาม implementation และ configuration แต่โดยทั่วไปแล้ว รูปแบบการกระจาย message สองแบบที่ถูกใช้บ่อยที่สุดคือ: - Process หนึ่งเพิ่ม message ลงใน _queue_ ที่มีชื่อ และ _consumer_ ของ queue นั้นจะได้รับ message ถ้ามี consumers หลายตัว หนึ่งในนั้นจะได้รับ message - Process หนึ่ง publish message ไปยัง _topic_ ที่มีชื่อ และ broker ส่ง message นั้นไปยัง _subscribers_ ทั้งหมดของ topic นั้น ถ้ามี subscribers หลายตัว พวกมันทั้งหมดจะได้รับ message Message brokers โดยทั่วไปไม่บังคับใช้ data model ใดโดยเฉพาะ message ก็แค่ลำดับของไบต์ที่มี metadata ดังนั้นคุณสามารถใช้ encoding format ใดก็ได้ วิธีที่พบบ่อยคือใช้ Protocol Buffers, Avro หรือ JSON และ deploy schema registry ควบคู่ไปกับ message broker เพื่อเก็บ schema versions ที่ถูกต้องทั้งหมดและตรวจสอบความเข้ากันได้ [ 20 , 22 ] AsyncAPI ซึ่งเป็น messaging-based equivalent ของ OpenAPI ก็สามารถใช้เพื่อระบุ schema ของ messages ได้เช่นกัน Message brokers แตกต่างกันในเรื่อง durability ของ messages หลายตัวเขียน messages ลงดิสก์เพื่อไม่ให้สูญหายถ้า message broker crash หรือต้อง restart ต่างจากฐานข้อมูล message brokers หลายตัวจะลบ messages โดยอัตโนมัติหลังจากถูก consume แล้ว อย่างไรก็ตาม บาง brokers สามารถกำหนดค่าให้เก็บ messages ไว้อย่างไม่มีกำหนด ซึ่งคุณจะต้องใช้ถ้าต้องการ event sourcing (ดู "Event Sourcing and CQRS" ) ถ้า consumer นำ messages ไป publish ซ้ำยังอีก topic คุณอาจต้องระวังในการรักษา unknown fields เพื่อป้องกันปัญหาที่อธิบายไว้ก่อนหน้านี้ในบริบทของฐานข้อมูล ( Figure 5-1 ) ### Distributed actor frameworks (Framework Actor แบบกระจาย) _Actor model_ เป็น programming model สำหรับ concurrency ใน process เดียว แทนที่จะจัดการกับ threads โดยตรง (และปัญหาที่เกี่ยวข้องอย่าง race conditions, locking และ deadlock) logic จะถูก encapsulate ไว้ใน _actors_ แต่ละ actor โดยทั่วไปแทน clients หรือ entities หนึ่งตัว มันอาจมี local state (ซึ่งไม่ได้แชร์กับ actor อื่น) และสื่อสารกับ actors อื่นด้วยการส่งและรับ asynchronous messages การส่ง message ไม่ได้รับการรับประกัน; ในสถานการณ์ error บางอย่าง messages จะสูญหาย เนื่องจากแต่ละ actor ประมวลผลเพียงหนึ่ง message ในแต่ละครั้ง มันจึงไม่ต้องกังวลเกี่ยวกับ threads และแต่ละ actor สามารถถูก schedule โดย framework ได้อย่างอิสระ ใน _distributed actor frameworks_ อย่าง Akka, Orleans [ 52 ] และ Erlang/OTP programming model นี้ถูกใช้เพื่อ scale แอปพลิเคชันข้ามหลาย nodes กลไกการส่ง message เดียวกันถูกใช้ ไม่ว่าผู้ส่งและผู้รับจะอยู่บน node เดียวกันหรือคนละ node ถ้าพวกมันอยู่คนละ node message จะถูก encode เป็นลำดับไบต์อย่างโปร่งใส ส่งผ่านเครือข่าย และ decode ที่ฝั่งอื่น Location transparency ทำงานได้ดีกว่าใน actor model มากกว่าใน RPC เพราะ actor model สันนิษฐานอยู่แล้วว่า messages อาจสูญหาย แม้แต่ภายใน process เดียวกัน แม้ว่า latency ผ่านเครือข่ายอาจสูงกว่าภายใน process เดียวกัน แต่ก็มีความไม่สอดคล้องพื้นฐานระหว่างการสื่อสารในเครื่องและระยะไกลน้อยกว่าเมื่อใช้ actor model Distributed actor framework โดยพื้นฐานแล้วรวม message broker และ actor programming model เข้าไว้ใน framework เดียวกัน อย่างไรก็ตาม ถ้าคุณต้องการทำ rolling upgrades ของแอปพลิเคชันแบบ actor-based คุณยังต้องกังวลเกี่ยวกับ forward และ backward compatibility เพราะ messages อาจถูกส่งจาก node ที่รันเวอร์ชันใหม่ไปยัง node ที่รันเวอร์ชันเก่า และในทางกลับกัน ซึ่งสามารถทำได้โดยใช้หนึ่งใน encodings ที่กล่าวถึงในบทนี้ # Summary (สรุป) ในบทนี้เราได้ดูหลายวิธีในการแปลงโครงสร้างข้อมูลเป็นไบต์บนเครือข่ายหรือบนดิสก์ เราเห็นว่ารายละเอียดของ encodings เหล่านี้มีผลไม่เพียงต่อประสิทธิภาพของมันเท่านั้น แต่ที่สำคัญกว่านั้นคือต่อสถาปัตยกรรมของแอปพลิเคชันและตัวเลือกของคุณในการพัฒนา (evolve) พวกมัน โดยเฉพาะอย่างยิ่ง หลาย services จำเป็นต้องรองรับ rolling upgrades ซึ่งเวอร์ชันใหม่ของ service จะถูก deploy ไปยังสองสาม nodes ในแต่ละครั้ง แทนที่จะ deploy ไปยัง nodes ทั้งหมดพร้อมกัน Rolling upgrades ช่วยให้สามารถปล่อยเวอร์ชันใหม่ของ service ได้โดยไม่ต้องหยุดให้บริการ (จึงส่งเสริมให้มีการปล่อยเวอร์ชันเล็ก ๆ บ่อยครั้งแทนที่จะปล่อยเวอร์ชันใหญ่ ๆ นาน ๆ ครั้ง) และทำให้ deployments มีความเสี่ยงน้อยลง (ช่วยให้ตรวจพบและ rollback การปล่อยที่มีปัญหา ก่อนที่จะส่งผลกระทบต่อผู้ใช้จำนวนมาก) คุณสมบัติเหล่านี้มีประโยชน์อย่างมากต่อ _evolvability_ หรือความสะดวกในการเปลี่ยนแปลงแอปพลิเคชัน ในระหว่าง rolling upgrades หรือด้วยเหตุผลอื่น ๆ มากมาย เราต้องสันนิษฐานว่า nodes ที่แตกต่างกันกำลังรันโค้ดแอปพลิเคชันเวอร์ชันที่แตกต่างกัน ดังนั้น มันจึงสำคัญที่ข้อมูลทั้งหมดที่ไหลผ่านระบบจะถูก encode ในรูปแบบที่ให้ backward compatibility (โค้ดใหม่อ่านข้อมูลเก่าได้) และ forward compatibility (โค้ดเก่าอ่านข้อมูลใหม่ได้) เราได้พูดถึงรูปแบบการเข้ารหัสข้อมูลหลายรูปแบบและคุณสมบัติความเข้ากันได้ของพวกมัน: - การเข้ารหัสเฉพาะภาษาโปรแกรมมิ่ง (Programming language-specific encodings) ถูกจำกัดอยู่ที่ภาษาใดภาษาหนึ่งและมักไม่มี forward และ backward compatibility - รูปแบบข้อความ (Textual formats) อย่าง JSON, XML และ CSV เป็นที่แพร่หลาย และความเข้ากันได้ของพวกมันขึ้นอยู่กับวิธีที่คุณใช้ พวกมันมีภาษา Schema แบบเลือกใช้ ซึ่งบางครั้งก็มีประโยชน์และบางครั้งก็เป็นอุปสรรค รูปแบบเหล่านี้ค่อนข้างคลุมเครือเกี่ยวกับ datatypes ดังนั้นคุณต้องระวังเรื่องตัวเลขและ binary strings - รูปแบบไบนารีที่ขับเคลื่อนด้วย Schema (Binary schema-driven formats) อย่าง Protocol Buffers และ Avro ช่วยให้ encode ที่กะทัดรัดและมีประสิทธิภาพด้วย forward และ backward compatibility semantics ที่ชัดเจน Schemas สามารถมีประโยชน์สำหรับ documentation และ code generation ในภาษาแบบ static typing อย่างไรก็ตาม รูปแบบเหล่านี้มีข้อเสียที่ข้อมูลต้องถูก decode ก่อนที่จะอ่านได้โดยมนุษย์ เรายังได้พูดถึงรูปแบบการไหลของข้อมูล (Dataflow) หลายรูปแบบ ซึ่งแสดงสถานการณ์ต่าง ๆ ที่การเข้ารหัสข้อมูลมีความสำคัญ: Databases (ฐานข้อมูล) กระบวนการที่เขียนข้อมูลลงฐานข้อมูลจะ encode ข้อมูล และกระบวนการที่อ่านจากฐานข้อมูลจะ decode มัน RPC and REST APIs (RPC และ REST APIs) Client encode request, server decode request และ encode response, และ client ก็ decode response ในที่สุด Event-driven architectures (using message brokers or actors) (สถาปัตยกรรมแบบ Event-driven ที่ใช้ message brokers หรือ actors) Nodes สื่อสารกันโดยส่ง messages ที่ถูก encode โดยผู้ส่งและ decode โดยผู้รับ เราสามารถสรุปได้ว่าด้วยความระมัดระวังเล็กน้อย Backward/Forward Compatibility และ Rolling Upgrades ก็สามารถบรรลุได้ ขอให้แอปพลิเคชันของคุณมีการพัฒนาที่รวดเร็วและการปรับใช้ที่บ่อยครั้ง