C# Tips | ファイル・ディレクトリ操作:TSV対応CSV

C sharp C#
スポンサーリンク

はじめに 「TSV対応CSV」とは“区切り文字を意識できる人”になること

業務でファイル連携をしていると、こういうことが起きます。

CSV ください、と言われたのに、実際に渡されるのはタブ区切り(TSV)。
Excel で開くときはカンマ区切りよりタブ区切りのほうが都合がいいと言われる。
ログはタブ区切り、マスタはカンマ区切り、どっちも同じように扱いたい。

ここで「CSV 用の処理」と「TSV 用の処理」を別々に書き始めると、
あっという間に似たようなコードが量産されて、メンテナンス地獄になります。

そこでキーワードになるのが「TSV対応CSV」、
つまり「区切り文字をパラメータ化して、CSV も TSV も同じユーティリティで扱えるようにする」という発想です。

ここでは、初心者向けに、

区切り文字を意識した設計の考え方
読み込み・書き込みを共通化する方法
CSV と TSV の違いと共通点

を、例題付きでかみ砕いて解説していきます。


CSV と TSV の違いと「本質的な共通点」

CSV と TSV の違いは「区切り文字」だけ

まず整理しておきたいのは、CSV と TSV の本質的な違いは「区切り文字」だけ、ということです。

CSV
Comma Separated Values。カンマ区切り。

TSV
Tab Separated Values。タブ区切り(\t)。

行の概念がある
1 行が 1 レコード
セルの中に区切り文字や改行が入るときは、ダブルクォートで囲む
ダブルクォートを含めたいときは "" と二重にする

といったルールは、CSV でも TSV でもほぼ同じように使えます。

つまり、「区切り文字を変えられる CSV 処理」を書いておけば、
それはそのまま TSV にも対応できる、ということです。

だから「区切り文字をハードコードしない」が超重要

典型的な“もったいないコード”は、こういうやつです。

line.Split(',');
string.Join(",", columns);
C#

これだと、「カンマ区切り専用」になってしまいます。

ここを「区切り文字を引数で受け取る」「設定で変えられる」ようにしておくと、
同じロジックで CSV も TSV も扱えるようになります。


共通インターフェースを決める 「区切り文字付きテーブル」として扱う

まずは「1 行=string[]」という共通の形を決める

CSV でも TSV でも、最終的には「1 行を列の配列として扱う」ことになります。

そこで、まずはこんなシンプルな行クラスを用意します。

public sealed class DelimitedRow
{
    public string[] Columns { get; }

    public DelimitedRow(string[] columns)
    {
        Columns = columns;
    }

    public string this[int index] => Columns[index];

    public int ColumnCount => Columns.Length;
}
C#

この DelimitedRow は、「区切り文字が何か」を知りません。
ただ「列の配列」としてデータを持っているだけです。

CSV でも TSV でも、「パースした結果」はこの形にしてしまえば、
その後の処理(集計・変換・検証など)は共通化できます。


TSV対応の読み込みユーティリティを作る

区切り文字を引数で受け取る「簡易版」から

まずは、「値の中に区切り文字や改行が出てこない」前提の簡易版から始めます。
(本番ではちゃんとしたパーサを使う前提で、“枠組み”の話に集中します)

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

public static class DelimitedReader
{
    public static IEnumerable<DelimitedRow> Read(
        string path,
        Encoding encoding,
        char delimiter,
        bool hasHeader)
    {
        if (!File.Exists(path))
        {
            throw new FileNotFoundException("ファイルが存在しません。", path);
        }

        using var reader = new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true);

        string? line;

        if (hasHeader)
        {
            reader.ReadLine();
        }

        while ((line = reader.ReadLine()) is not null)
        {
            if (string.IsNullOrWhiteSpace(line))
            {
                continue;
            }

            string[] columns = line.Split(delimiter);

            yield return new DelimitedRow(columns);
        }
    }
}
C#

ここでの重要ポイントは、「カンマをハードコードしていない」ことです。

CSV を読みたいときは delimiter: ','
TSV を読みたいときは delimiter: '\t'

と渡すだけで、同じロジックが両方に使えます。

CSV と TSV の読み込み例

CSV の例です。

string csvPath = @"C:\data\items.csv";

foreach (var row in DelimitedReader.Read(csvPath, Encoding.UTF8, ',', hasHeader: true))
{
    Console.WriteLine(string.Join(" | ", row.Columns));
}
C#

TSV の例です。

string tsvPath = @"C:\data\items.tsv";

foreach (var row in DelimitedReader.Read(tsvPath, Encoding.UTF8, '\t', hasHeader: true))
{
    Console.WriteLine(string.Join(" | ", row.Columns));
}
C#

呼び出し側の違いは「区切り文字の指定」だけです。
これが「TSV対応CSV」の一番シンプルな形です。


TSV対応の書き込みユーティリティを作る

「1 セルのエスケープ」と「区切り文字」を分けて考える

書き込み側も同じ発想で、「区切り文字をパラメータ化」します。

まずは、CSV と同じルールでエスケープする関数を用意します。
(ここでは CSV と TSV で同じエスケープルールを使う前提にします)

public static class DelimitedEscaping
{
    public static string Escape(string? value)
    {
        if (value is null)
        {
            return string.Empty;
        }

        bool containsSpecial =
            value.Contains(',') ||
            value.Contains('"') ||
            value.Contains('\r') ||
            value.Contains('\n') ||
            value.Contains('\t');

        if (!containsSpecial)
        {
            return value;
        }

        string escaped = value.Replace("\"", "\"\"");

        return $"\"{escaped}\"";
    }
}
C#

ここでは、カンマだけでなくタブも「特殊文字」として扱っています。
つまり、「CSV でも TSV でも、値の中に区切り文字が出てきたらクォートする」という方針です。

区切り文字を指定できる書き込みユーティリティ

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

public static class DelimitedWriter
{
    public static void Write(
        string path,
        IEnumerable<string[]> rows,
        Encoding encoding,
        char delimiter)
    {
        string? dir = Path.GetDirectoryName(path);
        if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
        {
            Directory.CreateDirectory(dir);
        }

        using var writer = new StreamWriter(path, false, encoding);

        foreach (var columns in rows)
        {
            string[] escaped = new string[columns.Length];

            for (int i = 0; i < columns.Length; i++)
            {
                escaped[i] = DelimitedEscaping.Escape(columns[i]);
            }

            string line = string.Join(delimiter, escaped);
            writer.WriteLine(line);
        }
    }
}
C#

CSV として書きたいとき。

var rows = new List<string[]>
{
    new[] { "Id", "Name", "Note" },
    new[] { "1", "Apple", "Simple" },
    new[] { "2", "Orange, Big", "Has comma" },
};

DelimitedWriter.Write(
    @"C:\data\items.csv",
    rows,
    Encoding.UTF8,
    delimiter: ',');
C#

TSV として書きたいとき。

DelimitedWriter.Write(
    @"C:\data\items.tsv",
    rows,
    Encoding.UTF8,
    delimiter: '\t');
C#

同じデータから、区切り文字だけ変えて CSV と TSV を両方出力できるようになります。


「フォーマット種別」を型で表現してみる

char だけ渡すより「意味のある型」にする

毎回 ',''\t' を直接書くのが嫌なら、
「フォーマット種別」を表す enum を用意してもよいです。

public enum DelimitedFormat
{
    Csv,
    Tsv
}

public static class DelimitedFormatExtensions
{
    public static char GetDelimiter(this DelimitedFormat format)
    {
        return format switch
        {
            DelimitedFormat.Csv => ',',
            DelimitedFormat.Tsv => '\t',
            _ => throw new ArgumentOutOfRangeException(nameof(format))
        };
    }
}
C#

これを使うと、呼び出し側はこう書けます。

DelimitedFormat format = DelimitedFormat.Tsv;

DelimitedWriter.Write(
    @"C:\data\items.tsv",
    rows,
    Encoding.UTF8,
    delimiter: format.GetDelimiter());
C#

「これは TSV 用の出力なんだな」という意図が、コードから読み取りやすくなります。


CSV/TSV を「型付きオブジェクト」として扱う

読み込みも書き込みも「業務クラス」との橋渡しを共通化する

CSV でも TSV でも、「1 行を業務クラスにマッピングする」「業務クラスから 1 行を作る」という部分は同じです。

例えば、商品クラスがあるとします。

public sealed class ItemRecord
{
    public int Id { get; }
    public string Name { get; }
    public int Price { get; }

    public ItemRecord(int id, string name, int price)
    {
        Id = id;
        Name = name;
        Price = price;
    }
}
C#

読み込み側のマッピング。

public static class ItemMapping
{
    public static ItemRecord FromRow(DelimitedRow row)
    {
        int id = int.Parse(row[0]);
        string name = row[1];
        int price = int.Parse(row[2]);

        return new ItemRecord(id, name, price);
    }
}
C#

書き込み側のマッピング。

public static class ItemMapping
{
    public static string[] ToColumns(ItemRecord item)
    {
        return new[]
        {
            item.Id.ToString(),
            item.Name,
            item.Price.ToString()
        };
    }
}
C#

これを使えば、CSV でも TSV でも、
「フォーマットの違い」はユーティリティ側に閉じ込めて、
業務コードは「ItemRecord の列」として統一できます。


例題:同じデータから CSV と TSV を両方出力する

最後に、実務でありがちなシナリオを一つ。

同じ売上データを、
外部システム A には CSV(カンマ区切り)で、
外部システム B には TSV(タブ区切り)で渡したい、というケースです。

var items = new List<ItemRecord>
{
    new ItemRecord(1, "Apple", 120),
    new ItemRecord(2, "Orange, Big", 200),
};

IEnumerable<string[]> ToRows(IEnumerable<ItemRecord> source)
{
    foreach (var item in source)
    {
        yield return ItemMapping.ToColumns(item);
    }
}

var rows = ToRows(items);

DelimitedWriter.Write(
    @"C:\data\items.csv",
    rows,
    Encoding.UTF8,
    delimiter: DelimitedFormat.Csv.GetDelimiter());

DelimitedWriter.Write(
    @"C:\data\items.tsv",
    rows,
    Encoding.UTF8,
    delimiter: DelimitedFormat.Tsv.GetDelimiter());
C#

同じ rows を使い回しつつ、
区切り文字だけ変えて CSV と TSV を両方出力できています。


まとめ 「TSV対応CSV」は“区切り文字をパラメータにする”という発想

TSV対応CSV という言葉の本質は、「CSV と TSV を別物として扱わない」ことです。
どちらも「区切り文字付きテキスト」として共通化し、
違いは「区切り文字」と「フォーマット名」に閉じ込めてしまう、という設計です。

押さえておきたいポイントはこうです。

CSV と TSV の本質的な違いは「区切り文字」だけ、と捉える。
読み込みも書き込みも、「区切り文字を引数で受け取る」形にしておく。
1 行は string[](あるいは DelimitedRow)として扱い、その上に業務クラスとのマッピングを乗せる。
エスケープ処理(ダブルクォート・改行・区切り文字)は CSV/TSV 共通のルールとしてユーティリティに閉じ込める。
フォーマット種別(CSV/TSV)を enum などで表現すると、コードの意図が読みやすくなる。

ここまでできると、「CSV 用のコード」「TSV 用のコード」がバラバラに増えていく世界から抜け出して、
「フォーマットの違いを吸収した、業務寄りのユーティリティ」を自分の手で育てていけるようになります。

タイトルとURLをコピーしました