ข้าม​ไป​ยัง​เนื้อหา

Queries: รายงาน​จาก Domain

ซอฟต์แวร์​ถูก​สร้าง​ขึ้น​เพื่อ​รับใช้​ความ​ต้องการ​ของ​ธุรกิจ — ไม่ใช่​แค่ feature ที่ “ทำ​เงิน” เท่านั้น แต่​รวม​ถึง​ความ​สามารถ​ใน​การ ประเมิน​ว่า​ธุรกิจ​ไป​ได้​ดี​แค่​ไหน ด้วย นี่​คือ​หน้าที่​ของ “รายงาน” (report): ติดตาม​ตัวเลข​สำคัญ​และ KPI อย่าง​ยอด​ขาย เป้าหมาย​ทางการเงิน หรือ​ความ​พึงพอใจ​ของ​ลูกค้า เหมือน​กับ​ที่​ตลอด​ทั้ง​หนังสือ​เล่ม​นี้​ได้​แสดง​ให้​เห็น​ว่า DDD มี​หลาย​วิธี​ใน​การ​สร้าง​ระบบ การ​ทำ​รายงาน​ก็​มี​หลาย​วิธี​เช่น​กัน

บท​นี้​พา​คุณ​สำรวจ​ตัว​เลือก​หลายๆ แบบ​ใน​การ “อ่าน​ข้อมูล​ออก​มา​แสดง​ผล” (query side) พร้อม trade-off ที่มา​กับ​แต่ละ​แบบ ตั้งแต่​วิธี​ที่​เร็ว​ที่สุด​ใน​การ​ขึ้น​หน้า​รายงาน ไป​จนถึง​สถาปัตยกรรม​ที่​รวม​ข้อมูล​จาก​หลายๆ Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design เข้า​ด้วย​กัน เป้าหมาย​คือ​ให้​คุณ คุ้น​เคย​กับ trade-off ของ​แต่ละ​แบบ เพื่อ​ให้​ตัดสิน​ใจ​ได้​อย่าง​มี​วิจารณญาณ​ใน project จริง

“รายงาน” ใน​บท​นี้​หมาย​ถึง​อะไร

ใน​บริบท​ของ​บท​นี้ “รายงาน” คือ การนำ​เสนอ​ชุด​ข้อมูล​ที่​สอดคล้อง​กัน​เพื่อ​จุด​ประสงค์​เชิง​วิเคราะห์ ไม่​จำกัด​เฉพาะ​รายงาน​ทางการ​สำหรับ​ผู้​บริหาร​หรือ BI เท่านั้น — สรุป​โปร file Twitter ที่​โชว์​จำนวน follower / tweet / retweet ก็​ถือ​เป็น “รายงาน” ตาม​นิ​ยา​มก​ว้างๆ นี้ พูด​ง่ายๆ คือ ฝั่ง​อ่าน​ข้อมูล (query) ทั้งหมด​ที่​เอา​ไป​แสดง​ให้​ผู้​ใช้​เห็น


สถานการณ์​ที่​ง่าย​ที่สุด​คือ​เมื่อ​ข้อมูล​ทั้งหมด​ที่​ต้อง​ใช้ อยู่​ใน Bounded Context เดียวกัน ถ้า​เป็น​เช่น​นั้น ยัง​ถือ​เป็น​สัญญาณ​ที่​ดี​ว่า​คุณ ลาก​เส้น​ขอบ​ของ context ได้​ถูกต้อง ด้วย เพราะ​รายงาน​มัก​ออกแบบ​มา​เพื่อ​แผนก​ใด​แผนก​หนึ่ง​ใน​ธุรกิจ ซึ่ง​ควร​จะ map ลง​ไป​ยัง Bounded Context ใด context หนึ่ง​ได้​พอดี

เมื่อ​ไม่​ต้อง​กังวล​เรื่อง​การกระจาย​ข้อมูล (distribution) คำถาม​ใหญ่​ที่สุด​คือ: จะ​ใช้ code domain ของ​เรา​ใน​การ​สร้าง​รายงาน​หรือ​ไม่?


ถ้า​ต้องการ​ขึ้น​หน้า​รายงาน​ให้​เร็ว วิธี​แรก​ที่​ควร​พิจารณา​คือ เอา domain object ที่​มี​อยู่​แล้ว​มา map ลง View Model (model ที่​เตรียม​ข้อมูล​และ​ตรรกะ​การ​แสดง​ผล​ให้ view) วิธี​นี้​ใช้​แรง​น้อย​ที่สุด เพราะ reuse code เดิม

trade-off ใหญ่​คือ ประสิทธิภาพ เพราะ​เรา​ควบคุม query ที่​ยิง​เข้า datastore ได้​น้อย ถ้า​ประสิทธิภาพ​ไม่ใช่​ข้อ​จำกัด​คอ​ขวด แต่​ความเร็ว​ใน​การ​พัฒนา​สำคัญ​กว่า — นี่​คือ​ตัว​เลือก​ที่​ลงตัว​ที่สุด

ลอง​นึกถึง Application ServiceApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design ที่​วน loop ดึง​ข้อมูล​แต่ละ​หน่วย​มา​ประกอบ​เป็น​รายการ​ใน​รายงาน:

DealershipPerformanceReportBuilder.cs
public DealershipPerformanceReport BuildReport(
IEnumerable<int> dealershipIds, DateTime start, DateTime end)
{
var statuses = new List<DealershipPerformanceStatus>();
foreach (var id in dealershipIds)
{
// select N+1 — reuse code domain เดิม (เขียนเร็ว)
// แต่เสี่ยงยิง query หลายรอบ (ประสิทธิภาพแย่)
var dealership = repository.Get(id);
var targets = provider.Get(dealership, start, end);
var actuals = calculator.CalculateFor(dealership, start, end);
// map จาก domain ลง view model เพื่อไม่ให้ UI ผูกกับ domain object
statuses.Add(new DealershipPerformanceStatus {
DealershipName = dealership.Name,
TotalRevenue = actuals.TotalRevenue,
TargetRevenue = targets.TargetRevenue,
NetProfit = actuals.NetProfit,
TargetProfit = targets.TargetProfit,
});
}
return new DealershipPerformanceReport {
ReportStartDate = start, ReportEndDate = end, Dealerships = statuses
};
}
กับดัก N+1 query

code ข้าง​บน​สวย​และ​เขียน​เร็ว แต่​มี​ราคา​แฝง — ทุก ID หนึ่ง​ตัว เรา​เรียก​ฐาน​ข้อมูล​หลาย​ครั้ง (dealership + targets + actuals) ถ้า ORM มี lazy-loading ด้วย​แล้ว ระบบ​ที่​มี 10 dealership อาจ​กลาย​เป็น 30 query ซึ่ง​ภาย​ใต้​ภาระ​โหลด​สูงสุด (peak load) อาจ​ล่ม​ได้ ประสิทธิภาพ​ไม่​ได้​สำคัญ​เสมอ​ไป แต่​ใน​งาน​รายงาน​แบบ​นี้ คุณ​ควร รู้ตัว​ว่า​กำลัง​จ่าย​ต้นทุน​อะไร​ไป

ปัญหา​อีก​ข้อ​ของ​การ map คือ บาง​ครั้ง​เรา​ต้อง เปิด property เพิ่ม บน domain object ที่จริงๆ อยาก​เก็บ​ไว้​เป็น private/internal ของ domain ผล​คือ service layer เริ่ม​ผูก​กับ​โครงสร้าง​ภายใน​ของ domain มาก​ขึ้น เพื่อ​ลด coupling ที่​ไม่​พึง​ประสงค์​นี้ เรา​จึง​มอง​หา pattern อื่น เช่น mediator

🛒 เทียบ​กับ e-commerce — Loyalty Report

ร้าน​กีฬา​ออนไลน์​อยาก​รู้​ว่า “โปรแกรม​สะสม​แต้ม” สร้าง​กำไร​ได้​ดี​แค่​ไหน รายงาน​แสดง​เป็น​ราย​เดือน: กำไร​สุทธิ (% ของ​กำไร​ทั้งหมด), จำนวน​การ​ซื้อ, อัตรา​แต้มต่อ​ดอลลาร์ และ​จำนวน​สมาชิก​ที่​สมัคร​ใหม่

ใน​เฟส​แรก ถ้า​ทุก​อย่าง​อยู่​ใน Bounded Context “การ​สั่ง​ซื้อ” อยู่​แล้ว เรา​ก็ map จาก domain object มา​เป็น view model ได้​เลย — รวดเร็ว​ทันใจ แต่​เมื่อ​ข้อมูล​เติบโต​ขึ้น จำนวน query จะ​กลาย​เป็น​ปัญหา จน​เรา​ต้อง​ขยับ​ไป​ทาง​เลือกถัดๆ ไป


ถ้า​อยาก​ได้ view model ที่​มี​ข้อมูล​ครบ แต่ ไม่​ผูก​กับ​โครงสร้าง​ภายใน​ของ domain มาก​เกิน​ไป เรา​ใช้ Mediator Pattern ได้ แทนที่​จะ​ให้ service layer ไป “อ่าน property” ของ domain เรา​จะ ส่ง view model ผ่าน​ตัวกลาง (mediator) เข้าไป​ใน domain แล้ว​ให้ domain object เป็น​ฝ่าย​เติม​ค่า​ลง​ใน mediator เอง

ที่​สำคัญ วิธี​นี้ ไม่​ทำลาย​การ​แบ่ง​ชั้น (layering) เพราะ mediator implement interface ที่ “เป็น​ของ domain” เอง:

IDealershipAssessment.cs
// interface mediator — โครงสร้างที่ “เสถียร” พอจะเปิดให้ service layer เห็น
public interface IDealershipAssessment {
int TotalRevenue { get; set; }
int NetProfit { get; set; }
}
public class DealershipPerformanceActuals {
// field เป็น private — ซ่อนโครงสร้าง domain ที่อาจเปลี่ยนได้
private int totalRevenue;
private int netProfit;
public void Populate(IDealershipAssessment mediator) {
mediator.TotalRevenue = totalRevenue;
mediator.NetProfit = netProfit;
}
}

ข้อดี​คือ field ที่​เป็น private เหล่า​นั้น ไม่​ถูก​เปิด​ออก​นอก domain จึง​มี​อิสระ​ที่​จะ​เปลี่ยนแปลง​ใน​อนาคต — ตรง​ข้าม​กับ​การ map ตรงๆ ที่​ทำให้​โครงสร้าง​ภายใน​รั่วไหล​ออก​มา ส่วน mediator ตัว​จริง​จะ​ห่อ view model ไว้: เมื่อ domain เซ็ต​ค่า​ลง mediator, mediator ก็​เซ็ต​ค่า​ต่อ​ลง view model ที่​มัน​ห่อ​อยู่

เมื่อไหร่​ควร​ใช้ mediator

เลือก mediator เมื่อ​คุณ ต้องการ share state ที่​เป็น private ของ domain โดย​ไม่​เปิด​โครงสร้าง​ภายใน​ออก​มา แต่​ถ้า domain ยัง​เติบโต​ไม่​นิ่ง​และ​ความ​ซับซ้อน​ของ mediator ยัง​ไม่​จำเป็น — นั่น​คือ​การ​ออกแบบ​เกิน​เหตุ (over-engineering) และ​ใน​รายงาน​ที่​เน้น​ประสิทธิภาพ​สูง mediator ก็​ไม่​เหมาะ เพราะ​ควบคุม​ใน​ระดับ​ล่าง​ไม่​ได้ กรณี​แบบ​นั้น​ให้​ลง​ไป​ยิง query ตรง​กับ​ฐาน​ข้อมูล​แทน


เมื่อ ประสิทธิภาพ​และ​ความ​คุ้ม​ค่า​เป็น​เรื่อง​สำคัญ หรือ​เมื่อ​ไม่​ต้องการ​ผ่าน​ชั้น mapping ที่​ซับซ้อน นัก DDD จำนวน​มาก​เลือก ดึง​ข้อมูล​รายงาน​ตรง​จาก​ฐาน​ข้อมูล โดย​ไม่​แตะ domain เลย

  • ระบบ​ที่​ใช้ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture อยู่​แล้ว​จะ​มี​สำเนา​ข้อมูล​แบบ denormalized เตรียม​ไว้​ให้​แต่ละ​รายงาน​โดย​เฉพาะ
  • ระบบ​ที่​ไม่​ใช้ CQRS มัก​ยิง query ดิบๆ ด้วย​เทคโนโลยี​ระดับ​ล่าง เช่น raw ADO.NET หรือ​ใช้ feature ระดับ​ล่าง​ของ ORM (เช่น HQL ของ NHibernate)

แนวคิด​เบื้องหลัง​คือ Micro-ORM อย่าง Dapper: มัน​ไม่​พยายาม “ซ่อน” ฐาน​ข้อมูล​เหมือน Big-ORM แต่​แค่​ทำให้ query สะดวก​ขึ้น​นิดหน่อย — คุณ​ยัง​คุม SQL ได้​เต็ม​ที่:

LoyaltyReportBuilder.cs (ตรง​เข้า DB)
// คุม SQL เองทุกบรรทัด → เขียน query ที่ join หลายตารางได้อย่างมีประสิทธิภาพ
var profits = con.Query<PurchasesAndProfit>(profitQuery, new { start, end });
// (SQL จริงจะ join orders, users, loyaltyAccounts ฯลฯ เพื่อคำนวณ % กำไร)
ราคา​ของ​การ query ตรง — ละเมิด DRY

ข้อ​ท้าทาย​ใหญ่​ของ​การ query ตรง​คือ ความ​ซ้ำซ้อน​ของ​แนวคิด (knowledge duplication) สังเกต​ว่า​ใน SQL เรา​คำนวณ “% กำไร​จาก loyalty เทียบ​กับ​กำไร​รวม” — การ​คำนวณ​แบบ​นี้ น่า​จะ​มี​อยู่​แล้ว​ใน domain model ด้วย เมื่อ​สูตร​เปลี่ยน คุณ​ต้อง​แก้​ทั้ง​ใน SQL และ​ใน domain ซึ่ง​หลงลืม​ได้​ง่าย​มาก จน​ทำให้​ธุรกิจ​ได้​ตัวเลข​ที่​ผิดพลาด

ทาง​บรรเทา​คือ “เก็บ​ค่าที่​คำนวณ​ไว้​ล่วงหน้า” ลง​ฐาน​ข้อมูล​ตอน​อัปเดต แต่​ถ้า​อัปเดต​จาก​หลาย​ที่ ก็​ต้อง​คำนวณ​ซ้ำ​ใน​หลาย​ที่ หรือ​ใช้ database trigger — ซึ่ง​ก็​มี trade-off ของ​มัน​เอง​อีก (อ่าน​ยาก​และ​ทดสอบ​ยาก)


บาง​ครั้ง​แม้แต่ query ตรง​ด้วย SQL ที่​เขียน​มือ ก็​ยัง​ช้า​เกิน​ไป นัก DDD บาง​คน​จึง​สร้าง สำเนา​ข้อมูล​แบบ denormalized ที่​เตรียม​ไว้​เฉพาะ​รายงาน เรียก​ว่า Denormalized View Cache แนวคิด​นี้​ใกล้​เคียง​กับ CQRS มาก: ทุก​ครั้ง​ที่​มี​การ​อัปเดต ข้อมูล​หลัก​ถูก​อัปเดต และ view cache ที่​เกี่ยวข้อง​ก็​ถูก​อัปเดต​ไป​พร้อม​กัน​ผ่าน​ตัว​ที่​เรียก​ว่า denormalizer

รูปที่ 26-1: Denormalized view cache สำหรับ loyalty report — เมื่อมีการสั่งซื้อหรือสมัครสมาชิก domain ทำงานตามปกติแล้วเขียนลงฐานข้อมูลหลัก (normalised) ขณะเดียวกัน denormalizer ก็ “แบนข้อมูล” ลง denormalized database ส่วนฝั่งรายงานแค่ query จากสำเนาที่แบนแล้ว

หน้าที่​ของ denormalizer คือ แบน​ข้อมูล (flatten) เพื่อ​ให้ query กลาย​เป็น SQL SELECT ธรรมดา​ที่​ไม่​ต้อง join เลย:

LoyaltyReportBuilder.cs (view cache)
public LoyaltyReport Build(DateTime start, DateTime end) {
// ไม่มี join — แค่ select จากตารางที่ denormalize ไว้แล้ว
var query = "select [Month], PointsPerDollar, NetProfit, Signups, Purchases"
+ " from denormalizedLoyaltyReportViewCache"
+ " where [Month] >= @start and [Month] < @end";
var summaries = con.Query<LoyaltySummary>(query, new { start, end });
return new LoyaltyReport { Summaries = summaries };
}
ย้าย​งาน​หนัก​ไป “ตอน​เขียน”

Denormalized view cache คือ​การ ยอม​ลงทุน​แรง​ใน​ฝั่ง​เขียน (denormalize) เพื่อ​แลก​กับ query ที่​อ่าน​ง่าย​และ​เร็ว​มากๆ นี่​คือ​ปรัชญา​เดียว​กับ CQRS — แยก​ฝั่ง​เขียน​ออก​จาก​ฝั่ง​อ่าน คุณ​ต้อง​ชั่ง​น้ำหนัก​ว่า​ความ​ซับซ้อน​ที่​ลด​ลง​และ​ประสิทธิภาพ​ที่​เพิ่ม​ขึ้น​นั้น คุ้ม​กับ​งาน denormalize ที่​เพิ่ม​เข้า​มา​หรือ​ไม่ และ​ผสมผสาน​หลาย​วิธี​ใน project เดียว​ได้​ตาม​ความ​เหมาะสม


ระบบ​ที่​ใช้ Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture ต้อง​ใช้​เทคนิค​ที่​แตก​ต่าง​ออก​ไป เพราะ​มัน ไม่​ได้​เก็บ​สถานะ​ปัจจุบัน ของ app แต่​เก็บ​ลำดับ event ทั้งหมด แทนที่​จะ query ตาราง เรา​ใช้ feature ที่​เรียก​ว่า Projection — คือ “query บน event stream ที่​ผลิต state ที่​ต้องการ หรือ​ผลิต stream ใหม่” โดย​อิง​จาก​เนื้อหา​ของ event ใน stream ต้นทาง

ลอง​ดู​ตัวอย่าง​รายงานการ​วินิจฉัย​โรค​ราย​เดือน​ของ​หน่วย​งาน​สาธารณสุข: เรา​สร้าง stream สรุป​ราย​เดือน​ของ​แต่ละ​การ​วินิจฉัย​ใน​รูปแบบ diagnosis-{id}-{month} จาก projection ที่​ทำงาน​บน stream กลาง​ที่​มี​ทุก​การ​วินิจฉัย (diagnoses):

รูปที่ 26-2: Projection แปลง event stream “diagnoses” ให้กลายเป็น stream สรุปรายเดือนของแต่ละการวินิจฉัย — event ที่มี diagnosisId และเดือนเดียวกันถูก “ชี้ link” ไปรวมใน stream เดียว เช่น diagnosis-dg1-201402

projection ใน Event Store เขียน​ด้วย JavaScript และ​ทำงาน​กับ​ทุก event ใน stream:

DiagnosesByMonth (projection)
fromStream('diagnoses')
.whenAny(function (state, ev) {
var date = ev.data.Date.replace('/', '');
var diagnosisId = ev.data.DiagnosisId;
// linkTo สร้าง “ตัวชี้” ไปยัง stream อีกอัน(สร้าง stream ถ้ายังไม่มี)
// projection ไม่ได้ copy event จริง — แค่สร้าง reference
linkTo('diagnosis-' + diagnosisId + '_' + date, ev);
});

พอ​จัด​กลุ่ม event เข้า stream ราย​เดือน​แล้ว การ “นับ​จำนวน” ก็​ทำ​ด้วย projection อีก​ตัว​ที่​วน​ทุก stream ใน​หมวด (fromCategory('diagnosis').foreachStream()) แล้ว​บวก​ที​ละ 1 เก็บ​ไว้​ใน state ของ projection สุดท้าย การ​สร้าง​รายงาน​เหลือ​เพียง เรียก HTTP ไป​ถาม​ค่า count ของ​แต่ละ stream แล้ว map:

HealthcareReportBuilder.cs
// ดึงยอดรวมรายเดือนจาก state ของ projection (ตอบกลับเป็น JSON)
var url = projectionStateUrl + "?partition=" + streamName; // เช่น month-201402
var count = Json.Decode<DiagnosisCount>(new WebClient().DownloadString(url));
Projection = ปรัชญา​เดียว​กับ view cache

สังเกต​ว่า projection ใช้​แนวคิด​เดียว​กับ denormalized view cache — ลงแรง​ทำงาน​หนัก​ล่วงหน้า เพื่อ​ให้​ตอน​อ่าน​เหลือ​เพียง “นับ​จำนวน event ใน stream” การ​ยิง HTTP ที​ละ​เดือน​ต่อ​การ​วินิจฉัย​อาจ​เกิด​ขึ้น​จำนวน​มาก แต่​ค่า​เหล่า​นี้ ไม่​เปลี่ยนแปลง​อีก​แล้ว จึง cache ได้​อย่าง​เต็ม​ที่ และ Event Store ถูก​ออกแบบ​มา​ให้​รองรับ stream จำนวนนับ​ล้าน​ได้​อย่าง​สบาย

🚢 เทียบ​กับ​ระบบ​ขนส่ง — รายงาน​จาก Handling Event

ระบบ Cargo เก็บ Handling EventHandling Eventการ​บันทึก​การ​จัดการ​สินค้า​จริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยก​ต่างหาก” จาก Cargo เพราะ​มี​ปริมาณ​มาก​และ​ต้อง​ประมวล​ผล​แบบ asyncTactical Design (LOAD, UNLOAD, RECEIVE, CLAIM) ไว้​เป็น​จำนวน​มหาศาล ถ้า​ระบบ​นี้​ใช้ event sourcing เรา​สามารถ​สร้าง projection ที่​จัด​กลุ่ม handling event ตาม “ท่าเรือ​ต่อ​เดือน” แล้ว​นับ​จำนวน เพื่อ​ทำ​รายงาน “ปริมาณ​งาน​ของ​แต่ละ​ท่าเรือ” โดย​ไม่​ต้อง​ไป​รบกวน AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ฝั่ง​เขียน​เลย — งาน​หนัก​ทำ​ตอน project, ตอน​อ่าน​แค่ count


ความ​ยุ่งยาก​เริ่มต้น​ขึ้น​จริง​เมื่อ​ข้อมูล​กระจาย​อยู่​หลาย Bounded Context ใน​ระบบ​กระจาย (distributed) ซึ่ง​แต่ละ context มี datastore ของ​ตัวเอง​และ loosely coupled ต่อ​กัน การ​จะ​รวม​ข้อมูล​มา​ทำ​รายงาน​จึง​ต้อง​ใช้​แรง​เพิ่ม หนังสือ​เสนอ​ไว้ 2 แนวทาง​หลัก

ถ้า​งาน​ส่วน​ใหญ่​ทำได้ เป็น​เฟสๆ โดย​แต่ละ​เฟส​อยู่​ใน context เดียว เรา​อาจ​รวม​ผลลัพธ์​ที่​ชั้น UI ได้​เลย ลอง​นึกถึง​รายงาน​เปรียบเทียบ​ความ​นิยม​ของ​ค่าย​เพลง​ใน​แต่ละ​ภูมิภาค โดย “ความ​นิยม” = ยอด stream + ยอด download รวม​กัน:

รูปที่ 26-3: รวมข้อมูลจากหลาย Bounded Context เป็นรายงานเดียว — Web UI context ดึงยอด download จาก Downloads context และยอด stream จาก Streaming context พร้อมกัน แล้วบวกรวมเป็น “Combined totals” ที่ฝั่งหน้าเว็บ

ข้อดี​คือ stream กับ download เป็น​คนละ Bounded Context กัน​โดย​สมบูรณ์ การ​รวม​เกิด​ขึ้น​เป็น​เฟส​อิสระ — ดึง​ยอด download จาก Downloads context พร้อม​กับ​ดึง​ยอด stream จาก Streaming context แล้ว​ใช้ aggregation ฝั่ง client หรือ server มา​รวม (เทคนิค UI composition จาก​บท​ประกอบ app) ส่วน​การ​แปลง ID เป็น​ชื่อ​ก็​ค่อย​ไป​ถาม context เจ้าของ​ข้อมูล​ทีหลัง​ได้

ด้วย​เหตุผล​ด้าน​ประสิทธิภาพ ความ​สะดวก หรือ​เพื่อ​ทำ data warehouse (ให้ data scientist “หั่น​และ​พลิก” (slice and dice) ข้อมูล​เพื่อ​หา​มุม​มองใหม่ๆ) บาง​ครั้ง​เรา​ต้องการ​ให้​ข้อมูล​รายงาน ทั้งหมด​อยู่​ใน datastore เดียว โดย​ปกติ​ทำ​ไม่​ได้​เพราะ​แต่ละ context แยก datastore กัน — แต่​เรา​รู้​ว่า context สื่อสาร​กัน​ด้วย event จึง​เปิด​โอกาส​ให้​สร้าง Reporting Bounded Context ที่ subscribe event จาก​หลาย context แล้ว​เก็บ​ข้อมูล​ที่​ต้อง​ใช้​ไว้​ใน​ที่​เดียว (อาศัย​หลัก event-driven และ Integration EventIntegration Eventevent ที่​สื่อสาร “ข้าม” Bounded Context หรือ​ข้าม microservice ผ่าน event bus — ต่าง​จาก Domain Event ที่​ทำงาน​ภายใน context เดียว (in-process)Architecture)

รูปที่ 26-4: Reporting context แบบมาตรฐาน — context สำหรับรายงานเพียง subscribe event จาก Bounded Context A, B, C แล้วเก็บลง SQL database ของตัวเอง เหมือน context ทั่วไปอันหนึ่ง

ขอบเขต​และ​ความ​ซับซ้อน​ของ reporting context มี​ได้​ตั้งแต่​เรียบ​ง่าย​ไป​จนถึง​หนักหน่วง: กรณี​ง่าย​ที่สุด​ก็​เพียง subscribe event แล้ว​เก็บ​ลง SQL (รูป​ที่ 26-4) ส่วน​ปลาย​อีก​ด้าน​หนึ่ง​คือ​การ​ป้อน​ข้อมูล​ผ่าน​เทคโนโลยี​ที่​หลากหลาย — recommendation engine และ machine learning คล้าย​กับ​ที่ Netflix ทำ:

รูปที่ 26-5: Reporting context แบบประมวลผลข้อมูลซับซ้อน — subscribe event จากหลาย context แล้วป้อนผ่านเครื่องมือ big data อย่าง Hadoop, S3/Cloud storage และ Mahout เพื่อทำการวิเคราะห์ขั้นสูง

เลือก​แนวทาง​ตาม trade-off
  • Composed UI — เบา ใช้​เมื่อ​รวม​ข้อมูล​เป็น​เฟส​อิสระ​ได้ ไม่​ต้อง​เก็บ​สำเนา​ข้อมูล แต่​ผูก​กับ​การ​ที่​ทุก context พร้อม​ตอบ query
  • Reporting BC แยก — หนัก​กว่า สร้าง​สำเนา​ข้อมูล​ใน​ที่​เดียว​ด้วย​การ subscribe event เหมาะ​กับ data warehouse / การ​วิเคราะห์​ข้าม​แผนก แต่​ต้อง​ดูแล eventual consistency และ infrastructure เพิ่ม

  • รายงาน​สร้าง​ได้​หลาย​วิธี โดย​มี​เป้าหมาย​ร่วม​กัน​คือ หลีก​เลี่ยง coupling กับ domain
  • บาง​รายงาน​อยู่​ใน context เดียว ขณะ​ที่​บาง​รายงาน​ต้อง query ข้าม​หลาย context
  • Map จาก domain object เป็น​วิธี​ที่​เร็ว​ที่สุด แต่​ควบคุม data access ใน​ระดับ​ล่าง​ได้​น้อย (ระวัง N+1 query)
  • Mediator pattern ช่วย​ซ่อน​โครงสร้าง​ภายใน​ของ domain และ​จัดการ trade-off เรื่อง coupling
  • ยิง query ตรง ทำได้​เมื่อ​ต้องการ​ประสิทธิภาพ แต่​ต้อง​ระวัง​การ​ละเมิด DRY (ตรรกะ​ซ้ำ​กับ domain)
  • Denormalized view cache และ Projection ของ event stream ต่าง​ก็​ย้าย​งาน​หนัก​ไป​ไว้ “เบื้องหลัง/ฝั่ง​เขียน” เพื่อ​แลก​กับ​การ​อ่าน​ที่​เรียบ​ง่าย
  • รายงาน​ข้าม context ทำได้​ด้วย UI composition ใน​บาง​กรณี และ reporting context แยก​ต่างหาก ใน​อีก​บาง​กรณี
กฎ​เหล็ก​ของ​ฝั่ง query

ไม่มี “วิธี​ที่​ถูกต้อง​ที่สุด” มี​แต่ trade-off — ความเร็ว​ใน​การ​พัฒนา vs ประสิทธิภาพ vs การ​ดูแล​รักษา vs ความ​ซ้ำซ้อน หน้าที่​ของ​คุณ​คือ รู้​ให้​ชัด​ว่า​กำลัง​แลก​อะไร​กับ​อะไร แล้ว​เลือก​ให้​เหมาะ​กับ​บริบท​ของ​ราย​งานนั้นๆ และ​จำ​ไว้​ว่า​คุณ​ผสมผสาน​หลาย​วิธี​ใน​ระบบ​เดียว​ได้​เสมอ


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • CQRS — ปรัชญา​แยก​ฝั่ง​เขียน/อ่าน​ที่ denormalized view cache และ projection ของ event sourcing ใน​บท​นี้​ยึด​ตาม​อยู่
  • Domain Events — กลไก​เบื้องหลัง Reporting Bounded Context ที่ subscribe event จาก​หลาย context มาสร้าง​รายงาน​รวม
  • Bounded Context — หน่วย​ขอบเขต​ที่​กำหนด​ว่า​รายงาน​หนึ่ง​จะ​อยู่​ใน context เดียว​หรือ​ต้อง​รวม​ข้าม​หลาย context
  • Lazy Loading — ตัวการ​ที่​ทำให้การ map จาก domain object กลาย​เป็น​กับดัก N+1 query อย่าง​ที่​บท​นี้​เตือน​ไว้

เช็กความเข้าใจ — บทที่ 26

ข้อ 1 / 4

trade-off หลักของการ ‘map จาก domain object ลง view model’ เพื่อทำรายงานคืออะไร?