ASP.NET MVCにおけるCsvHelperとDataAnnotationsを活用したCSVエクスポート実装

ASP.NET MVCアプリケーションでデータを一括ダウンロードする機能を実装する際、アクションメソッド内でストリーム操作を直接記述するとコードが冗長になりがちです。これを解決するには、FileResultを継承したカスタムクラスを定義し、型安全なエクスポート処理をカプセル化します。本稿では、CsvHelperライブラリを基盤とし、標準のDataAnnotationsと独自属性を連携させることで、列見出しのローカライズや不要フィールドの除外をデコレータベースで制御するパターンを解説します。

コントローラー側の実装

一覧表示アクションに出力形式のパラメータを追加し、条件分岐により処理を振り分けます。検索ロジックやページネーションは通常通り記述し、エクスポートが要求された場合にのみリスト全体をカスタムResultに委譲します。

public async Task<ActionResult> ListContacts(string keyword, int page = 1, int size = 20, OutputFormat mode = OutputFormat.Web)
{
    var syncManager = new ContactSyncManager();
    await syncManager.ExecuteSync(SyncTarget.Contact, CurrentTenantId, true, CurrentUserId);

    var query = _context.Contacts.AsQueryable();
    if (!string.IsNullOrWhiteSpace(keyword))
    {
        query = query.Where(c => c.Nickname.Contains(keyword) 
                                || c.Region.Contains(keyword) 
                                || c.AdminNote.Contains(keyword));
    }

    query = query.OrderByDescending(c => c.RegisteredAt);

    if (mode == OutputFormat.Csv)
    {
        var records = await query.ToListAsync();
        return GenerateCsv(records);
    }

    var totalCount = await query.CountAsync();
    var pagedData = await query
        .Skip((page - 1) * size)
        .Take(size)
        .ToListAsync();

    var partitions = await _context.Partitions.Where(p => p.OrgId == CurrentTenantId).ToListAsync();
    foreach (var contact in pagedData)
    {
        contact.PartitionInfo = partitions.FirstOrDefault(p => p.Id == contact.PartitionId);
    }

    ViewBag.PartitionOptions = new SelectList(partitions, "Id", "Label");
    return View(new PaginatedResult<Contact>(pagedData, page, size, totalCount));
}

カスタムResultクラスの設計

FileResultを継承し、ヘッダー設定とストリーム書き込みをWriteFileメソッド内で一元管理します。ここではCsvHelperのインスタンスを生成し、ジェネリック型から列定義を自動取得します。

public class CsvExportResult<T> : FileResult where T : class
{
    private readonly IEnumerable<T> _records;

    public CsvExportResult(IEnumerable<T> sourceData) 
        : base("text/csv; charset=utf-8")
    {
        _records = sourceData ?? throw new ArgumentNullException(nameof(sourceData));
        FileDownloadName = $"export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv";
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        using var streamWriter = new StreamWriter(response.OutputStream, Encoding.UTF8);
        using var csvWriter = new CsvHelper.CsvWriter(streamWriter, new System.Globalization.CultureInfo("ja-JP"));

        MapCustomAttributes(csvWriter.Context);

        csvWriter.WriteHeader<T>();
        csvWriter.NextRecord();

        foreach (var entity in _records)
        {
            csvWriter.WriteRecord(entity);
        }

        streamWriter.Flush();
        response.Flush();
    }

    private static void MapCustomAttributes(CsvHelper.Configuration.CsvConfiguration config)
    {
        // CsvHelperの型マップにDisplay属性からNameを取得、ExportIgnore属性でフィールドを除外するカスタムロジックを適用
    }
}

モデル定義と属性アノテーションの連携

エンティティクラスに対して標準の[Display]属性と拡張の[ExportIgnore]属性を付与することで、CSV出力時の振る舞いを制御します。[Display(Name = "...")]は出力カラムのタイトルを上書きし、[ExportIgnore]はセリアライズ対象から除外されるマークとして機能します。

属性 役割
[ExportIgnore] エクスポート処理実行時に該当プロパティをスキップする指示
[Display(Name = "...")] 出力ファイルの列見出しに使用する表示名を定義(マルチ言語対応可能な構造)
public class WeChatContact
{
    [Key]
    [Display(Name = "一意識別子")]
    public string OpenIdentifier { get; set; }

    [Display(Name = "購読状態")]
    public bool IsSubscribed { get; set; }

    [Display(Name = "表示名")]
    public string Nickname { get; set; }

    [Display(Name = "性別")]
    public GenderType Gender { get; set; }

    [Display(Name = "居住地域")]
    public string City { get; set; }

    [Display(Name = "国籍")]
    public string Country { get; set; }

    [Display(Name = "都道府県")]
    public string Prefecture { get; set; }

    [Display(Name = "インターフェース言語")]
    public string Locale { get; set; }

    [Display(Name = "アバターURL")]
    public string PortraitUrl { get; set; }

    [Display(Name = "登録日時")]
    public DateTime RegisteredAt { get; set; }

    [Display(Name = "連合アカウントID")]
    public string UnionKey { get; set; }

    [Display(Name = "管理者備考")]
    public string AdminNote { get; set; }

    [Display(Name = "所属グループID")]
    public int? PartitionId { get; set; }

    [NotMapped]
    [Display(Name = "グループ詳細")]
    [ExportIgnore]
    public PartitionDetail PartitionInfo { get; set; }

    [Display(Name = "テスト対象")]
    [ExportIgnore]
    public bool IsTestAccount { get; set; }

    [ExportIgnore]
    public int OrgId { get; set; }
}

上記の定義により、PartitionInfoIsTestAccountOrgIdは出力対象から自動的に除外され、残りのカラムはDisplay属性に指定された日本語名称で書き出されます。このアプローチにより、UI画面の表示ロジックとデータエクスポートのフォーマット設定がエンティティ定義に集約され、コントローラー側のコード肥大を抑制できます。

タグ: ASP.NET MVC CsvHelper DataAnnotations FileResult CSVエクスポート

6月28日 01:33 投稿