工作中的 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
。這大大增加了程式碼的可重用性和靈活性。