Avoiding Breaking Changes (การหลีกเลี่ยง Breaking Change)

ถ้า คุณต้องการหลีกเลี่ยงการทำ breaking change มีแนวคิดสำคัญสองสามอย่างที่ควรสำรวจ ซึ่งหลายอย่างเราได้แตะไปแล้วตอนต้นบท ถ้าคุณสามารถนำแนวคิดเหล่านี้ไปใช้ปฏิบัติจริงได้ คุณจะพบว่ามันง่ายขึ้นมากที่จะทำให้ไมโครเซอร์วิสถูกเปลี่ยนแปลงได้อย่างอิสระจากกันและกัน

Expansion changes

เพิ่มสิ่งใหม่เข้าไปใน microservice interface อย่าลบสิ่งเก่าออก

Tolerant reader

เมื่อเรียกใช้ microservice interface ให้ยืดหยุ่นในสิ่งที่คุณคาดหวัง

Right technology

เลือกเทคโนโลยีที่ทำให้ง่ายขึ้นที่จะทำการเปลี่ยนแปลง interface แบบ backward-compatible

Explicit interface

ชัดเจนว่าไมโครเซอร์วิสเปิดเผยอะไร สิ่งนี้ทำให้ง่ายขึ้นทั้งสำหรับ client และสำหรับผู้ดูแลไมโครเซอร์วิสที่จะเข้าใจว่าอะไรสามารถ เปลี่ยนแปลงได้อย่างอิสระ

Catch accidental breaking changes early

มีกลไกพร้อมใช้เพื่อจับการเปลี่ยนแปลง interface ที่จะทำให้ผู้เรียกใช้พังใน production ก่อนที่การเปลี่ยนแปลงเหล่านั้นจะถูก deploy

แนวคิดเหล่านี้เสริมกันและกัน และหลายอย่างต่อยอดมาจากแนวคิดสำคัญของ information hiding ที่เราคุยกันบ่อยๆ มาดูแต่ละแนวคิดกัน

Expansion Changes (การเปลี่ยนแปลงแบบขยาย)

จุดเริ่มต้นที่ง่ายที่สุดน่าจะเป็นการเพิ่มเฉพาะสิ่งใหม่เข้าไปใน microservice contract และไม่ลบอะไรออก ลองพิจารณาตัวอย่างของ การเพิ่มฟิลด์ใหม่ใน payload—สมมติว่า client ยืดหยุ่นในระดับหนึ่งกับการเปลี่ยนแปลงแบบนี้ มันไม่ควรมีผลกระทบที่สำคัญ การเพิ่มฟิลด์ dateOfBirth ใหม่เข้าไปใน customer record ควรจะไม่มีปัญหา ตัวอย่างเช่น

Tolerant Reader (ผู้อ่านที่ยืดหยุ่น)

วิธีที่ผู้เรียกใช้ไมโครเซอร์วิสถูก implement สามารถมีผลอย่างมากต่อการทำให้การเปลี่ยนแปลงแบบ backward-compatible ทำได้ง่ายขึ้น โดยเฉพาะ เราต้องการหลีกเลี่ยงการที่ client code ผูกติดกับ interface ของไมโครเซอร์วิสแน่นเกินไป ลองพิจารณาไมโครเซอร์วิส Email ที่มีหน้าที่ส่งอีเมลให้ลูกค้าของเราเป็นครั้งคราว มันถูกขอให้ส่งอีเมล "order shipped" ให้ลูกค้าที่มี ID 1234 มันจึงไปดึงข้อมูล ลูกค้าที่มี ID นั้นและได้รับกลับมาบางอย่างเหมือนกับ response ที่แสดงใน Example 5-3

Example 5-3. Sample response from the Customer service
<customer>
  <firstname>Sam</firstname>
  <lastname>Newman</lastname>
  <email>[email protected]</email>
  <telephoneNumber>555-1234-5678</telephoneNumber>
</customer>

ตอนนี้ เพื่อส่งอีเมล ไมโครเซอร์วิส Email ต้องการเพียงฟิลด์ firstname , lastname และ email เท่านั้น เราไม่จำเป็นต้องรู้ telephoneNumber เราต้องการแค่ดึงเฉพาะฟิลด์ที่เราสนใจออกมาและละเลยส่วนที่เหลือ binding technology บางตัว โดยเฉพาะที่ใช้โดยภาษาแบบ strongly typed อาจพยายาม bind ทุก ฟิลด์ไม่ว่าผู้เรียกใช้จะต้องการหรือไม่ก็ตาม จะเกิดอะไรขึ้นถ้าเราตระหนักว่าไม่มีใครใช้ telephoneNumber และเราตัดสินใจลบมันออก? นี่อาจทำให้ผู้เรียกใช้พังโดยไม่จำเป็น

ในทำนองเดียวกัน จะเป็นอย่างไรถ้าเราต้องการปรับโครงสร้าง object Customer ของเราใหม่เพื่อรองรับรายละเอียดเพิ่มเติม บางทีอาจเพิ่มโครงสร้างเพิ่มเติมบางอย่าง ดังใน Example 5-4 ? ข้อมูลที่ service Email ของเราต้องการยังคงอยู่ตรงนั้น ด้วยชื่อเดียวกัน แต่ถ้า code ของเราตั้งสมมติฐานที่ชัดเจนมากว่าฟิลด์ firstname และ lastname จะถูกเก็บไว้ที่ไหน มันก็อาจพังอีกครั้ง ในกรณีนี้ เราสามารถใช้ XPath แทนเพื่อดึงฟิลด์ที่เราสนใจออกมา ทำให้เราไม่ต้องสนใจว่าฟิลด์ อยู่ที่ไหนตราบใดที่เราหามันเจอ pattern นี้—ของการ implement ผู้อ่านที่สามารถละเลยการเปลี่ยนแปลงที่เราไม่สนใจได้—คือสิ่งที่ Martin Fowler เรียกว่า tolerant reader

Example 5-4. A restructured Customer resource: the data is all still there, but can our consumers find it?
<customer>
  <naming>
    <firstname>Sam</firstname>
    <lastname>Newman</lastname>
    <nickname>Magpiebrain</nickname>
    <fullname>Sam "Magpiebrain" Newman</fullname>
  </naming>
  <email>[email protected]</email>
</customer>

ตัวอย่าง ของ client ที่พยายามยืดหยุ่นให้มากที่สุดเท่าที่จะทำได้ในการเรียกใช้ service แสดงให้เห็น Postel's law (หรือที่รู้จักกันในชื่อ robustness principle ) ซึ่งระบุว่า: "จงระมัดระวังในสิ่งที่คุณทำ จงใจกว้างในสิ่งที่คุณรับจากผู้อื่น" บริบทดั้งเดิมของภูมิปัญญาชิ้นนี้คือ interaction ของอุปกรณ์ผ่านเครือข่าย ที่คุณควรคาดหวังว่าจะมีสิ่งแปลกๆ เกิดขึ้นได้ทุกรูปแบบ ในบริบทของ microservice-based interaction มันนำเราไปสู่การพยายามจัดโครงสร้าง client code ของเราให้ยืดหยุ่นต่อการเปลี่ยนแปลงของ payload

Right Technology (เทคโนโลยีที่ถูกต้อง)

อย่างที่เราได้สำรวจไปแล้ว เทคโนโลยีบางตัวอาจเปราะบางกว่าเมื่อพูดถึงการอนุญาตให้เราเปลี่ยน interface—ผมได้เน้นความหงุดหงิดส่วนตัว ของผมกับ Java RMI ไปแล้ว ในทางกลับกัน integration implementation บางตัวก็พยายามอย่างมากเพื่อทำให้การเปลี่ยนแปลงเกิดขึ้นได้ง่าย ที่สุดโดยไม่ทำให้ client พัง ที่ปลายด้านง่ายของสเปกตรัม protocol buffer ซึ่งเป็น serialization format ที่ใช้เป็นส่วนหนึ่งของ gRPC มีแนวคิดของ field number แต่ละ entry ใน protocol buffer ต้องกำหนด field number ซึ่ง client code คาดหวังว่าจะพบ ถ้ามีการเพิ่ม ฟิลด์ใหม่ client ก็ไม่สนใจ Avro อนุญาตให้ schema ถูกส่งไปพร้อมกับ payload ทำให้ client สามารถตีความ payload ได้คล้ายกับ dynamic type

ที่ปลายด้านสุดขั้วกว่าของสเปกตรัม แนวคิด HATEOAS ของ REST ส่วนใหญ่แล้วเป็นเรื่องของการทำให้ client สามารถใช้ REST endpoint ได้ แม้ว่ามันจะเปลี่ยนแปลง โดยใช้ hypermedia link ที่คุยกันไปก่อนหน้านี้ แน่นอนว่านี่ต้องการให้คุณยอมรับแนวคิด HATEOAS ทั้งหมด

Explicit Interface (Interface ที่ชัดเจน)

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

การมี schema ที่ชัดเจนสำหรับ RPC เป็นที่ยอมรับกันมานานแล้ว และที่จริงแล้วเป็นข้อกำหนดสำหรับ RPC implementation หลายตัว REST ในทางกลับกัน มักมองแนวคิดของ schema เป็นทางเลือก จนถึงจุดที่ผมพบว่า schema ที่ชัดเจนสำหรับ REST endpoint นั้นหายากมาก สิ่งนี้ กำลังเปลี่ยนไป โดยสิ่งต่างๆ อย่าง OpenAPI specification ที่กล่าวถึงไปแล้วได้รับความนิยมมากขึ้น และ JSON Schema specification ก็มีความเป็นผู้ใหญ่มากขึ้นด้วยเช่นกัน

Asynchronous messaging protocol ประสบปัญหามากกว่าในพื้นที่นี้ คุณสามารถมี schema สำหรับ payload ของ message ได้ง่ายพอสมควร และที่จริงแล้วนี่คือพื้นที่ที่ Avro ถูกใช้บ่อย อย่างไรก็ตาม การมี explicit interface จำเป็นต้องไปไกลกว่านี้ ถ้าเราพิจารณา ไมโครเซอร์วิสที่ยิง event ออกไป มันเปิดเผย event อะไรบ้าง? ความพยายาม บางส่วนในการสร้าง schema ที่ชัดเจนสำหรับ event-based endpoint กำลังดำเนินการอยู่ตอนนี้ หนึ่งในนั้นคือ AsyncAPI ซึ่งมีผู้ใช้ชื่อดัง จำนวนหนึ่ง แต่ตัวที่ดูเหมือนจะได้รับความนิยมมากที่สุดคือ CloudEvents ซึ่งเป็น specification ที่ได้รับการสนับสนุนโดย Cloud Native Computing Foundation (CNCF) product event grid ของ Azure รองรับ format CloudEvents ซึ่งเป็นสัญญาณว่า vendor ต่างๆ กำลังสนับสนุน format นี้ ซึ่งควรช่วยเรื่อง interoperability นี่ยังคงเป็นพื้นที่ที่ค่อนข้างใหม่ ดังนั้นมันจะน่าสนใจที่จะดูว่าเรื่องราวจะคลี่คลายอย่างไรในอีกไม่กี่ปีข้างหน้า

Semantic Versioning (การกำหนด Version แบบ Semantic)

จะดี แค่ไหนถ้าในฐานะ client คุณสามารถดูแค่หมายเลข version ของ service แล้วรู้ว่าคุณสามารถ integrate กับมันได้หรือไม่? Semantic versioning เป็น specification ที่ทำให้ทำแบบนั้นได้จริง ด้วย semantic versioning หมายเลข version แต่ละตัวจะอยู่ในรูปแบบ MAJOR.MINOR.PATCH เมื่อหมายเลข MAJOR เพิ่มขึ้น หมายความว่ามีการเปลี่ยนแปลงแบบ backward-incompatible เกิดขึ้น เมื่อ MINOR เพิ่มขึ้น หมายความว่ามีการเพิ่ม functionality ใหม่ที่ควรจะ backward compatible สุดท้าย การเปลี่ยนแปลง PATCH ระบุว่ามีการแก้ bug ใน functionality ที่มีอยู่แล้ว

เพื่อดูว่า semantic versioning มีประโยชน์แค่ไหน มาดู use case ง่ายๆ กัน application helpdesk ของเราถูกสร้างมาให้ทำงานกับ version 1.2.0 ของ service Customer ถ้ามีการเพิ่ม feature ใหม่ ทำให้ service Customer เปลี่ยนเป็น 1.3.0 application helpdesk ของเราควรไม่เห็นการเปลี่ยนแปลงพฤติกรรมใดๆ และไม่ควรต้องทำการเปลี่ยนแปลงใดๆ แต่เราไม่สามารถรับประกันได้ว่าเราจะทำงานกับ version 1.1.0 ของ service Customer ได้ เพราะเราอาจพึ่งพา functionality ที่เพิ่มเข้ามาใน release 1.2.0 เราอาจคาดหวังได้ด้วยว่าจะต้องทำการเปลี่ยนแปลง application ของเรา ถ้า release 2.0.0 ใหม่ของ service Customer ออกมา

คุณอาจตัดสินใจมี semantic version สำหรับ service หรือแม้แต่สำหรับ endpoint แต่ละตัวบน service ถ้าคุณกำลังให้พวกมันอยู่ร่วมกัน ตามที่อธิบายในหัวข้อถัดไป

รูปแบบ versioning นี้ทำให้เราสามารถบรรจุข้อมูลและความคาดหวังจำนวนมากลงในแค่สามฟิลด์ specification ฉบับเต็มระบุความคาดหวัง ที่ client สามารถมีต่อการเปลี่ยนแปลงของหมายเลขเหล่านี้ในรูปแบบที่เรียบง่ายมาก และมันสามารถทำให้กระบวนการสื่อสารว่าการเปลี่ยนแปลง ควรส่งผลกระทบต่อผู้เรียกใช้หรือไม่ง่ายขึ้น น่าเสียดายที่ผมยังไม่เห็นแนวทางนี้ถูกใช้มากพอใน distributed system ที่จะเข้าใจ ประสิทธิภาพของมันในบริบทนั้น—สิ่งที่ยังไม่ได้เปลี่ยนแปลงจริงๆ นับตั้งแต่ฉบับพิมพ์ครั้งแรกของหนังสือเล่มนี้

Catch Accidental Breaking Changes Early (จับ Breaking Change โดยไม่ตั้งใจตั้งแต่เนิ่นๆ)

มันสำคัญมากที่เราจะจับการเปลี่ยนแปลงที่จะทำให้ผู้เรียกใช้พังให้เร็วที่สุดเท่าที่จะทำได้ เพราะแม้เราจะเลือกเทคโนโลยีที่ดีที่สุด เท่าที่จะเป็นไปได้ การเปลี่ยนแปลงไมโครเซอร์วิสที่ดูไม่มีพิษภัยก็อาจทำให้ผู้เรียกใช้พังได้ อย่างที่เราแตะไปแล้ว การใช้ schema สามารถช่วยเราจับการเปลี่ยนแปลงแบบ structural ได้ สมมติว่าเราใช้ tooling บางอย่างเพื่อช่วยเปรียบเทียบ schema version มี tooling หลากหลายที่มีอยู่เพื่อทำสิ่งนี้สำหรับ schema type ที่ต่างกัน เรามี Protolock สำหรับ protocol buffer, json-schema-diff-validator สำหรับ JSON Schema และ openapi-diff สำหรับ OpenAPI specification 2 เครื่องมือเพิ่มเติมดูเหมือนจะผุดขึ้นมาตลอดเวลาในพื้นที่นี้ อย่างไรก็ตาม สิ่งที่คุณกำลังมองหาคือสิ่งที่ไม่ได้แค่รายงานความแตกต่าง ระหว่าง schema สองตัว แต่จะ pass หรือ fail ตาม compatibility นี่จะทำให้คุณสามารถทำให้ CI build fail ได้ ถ้าพบ schema ที่ไม่ compatible กัน ทำให้แน่ใจว่าไมโครเซอร์วิสของคุณจะไม่ถูก deploy

open source Confluent Schema Registry รองรับ JSON Schema, Avro และ protocol buffer และสามารถเปรียบเทียบ version ที่เพิ่ง upload ใหม่เพื่อดู backward compatibility ได้ แม้ว่ามันจะถูกสร้างขึ้นเพื่อช่วยเป็นส่วนหนึ่งของ ecosystem ที่ Kafka ถูกใช้งาน และต้องการ Kafka ในการรัน แต่ก็ไม่มีอะไรมาห้ามคุณ จากการใช้มันเพื่อเก็บและตรวจสอบ schema ที่ถูกใช้สำหรับการสื่อสารที่ไม่ใช่แบบ Kafka ได้

เครื่องมือ เปรียบเทียบ schema สามารถช่วยเราจับ structural breakage ได้ แต่แล้ว semantic breakage ล่ะ? หรือถ้าคุณไม่ได้ใช้ schema ตั้งแต่แรก? แล้วเราก็ต้องมองไปที่ testing นี่คือหัวข้อที่เราจะสำรวจในรายละเอียดมากขึ้นใน "Contract Tests and Consumer-Driven Contracts (CDCs)" แต่ผมอยากเน้น consumer-driven contract testing ซึ่งช่วยได้อย่างชัดเจนในพื้นที่นี้—Pact เป็นตัวอย่างที่ยอดเยี่ยมของเครื่องมือ ที่มุ่งเป้าไปที่ปัญหานี้โดยเฉพาะ แค่จำไว้ว่า ถ้าคุณไม่มี schema ให้คาดหวังว่า testing ของคุณจะต้องทำงานมากขึ้นเพื่อจับ breaking change

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