[{"data":1,"prerenderedAt":790},["ShallowReactive",2],{"portfolio-\u002Fportfolio\u002Fecommerce-microservices-observability":3},{"id":4,"title":5,"award":6,"body":7,"cover":764,"description":221,"extension":765,"featured":279,"link":6,"meta":766,"navigation":279,"order":767,"path":768,"pinned":279,"repo":769,"role":770,"seo":771,"stack":772,"stem":786,"summary":787,"year":788,"__hash__":789},"portfolio\u002Fportfolio\u002Fecommerce-microservices-observability.md","電商微服務可觀測性專案",null,{"type":8,"value":9,"toc":753},"minimark",[10,14,27,31,38,100,131,135,138,145,166,173,177,180,583,586,593,635,654,657,660,704,707,739,742,749],[11,12,13],"h2",{"id":13},"專案概述",[15,16,17,18,22,23,26],"p",{},"一個以訂單（Order）、付款（Payment）、物流（Shipping）三個微服務組成的電商練\n習專案，目的不是做出完整電商功能，而是紮實地練習 ",[19,20,21],"strong",{},"DDD 戰略與戰術設計、\nEvent Sourcing"," 與",[19,24,25],{},"可觀測性（Observability）"," 在 .NET 微服務中的落地方\n式。每個服務都各自擁有獨立資料庫，並透過領域事件串接彼此的業務流程。",[11,28,30],{"id":29},"ddd-戰略設計strategic-design","DDD 戰略設計（Strategic Design）",[15,32,33,34,37],{},"三個服務對應三個獨立的 ",[19,35,36],{},"Bounded Context","，各自有自己的資料庫、程式碼庫與通\n用語言（Ubiquitous Language），彼此不共用資料表、也不直接呼叫對方的 API：",[39,40,41,56],"table",{},[42,43,44],"thead",{},[45,46,47,50,53],"tr",{},[48,49,36],"th",{},[48,51,52],{},"核心聚合",[48,54,55],{},"負責的通用語言",[57,58,59,74,87],"tbody",{},[45,60,61,65,71],{},[62,63,64],"td",{},"OrderService",[62,66,67],{},[68,69,70],"code",{},"Order",[62,72,73],{},"建立訂單、確認付款、揀貨、完成訂單、取消訂單",[45,75,76,79,84],{},[62,77,78],{},"PaymentService",[62,80,81],{},[68,82,83],{},"Payment",[62,85,86],{},"請款、付款完成、付款失敗、取消付款",[45,88,89,92,97],{},[62,90,91],{},"ShippingService",[62,93,94],{},[68,95,96],{},"Shipment",[62,98,99],{},"請求出貨、已派送、已送達",[15,101,102,103,106,107,109,110,113,114,116,117,119,120,123,124,127,128,130],{},"Context 之間",[19,104,105],{},"只透過 Kafka 上的領域事件溝通","，不共享資料庫、不同步呼叫對\n方，屬於以「Published Language（事件即契約）」串接的 Context Mapping：\n",[68,108,64],{}," 發布 ",[68,111,112],{},"OrderCreatedEvent"," 後即結束自己的職責，完全不知道\n",[68,115,78],{}," 內部如何處理請款；",[68,118,78],{}," 處理完也只發布\n",[68,121,122],{},"PaymentCompletedEvent"," \u002F ",[68,125,126],{},"PaymentFailedEvent","，同樣不知道 ",[68,129,64],{},"\n收到後會做什麼。這種鬆耦合讓三個服務可以獨立開發、獨立部署、獨立擴展。",[132,133,134],"h3",{"id":134},"事件流程示意圖",[15,136,137],{},"下圖是訂單成立、付款、出貨（以及付款失敗導致取消訂單）的完整事件流：",[15,139,140],{},[141,142],"img",{"alt":143,"src":144},"電商訂單事件流程圖","\u002Fimages\u002Fportfolio\u002Fecommerce-order-event-flow.png",[146,147,148,158],"ul",{},[149,150,151,154,155],"li",{},[19,152,153],{},"成功路徑","：",[68,156,157],{},"User → 建立訂單 → OrderCreated → 發起支付請求 → PaymentRequested → 支付完成 → PaymentCompleted → 確認付款 → OrderPaid → 發起配送請求 → ShippingRequested",[149,159,160,154,163],{},[19,161,162],{},"失敗路徑",[68,164,165],{},"支付失敗 → PaymentFailed → 取消訂單 → OrderCancelled",[15,167,168,169,172],{},"整個流程是典型的",[19,170,171],{},"事件編舞（Choreography）","：沒有一個中控服務下指令，每個\n服務只是「訂閱自己關心的事件、做完事、發布下一個事件」，服務間的協作關係完全\n從事件的訂閱／發布關係中浮現出來。",[11,174,176],{"id":175},"ddd-戰術設計tactical-design","DDD 戰術設計（Tactical Design）",[15,178,179],{},"在單一 Bounded Context 內部，則落實了完整的戰術設計工具箱：",[146,181,182,486,509,536,547,564],{},[149,183,184,154,187,189,190,189,192,194,195,198,199,202,203,206,207,210,211,214,215],{},[19,185,186],{},"Aggregate Root + Event Sourcing",[68,188,70],{},"、",[68,191,83],{},[68,193,96],{}," 都繼承\n共用的 ",[68,196,197],{},"AggregateRoot\u003CTId>","。狀態",[19,200,201],{},"不能被直接賦值修改","，只能透過\n",[68,204,205],{},"Apply(DomainEvent)"," 產生事件、在 ",[68,208,209],{},"When()"," 中依事件重建狀態，並在\n",[68,212,213],{},"EnsureValidState()"," 中檢查不變條件（Invariant），修改狀態與驗證規則被綁在\n一起，不會有「改了忘記驗證」的問題\n",[216,217,222],"pre",{"className":218,"code":219,"language":220,"meta":221,"style":221},"language-csharp shiki shiki-themes github-dark","public void ConfirmPayment()\n{\n    Apply(new OrderPaidEvent(Id, ShipmentAddress));\n}\n\nprotected override void When(DomainEvent domainEvent)\n{\n    switch (domainEvent)\n    {\n        case OrderCreatedEvent e:\n            Status = Status.PendingPayment;\n            break;\n        case OrderPaidEvent:\n            Status = Status.Paid;\n            break;\n        \u002F\u002F ...\n    }\n}\n\nprotected override void EnsureValidState()\n{\n    if (Status == Status.Cancelled && string.IsNullOrEmpty(CancellationReason))\n        throw new OrderDomainException(\"Cancellation reason is required.\");\n}\n","csharp","",[68,223,224,244,250,268,274,281,306,311,320,326,341,353,362,371,381,388,395,401,406,411,425,430,460,481],{"__ignoreMap":221},[225,226,229,233,236,240],"span",{"class":227,"line":228},"line",1,[225,230,232],{"class":231},"snl16","public",[225,234,235],{"class":231}," void",[225,237,239],{"class":238},"svObZ"," ConfirmPayment",[225,241,243],{"class":242},"s95oV","()\n",[225,245,247],{"class":227,"line":246},2,[225,248,249],{"class":242},"{\n",[225,251,253,256,259,262,265],{"class":227,"line":252},3,[225,254,255],{"class":238},"    Apply",[225,257,258],{"class":242},"(",[225,260,261],{"class":231},"new",[225,263,264],{"class":238}," OrderPaidEvent",[225,266,267],{"class":242},"(Id, ShipmentAddress));\n",[225,269,271],{"class":227,"line":270},4,[225,272,273],{"class":242},"}\n",[225,275,277],{"class":227,"line":276},5,[225,278,280],{"emptyLinePlaceholder":279},true,"\n",[225,282,284,287,290,292,295,297,300,303],{"class":227,"line":283},6,[225,285,286],{"class":231},"protected",[225,288,289],{"class":231}," override",[225,291,235],{"class":231},[225,293,294],{"class":238}," When",[225,296,258],{"class":242},[225,298,299],{"class":238},"DomainEvent",[225,301,302],{"class":238}," domainEvent",[225,304,305],{"class":242},")\n",[225,307,309],{"class":227,"line":308},7,[225,310,249],{"class":242},[225,312,314,317],{"class":227,"line":313},8,[225,315,316],{"class":231},"    switch",[225,318,319],{"class":242}," (domainEvent)\n",[225,321,323],{"class":227,"line":322},9,[225,324,325],{"class":242},"    {\n",[225,327,329,332,335,338],{"class":227,"line":328},10,[225,330,331],{"class":231},"        case",[225,333,334],{"class":238}," OrderCreatedEvent",[225,336,337],{"class":238}," e",[225,339,340],{"class":242},":\n",[225,342,344,347,350],{"class":227,"line":343},11,[225,345,346],{"class":242},"            Status ",[225,348,349],{"class":231},"=",[225,351,352],{"class":242}," Status.PendingPayment;\n",[225,354,356,359],{"class":227,"line":355},12,[225,357,358],{"class":231},"            break",[225,360,361],{"class":242},";\n",[225,363,365,367,369],{"class":227,"line":364},13,[225,366,331],{"class":231},[225,368,264],{"class":238},[225,370,340],{"class":242},[225,372,374,376,378],{"class":227,"line":373},14,[225,375,346],{"class":242},[225,377,349],{"class":231},[225,379,380],{"class":242}," Status.Paid;\n",[225,382,384,386],{"class":227,"line":383},15,[225,385,358],{"class":231},[225,387,361],{"class":242},[225,389,391],{"class":227,"line":390},16,[225,392,394],{"class":393},"sAwPA","        \u002F\u002F ...\n",[225,396,398],{"class":227,"line":397},17,[225,399,400],{"class":242},"    }\n",[225,402,404],{"class":227,"line":403},18,[225,405,273],{"class":242},[225,407,409],{"class":227,"line":408},19,[225,410,280],{"emptyLinePlaceholder":279},[225,412,414,416,418,420,423],{"class":227,"line":413},20,[225,415,286],{"class":231},[225,417,289],{"class":231},[225,419,235],{"class":231},[225,421,422],{"class":238}," EnsureValidState",[225,424,243],{"class":242},[225,426,428],{"class":227,"line":427},21,[225,429,249],{"class":242},[225,431,433,436,439,442,445,448,451,454,457],{"class":227,"line":432},22,[225,434,435],{"class":231},"    if",[225,437,438],{"class":242}," (Status ",[225,440,441],{"class":231},"==",[225,443,444],{"class":242}," Status.Cancelled ",[225,446,447],{"class":231},"&&",[225,449,450],{"class":231}," string",[225,452,453],{"class":242},".",[225,455,456],{"class":238},"IsNullOrEmpty",[225,458,459],{"class":242},"(CancellationReason))\n",[225,461,463,466,469,472,474,478],{"class":227,"line":462},23,[225,464,465],{"class":231},"        throw",[225,467,468],{"class":231}," new",[225,470,471],{"class":238}," OrderDomainException",[225,473,258],{"class":242},[225,475,477],{"class":476},"sU2Wk","\"Cancellation reason is required.\"",[225,479,480],{"class":242},");\n",[225,482,484],{"class":227,"line":483},24,[225,485,273],{"class":242},[149,487,488,154,491,189,494,189,497,500,501,504,505,508],{},[19,489,490],{},"強型別 ID（Value Object）",[68,492,493],{},"OrderId",[68,495,496],{},"PaymentId",[68,498,499],{},"ShipmentId","、\n",[68,502,503],{},"MemberId"," 都是獨立的 Value Object，而不是裸的 ",[68,506,507],{},"Guid","，避免把訂單 ID 誤傳\n成會員 ID 這種編譯期抓不到的錯誤",[149,510,511,154,514,516,517,520,521,500,524,527,528,531,532,535],{},[19,512,513],{},"狀態機驅動",[68,515,70],{}," 從 ",[68,518,519],{},"PendingPayment"," 開始，依序經過 ",[68,522,523],{},"Paid",[68,525,526],{},"PickingInProgress","，最終到達 ",[68,529,530],{},"Completed","，或在任何節點被 ",[68,533,534],{},"Cancelled","；狀\n態轉換完全由事件決定，畫面上看到的狀態永遠是「套用過哪些事件」的結果",[149,537,538,541,542,189,544,546],{},[19,539,540],{},"事件即事實，而非指令","：領域事件一律用過去式命名\n（",[68,543,112],{},[68,545,122],{},"），代表「已經發生的事\n實」，而不是「請你做什麼」的命令，這也是它能安全地被多個服務訂閱、不需要協\n調誰先誰後的原因",[149,548,549,552,553,123,556,559,560,563],{},[19,550,551],{},"Null Object Pattern","：以 ",[68,554,555],{},"INullObject",[68,557,558],{},"Order.Null"," 取代 ",[68,561,562],{},"null"," 判\n斷，减少呼叫端重複的防禦性檢查",[149,565,566,154,569,572,573,500,576,189,579,582],{},[19,567,568],{},"事件溯源基礎設施",[68,570,571],{},"AggregateRoot"," 內建 ",[68,574,575],{},"Version",[68,577,578],{},"GetUncommittedEvents()",[68,580,581],{},"Load(history)","，聚合可以完全由事件歷史重建，而\n非只依賴目前快照",[11,584,585],{"id":585},"系統架構",[15,587,588,589,592],{},"三個服務都採用 ",[19,590,591],{},"Hexagonal Architecture（Ports & Adapters）","，並拆成獨立的\n專案分層：",[146,594,595,601,617,623,629],{},[149,596,597,600],{},[68,598,599],{},"Entities","：聚合根與領域事件，對應各服務的狀態機",[149,602,603,606,607,500,610,189,613,616],{},[68,604,605],{},"UseCase","：應用層服務與 In\u002FOut Port 介面（如 ",[68,608,609],{},"CreateOrderService",[68,611,612],{},"RequestPaymentService",[68,614,615],{},"DispatchOrderService","）",[149,618,619,622],{},[68,620,621],{},"Adapter.Out","：以 EF Core 實作的資料庫存取層與 Migration",[149,624,625,628],{},[68,626,627],{},"WebApi","：Minimal API 端點與事件訂閱者（Event Handler）",[149,630,631,634],{},[68,632,633],{},"MainComponent","：各服務的組裝根（DI 註冊）",[15,636,637,638,641,642,645,646,649,650,653],{},"共用套件則抽成獨立專案：",[68,639,640],{},"E-Commerce.SeedWork","（AggregateRoot、DomainEvent 等\nDDD 基礎建構）、",[68,643,644],{},"E-Commerce.EventBus","（以 Wolverine 包裝 Kafka 的領域事件匯流\n排）、",[68,647,648],{},"E-Commerce.Diagnostics","（Tracing／Metrics／AOP）。並以 ",[19,651,652],{},".NET Aspire","\n作為本機開發時的服務編排（AppHost）。",[11,655,656],{"id":656},"可觀測性亮點",[15,658,659],{},"這個專案真正想練習的重點：",[146,661,662,673,683,690,697],{},[149,663,664,665,668,669,672],{},"以 .NET 內建 ",[68,666,667],{},"ActivitySource"," 在三個服務中發送 Trace，並將 ",[19,670,671],{},"TraceId 寫入\n領域事件本身","，讓一筆訂單從建立、付款到出貨的完整流程，能在分散式追蹤中被\n串成同一條 Trace",[149,674,675,676,679,680],{},"自訂 ",[68,677,678],{},"MethodTracingAttribute","（AOP）快速為方法加上追蹤埋點，不需手動包\n",[68,681,682],{},"using activity",[149,684,685,686,689],{},"透過 ",[68,687,688],{},"IMeterFactory"," 建立自訂 Metrics（計數器、直方圖、浮動計數器），例如\n訂單金額分佈、待處理付款數量",[149,691,692,693,696],{},"以 OpenTelemetry Collector 統一收集 Trace／Metric／Log，轉送至 ",[19,694,695],{},"Tempo、\nPrometheus、Loki","，並用 Grafana 儀表板呈現",[149,698,699,700,703],{},"整合 ",[19,701,702],{},"Grafana MCP 與 Tempo MCP","，讓開發時能直接用 TraceQL／PromQL／LogQL\n在編輯器內查詢追蹤與指標，不必切換到 Grafana 介面除錯",[11,705,706],{"id":706},"使用技術",[146,708,709,715,721,727,733],{},[149,710,711,714],{},[19,712,713],{},"應用層","：.NET 8、ASP.NET Core Minimal API、Wolverine、Kafka",[149,716,717,720],{},[19,718,719],{},"架構","：Domain-Driven Design（戰略＋戰術設計）、Event Sourcing、\nHexagonal Architecture",[149,722,723,726],{},[19,724,725],{},"資料層","：EF Core、Code First、MSSQL",[149,728,729,732],{},[19,730,731],{},"可觀測性","：OpenTelemetry、Grafana、Tempo、Loki、Prometheus",[149,734,735,738],{},[19,736,737],{},"開發體驗","：.NET Aspire、xUnit、Docker Compose",[11,740,741],{"id":741},"目前狀態",[15,743,744,745,748],{},"專案仍在持續補充中，目前已完整展示三服務間的事件編舞（Choreography）與可觀測\n性串接；後續規劃導入 ",[19,746,747],{},"SAGA Pattern"," 讓跨服務交易一致性有明確的補償流程可\n循，並在服務前加上 API Gateway。",[750,751,752],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":221,"searchDepth":246,"depth":246,"links":754},[755,756,759,760,761,762,763],{"id":13,"depth":246,"text":13},{"id":29,"depth":246,"text":30,"children":757},[758],{"id":134,"depth":252,"text":134},{"id":175,"depth":246,"text":176},{"id":585,"depth":246,"text":585},{"id":656,"depth":246,"text":656},{"id":706,"depth":246,"text":706},{"id":741,"depth":246,"text":741},"https:\u002F\u002Fraw.githubusercontent.com\u002Ftom3019\u002FE-Commerce\u002Fmaster\u002Fimgs\u002Ftracing.png","md",{},1.5,"\u002Fportfolio\u002Fecommerce-microservices-observability","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FE-Commerce","個人 Side Project ‧ 架構設計與開發",{"title":5,"description":221},[773,774,775,776,777,778,779,652,780,781,782,783,784,785],".NET 8","ASP.NET Core Minimal API","DDD","Event Sourcing","Hexagonal Architecture","Wolverine","Kafka","OpenTelemetry","Grafana","Tempo","Loki","Prometheus","EF Core","portfolio\u002Fecommerce-microservices-observability","訂單、付款、物流三個微服務組成的電商練習專案，以 DDD 戰略／戰術設計 + Hexagonal Architecture 實作 Event Sourcing，並完整導入 OpenTelemetry 可觀測性堆疊與 Grafana／Tempo MCP 查詢工具。",2024,"uAOzN5MK_-PBR8yFYoG4EJtJrgar0vzEby5kl5zuZDU",1792959149881]