工作中的 C# 實作:輸出 CSV 檔案 (一)

AdSense

前言

在 Web 後端開發中,匯出資料報表是很常見的功能。其中,CSV (Comma-Separated Values) 是一種最廣泛使用的檔案格式,因為它簡潔且通用性高,無論是用 Excel、Google Sheets 或其他應用程式都能輕鬆開啟。

將檔案匯出的邏輯封裝成一個獨立的 Service,是軟體設計中一個非常重要的原則:單一職責原則 (Single Responsibility Principle)。這讓你的程式碼更容易維護、測試,並且具備高度的可重用性

一、基礎概念:StreamWriter 的差異

在開始實作前,我們需要先了解幾個關鍵概念,它們是整個檔案匯出流程的核心。

MemoryStreamStreamWriter

想像一下你在寫一份報告:

  • MemoryStream:就像你手上的稿紙,它是一個在記憶體中的緩衝區,專門用來儲存原始的二進位資料 (bytes)。
  • StreamWriter:就像你手中的,它是一個專門用來處理文字的工具。它能將字串轉換成對應的編碼 (例如 UTF-8),並寫入到底層的 Stream (也就是你的 MemoryStream) 中。

這兩者通常會搭配使用:你先建立一個空的 MemoryStream,然後將其傳入 StreamWriter 中,接著就可以用 StreamWriter 提供的便利方法來寫入文字資料。

Flush

當你寫完所有字時,你需要一個動作來確保所有墨水都已從筆管流到稿紙上Flush() 就是這個動作,它強制將 StreamWriter 緩衝區中所有暫存的資料,立即寫入到底層的 Stream

二、實作:建立可重用的 CSV 服務

我們將 CSV 生成的邏輯封裝在一個獨立的 Service 中。

1. 定義服務介面 (ICsvExportService.cs)

首先,定義一個介面,這有助於降低程式碼的耦合性,並方便使用依賴注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Interfaces/ICsvExportService.cs
using System.Collections.Generic;
using System.IO;

public interface ICsvExportService
{
/// <summary>
/// 將任何型別的資料清單匯出為 CSV 格式的資料流。
/// </summary>
/// <typeparam name="T">報表資料的型別。</typeparam>
/// <param name="reportData">要匯出的資料清單。</param>
/// <returns>包含 CSV 內容的 MemoryStream。</returns>
Stream ExportToCsv<T>(IEnumerable<T> reportData);
}

2. 實作服務類別 (CsvExportService.cs)

接著,我們來實作這個介面。這裡的邏輯會動態處理表頭和內容。

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
// Services/CsvExportService.cs
using System.IO;
using System.Text;
using System.Collections.Generic;
using System.Reflection;
using System.Linq;

public class CsvExportService : ICsvExportService
{
public Stream ExportToCsv<T>(IEnumerable<T> reportData)
{
var memoryStream = new MemoryStream();
var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8);

if (reportData != null && reportData.Any())
{
// 透過反射取得 T 的所有公開屬性
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);

// 動態生成表頭 (Header)
var headerText = string.Join(",", properties.Select(p => p.Name));
streamWriter.WriteLine(headerText);

// 寫入報表資料 (Data Rows)
foreach (var item in reportData)
{
var values = new List<string>();
foreach (var prop in properties)
{
var value = prop.GetValue(item)?.ToString() ?? string.Empty;
// 處理含逗號的值,用雙引號包起來
if (value.Contains(','))
{
value = $""{value.Replace(""", """")}"";
}
values.Add(value);
}
streamWriter.WriteLine(string.Join(",", values));
}
}

streamWriter.Flush();
memoryStream.Position = 0; // 重設資料流位置,讓 Controller 從頭讀取

return memoryStream;
}
}

三、整合:Controller 如何使用服務並回傳檔案

現在我們有了 CsvExportService 服務,接下來要讓 Controller 呼叫它來處理檔案匯出。我們將從資料庫動態取得資料,然後傳給服務。

1. 資料模型 (Student.cs)

這是一個用來對應資料庫表格的類別。

1
2
3
4
5
6
7
// Models/Student.cs
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public int Score { get; set; }
}

2. DbContext 類別 (YourDbContext.cs)

這個類別繼承自 DbContext,並包含一個 DbSet<Student> 屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Data/YourDbContext.cs
using Microsoft.EntityFrameworkCore;
using YourAppName.Models;

public class YourDbContext : DbContext
{
public YourDbContext(DbContextOptions<YourDbContext> options)
: base(options)
{
}

public DbSet<Student> Students { get; set; }
}

3. Controller (ReportController.cs)

Controller 負責處理 HTTP 請求、用 DbContext 來查詢資料,並呼叫服務。

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
// Controllers/ReportController.cs
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks; // 需要引入這個命名空間
using Microsoft.EntityFrameworkCore; // 需要引入這個命名空間
using YourAppName.Services;
using YourAppName.Data; // 引入 DbContext 命名空間
using YourAppName.Models; // 引入 Student 命名空間

[ApiController]
[Route("[controller]")]
public class ReportController : ControllerBase
{
private readonly ICsvExportService _csvExportService;
private readonly YourDbContext _dbContext; // 注入 DbContext

// 透過建構式 (constructor) 注入服務和 DbContext
public ReportController(ICsvExportService csvExportService, YourDbContext dbContext)
{
_csvExportService = csvExportService;
_dbContext = dbContext;
}

[HttpGet("export")]
[Produces("text/csv")]
public async Task<IActionResult> ExportStudents()
{
// 1. 使用 DbContext 取得所有學生資料
// ToListAsync() 會執行查詢並將結果轉換成清單
// 這是一個非同步操作,有助於避免堵塞主執行緒
var students = await _dbContext.Students.ToListAsync();

if (students == null || !students.Any())
{
return NotFound("找不到學生資料。");
}

// 2. 呼叫服務產生 CSV 資料流
// CsvExportService 接受任何 IEnumerable<T>
var csvStream = _csvExportService.ExportToCsv(students);

// 3. 設定回傳的檔案名稱和內容類型
var fileName = "Students.csv";
var contentType = "text/csv";

// 4. 回傳檔案
return File(csvStream, contentType, fileName);
}
}

[Produces("text/csv")]

為什麼在 Controller 方法上需要加上 [Produces("text/csv")] 這個屬性?

[Produces] 是一個 ASP.NET Core 的篩選器屬性 (Filter Attribute),它用於明確指定一個 Action (方法) 可以產生 (produce) 的回應內容類型 (Content Type)

  • 功能: 當你使用 File() 方法回傳檔案時,它已經會自動設定正確的 Content-Type 標頭,所以程式功能上來說,這個屬性並非必要。
  • 優點: 它的主要優點是提升程式碼的可讀性可維護性。任何閱讀你程式碼的人,一眼就能知道這個 API 端點預計回傳的是 CSV 格式。此外,如果你的專案使用了 Swagger/OpenAPI 等 API 文件生成工具,這些工具會自動讀取 [Produces] 屬性,並將其包含在 API 文件中,讓你的文件更完整、更精確,對於 API 的使用者來說非常方便。

四、設定依賴注入

Program.cs 裡註冊你的 DbContextICsvExportService

Program.cs 裡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

// 註冊 DbContext
builder.Services.AddDbContext<YourDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// 註冊服務
builder.Services.AddScoped<ICsvExportService, CsvExportService>();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.MapControllers();
app.Run();