xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

CSV/TSVなどの区切り文字形式ファイルを簡単生成

CSV (Comma-Separated Values : カンマ区切り)TSV (Tab-Separeted Values : タブ区切り)、SSV (Space-Saparated Values : 半角スペース区切り) と言った「データを特定の区切り文字で区切って並べたファイル形式」があるのはご存知の事と思います。特に表形式でデータを出力する場合、Excel形式での出力は非常に面倒臭いのでCSVのような簡易な形式が多用されます。しかしながら、書き込むデータ自体に「,」などの例外文字が含まれていることを考慮しなければならず、それがまた地味にメンドクサイ。簡易な形式は苦労せずにサッと出力できるべき。ということで作ってみました。(というか、むかーしむかしに作ったのを非同期でも書けるようにした)

実装

CSV/TSV/SSVなどの違いは基本的に区切り文字だけです。なので、そのあたりは設定として切り出し汎用化したいというのが人情というものです。まずは書き込み設定クラスを実装します。

public class SeparatedValuesWriterSetting
{
    #region 一般的な形式のインスタンス取得
    public static SeparatedValuesWriterSetting Csv{ get{ return new SeparatedValuesWriterSetting(); } }
    public static SeparatedValuesWriterSetting Tsv{ get{ return new SeparatedValuesWriterSetting(){ FieldSeparator = "\t" }; } }
    public static SeparatedValuesWriterSetting Ssv{ get{ return new SeparatedValuesWriterSetting(){ FieldSeparator = " " }; } }
    #endregion

    #region プロパティ
    public string FieldSeparator{ get; set; }   //--- フィールドの区切り文字
    public string RecordSeparator{ get; set; }  //--- レコードの区切り文字
    public string TextModifier{ get; set; }     //--- テキストの修飾子
    #endregion

    #region コンストラクタ
    public SeparatedValuesWriterSetting()
    {
        //--- 既定はCSV
        this.FieldSeparator  = ",";
        this.RecordSeparator = Environment.NewLine;
        this.TextModifier    = "\"";
    }
    #endregion
}

次に書き込み機能を実装します。TextWriterを内包し、必要ないくつかの機能を外出ししています。また書き込み先を簡単に指定できるよう、コンストラクタにはPath/Stream/TextWriterを受けられるオーバーロードが用意されています。データの書き込みは行単位で行うため、1行分のデータを一気に渡すようなインターフェース (IEnumerable<T>) になっています。

public class SeparatedValuesWriter : IDisposable
{
    #region フィールド / プロパティ
    private readonly TextWriter writer = null;
    public SeparatedValuesWriterSetting Setting{ get; private set; }
    #endregion


    #region コンストラクタ
    public SeparatedValuesWriter(string path, SeparatedValuesWriterSetting setting)
        : this(new StreamWriter(path), setting){}

    public SeparatedValuesWriter(string path, bool append, SeparatedValuesWriterSetting setting)
        : this(new StreamWriter(path, append), setting){}

    public SeparatedValuesWriter(string path, bool append, Encoding encoding, SeparatedValuesWriterSetting setting)
        : this(new StreamWriter(path, append, encoding), setting){}

    public SeparatedValuesWriter(Stream stream, SeparatedValuesWriterSetting setting)
        : this(new StreamWriter(stream), setting){}

    public SeparatedValuesWriter(Stream stream, Encoding encoding, SeparatedValuesWriterSetting setting)
        : this(new StreamWriter(stream, encoding), setting){}

    public SeparatedValuesWriter(TextWriter writer, SeparatedValuesWriterSetting setting)
    {
        this.writer  = writer;
        this.Setting = setting;
    }
    #endregion


    #region IDisposable メンバー
    public void Dispose()
    {
        this.Close();
    }
    #endregion


    #region 書き込み関連メソッド
    //--- 1行分のデータを一気に同期書き込み
    public void WriteLine<T>(IEnumerable<T> fields, bool quoteAlways = false)
    {
        this.WriteLineAsync(fields, quoteAlways).Wait();
    }

    //--- 1行分のデータを一気に非同期書き込み
    public Task WriteLineAsync<T>(IEnumerable<T> fields, bool quoteAlways = false)
    {
        if (fields == null)
            throw new ArgumentNullException("fields");

        var formated = fields.Select(x => this.FormatField(x, quoteAlways));
        var record   = string.Join(this.Setting.FieldSeparator, formated);
        return this.writer.WriteAsync(record + this.Setting.RecordSeparator);
    }

    public void Flush()
    {
        this.writer.Flush();
    }

    public Task FlushAsync()
    {
        return this.writer.FlushAsync();
    }

    public void Close()
    {
        this.writer.Close();
    }
    #endregion


    #region 補助メソッド
    //--- フィールド文字列を整形します
    private string FormatField<T>(T field, bool quoteAlways = false)
    {
        var text = field is string ? field as string
                 : field == null   ? null
                 : field.ToString();
        text     = text ?? string.Empty;

        if (quoteAlways || this.NeedsQuote(text))
        {
            var modifier = this.Setting.TextModifier;
            var escape   = modifier + modifier;
            var builder  = new StringBuilder(text);
            builder.Replace(modifier, escape);
            builder.Insert(0, modifier);
            builder.Append(modifier);
            return builder.ToString();
        }
        return text;
    }

    //--- 指定された文字列を引用符で括る必要があるかどうかを判定
    private bool NeedsQuote(string text)
    {
        return  text.Contains('\r')
            ||  text.Contains('\n')
            ||  text.Contains(this.Setting.TextModifier)
            ||  text.Contains(this.Setting.FieldSeparator)
            ||  text.StartsWith("\t")
            ||  text.StartsWith(" ")
            ||  text.EndsWith("\t")
            ||  text.EndsWith(" ");
    }
    #endregion
}

使い方

使い方は非常に簡単で、行単位でデータを準備してWriteLine/WriteLineAsyncメソッドに与えるだけです。自分で区切り文字入りの文字列を作る必要も、エスケープを考慮して引用符で括る必要もありません。文字列以外の型が与えられてもToStringで自動的に文字列化します。また、カンマや半角スペースなどが含まれていても上手く引用符で括ってエスケープしてくれるので安心です。

async Task SampleOutputAsync()
{
    var path    = @"C:\Temp\test.csv";
    var setting = SeparatedValuesWriterSetting.Csv;
    using (var writer = new SeparatedValuesWriter(path, setting))
    {
        var sample = new []
        {
            new []{ "1", "2 3" },
            new []{ "4,5", "6 " },
        };
        foreach (var fields in sample)
            await writer.WriteLineAsync(fields);  //--- 第2引数をtrueにした場合は常に引用符で括る (既定値 : false)
        await writer.FlushAsync();
    }
}

//--- 出力結果
/*
1,2 3
"4,5","6 "
*/