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