แนวทางการออกแบบ Service สำหรับ Microservice Architecture

ส่วนยากที่สุดของ Microservice Architecture คือ การออกแบบให้ดี

ถ้าใครเคยหรือกำลังพัฒนาระบบที่เป็น Microservice Architecture จะรู้ว่าส่วนที่ยากที่สุด คือ การออกแบบ เพราะถึงจะทำงานได้ถูกต้อง แต่ออกแบบไม่ดีก็จะไม่ได้ประโยชน์จากการเป็น Microservice เท่าตอนที่เป็น Monolithic Architecture ก็ได้ เพิ่มความซับซ้อนชวนปวดหัวไปเปล่าๆ เผลอๆเป็น Monolithic แบบเดิมดีกว่า

คุณสมบัติของ Service ที่ดี

สำหรับผมคุณสมบัติของ Service ที่ดี ต้องทำงานได้ด้วยตัวมันเองได้ (autonomous) หรือ ถ้าไม่สามารถทำได้จริงๆก็ให้ “พึ่งพา” Service อื่นๆให้น้อยที่สุด

ลองคิดดูว่าในฐานะ Developer แล้วในการทำ Feature ใหม่สักอันนึง จะต้องตามเปิด Services อื่นๆ (แค่ Database ก็ลำบากพอแล้ว) หรือ จะต้องแก้ไข Code หลายๆ Services (อาจจะต่างภาษา ต่าง Framework อีกด้วยนะ) มันไม่ค่อยมีความสุขเท่าไรนักหรอกครับ ถึงแม้ว่าปัจจุบันจะมี Docker ช่วยเราในการรัน Service อื่นๆในเครื่องเราได้ง่ายขึ้นก็ตาม มันก็ยังเป็นความยุ่งยาก และไม่ค่อยจะอยากทำกันสักเท่าไหร่อยู่ดี

การที่ Service ไม่สามารถทำงานได้ด้วยตนเอง ทำให้ทีมไม่สามารถทำงานได้เสร็จในทีมอีกด้วย ต้องรอทีมอื่นทำของบางอย่างให้ก่อน แทนที่จะ Scale ทีมง่ายๆ ทีมใครทีมมันวิ่งกันได้เต็มที่ตามที่เราหวังไว้ ก็จะทำไม่ได้

ผมเชื่อว่าเหตุผลที่สำคัญที่สุดในการเลือกใช้ Microservice Architecture คือ การที่ทีมหลายๆทีมสามารถทำงานได้โดยไม่ต้องรอกัน เหตุผลอื่นๆเป็นเรื่องรองทั้งนั้นแหละ

Low Coupling + High Cohesion

หลักการสุดคลาสสิก ได้ยินมาตั้งแต่สมัยยังเป็นนิสิตเรียน OOP ซึ่งใช้ได้กับหลายๆอย่าง อาจจะมีเทคนิคท่านู้นท่านี้บ้าง แต่สุดท้ายผลที่ต้องการคือ Coupling น้อยๆ และ Cohesion มากๆ นั่นเอง

  • Coupling คือ ตัววัดการที่เรารู้จักคนอื่น ในที่นี้คือ Service หนึ่งรู้จักอีก Service ที่ต้องการใช้ขนาดไหน
  • Cohesion คือ ตัววัดการทำงานร่วมกันของของที่อยู่ใกล้กัน ในที่นี้คือ Service หนึ่งมีของที่ร่วมกันทำงานด้วยเป้าหมายที่สัมพันธ์กัน

สิ่งที่เราต้องการคือ Service ที่ไม่รู้จัก Service อื่นเลย หรือ รู้จัก Service อื่นน้อยๆ (Low Coupling) เพื่อที่ Service จะไปใช้งานมีการเปลี่ยนแปลงเราจะได้ไม่ต้องแก้ไข Service เราตาม และ Service เราควรจะมีข้อมูลที่ทำงานเกี่ยวข้องกันทั้งหมด (High Cohesion) เพื่อให้เราไม่ต้องไปขอความช่วยเหลือจาก Service อื่นๆ

สองอย่างนี้มักจะแปรผันกัน ถ้าเราสามารถทำให้ Cohesion มากๆได้ มักจะทำให้ Coupling น้อยๆเสมอ

อ่านมาถึงตรงนี้แล้วน่าจะได้หลักไปแล้วนะครับ แก่นมันแค่นี้เอง

ตัวอย่าง สมมติมี User Service อยู่แล้ว และต้องการจะทำ Feature ใหม่ให้ User สามารถให้ของขวัญกับ User อื่นๆได้ เราจึงสร้าง Reward Service ขึ้นมา แต่มีเงื่อนไขว่า User1 จะให้ของขวัญกับ User2 ได้ก็ต่อเมื่อ ทั้ง User1 และ User2 จะต้องสมัครมาแล้วอย่างน้อย 1 สัปดาห์

วิธีตรงไปตรงมาที่สุดคือ Reward Service ไปขอวันที่ User1 และ User2 สมัครเป็นสมาชิกจาก User Service ความหมายคือ Reward Service ไม่สามารถทำงานที่ตัวเองรับผิดชอบได้นั่นเอง เพราะมีข้อมูลที่ต้องการไปอยู่ที่อื่น (Cohesion น้อย)

Low Cohesion — Review Service ไม่สามารถจบการทำงานด้วยตัวเองได้

งั้นให้ Reward Service เก็บวันที่เริ่มเป็นสมาชิกเองซะเลยสิ จะได้ไม่ต้องไปขอ User Service เป็นวิธีที่ควรจะทำ ปัญหาคือเราจะเอาวันที่เริ่มเป็นสมาชิกมาได้อย่างไรหละ?

ปัญหาจริงๆอยู่ที่ข้อมูล

ปัญหาของการออกแบบ จริงๆแล้วอยู่ที่ข้อมูล ข้อมูลที่เราสนใจควรจะอยู่ที่ Service ไหน Dependency (Coupling) จะเกิดขึ้นเมื่อเราต้องการใช้ข้อมูลของ Service อื่นๆ ซึ่งเป็นสิ่งที่เราไม่ต้องการ ถ้าต้องการข้อมูลน้อยหน่อยก็ Coupling น้อย ถ้าต้องการข้อมูลมากก็ Coupling มาก

การบอกว่าข้อมูลอยู่ที่ Service ไหน จะรวมไปถึงที่เก็บข้อมูลอย่าง Database ด้วย ดีที่สุดควรจะมี Database Schema ของแต่ละ Service เอง ไม่มี Service อื่นๆมาเรียกใช้งานได้เลย


เรามาเริ่มดูแนวทางกันเลยดีกว่า

1. เริ่มจาก Bounded Context เสมอ

วิธีที่ถูกพูดบ่อยคือ Bounded Context ใน Domain Driven Design ของ Eric Evans นั่นเอง ใครเจอคำนี้อย่าเพิ่งข้ามนะครับ มันสำคัญจริงๆ ขอดักไว้ก่อนเพราะตอนผมเริ่มศึกษา DDD เมื่อหลายปีก่อนเจอไอ่คำนี้ก็ข้ามทันทีและทุกทีเหมือนกัน ฮ่าๆๆ

ในโปรแกรมทุกโปรแกรมจะมีส่วนที่รับผิดชอบเกี่ยวกับกฎกติกาและข้อมูลทางธรุกิจ เรียกว่า Domain Model ซึ่งมีหลายส่วนประกอบทำงานร่วมกันเพื่อจะได้ผลที่ต้องการ

Bounded Context คือ ขอบเขตของ Domain ที่รวมกฎกติกาทางธุรกิจ และมีข้อมูลเพียงพอต่อการทำงานเพื่อแก้ปัญหาของ Domain นั้นๆ แต่ละ Domain มีหน้าที่และความรับผิดชอบต่างกัน ไม่ข้ามขอบเขตกัน

Domain สอง Domain อาจจะมีคำใช้เรียกสิ่งของ (Entity) อย่างเดียวกันด้วยชื่อที่ต่างกัน หรือ เรียกสิ่งของคนละอย่างกันด้วยคำคำเดียวกันได้ ของเหล่านี้ที่เป็นตัวปัญหาที่จะทำให้ไม่สามารถกำหนดขอบเขตได้ ยกตัวอย่างเช่น พอพูดถึงคำว่า User แล้วน่าจะเป็นคำที่สามารถอยู่ได้ในทุกๆ Context เลยก็ได้

  • ถ้าพูดใน Review Domain แล้วไม่น่าจะต้องสนใจว่า User เพศอะไร อยู่ที่ไหน อายุเท่าไร แต่น่าจะสนใจมากกว่าว่า User คนนี้รีวิวร้านมาเยอะเท่าไรแล้ว ใน Review Domain อาจจะเรียก User ว่า Reviewer
  • แต่ถ้าพูดถึง User ใน Deal Domain น่าจะสนใจเฉพาะเรื่องการซื้อ Deal เท่านั้น ใน Domain นี้อาจจะเรียก User ว่า Buyer
แสดง Bounded Context ของ Review Domain และ Deal Domain

จากตัวอย่าง ทั้ง Reviewer และ Buyer เป็น User ทั้งคู่เป็นของชิ้นเดียวกัน เพียงแค่มีข้อมูลที่ถูกใช้ต่างกันในแต่ละ Domain เราก็ควรเก็บข้อมูลของ User ที่เกี่ยวกับการรีวิวไว้ที่ Review Service และ เก็บข้อมูลของ User ที่เกี่ยวกับ Deal ไว้ที่ Deal Service นั่นเอง ไม่น่าจะต้องเก็บข้อมูลพวกนี้ใน User Service

แล้ว Microservice เกี่ยวอะไรกับ Bounded Context?

หากเรามี Service ที่ตรงตาม Bounded Context ได้ ทำให้ Service นั้นสามารถทำงานที่ต้องรับผิดชอบด้วยตัวเองได้นั่นเอง ทีมที่ดูแล Service นั้นก็มีขอบเขตที่ชัดเจนว่าอะไรคือสิ่งที่ต้องทำ อะไรไม่ควรจะทำ ตรงตามนิยามของ Service ที่ดีที่พูดมาข้างบนนั่นเอง

โครงสร้างองค์กร

Bounded Context มักจะมีรูปแบบแทบจะเหมือนกับโครงสร้างองค์กรอยู่แล้วครับ เราเอาง่ายก็เริ่มจากโครงสร้างขององค์กรเลยก็ได้ เช่น

  • Review Service ก็น่าจะเป็นของทีม Marketing ฝั่ง User ที่มีหน้าที่ทำให้ Community ยั่งยืน
  • Business Service ก็ควรจะเป็นของทีม Data ที่คอยดูแลความถูกต้องของข้อมูลอยู่ทุกๆวันอยู่แล้ว

2. เลือกวิธีการคุยกันให้เหมาะสม

เมื่อกำหนด Bounded Context ได้แล้ว จะมีส่วนที่ Service จะต้องติดต่อกันข้าม Domain อยู่ ให้ออกแบบ API และเลือกวิธีการคุยกันให้เหมาะสม หลักการเลือกที่ดีคือ ใช้งานได้ง่าย ไม่ขึ้นกับภาษา เพราะเราไม่อยากเพิ่ม Technology Coupling

ตัวเลือกที่นิยมมักจะเป็น REST over HTTP แต่ REST over HTTP จะมีข้อเสียเรื่อง Overhead ถ้า Performance เป็นเรื่องหลัก อาจจะต้องดูตัวเลือกอื่น เช่น gRPC หรือ Thrift ซึ่งทั้งคู่เป็น protocol ที่ไม่ขึ้นกับภาษา

หรือ Asynchronous Style พวก Message Queue ก็ควรจะเลือกที่ไม่ขึ้นกับภาษาด้วยเหมือนกัน

3. หาทางลด Coupling

จริงๆแล้ว Service ที่เราอยากได้มากที่สุดคือ Service ที่ไม่ใช้ข้อมูลจาก Service อื่นๆเลย แต่มันก็อาจจะเป็นไปไม่ได้ก็ได้ ผมมีวิธีลด Coupling ง่ายๆมาให้ลองใช้

API Composition

มักจะมี Service อยู่ประเภทนึงที่ทำหน้าที่ดึงข้อมูลไปแสดงอย่างเดียว โดยส่วนมากมักจะเป็น API Gateway หรือ UI Service ที่จะต้องรวบรวมข้อมูลให้คนอ่านรู้เรื่อง เราจะลด Coupling ของ Core Service อื่นโดยโยนความรับผิดชอบที่ต้องมี Coupling ให้กับ Service ประเภทนี้ซะเลย (ไหนๆมันก็ต้องมีอยู่แล้ว เอาไปเยอะๆเลย)

Service ประเภทนี้มักจะถูกออกแบบให้แก้ไขง่าย และไม่มี Business logic อยู่เลย มีหน้าที่เพียง แปลความหมายของ Query Request เพื่อสร้าง Response จากการ Query ไปที่ Service อื่นๆเท่านั้น ที่ช่วงนี้พูดกันเยอะๆอยู่ก็ใช้พวก GraphQL ทำ

ในหลายๆครั้ง Service ประเภทนี้อาจจะไม่ได้รับผิดชอบเฉพาะ Query เท่านั้น อาจจะรับผิดชอบ Request ที่เป็น Transaction ด้วยก็ได้

สมมติว่ามี Like Service ที่เก็บ Like ของ Object ทุกอย่างในระบบได้ และอยากให้ User สามารถ Like Review และ Photo ได้ ถ้าเราให้ Photo Service ไปใช้ Like Service และ Review Service ไปใช้ Like Service ก็จะเกิด Coupling กับทั้ง 2 Services ตามรูปแรก แต่จะให้ API Gateway เป็นคนที่มี API ไปเรียก Like Service แทนให้ตามรูปที่สอง

เกิด Coupling ที่ทั้ง Photo Service และ Review Service
เกิด Coupling ที่ API Gateway เท่านั้น

ข้อดี วิธีนี้ตรงไปตรงมามาก แทบจะไม่ต้องเปลี่ยนวิธีคิดจากเดิมเลย และยังได้ข้อมูลที่ถูกต้องเสมอด้วย

ข้อเสีย มี Coupling เยอะมากอาจจะต้องดูและเป็นพิเศษหรือมีวิธีการจัดการเมื่อไม่สามารถเรียก Service บาง Service ได้ และ อาจจะมีปัญหา Performance

Reactive Service

หากวิธี Query Composition ไม่ตอบโจทย์เรื่อง Performance เพราะต้องมีการ Query Service อื่นๆ และยังเพิ่ม Coupling ให้กับตัว Service อีก สามารถทำเป็น Reactive Service ได้ โดยจะต้องพึ่ง Message Queue ด้วย

วิธีที่นิยมคือ CQRS ซึ่งเป็น วิธีการแยก Model สำหรับการเขียนกับอ่านออกจากการ ปกติเราจะคิดเป็น CRUD ที่ใช้ Model เดียวกันทั้งหมด วิธีนี้จะแยก Service ที่เขียนกับ Service ที่อ่านออกจากกัน ยกตัวอย่างเช่น

สมมติว่ามี Restaurant Service และ Review Service อยู่ และต้องการหาร้านที่มีรีวิวมากกว่า 5 รีวิว ผ่าน Search Service ถ้าทำด้วย API Composition เราจะต้องถาม Review Service ว่า Object ID อะไรที่มีจำนวนรีวิวมากกว่า 5 แล้วเอา Response ที่ได้ไปหา Query ร้านจาก Restaurant Service ซึ่งจำนวน Objects จาก Review Service อาจจะเยอะมากเป็นหมื่นๆ Objects เลยก็ได้ Search เราช้าแน่นอน

ถ้าใช้ CQRS จะเป็นว่า Search Service มี Database เป็นของตัวเอง ที่คอยเก็บข้อมูลที่ต้องการจาก Restaurant Service และ Review Service ทุกครั้งที่มีการเปลี่ยนแปลง อาจจะทำผ่าน Message Queue ก็ได้เพื่อลด Coupling ของ Services โดยให้ Restaurant Service และ Review Service ส่งข้อมูลที่เปลี่ยนแปลกออกมาเป็น Event เข้า Message Queue

Search Service มี Database ของตัวเอง ซึ่งจะรับ DataChange จาก Queue

วิธีคล้ายๆกันนี้สามารถนำไปแก้ปัญหาที่ติดอยู่กับเรื่อง Reward Service ที่พูดมาก่อนหน้าได้อีกด้วย ทำในลักษณะเดียวกันเลย โดยให้มี Application Event Queue รอรับ RegisteredEvent จาก User Service นั่นเอง

ใช้ Message Queue มารับ Event

ข้อดี วิธีนี้ช่วยแก้ปัญหาเรื่อง Performance ได้ และยังมี Coupling น้อยด้วย

ข้อเสีย ข้อมูลอาจจะไม่ถูกต้องมากนัก ยุ่งยากกว่า และ อาจจะไม่สามารถทำ Bootstrap Data ได้เลยถ้าไม่ได้ทำคู่กับ Event Sourcing ที่ซับซ้อนมากยิ่งขึ้นเข้าไปอีก

4. อย่าใส่ใจกับ DRY มากนัก

DRY = Don’t repeat yourself แปลตรงตัวคือ อย่าทำของที่เคยทำมาแล้ว ซึ่งวิธีแก้ DRY คือ ทำ Library ให้เอาไปใช้จะได้ไม่ต้องเขียนใหม่นั่นเอง พอมาพูดเรื่องนี้ในโลกของ Microservice เราก็พยายามจะทำ Service ที่ทำหน้าที่เฉพาะทางให้ Service อื่นๆมาเรียกใช้ ซึ่งมักจะเป็น Service ที่ช่วยแก้ปัญหาทางเทคนิคซะมากกว่า ถ้าปัญหาไม่ได้มีลักษณะที่ทั่วไปมากๆๆๆ Service นี้จะถูกแก้ไขอยู่เรื่อยๆ และ Service ที่เรียกใช้งานมันก็อาจจะต้องแก้ตามด้วย

อย่าเพิ่งเข้าใจผิดนะครับ ว่าไม่ต้องสนใจ DRY แล้ว แต่ที่ผมจะสื่อคือ ภายใน Service ยังจะต้องใช้ DRY อยู่ แต่ข้าม Service กันไม่ต้องใส่ใจมากนักก็ได้ เราอาจจะไม่ต้องการทำ DTO library ไว้ให้ Client ที่ต้องการต่อกับเราใช้ก็ได้ ให้ Service ที่ต้องการไปหาทางเอาเอง ยอมให้มีการทำซ้ำบ้างดีกว่า ถ้าสิ่งที่กำลังทำมันไม่ใช่สิ่งที่ช่วยแก้ปัญหาทางเทคนิคโดยเฉพาะ


ลองเอาแนวทางการออกแบบที่เสนอไปใช้ดูนะครับ คิดว่าเป็นประโยชน์กับผู้อ่านทุกท่านที่ทำงานด้านนี้แน่นอน