工作中的 C# 實作:輸出 CSV 檔案 (二) - 用 OutputFormatter

AdSense

前言

在現代 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

實作流程

這個方法需要三個核心組件:

  1. CsvOutputFormatterOptions:一個類別來定義 CSV 格式化的設定,例如分隔符號。
  2. IConfigureOptions<CsvOutputFormatterOptions>:一個實作類別,用來設定上面的 Options
  3. CsvOutputFormatter:一個繼承自 OutputFormatter 的類別,負責將資料轉換為 CSV 並寫入回應。

範例程式碼

1. 設定檔:CsvOutputFormatterOptionsConfigureCsvOutputFormatter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CsvOutputFormatterOptions.cs
public class CsvOutputFormatterOptions
{
// CsvHelper 的設定
public CsvHelper.Configuration.CsvConfiguration CsvConfiguration { get; set; } = new(CultureInfo.InvariantCulture);
}

// ConfigureCsvOutputFormatter.cs
public class ConfigureCsvOutputFormatter : IConfigureOptions<CsvOutputFormatterOptions>
{
// 這個方法會被框架自動呼叫,用來設定 options
public void Configure(CsvOutputFormatterOptions options)
{
// 這裡可以設定 CsvHelper 的行為,例如分隔符號
options.CsvConfiguration.Delimiter = ",";
}
}

2. 核心組件:CsvOutputFormatter

CsvOutputFormatter 繼承自 ASP.NET Core 的 OutputFormatter 抽象類別,我們需要覆寫一些方法。

  • WriteResponseBodyAsync(OutputFormatterWriteContext context):這是真正寫入資料的核心方法
  • OutputFormatterWriteContext 是一個非常重要的類別,當框架呼叫 OutputFormatterWriteResponseBodyAsync 方法時,context 它會負責傳遞HTTP Response 所需要的資訊,例如:
    • context.HttpContext:當前的 HttpContext 物件,你可以透過它存取 RequestResponse 等。
    • context.Object:要被序列化 (serialize) 成回應主體 (Response Body) 的資料物件,例如:List<Student> 的實例。
    • context.ObjectTypecontext.Object類型,例如:typeof(List<Student>)
    • context.ContentType:回應的內容類型 (Content Type),例如:text/csv
    • context.WriterFactory:一個工廠方法,用來建立 TextWriter,這個 TextWriter 會直接將資料寫入 Response.Body 回應資料流。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// CsvOutputFormatter.cs
using System.Collections;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Net.Http.Headers;
using CsvHelper;
using System.Text;

public class CsvOutputFormatter : OutputFormatter
{
private readonly CsvOutputFormatterOptions _options;

// 透過建構子注入設定選項
public CsvOutputFormatter(IOptions<CsvOutputFormatterOptions> options)
{
_options = options.Value;

// 設定支援的媒體類型
SupportedMediaTypes.Clear();
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
}

// 核心寫入邏輯
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
{
var response = context.HttpContext.Response;

// 設定 HTTP Header,讓瀏覽器知道這是一個可下載的檔案
response.Headers.Add("Content-Disposition", new[] { "attachment; filename=export.csv" });

// 從 context.Object 中取得要輸出的資料物件
var data = context.Object as IEnumerable;
if (data == null)
{
// 如果資料為空,回傳空內容
return;
}

// 取得資料的泛型類型,例如 typeof(Student)
var genericType = GetEnumerableGenericType(context.ObjectType);
if (genericType == null)
{
return;
}

// 使用 context.HttpContext.Response.Body 取得回應的資料流,並建立 textWriter
await using var textWriter = context.WriterFactory(context.HttpContext.Response.Body, Encoding.UTF8);

// 建立 CsvWriter
await using var csvWriter = new CsvWriter(textWriter, _options.CsvConfiguration);

// 根據泛型類型動態寫入資料
var writeRecordsMethod = typeof(CsvWriter)
.GetMethod(nameof(CsvWriter.WriteRecords), new Type[] { typeof(IEnumerable) });

if (writeRecordsMethod != null)
{
// 使用反射 (Reflection) 動態呼叫 WriteRecords 方法
writeRecordsMethod.MakeGenericMethod(genericType).Invoke(csvWriter, new[] { data });
}
}

// 輔助方法:取得 IEnumerable<T> 中的 T
private static Type? GetEnumerableGenericType(Type type)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
{
return type.GetGenericArguments()[0];
}

var enumerableInterface = type.GetInterfaces()
.FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>));

return enumerableInterface?.GetGenericArguments()[0];
}
}

3. 服務註冊

Program.cs (或 Startup.cs) 中註冊服務。因為:

  1. 有一個 CsvOutputFormatter 服務需要被註冊為單例 (AddSingleton)。
  2. 這個服務需要 IOptions<CsvOutputFormatterOptions> 作為建構子參數。
  3. 這個服務需要在 AddControllers 的選項中被使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 註冊 CsvOutputFormatterOptions 的設定
builder.Services.ConfigureOptions<ConfigureCsvOutputFormatter>();

// 先註冊單例服務
builder.Services.AddSingleton<CsvOutputFormatter>();

builder.Services.AddControllers(options =>
{
// 配置 controller 的服務
// 添加 CsvOutputFormatter,讓它可以處理 text/csv 請求
options.OutputFormatters.Add(
builder.Services.BuildServiceProvider().GetRequiredService<CsvOutputFormatter>()
);
}).AddNewtonsoftJson();

AddControllers:會將 API 的控制器 (controllers) 所需的服務添加到指定的 IServiceCollection 中。

4. Controller 簡化

現在你的 Controller 方法可以變得非常簡潔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// StudentsController.cs
[HttpGet("api/csv-students")]
[Produces("text/csv")]
public List<Student> GetStudents()
{
var students = new List<Student>
{
new Student { Id = 1, Name = "Alice", Grade = "A" },
new Student { Id = 2, Name = "Bob", Grade = "B" },
new Student { Id = 3, Name = "Charlie", Grade = "C" }
};

return students;
}

當客戶端發出 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>

當你使用 CsvHelperWriteRecords 方法時,它通常需要一個明確的泛型類型,例如 csv.WriteRecords<Student>(data)

然而,在 OutputFormatter 裡,我們拿到的 context.Object 是一個 object 類型,我們不知道它的具體類型是什麼。因此,我們需要:

  1. 取得泛型類型:透過 context.ObjectType 屬性,我們可以得到像 typeof(List<Student>) 這樣的類型資訊。然後,我們寫了一個輔助方法 GetEnumerableGenericType 來從中提取出 Student 這個類型。
  2. 動態呼叫方法:有了 Student 這個 Type 資訊後,我們就可以使用 C# 的反射 (Reflection) 技術,動態地找到 CsvWriter 上的 WriteRecords 方法,並將 Student 這個類型作為泛型參數用 MakeGenericMethod 傳入。

簡單來說,動態寫入的目的是為了讓 OutputFormatter 有通用性,讓不知道 T 是什麼的OutputFormatter 能自動處理不同的資料類型,而不需要為每一種 List<T> 寫一個獨立的 OutputFormatter。這大大增加了程式碼的可重用性和靈活性。