ビフォアーアフター(CSV出力機能編)


2011年 10月 01日

あらすじ

あなたはとある業務用アプリケーションの開発・保守を任されています。
このアプリケーションはASP.NET / C#で作成されており、
多数の一般社員が作成したデータが積み上げられ、
溜まったデータを集計・出力したものを上層部が眺める、
といったことが日夜行われています。
様々な角度からデータを確認する必要があるため、
データ出力機能はアプリケーション内に山ほどあります。
出力形式は様々ですが、とりわけCSVで出力されているデータが多くなっています。

さて、その数ある出力機能の中でも、
特定の条件を満たす社員の一覧をCSVで出力する機能があるのですが、
クライアントからの要望で出力されるCSVのカラムの並びの変更や追加を行うことになりました。
出力機能の改修は今回が初めてなのですが、
既にあるものに少し手を加えるだけなので、
大した仕事にはならないはずです。
ちゃっちゃと片付けてしまいましょう。

5分後 ──

問題

該当する出力機能のソースコードを発見したあなたは何とも言えない気持ちになりました。
具体的には以下のようなソースコードになっていたからです
(※掲載にあたり比較的読み易くなるよう整理してあります):

public void ExportAsCsv()
{
    ArrayList csvRows = new ArrayList();

    ArrayList headerRow = new ArrayList();
    headerRow.Add("社員番号");
    headerRow.Add("名前");
    headerRow.Add("メールアドレス");
    headerRow.Add("役職");
    headerRow.Add("入社日");
    headerRow.Add("退社日");
    csvRows.Add(headerRow);

    foreach (Staff s in GatherInterestingStaffs())
    {
        ArrayList dataRow = new ArrayList();
        dataRow.Add(s.id);
        dataRow.Add(s.name);
        dataRow.Add(s.email);
        dataRow.Add(s.post);
        dataRow.Add(s.entry_date.ToString("{0:yyyy/mm/dd}"));
        dataRow.Add(s.retire_date.ToString("{0:yyyy/mm/dd}"));
        csvRows.Add(dataRow);
    }

    Utility.ExportFile("Staffs.csv",
                       new CsvManager().CreateCsvText(csvRows, true),
                       Response);
}

まさかと思って調べてみたところ、アプリケーション内のCSV出力機能は全てこの形式に沿って書かれていました。
出力機能の追加・変更は結構な頻度であるものの、この形式では書くのも読むのも直すのも大変です。
この機会に刷新してしまいましょう。
そうすれば保守にかかる手間が削減でき、
結果として他のタスクに注力することができ、
最終的にクライアントもハッピーになれます。

難点の考察

ヘッダーと値の出力が別々の箇所になっている

一見すると大したことはないように思えますが、
関連するものが全く別の箇所に記述されていると混乱を招き易いですし、
そうでなくとも双方を往復する手間がかかって時間の無駄です。

今回の例の場合はカラムが6個しかありませんが、
実際の出力機能ではカラムが30個以上あるものがゴロゴロしているため、
さらに状況はひどいものになります。

出力するデータを二次元配列の形で作っている

二次元配列を使うことはここで使われているオレオレCSV出力ライブラリの実装の都合ですから、
その作成を使用側のコードに押し付けるのは面倒臭さを増すだけです。
そうでなくとも二次元配列を作るというのは高度に知的な作業なので、
余計なミス(例えばヘッダーの幅は10個なのに値は12個になっている等)を混入させる隙を増やすことになります。

最終的にはCSVを文字列化したものを使ってファイルの出力を行っている

もう考えるのも嫌になってきました。
そんな低レイヤーの詳細は使用側のコードでは見たくも書きたくもありません。

願望

つまりは以下のような形で記述できるようになっていれば、
余計なミスや副作用を混入させる隙がなくなり、
改修が簡単になるはずです:

public void ExportAsCsv()
{
    var oracle = new CsvOracle<Staff> {
        {"社員番号", s => s.id},
        {"名前", s => s.name},
        {"メールアドレス", s => s.email},
        {"役職", s => s.post},
        {"入社日", s => s.entry_date.ToString("{0:yyyy/mm/dd}")},
        {"退社日", s => s.retire_date.ToString("{0:yyyy/mm/dd}")},
    };
    oracle.Export("Staffs.csv",
                  GatherInterestingStaffs(),
                  Response);
}

実装

では願望通りの記述ができるように既存のオレオレCSV出力ライブラリをラップしてしまいましょう。
具体的な実装は以下の通りです。

public class CsvColumn<T>
{
    public string ColumnName {get; set;}
    public Func<T, string> Converter {get; set;}

    public CsvColumn(string columnName, Func<T, string> converter)
    {
        this.ColumnName = columnName;
        this.Converter = converter;
    }
}

public class CsvOracle<T> : IEnumerable<T>
{
    private ICollection<CsvColumn<T>> ColumnDefinitions {get; set;}

    public CsvOracle()
    {
        this.ColumnDefinitions = new List<CsvColumn<T>>();
    }

    public void Export(string filename,
                       IEnumerable<T> originalDataSequence,
                       System.Web.HttpResponse response)
    {
        Utility.ExportFile(filename,
                           this.ExportAsText(originalDataSequence),
                           response);
    }

    public ArrayList ExportAsArrayListOfArrayLists(IEnumerable<T> originalDataSequence)
    {
        var csvRows = new ArrayList();

        var headerRow = new ArrayList();
        foreach (var columnDefinition in this.ColumnDefinitions)
            headerRow.Add(columnDefinition.ColumnName);
        csvRows.Add(headerRow);

        foreach (var data in originalDataSequence)
        {
            var dataRow = new ArrayList();
            foreach (var columnDefinition in this.ColumnDefinitions)
                dataRow.Add(columnDefinition.Converter(data));
            csvRows.Add(dataRow);
        }

        return csvRows;
    }

    public string ExportAsText(IEnumerable<T> originalDataSequence)
    {
        return (new CsvManager()).CreateCsvText(
            this.ExportAsArrayListOfArrayLists(originalDataSequence),
            true
        );
    }

    #region IEnumerable<T>関連

    public void Add(string columnName, Func<T, string> converter)
    {
        this.ColumnDefinitions.Add(new CsvColumn<T>(columnName, converter));
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        throw new NotImplementedException();
    }

    IEnumerator<T> IEnumerable<T>.GetEnumerator()
    {
        throw new NotImplementedException();
    }

    #endregion
}

なお、上記の願望通りの記述をするためだけに
CsvOracle<T>IEnumerable<T> を実装するフリだけをしています。

結果

上記の願望通りの記述ができるようになったことで、
コードがすっきりしたことであなたの精神的平穏が保たれ、
CSV出力機能の追加や変更は簡単に対応できるようになり、
結果として他の重要なタスクに時間を配分できるようになり、
同じコストでより多くの付加価値を生み出せるようになったため、
最終的にクライアントもハッピーになれました。

次回はより残念なコードの改善をします。