ทีมส่วนใหญ่หันมาใช้ microservices เพื่อให้ทำงานได้เร็วขึ้น ทั้ง deploy ได้อิสระ scale ได้อิสระ และแยกความเป็นเจ้าของได้อิสระ แต่หลายทีมกลับได้ผลตรงกันข้าม คือได้ distributed monolith มา ทุกฟีเจอร์ต้องแตะห้า service การ deploy ต้องประสานจังหวะกัน และ service ที่ช้าเพียงตัวเดียวก็ถ่วงทุก request ให้ช้าตามไปด้วย
ต้นเหตุแทบไม่เคยอยู่ที่เทคโนโลยี แต่เกือบทุกครั้งอยู่ที่ การขีดเส้นแบ่งไว้ผิดที่ Domain-Driven Design (DDD) มอบทั้งคำศัพท์และชุดเครื่องมือที่ช่วยให้ขีดเส้นแบ่งเหล่านั้นได้ดี
วิธีแบ่งที่ผิด
ก่อนจะดูว่าอะไรได้ผล ควรรู้จักความผิดพลาดที่พบบ่อยก่อน:
- แบ่งตาม technical layer: มี "API service", "business logic service" และ "database service" ทุกฟีเจอร์ต้องข้ามทุก layer ดังนั้นทุกฟีเจอร์จึงต้องแตะทุก service
- แบ่งตาม data entity: มี
user-service,order-serviceและproduct-serviceซึ่งแต่ละตัวเป็นแค่ CRUD บาง ๆ ครอบตารางเดียว ผลคือ business workflow กลายเป็นสายการเรียก remote call ที่คุยกันไปมาไม่หยุด - แบ่งตามผังองค์กร ณ ขณะนั้น: ทีมถูกปรับโครงสร้างบ่อยกว่าที่ domain เปลี่ยนเสียอีก
ทุกวิธีข้างต้นสร้าง service ที่ต้องเปลี่ยนแปลงไปพร้อมกัน และการที่ต้องเปลี่ยนแปลงไปพร้อมกันนั่นเองคือนิยามของ coupling
Bounded context: แนวคิดหลัก
bounded context คือส่วนหนึ่งของธุรกิจที่ใช้ model เดียวและคำศัพท์ชุดเดียวอย่างสอดคล้องกัน คำเดียวกันอาจมีความหมายต่างกันในแต่ละ context ซึ่งเป็นเรื่องปกติ
ลองดูคำว่า "product" ในบริษัท e-commerce:
- ใน Catalog สินค้ามีคำอธิบาย รูปภาพ หมวดหมู่ และ SEO metadata
- ใน Pricing สินค้าคือ SKU ที่มีราคาตั้งต้น ส่วนลด และกฎเกี่ยวกับสกุลเงิน
- ใน Inventory สินค้าคือหน่วยสต็อกที่มีจำนวนคงเหลือแยกตามคลังสินค้า
- ใน Shipping สินค้าคือพัสดุที่มีน้ำหนักและขนาด
การบังคับให้ทั้งหมดนี้อยู่ใน model Product ตัวเดียวที่ใช้ร่วมกัน จะได้ object ขนาดมหึมาที่ทุกทีมแย่งกันแก้ การปล่อยให้แต่ละ context มี model ของตัวเอง โดยเชื่อมกันด้วย ID ร่วม จะทำให้แต่ละ context เปลี่ยนแปลงได้ตามจังหวะของตัวเอง
bounded context คือจุดตั้งต้นที่ดีที่สุดในการกำหนดขอบเขตของ service
ค้นหา context ด้วย event storming
คุณหาขอบเขตไม่เจอด้วยการนั่งจ้อง database schema แต่จะหาเจอได้จากการเข้าใจว่าธุรกิจทำงานอย่างไร event storming เป็นเทคนิคการทำ workshop ที่ช่วยให้เรื่องนี้จับต้องได้:
- ให้วิศวกรและผู้เชี่ยวชาญด้าน domain มาอยู่ในห้องเดียวกัน (จะเป็นห้องจริงหรือออนไลน์ก็ได้) ที่มีผนังยาว ๆ
- เขียน domain event ลงบนกระดาษโน้ตในรูปอดีตกาล เช่น
OrderPlaced,PaymentCaptured,ItemsReserved,ShipmentDispatched - เรียงมันตาม timeline
- เพิ่ม command ที่เป็นตัวกระตุ้นให้เกิด event เหล่านั้น และ actor ที่เป็นผู้สั่ง command
- สังเกตกลุ่มที่เกาะตัวกัน และจุดที่ภาษาที่ใช้เริ่มเปลี่ยนไป
ตรงไหนที่คำศัพท์เปลี่ยน ตรงไหนที่คนละกลุ่มเป็นเจ้าของการตัดสินใจ และตรงไหนที่มีการส่งต่องานกันอย่างเป็นธรรมชาติ ตรงนั้นน่าจะเป็นขอบเขตของ context
Aggregate: หน่วยของความสอดคล้องของข้อมูล
ภายใน context หนึ่ง aggregate คือกลุ่มของ object ที่ต้องคงความสอดคล้องไปด้วยกัน โดยมี root entity หนึ่งตัวคอยปกป้อง invariant ของมัน เช่น aggregate Order อาจประกอบด้วยรายการสินค้า และบังคับกฎว่า "ยอดรวมต้องเท่ากับผลรวมของแต่ละรายการ" และ "เพิ่มสินค้าหลัง checkout ไม่ได้"
กฎที่สำคัญต่อการออกแบบ service:
- หนึ่ง transaction แก้ไขได้หนึ่ง aggregate ถ้า use case ใดต้องอัปเดตสอง aggregate แบบ atomic แสดงว่าสอง aggregate นั้นควรอยู่ด้วยกัน หรือ requirement นั้นควรเปลี่ยนไปใช้ eventual consistency
- aggregate อ้างอิงถึงกันด้วย ID ไม่ใช่อ้างอิงด้วย object reference
- ทำให้ aggregate มีขนาดเล็ก aggregate ขนาดใหญ่หมายถึง lock contention และการโหลดข้อมูลที่บวมเกินจำเป็น
โดยทั่วไป service หนึ่งจะเป็นเจ้าของ aggregate หนึ่งตัวหรือหลายตัวที่เกี่ยวข้องกัน และแทบไม่ควรแชร์ aggregate กับ service อื่นเลย
แบบทดสอบเชิงปฏิบัติสำหรับขอบเขตที่เสนอ
เมื่อได้ service ที่เป็นตัวเลือกแล้ว ให้ลองทดสอบมันด้วยคำถามเหล่านี้:
1. แบบทดสอบการเปลี่ยนแปลง
หยิบ feature request 20 รายการล่าสุดมาดู มีกี่รายการที่ต้องแตะ service ที่คุณเสนอมากกว่าหนึ่งตัว? ถ้าส่วนใหญ่เป็นแบบนั้น แปลว่าขอบเขตผิด
2. แบบทดสอบความเป็นเจ้าของข้อมูล
คุณบอกได้ไหมว่ามี service เพียงตัวเดียว ตัวไหนที่เขียนข้อมูลลงแต่ละตาราง? การที่หลาย service มีสิทธิ์เขียนตารางเดียวกันคือ contract แฝงที่ทำให้การ deploy แบบอิสระเป็นไปไม่ได้
3. แบบทดสอบความช่างคุย
request ทั่วไปต้องเรียกแบบ synchronous ข้าม service ตั้งแต่สามตัวขึ้นไปหรือเปล่า? สาย synchronous ที่ยาวจะทำให้ latency และโอกาสล้มเหลวทวีคูณ ถ้า service A ต้องเรียก service B ในทุก operation ทั้งสองอาจควรอยู่ด้วยกัน
4. แบบทดสอบทีม
ทีมเดียวสามารถเป็นเจ้าของ service แบบครบวงจรได้ไหม ทั้งโค้ด on-call และ roadmap? Conway's Law มีผลเสมอไม่ว่าคุณจะวางแผนไว้หรือไม่ service ที่มีเจ้าของสามทีม ก็คือ service ที่ไม่มีใครเป็นเจ้าของ
5. แบบทดสอบภาษา
ชื่อใน API ของ service ตรงกับคำที่ผู้เชี่ยวชาญด้าน domain ใช้หรือไม่? ถ้าไม่ตรงกัน มักหมายความว่าขอบเขตนั้นกำลังตัดผ่านกลางแนวคิดหนึ่ง
เชื่อมต่อ context เข้าหากันโดยไม่สร้าง coupling
context ต่าง ๆ ยังต้องทำงานร่วมกัน DDD ได้ตั้งชื่อ integration pattern ไว้หลายแบบ:
- Published events
OrderingpublishOrderPlacedแล้วInventoryกับNotificationsก็ตอบสนองต่อ event นั้น ฝั่งผู้ publish ไม่จำเป็นต้องรู้ว่าใครกำลังฟังอยู่ - Anti-corruption layer (ACL) เมื่อคุณต้องใช้ model ของ context อื่น โดยเฉพาะของระบบ legacy ให้แปลงมันเป็น model ของคุณเองที่ขอบของระบบ เพื่อไม่ให้แนวคิดแปลกปลอมรั่วเข้ามาใน domain ของคุณ
- Open host service context หนึ่งเปิด API ที่เสถียรและมีเอกสารชัดเจน ออกแบบมาเพื่อรองรับผู้ใช้หลายราย แทนที่จะสร้าง endpoint เฉพาะกิจให้ผู้เรียกแต่ละราย
แต่ละ context เก็บข้อมูลของตัวเอง เมื่อ Shipping ต้องการที่อยู่ของลูกค้า มันจะเก็บสำเนาของตัวเองที่เติมข้อมูลมาจาก event แทนที่จะไป query service Customer ในทุก request
เริ่มจาก modular monolith
การกำหนดขอบเขตให้ถูกตั้งแต่ครั้งแรกเป็นเรื่องยาก และการย้ายขอบเขตระหว่างสอง service ที่ deploy แล้วมีต้นทุนสูง ในขณะที่การย้ายขอบเขตระหว่างสอง module ใน codebase เดียวกันเป็นแค่การ refactor
แนวทางที่แข็งแรงคือ สร้าง modular monolith ก่อน:
- มี deployable unit เดียว โดยแบ่ง module ให้ตรงกับ bounded context ของคุณ
- module สื่อสารกันผ่าน interface ที่ชัดเจนหรือ in-process event เท่านั้น ห้ามล้วงเข้าไปใช้ตารางของกันและกัน
- บังคับใช้กฎด้วยเครื่องมือ เช่น import linter และการแยก schema ตาม module
เมื่อ module ใดมีเหตุผลชัดเจนที่จะแยกตัวเป็นอิสระ (รูปแบบการ scale ต่างกัน รอบการ release ต่างกัน หรือมีทีมแยกต่างหาก) ก็ค่อยแยกมันออกมา เนื่องจากขอบเขตของมันผ่านการพิสูจน์บน production มาแล้ว การแยกออกมาจึงแทบจะเป็นงานเชิงกลไกล้วน ๆ
สัญญาณเตือนว่าคุณขีดเส้นผิด
- การ release ต้องมีลำดับการ deploy ข้ามหลาย service
- มี "shared library" ที่บรรจุ domain model ซึ่งถูกใช้โดยหลาย service
- service ต่าง ๆ อ่าน database ของกันและกัน "แค่เพื่อทำรายงาน"
- incident ส่วนใหญ่เกี่ยวข้องกับ timeout ที่ลามต่อกันเป็นทอด ๆ ไปตาม call chain
- ไม่มีใครอธิบายได้ในประโยคเดียวว่า service หนึ่งรับผิดชอบอะไร
สรุป
ขอบเขตของ microservices ที่ดีต้องอิงตาม domain ไม่ใช่ database หรือผังองค์กร ค้นหา bounded context ผ่านการทำงานร่วมกับผู้เชี่ยวชาญด้าน domain รักษา aggregate ให้เล็กและจบใน transaction เดียว เชื่อมต่อกันผ่าน event และ translation layer และมองการแบ่ง service ครั้งแรกเป็นเพียงสมมติฐาน ซึ่ง modular monolith คือที่ที่ถูกที่สุดในการทดสอบสมมติฐานนั้น
