稽核紀錄的價值不在於「有寫一列」,而在於讀者能否迅速判斷哪個欄位改了、變更是否合理,以及是否需要追查。反射式差異產生器若把集合誤認成一般物件,常會把完整物件圖轉成長字串。這不只造成閱讀負擔,也可能產生錯誤敘事:更新入口沒有攜帶導覽集合,摘要卻看起來像是全部子項目遭到移除。本文聚焦一個明確問題:在 C# 與 ASP.NET Core 應用中,如何以 IEnumerable 而非非泛型 ICollection 辨識集合,接著用有界、可重現、可測試的規則產生可讀稽核摘要。

核心做法分成三層。第一層只負責分類,確認值是可列舉序列且不是字串。第二層負責比較,區分未變更、筆數改變、筆數相同但內容改變,以及輸入未攜帶集合等情境。第三層才負責呈現,只輸出欄位名稱、筆數與有限狀態,不把整個集合展開。完整快照可以保留在原本的結構化資料邊界,供需要時進行逐欄比對;人類閱讀的摘要則維持短小。

為什麼 ICollection 會漏掉常見泛型集合

System.Collections.ICollection 是非泛型集合介面。HashSet 的官方型別頁列出的實作包含 ICollection、IEnumerable 與非泛型 IEnumerable,但沒有非泛型 ICollection。因此,當執行期值是 HashSet 時,value is ICollection 可以是 false,即使它顯然是一個集合。Microsoft Learn 的 HashSet 型別定義可直接核對這組介面:https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.hashset-1?view=net-10.0 。

這個差異容易被忽略,因為 List 等某些型別可能同時支援較多介面,讓錯誤判斷在部分測試資料上看似正常。若測試只放陣列或清單,便可能沒有涵蓋 ORM 導覽屬性常見的集合實作。反射工具不應根據某個具體容器猜測語意,而要從「能否列舉」與「是否為純量」建立一致規則。

Microsoft Learn 將非泛型 IEnumerable 定義為提供列舉器、支援簡單反覆運算的介面:https://learn.microsoft.com/en-us/dotnet/api/system.collections.ienumerable?view=net-10.0 。它是較合適的廣泛入口,但必須排除 string,因為字串本身也可列舉。若未排除,摘要器可能把一段文字當成字元集合,比較結果會變成毫無意義的筆數變化。

using System.Collections;

static bool IsSequence(object? value) =>
    value is IEnumerable && value is not string;

若需求不是「任何序列」,而是確認型別實作封閉的 ICollection,可以透過反射檢查泛型介面定義。Type.GetInterfaces() 會回傳目前型別實作或繼承的所有介面;官方說明位於 https://learn.microsoft.com/en-us/dotnet/api/system.type.getinterfaces?view=net-10.0 。這個檢查適合需要 Count 或可修改集合語意的元件,但對只想辨識導覽序列並產生摘要的情境,IEnumerable 通常更直接。

static bool ImplementsClosedGeneric(Type type, Type openGeneric) =>
    type.GetInterfaces().Any(i =>
        i.IsGenericType &&
        i.GetGenericTypeDefinition() == openGeneric);

分類函式應維持單一責任。它不應在判斷時列舉資料、觸發延遲載入、序列化物件或改變追蹤狀態。這使分類結果容易用多種容器測試,也降低診斷工具反過來改變應用行為的風險。

可讀稽核摘要需要有界資訊

集合摘要最重要的設計決策,是停止把所有元素塞進 Content。一般管理畫面首先需要回答的是:集合有沒有變、筆數如何變、內容是否可能改變。若使用者真的需要逐項細節,再從兩份結構化快照計算差異,並在獨立畫面分頁或按需展開。摘要與詳細差異是兩種產品需求,不應共用一個無限制字串。

建議的輸出規則如下:

情境 建議摘要
前後序列完全相同 不輸出該欄位
筆數不同 Items: 24 => 22
筆數相同但內容不同 Items: content changed (24)
輸入明確表示未攜帶集合 不輸出該欄位,另留診斷狀態
單一純量改變 Status: Pending => Approved

有界摘要不等於忽略資料。它只是把「快速掃描」與「完整證據」分離。摘要適合列表、搜尋結果與通知;快照差異適合調查頁。若直接解析既有摘要字串來重建欄位,值中的標點、HTML 或格式片段可能被誤認成新的欄位。結構化快照保留層級與型別,因此較適合重建歷史差異。

摘要器還要設定元素與文字上限。即使決定顯示少量元素,也應限制最多幾項、每項最多幾個字元,超過時顯示「另有 N 項」。不可把上限當成畫面 CSS 問題;資料在後端形成時就應受到限制,避免巨量字串先耗用記憶體,再交給前端截斷。

區分未攜帶集合與明確清空

單看一個空集合,無法知道呼叫端是「沒有送這個欄位」,還是「明確要求清空」。若兩者共用相同模型,差異產生器就缺少必要語意。把所有空集合都視為清空,會產生大量錯誤紀錄;把所有空集合都忽略,又會漏掉真正的清空操作。因此長期解法不是更複雜的猜測,而是讓更新契約表達意圖。

可以在命令模型加入 CollectionMode,例如 Omitted、Replace、Clear,或改用能表示欄位是否出現的 patch 契約。如此一來,摘要器不必從容器內容猜測。對既有入口無法立刻改造時,可以採保守規則:舊資料非空、輸入為空、且端點慣例是不攜帶導覽序列時,暫不報清空;同時加上可觀測計數,追蹤此分支出現頻率,再安排契約升級。

以下函式把呼叫端是否省略序列當成顯式參數。正式程式可以改成列舉型別,而不是布林值,讓狀態更清楚。

static string? SummarizeSequence(
    IEnumerable before,
    IEnumerable after,
    bool incomingWasOmitted)
{
    var left = before.Cast()
        .Select(x => x?.ToString() ?? "").ToArray();
    var right = after.Cast()
        .Select(x => x?.ToString() ?? "").ToArray();

    if (incomingWasOmitted && right.Length == 0 && left.Length > 0)
        return null;
    if (left.SequenceEqual(right))
        return null;
    if (left.Length != right.Length)
        return $"Items: {left.Length} => {right.Length}";
    return $"Items: content changed ({left.Length})";
}

這個範例為了可重現測試而將序列具現化;正式環境要評估序列是否可能只允許走訪一次、是否會執行查詢,以及項目身分如何比較。稽核比較最好接收已經載入、範圍已受控的快照 DTO,而不是任意查詢物件。如此可讓比較成本與資料存取時機更明確。

三步導入集合摘要器

  1. 建立分類矩陣。 列出目前可能出現的 HashSet、List、陣列、自訂集合、查詢結果與字串,針對每種型別記錄預期分類。至少加入一個只實作泛型集合介面與非泛型 IEnumerable 的案例,避免測試被單一容器誤導。
  2. 建立語意化比較。 先處理未攜帶、完全相同、筆數不同與同筆數內容改變,再處理純量。不要在這一層產生整包 JSON,也不要從顯示文字反推資料結構。比較項目時使用穩定身分與明確欄位,而非預設 ToString() 作為正式判準。
  3. 建立有界呈現與回歸測試。 設定摘要長度、最多欄位數與最多元素數,保留完整快照供詳細畫面使用。測試必須同時斷言分類、摘要文字、未攜帶政策與最大輸出,並把真實輸出保存於建置紀錄。

導入時先影子執行新舊產生器也是實用方法:對同一組合成輸入同時計算結果,但只保存新規則的統計與測試輸出,不改寫歷史資料。比對一段時間後再切換顯示,可降低一次改造所有閱讀介面的風險。歷史稽核列通常不應回頭改寫,因為原始紀錄本身也是當時系統行為的證據;改善閱讀應透過快照差異檢視器完成。

反射與 Nullable 型別的邊界

集合辨識只是反射差異器的一部分。屬性型別若為 Nullable,應先用 Nullable.GetUnderlyingType(propertyType) ?? propertyType 取得實際比較型別,否則路徑或格式化程式可能錯把包裝結構當成業務欄位。日期、列舉與識別值也應各自走明確格式化器,避免受到目前文化設定或預設 ToString() 影響。

var effectiveType = Nullable.GetUnderlyingType(property.PropertyType)
                    ?? property.PropertyType;

if (effectiveType == typeof(DateTime))
{
    // 使用固定、可排序且可重現的格式。
}

反射取得介面時,也不要依賴回傳順序來決定優先規則。分類應使用 Any 檢查介面定義,而不是假設第一個介面代表主要語意。最終輸出若要穩定比較,欄位應使用固定排序,集合項目則使用穩定鍵排序,否則同一組資料可能因列舉順序不同而產生假的內容變更。

若集合很大,不要為了取得筆數就無限制列舉。可優先讀取 IReadOnlyCollection 或 ICollection 的 Count;若只有 IEnumerable,便使用設定上限的計數方式,達到門檻即停止並輸出「至少 N 項」。分類廣泛採 IEnumerable,不代表比較一定要把整個序列讀完。

驗證:在 .NET 10.0.400 重現介面與摘要行為

本次驗證使用隔離目錄、net10.0 主控台專案與標準程式庫,沒有連線到外部服務,也沒有寫入應用資料。測試以合成集合重現三個決策:HashSet 不是非泛型 ICollection,但它是 IEnumerable 且實作 ICollection;同筆數內容改變與筆數改變會得到短摘要;未攜帶與明確清空會得到不同結果。

依下列步驟重現:

  1. 建立空白目錄,放入目標為 net10.0 的主控台專案,不加入第三方套件。
  2. 將本文分類函式與摘要函式加入 Program.cs,用 HashSet、陣列與字串建立合成案例。
  3. 先執行 dotnet --version 確認為 10.0.400,再執行 dotnet run --configuration Release;任何例外、缺少 PASS 標記或非零結束碼都視為失敗。

實際執行輸出如下:

10.0.400
CHECK1 PASS hashset ICollection=False IEnumerable=True ICollection=True stringIsSequence=False
CHECK2 PASS sameCount="Items: content changed (2)" changedCount="Items: 3 => 2"
CHECK3 PASS omitted="ignored: omitted sequence" explicitClear="Items: 3 => 0"
ALL PASS

第一個檢查證明分類器不能只靠非泛型 ICollection;第二個檢查證明摘要只報筆數或內容狀態,不展開完整元素;第三個檢查把資料契約政策放進可執行測試,避免日後重構又把省略輸入當成清空。這些測試不替代應用層整合測試,但能把最容易被容器型別差異破壞的核心規則固定下來。

上線前檢查清單與結論

上線前應逐項確認:所有集合分類都排除字串;集合摘要有長度與項目上限;未攜帶與清空具有不同語意;詳細檢視從結構化快照計算,不解析人類摘要;欄位與項目排序固定;Nullable、日期與列舉使用明確格式;測試涵蓋 HashSet 而非只有清單;歷史列不被改寫;新摘要在列表、搜尋與展開畫面都能閱讀。

可讀稽核紀錄的關鍵,不是更積極地序列化,而是更精準地決定什麼值得出現在摘要。以 IEnumerable 加上字串排除建立廣泛分類,以封閉泛型介面反射補充特定能力判斷,再用有界摘要與結構化快照分工,能同時降低雜訊與錯誤敘事。最後,把省略、清空、筆數改變與內容改變寫成可重現測試,稽核文字才會成為可信的操作線索,而不是另一個需要先除錯的資料來源。