ASP.NET Core 站內通知最常見的判斷錯誤,是只看更新後的狀態。若程式寫成「新狀態等於 Ready 就通知」,使用者在 Ready 階段修改標題、備註或排序時,每次儲存都會再次產生通知。真正的需求通常不是「目前位於 Ready」,而是「這一次由非 Ready 進入 Ready」。因此判斷式必須同時比較更新前與更新後的值,並且另外處理同一事件被重播的情況。
本文將來源筆記的耐久觀念抽象成一個不依賴特定資料表、部門名稱、路由或執行主機的設計。重點不是建立另一套一般訊息佇列,而是把狀態邊界、事件身分、批次更新與失敗觀測寫成可測試契約。範例使用 .NET SDK 10.0.400 的隔離主控台程式,只在記憶體中計數,不連接網站、資料庫或任何外部服務。
核心答案:通知條件是狀態邊界,不是狀態快照
短答:只有 before != Ready && after == Ready 才代表進入 Ready;Ready -> Ready 是維持,不應再次通知。 這個雙值判斷把「物件現在在哪裡」與「這次操作發生了什麼」分開,讓更新其他欄位不會被誤解成新的業務事件。
狀態快照只回答觀察當下的結果。例如資料列讀出來是 Ready,可能是剛進入,也可能已停留數天,甚至只是重送同一請求。單看 after == Ready 無法區分這些原因。狀態轉換則由有方向的配對表示:Draft 到 Ready 是進入,Ready 到 Ready 是維持,Ready 到 Done 是離開,Done 再到 Ready 是一次新的進入。這四種情形應有各自的驗收結果。
這種設計也讓程式審查更準確。方法若只接收新狀態,便不具備判斷邊界所需的資訊;呼叫端必須在寫入前取得舊值,或由持久層以明確的比較條件完成更新。若先覆寫追蹤中的實體,再讀取同一個屬性當舊值,兩邊可能已相同,正確邊界就會消失。最安全的做法是把 before 保存為不可變區域變數,再套用輸入。
C# 模式比對可以清楚表達值、型別與形狀條件;Microsoft Learn 說明模式可用於 is、switch 與 switch 運算式。本文的二值條件不需要複雜語法,但可用 tuple switch 將每一種狀態配對集中列出,避免布林式散落於控制器、服務與批次迴圈。官方說明見文末參考資料。
把事件身分與物件身分分開
短答:狀態邊界決定「是否值得通知」,事件身分決定「這次是否已處理」。兩者缺一不可。 只做邊界判斷,重播相同的 Draft 到 Ready 事件仍可能再送一次;只做事件去重,又無法阻止 Ready 到 Ready 這類根本不該送出的更新。
物件身分通常代表被修改的工作項目,事件身分則代表一次已接受的變更。兩次不同操作可能針對同一物件:第一次由 Draft 進入 Ready,稍後離開,再由 Done 回到 Ready。這是兩次有效邊界,應有兩個不同事件身分,也應通知兩次。反過來,同一事件因逾時、重試或讀取端重新啟動而重播,仍應保持相同事件身分,只接受第一次。
範例使用 HashSet 記錄已處理事件,目的是展示語意,而不是宣稱它足以支援多程序網站。記憶體集合會在程序重啟後消失,也無法在多個站台節點之間自動協調。正式設計可使用具唯一限制的處理紀錄,讓「首次接受事件」成為原子結果;真正必要的是穩定事件身分與唯一性規則,而不是某個特定儲存產品。
去重順序也值得明確規範。範例先登記事件,再檢查是否為目標邊界,表示每個事件身分只能評估一次。另一種策略只記錄會通知的事件,適合上游可能修正非目標事件內容的情境。兩者沒有通用答案,但不可含糊:測試必須說明同一身分若先以非目標配對出現、稍後改成目標配對,系統要拒絕還是接受。不可變事件通常選擇前者。
更新入口必須保留舊狀態
短答:單筆與批次入口都要逐筆保存 before,再套用 after;不能在整批外共用一個舊值。 每一筆資料的起點可能不同,只有自己的前後配對能判斷是否跨越目標邊界。
單筆服務可以先查詢目前狀態,把值複製到區域變數,驗證允許的轉換,套用更新,儲存後再交由通知協調元件處理。若資料可能被並行修改,更新條件還應包含原版本或原狀態;受影響筆數不符合預期時,應回到衝突處理,而不是以過期的 before 送出通知。這能避免兩個請求同時看見 Draft,之後都自認完成 Draft 到 Ready。
批次更新更容易出錯。不能先讀第一筆的狀態,然後把它套在整個迴圈;也不能把所有項目改完後,再從已修改的集合回推原值。應先取得每筆的識別值、舊狀態與版本,形成不可變快照,再逐筆產生變更事件。即使輸入要求全數設為 Ready,原本已在 Ready 的項目也必須被判定為維持,不可因為同一批次而全部通知。
控制器不宜承擔全部判斷。控制器負責解析請求與回應,領域服務負責轉換規則,通知閘門負責邊界與重播契約。這樣單筆畫面、批次端點與背景補償流程都可呼叫同一規則。若每個入口各寫一份 if,日後新增狀態或改名時,很容易只有其中一條路徑正確。
通知失敗與主要更新要有明確政策
短答:先定義主要更新是否可因通知失敗而回復,再讓記錄、重試與去重遵守同一政策。 單純用空的例外處理包住通知,雖能保住主流程,卻會把失敗變成無法追查的遺失。
若通知屬於輔助資訊,主要狀態通常不應因呈現層通知暫時失敗而無法保存。此時捕捉通知階段的錯誤是合理起點,但至少要留下結構化記錄:事件身分、物件身分、前狀態、後狀態、處理階段與結果。ASP.NET Core 提供 ILogger 的結構化記錄機制,並可由不同 provider 寫往設定的目的地;Microsoft Learn 的 logging 文件可作為 API 與設定依據。
然而「主流程不失敗」不等於「通知可直接遺失」。若系統需要稍後補送,就要保留可重新讀取的待處理事實,並讓重試使用相同事件身分。重播保護不可在補送時產生新身分,否則每次嘗試都會看起來像新事件。本文刻意不延伸成一般佇列架構;這裡只要求轉換事實可追蹤、補送可識別、成功結果可去重。
也要檢查建立通知方法的回傳結果。若內部捕捉錯誤後以空值或失敗物件回傳,而呼叫端只因沒有拋出例外就當作成功,監控會得到錯誤結論。建立結果應明確區分成功、已處理、非目標邊界與失敗。日誌訊息則應描述發生的階段,不應把所有失敗都記成「重複通知」。
內容與收件規則要可驗證但保持解耦
短答:狀態規則只產生一個中性的通知命令,收件人展開、顯示內容與連結驗證由後續元件完成。 這樣邊界測試不必知道人名、頁面路徑或畫面框架,內容變更也不會改動核心判斷。
收件範圍應由角色或群組規則取得,不要把個別人名寫進狀態服務。查詢「唯一一位」對象時,應使用唯一識別值或精確條件,而不是模糊文字比對。模糊條件可能在資料較少時看似正確,資料增加後卻把單人通知擴張為多人通知。這屬於收件規則測試,應與狀態邊界矩陣分開驗收。
內容可以包含可辨識標題與可操作連結,但路由必須以應用程式實際登錄的端點驗證。把內容序列化為 JSON 前,先定義固定結構,例如 message 與 links,並測試前端能解析。內容測試應使用合成識別值,確認標題、連結文字與相對路徑,不需要接觸真實使用者資料。
解耦後,一次狀態事件可以先產生 NotificationIntent:包含事件身分、工作項目身分、轉換配對與顯示所需的最少欄位。收件服務將角色展開為當下有效成員,內容服務依既定路由產生模型,寫入元件回報結果。每一層都有窄而清楚的輸入,因此能判斷究竟是邊界錯、收件範圍錯、內容錯或寫入失敗。
驗證:用 .NET 10 隔離重現邊界與重播
短答:最小測試至少覆蓋進入、維持、離開、同事件重播與離開後再次進入,最後核對通知總數。 本次已在 Windows 主機以 .NET SDK 10.0.400 實際建置與執行,六項檢查通過,總通知數為二,測試程式沒有外部寫入。
重現步驟如下:
- 建立空白目錄,執行
dotnet new console --framework net10.0,並確認dotnet --version顯示10.0.400。將下列程式存成Program.cs;專案不需要額外套件。 - 執行
dotnet build --configuration Release。只有建置輸出為零個警告、零個錯誤時才進入下一步;不要以編輯器語法提示取代真實建置。 - 執行
dotnet run --configuration Release --no-build,逐行核對六個 PASS 與最後的總數。任何一行缺少、值不同或程序結束碼非零,都代表契約尚未通過。 - 修改案例矩陣時,保留相同事件身分給重播案例,並給真正的再次進入新事件身分。若全部改成新身分,便沒有測到重播保護。
enum WorkState { Draft, Ready, Done }
sealed record Change(string EventId, WorkState Before, WorkState After);
sealed class NotificationGate
{
private readonly HashSet _processed = [];
public int SentCount { get; private set; }
public bool Apply(Change change)
{
if (!_processed.Add(change.EventId)) return false;
if (change.Before == WorkState.Ready || change.After != WorkState.Ready) return false;
SentCount++;
return true;
}
}
static class Check
{
public static void Equal(T expected, T actual, string name)
{
if (!EqualityComparer.Default.Equals(expected, actual))
throw new InvalidOperationException($"FAIL {name}: expected={expected}, actual={actual}");
Console.WriteLine($"PASS {name}: {actual}");
}
}
static class Program
{
public static void Main()
{
var gate = new NotificationGate();
Check.Equal(true, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "enter-target-sends");
Check.Equal(false, gate.Apply(new("evt-002", WorkState.Ready, WorkState.Ready)), "stay-target-does-not-send");
Check.Equal(false, gate.Apply(new("evt-003", WorkState.Ready, WorkState.Done)), "leave-target-does-not-send");
Check.Equal(false, gate.Apply(new("evt-001", WorkState.Draft, WorkState.Ready)), "replay-same-event-does-not-send");
Check.Equal(true, gate.Apply(new("evt-004", WorkState.Done, WorkState.Ready)), "reenter-target-new-event-sends");
Check.Equal(2, gate.SentCount, "exact-total");
Console.WriteLine("RESULT all 6 checks passed; notifications=2; externalWrites=0");
}
}
真實執行輸出如下:
PASS enter-target-sends: True
PASS stay-target-does-not-send: False
PASS leave-target-does-not-send: False
PASS replay-same-event-does-not-send: False
PASS reenter-target-new-event-sends: True
PASS exact-total: 2
RESULT all 6 checks passed; notifications=2; externalWrites=0
這個測試刻意不啟動 ASP.NET Core、不中介 HTTP,也不連接持久層,因此結果只證明純函式式閘門的規則與單程序重播集合。它不證明多節點原子性、交易邊界、收件人展開或前端顯示。下一層整合測試應使用隔離資料庫與合成對象,建立唯一事件紀錄,再以並行請求確認只有一個寫入者取得首次處理權。
驗收矩陣與回歸策略
短答:把每個前後狀態配對、事件是否重播、預期通知數與理由列成表格,讓新增入口必須通過同一矩陣。 測試名稱應描述行為,而不是描述某個方法的內部實作。
| before | after | 事件情況 | 預期 | 理由 |
|---|---|---|---|---|
| Draft | Ready | 首次 | 通知一次 | 進入目標邊界 |
| Ready | Ready | 首次 | 不通知 | 只是在目標狀態內更新 |
| Ready | Done | 首次 | 不通知 | 離開目標狀態 |
| Draft | Ready | 同身分重播 | 不再通知 | 事件已處理 |
| Done | Ready | 新事件 | 通知一次 | 再次跨入目標邊界 |
回歸測試還要涵蓋批次混合資料。準備三筆合成項目,分別從 Draft、Ready、Done 更新到 Ready,預期只有 Draft 與 Done 兩筆建立意圖。重新提交完全相同的三個事件身分,總數仍維持二。接著只替 Done 到 Ready 建立另一個新事件,才可增加一次。這組測試能同時抓出共用舊值、只看新狀態與每次重試產生新身分三種問題。
若採用持久處理紀錄,還應加上競爭測試:兩個執行路徑同時提交相同事件,只有一個能建立唯一紀錄並繼續。失敗的一方應得到「已處理」結果,而不是一般系統錯誤。測試必須查詢實際紀錄數與通知數,不可只看 HTTP 成功碼;成功回應只能證明請求被處理,不能證明副作用剛好一次。
上線後的觀測可統計四類結果:非目標邊界、首次接受、已處理重播與通知失敗。這些計數能解釋為何某次更新沒有通知,也能發現重播突然增加。不要只記一個籠統的成功率,因為「正確地不通知」本來就是大量正常結果。
官方參考與設計界線
短答:Microsoft 官方文件支持本文使用的 C# 條件表達、ILogger 診斷與 dotnet test 測試流程;邊界與重播契約則由本文的明確案例驗證。 官方文件不會替特定產品決定何時通知,因此需求仍要以可觀察案例寫清楚。
- C# pattern matching overview:說明 pattern 可在
is、switch與switchexpression 中測試值、型別或形狀。 - Logging in .NET and ASP.NET Core:說明 ASP.NET Core 的
ILogger與 logging providers。 - Get started with C# and MSTest:示範建立測試專案並以
dotnet test執行測試。
本文的隔離程式採自訂斷言,避免引入套件並讓主控台輸出可直接核對。正式程式庫可將同一案例矩陣搬入 MSTest 或既有測試框架。無論採用哪種框架,核心驗收不變:只有跨入目標狀態才通知;維持與離開不通知;相同事件重播不增加數量;真正離開後以新事件再次進入則再次通知。