読者です 読者をやめる 読者になる 読者になる

1.21 jigowatts

Great Scott!

ASP.NETでCSVなどのファイルのダウンロードを実装する

ASP.NET

概要

ASP.NETでファイルのダウンロード機能を実装するとしたらどんな感じがいいのでしょうか。
ファイルのダウンロード機能ひとつでも考えることがたくさんあります。

今回の要件は

  1. 複数のタイプのファイルをダウンロードする
  2. エラーが発生した場合、エラーページを表示する

として、下記の記事をベースにサンプルコードを書いてみました。

参考:ファイルのダウンロード機能を実装する方法

実装

画面

まずは画面から。簡単にラベルとボタンを設置します。
ダウンロードするのはテキスト形式のファイルとCSVファイルの2種類です。
f:id:sh_yoshida:20140310235624p:plain
各ボタンのクリックイベントでハンドラにリダイレクトします。
リクエストパラメータには処理を行うクラス名を付与しておきます。

protected void btnDLtxt_Click(object sender, EventArgs e)
{
    string url = string.Format("~/Service/DownloadHandler.ashx?clsName={0}", "TxtData");
    Response.Redirect(url);
}

protected void btnDLcsv_Click(object sender, EventArgs e)
{
    string url = string.Format("~/Service/DownloadHandler.ashx?clsName={0}", "CSVData");
    Response.Redirect(url);
}
ジェネリックハンドラ

サンプルコードと同じく、自分のページのクリックイベントに書いてみたり、別のWebフォームのページロードイベントに書いてみたりしましたが、最終的にはファイルのダウンロード処理は画面を必要としないのでジェネリックハンドラに書きました。うーん、どれが一般的なんだろう。

リクエストパラメータよりクラス名を取得し、リフレクションにより実装クラスをインスタンス化します。
ここも各イベントごとにジェネリックハンドラに飛ばすか迷いましたが、リフレクションの理解のためにも処理をまとめてみました。

処理中にブラウザを閉じるなどの事態に備え、ブラウザとの接続確認を行います。

CSVダウンロードなどで調べているとResponse.End()メソッドのThreadAbortExceptionについての記事がたくさんヒットします。ありがたく教えに従い、回避します。

想定外のエラーについては、仕様にもよりますが、今回は専用ページにリダイレクトしました。

using FileDownloader.Common;
using FileDownloader.Report;
using System;
using System.Threading;
using System.Web;

namespace FileDownloader.Service
{
    public class DownloadHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            try
            {
                string clsName = context.Request.Params["clsName"];
                Type type = Const.FileType[clsName];

                var generator = Activator.CreateInstance(type);

                if (generator is IDownloadFileGenerator) 
                {
                    var data = generator as IDownloadFileGenerator;

                    context.Response.ContentType = data.ContentType;
                    context.Response.AddHeader("Content-Disposition",
                        "attachment; filename=" + HttpUtility.UrlEncode(data.FileName, System.Text.Encoding.UTF8));

                    data.Write(context.Response.Output);

                    if (context.Response.IsClientConnected)//Clientとの接続を確認します。
                    {
                        context.Response.Flush();
                        context.Response.End();
                    }
                    else 
                    {
                        System.Diagnostics.Debug.WriteLine("ブラウザとの接続が切断されています。");
                        context.Response.End();
                    }

                }
            }
            catch (ThreadAbortException) 
            {
                System.Diagnostics.Debug.WriteLine("ThreadAbortExceptionが発生します。");
            }
            catch (Exception)
            {
                context.Response.Clear();

                //HACK:この辺は仕様による
                //context.Response.StatusCode = 500;
                //context.Response.End();

                context.Response.Redirect("~/Error.aspx");
            }
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}
定数クラス

定数クラスとしてDictionaryにキーとTypeを登録しておきます。
これを元にリフレクションでクラスのインスタンス化をします。

public static readonly Dictionary<string, Type> FileType =
    new Dictionary<string, Type>() { 
        {"CSVData",typeof(CSVData)},
        {"TxtData",typeof(TxtData)}
    };
ダウンロード処理用インタフェース
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace FileDownloader.Report
{
    interface IDownloadFileGenerator
    {
        string ContentType { get; }
        string FileName { get; }
        void Write(System.IO.TextWriter tw);
    }
}
詳細クラス

処理の詳細はインタフェースを実装した各クラスに書きます。
実際にはDBアクセスなどを行いデータを取得すると思いますが面倒なので簡単のために雰囲気のみです。
今回悩んだ部分のひとつなのですが、ContentTypeの設定がいまいち理解できませんでした。
ContentTypeにテキスト、CSVExcel、存在しないMIMEタイプなどを設定してみましたが処理に影響はありませんでした。ContentTypeが影響したのはファイル名の拡張子を指定しないときだったのでブラウザはContentTypeよりファイル名の拡張子を優先して判断しているのでしょうか。(ちなみにIEGoogle Chromeで確認)
ここではサンプル通り、application/octet-streamというContentTypeを指定します。これによりダウンロード対象であることを表すことができます。


CSV出力用クラス

using System.Text;
namespace FileDownloader.Report
{
    public class CSVData : IDownloadFileGenerator
    {
        public string ContentType { get { return "application/octet-stream"; } }
        public string FileName { get { return "Test.csv"; } }

        public void Write(System.IO.TextWriter tw) 
        {
            tw.Write(CreateData());
        }

        private string CreateData() 
        {
            StringBuilder sb = new StringBuilder();

            sb.AppendFormat("\"{0}\",", "A");
            sb.AppendFormat("\"{0}\",", "B");
            sb.AppendFormat("\"{0}\",", "C");
            sb.Remove(sb.Length - 1, 1);

            return sb.ToString();
        }
    }
}

Textファイル出力用クラス

using System;
using System.Text;

namespace FileDownloader.Report
{
    public class TxtData : IDownloadFileGenerator
    {
        public string ContentType { get { return "application/octet-stream"; } }
        public string FileName { get { return "Test.txt"; } }

        public void Write(System.IO.TextWriter tw)
        {
            tw.Write(CreateData());
        }

        private string CreateData()
        {
            StringBuilder sb = new StringBuilder();

            sb.AppendLine(DateTime.Now.ToString());
            sb.AppendLine("This is Text file.");
            sb.AppendLine("Download is completed.");

            return sb.ToString();
        }
    }
}

出力結果

Test.txt

2014/03/10 23:26:28
This is Text file.
Download is completed.

Test.csv

"A","B","C"