工作中的 C# 實作:輸出 CSV 檔案 (二) - 用 OutputFormatter
前言
在現代 Web 應用程式中,從後端匯出資料到 CSV 檔案是一個非常常見的需求。不像工作中的 C# 實作:輸出 CSV 檔案 (一),這篇是要直接撰寫一個自訂的 OutputFormatter,它能將任何 IEnumerable 結果,寫入到回應主體(response body),直接作為 HTTP Response 傳回 CSV 格式。
為什麼不使用 MemoryStream?
MemoryStream 會將所有資料一次性載入到記憶體中,對於數百萬筆的大量資料來說,可能會導致嚴重的記憶體消耗,甚至造成 OutOfMemoryException。
使用 IOutputFormatter 實現串流輸出
MemoryStream 有其限制,我們來想一個更符合 ASP.NET Core 設計模式的方式。這個方法的核心思想是:直接將 CSV 資料寫入到 HTTP Response 的資料流中,實現真正的串流輸出,而不是先將所有資料都緩衝到記憶體裡。
從伺服器端來看,當你回應一個 HTTP Request 時,資料並不是一次性全部傳送出去,而是一個資料流 (data stream),一塊一塊地往客戶端發送。因此,不需要用 MemoryStream,將資料寫入回應主體(response body)即可,因為 Response.Body 就是一個 Stream。
為什麼這個方法更好?
- 降低記憶體消耗:資料邊產生邊傳輸,不需要將整個檔案都讀入記憶體。對於處理大量資料的場景至關重要。
- 關注點分離 (Separation of Concerns):將「CSV 生成」和「HTTP 回應」的邏輯分開,Controller 只需負責回傳資料,
OutputFormatter則專注於格式化和寫入。 - 可擴充和可重用:
OutputFormatter可以處理任何類型List<T>的資料,你可以為不同的資料類型重複使用這個Formatter。
實作流程
這個方法需要三個核心組件:
CsvOutputFormatterOptions:一個類別來定義 CSV 格式化的設定,例如分隔符號。IConfigureOptions<CsvOutputFormatterOptions>:一個實作類別,用來設定上面的Options。CsvOutputFormatter:一個繼承自OutputFormatter的類別,負責將資料轉換為 CSV 並寫入回應。
範例程式碼
1. 設定檔:CsvOutputFormatterOptions 和 ConfigureCsvOutputFormatter
1 | // CsvOutputFormatterOptions.cs |
2. 核心組件:CsvOutputFormatter
CsvOutputFormatter 繼承自 ASP.NET Core 的 OutputFormatter 抽象類別,我們需要覆寫一些方法。
WriteResponseBodyAsync(OutputFormatterWriteContext context):這是真正寫入資料的核心方法。OutputFormatterWriteContext是一個非常重要的類別,當框架呼叫OutputFormatter的WriteResponseBodyAsync方法時,context它會負責傳遞HTTP Response 所需要的資訊,例如:context.HttpContext:當前的HttpContext物件,你可以透過它存取Request、Response等。context.Object:要被序列化 (serialize) 成回應主體 (Response Body) 的資料物件,例如:List<Student>的實例。context.ObjectType:context.Object的類型,例如:typeof(List<Student>)。context.ContentType:回應的內容類型 (Content Type),例如:text/csv。context.WriterFactory:一個工廠方法,用來建立TextWriter,這個TextWriter會直接將資料寫入Response.Body回應資料流。
1 | // CsvOutputFormatter.cs |
3. 服務註冊
在 Program.cs (或 Startup.cs) 中註冊服務。因為:
- 有一個
CsvOutputFormatter服務需要被註冊為單例 (AddSingleton)。 - 這個服務需要
IOptions<CsvOutputFormatterOptions>作為建構子參數。 - 這個服務需要在
AddControllers的選項中被使用。
1 | // 註冊 CsvOutputFormatterOptions 的設定 |
AddControllers:會將 API 的控制器 (controllers) 所需的服務添加到指定的 IServiceCollection 中。
4. Controller 簡化
現在你的 Controller 方法可以變得非常簡潔。
1 | // StudentsController.cs |
當客戶端發出 GET 請求,ASP.NET Core 框架就會自動呼叫你的 CsvOutputFormatter 來處理 List<Student> 回應。
補充:深入理解 OutputFormatter 的細節
1. CsvOutputFormatter 是在什麼時候被建立的?
CsvOutputFormatter 的實例並不是在程式一啟動時就被建立的,而是在 HTTP Request 處理的過程中,當它被需要時才被建立。
- 在
Program.cs裡,當你執行builder.Services.AddSingleton<CsvOutputFormatter>()時,ASP.NET Core 的依賴注入 (DI) 容器會知道「喔,有一個CsvOutputFormatter服務要註冊,它的生命週期是 Singleton (單例)」。 - 在第一個需要 CSV 輸出的 HTTP Request 進來時,DI 容器會發現還沒有
CsvOutputFormatter的實例,這時它會建立一個新的實例,並將其保存起來。 - 之後的任何請求,只要需要 CSV 輸出,DI 容器都會直接回傳同一個已經建立好的實例。
這種設計的好處是:高效,避免了重複建立和銷毀物件的開銷;記憶體友善,只使用一個實例來服務所有請求。
2. 串流輸出如何實現?
當你使用 csvWriter 物件寫入資料時 (例如:csvWriter.WriteRecords(data) 或 csv.WriteLine(...)),所有的資料都會直接被寫入到 textWriter,然後直接流向 context.HttpContext.Response.Body,最終傳輸給客戶端。
整個過程沒有 MemoryStream 中間的記憶體緩衝,實現了高效的串流輸出。
3. 為什麼要「根據泛型類型動態寫入資料」?
在範例中,CsvOutputFormatter 是一個通用的格式化工具,它不只會處理 List<Student>,也可能要處理 List<Product> 或 List<Order>。
當你使用 CsvHelper 的 WriteRecords 方法時,它通常需要一個明確的泛型類型,例如 csv.WriteRecords<Student>(data)。
然而,在 OutputFormatter 裡,我們拿到的 context.Object 是一個 object 類型,我們不知道它的具體類型是什麼。因此,我們需要:
- 取得泛型類型:透過
context.ObjectType屬性,我們可以得到像typeof(List<Student>)這樣的類型資訊。然後,我們寫了一個輔助方法GetEnumerableGenericType來從中提取出Student這個類型。 - 動態呼叫方法:有了
Student這個Type資訊後,我們就可以使用 C# 的反射 (Reflection) 技術,動態地找到CsvWriter上的WriteRecords方法,並將Student這個類型作為泛型參數用MakeGenericMethod傳入。
簡單來說,動態寫入的目的是為了讓 OutputFormatter 有通用性,讓不知道 T 是什麼的OutputFormatter 能自動處理不同的資料類型,而不需要為每一種 List<T> 寫一個獨立的 OutputFormatter。這大大增加了程式碼的可重用性和靈活性。