【C#】CSVを複数のテーブルへ取込む

こんにちは。
そもそもの設計があれな話である。
只野です。

はい。
業務用のプログラムを開発する時にCSVでDBにデータを登録したいという要望が結構ありますね。
企業さんの扱うデータ量を考えれば、業務画面を利用するよりCSVを利用した方が効率的な事も多いでしょう。

だがしかし!

複雑な機能に限ってCSVでデータを登録したいと言われます笑
私はCSVを利用する場合は、単一テーブルに対して行う方が良いと考えます。
主にマスタメンテナンス系の機能とかですね。
CSVのように一括で取込む機能はシンプルに作成すれば運用中に障害が発生する可能性が低くなりますし。

例えば業務画面でよくある、ヘッダと明細テーブルを持つような機能でCSV取込みしたいぞ!ってなるとCSVのデータ1行で複数のテーブルに登録しなくてはならないこともしばしばです。
このような機能だとプログラムでCSVデータを加工したりするので、障害が発生しやすくなるわけです。

CSV取込時に行うデータチェックの量も膨大です。
利用者使えるんかこれ?と思うほどデータチェックします。

それでもお客様が欲しいと言えば作るのが、システムエンジニアの仕事です。
障害が発生しないよう、あとで改修しやすいようにとプログラムを開発していきます。
どうプログラムを作成したらよいのか悩みますよね。

そんな時に少しでも参考になればと、本日は私が考えるCSV取込プログラムのサンプルを記載します。
私がプログラム忘れた時に思い出せるようの意味もあります笑

はい。
作成するプログラムの仕様は下記とします。

テーブルレイアウトは下記とします。

CSVで入力されるデータは明細テーブル単位とします。

プログラム言語は「C#」です。
私はプログラムが得意な方ではないので、たまに無駄な処理をやっているかもしれません笑

CSVデータリスト取得

/// <summary>
/// CSVリスト取得
/// </summary>
/// <param name="filepath">CSVファイルパス</param>
/// <returns></returns>
public List<List<string>> GetCsvRows(string filepath) 
{
    List<List<string>> csvrows = new List<List<string>>();

    using (FileStream fs = new FileStream(filepath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    using (StreamReader sr = new StreamReader(fs, Encoding.GetEncoding("UTF-8")))
    {
        //読込テキスト
        string text = string.Empty;
        //CSVデータ
        string csv = string.Empty;
        //改行フラグ
        bool crlf = false;

        while ((text = sr.ReadLine()) != null)
        {
            //改行
            if (crlf)
            {
                csv += "\n";
            }

            if (string.IsNullOrEmpty(text))
            {
                continue;
            }

            //正常データ
            if (text.Substring(0, 1).Equals("\"")
                && text.Substring(text.Length - 1, 1).Equals("\""))
            {
                csv = text;
            }

            //改行データ(終了)
            if (!text.Substring(0, 1).Equals("\""))
            {
                csv += text;
                crlf = false;
            }

            //改行データ(開始)
            if (!text.Substring(text.Length - 1, 1).Equals("\""))
            {
                csv = string.IsNullOrEmpty(csv) ? text : csv;
                crlf = true;
            }

            //正常データリスト化
            if (csv.Substring(0, 1).Equals("\"")
                && csv.Substring(csv.Length - 1, 1).Equals("\""))
            {
                csvrows.Add(csv.Replace("\"", string.Empty).Split(',').ToList());
                csv = string.Empty;
            }
        }
    }

    //ヘッダ行が不要な場合
    //if(csvrows.Count > 0)
    //{
    //    csvrows.RemoveAt(0);
    //}

    return csvrows;
}

はい。
まずはCSVファイルをプラグラムで扱いやすいようにstring型のリストを作成します。
CSVファイルを扱う場合は良くやる方法です。
今回のプログラムはたまにあるダブルコーテーション~ダブルコーテーションまでを一つの項目として扱う場合の処理です。

CSVの途中で改行コードとか入っているような時にこのパターンになります。
業務プログラムだとよくあるんですよね…
CSVで改行コード含めるのはやめてくれ~っと思います。
今回は改行コードしか対応していません。ダブルコーテーションの間にセパレータ「,」があると死亡します笑
CSVフォーマットは業務によりけりなので、カンマもセパレータ以外で使用できる仕様ならば、58行目のリスト作成する箇所を修正すれば大丈夫です。

はい。
今回のサンプルは11行目で文字コードを指定している箇所と58行目でSplitに使用しているCSVセパレータを固定で記載していますが、本来ならば引数にして汎用的に使える関数を作成します。

64行目でヘッダ行削除も引数で削除するのかしないのかをboolで渡してあげればよいですね。

CSV項目用クラスを作る

/// <summary>
/// CSV項目
/// </summary>
public class CsvItem 
{
    /// <summary>
    /// CSV項目(文字1)
    /// </summary>
    public string Csvitem_S1 { get; set; }
    /// <summary>
    /// CSV項目(文字2)
    /// </summary>
    public string Csvitem_S2 { get; set; }
    /// <summary>
    /// CSV項目(文字3)
    /// </summary>
    public string Csvitem_S3 { get; set; }
    /// <summary>
    /// CSV項目(整数)
    /// </summary>
    public int? Csvitem_I { get; set; }
    /// <summary>
    /// CSV項目(浮動小数点)
    /// </summary>
    public decimal? Csvitem_Dc { get; set; }
    /// <summary>
    /// CSV項目(日付)
    /// </summary>
    public DateTime? Csvitem_Dt { get; set; }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public CsvItem()
    {
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="csvrow"></param>
    public CsvItem(List<string> csvrow)
    {
        this.Csvitem_S1 = csvrow[0];
        this.Csvitem_S2 = csvrow[1];
        this.Csvitem_S3 = csvrow[2];
        this.Csvitem_I = this.CnvStrToInt(csvrow[3]);
        this.Csvitem_Dc = this.CnvStrToDecimal(csvrow[4]);
        this.Csvitem_Dt = this.CnvStrToDate(csvrow[5]);
    }

    /// <summary>
    /// 数値変換(文字⇒数値)
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public int? CnvStrToInt(string value)
    {
        int ret = 0;

        if (!Int32.TryParse(value, out ret))
        {
            return null;
        }

        return ret;
    }

    /// <summary>
    /// 数値変換(文字⇒数値)
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public decimal? CnvStrToDecimal(string value) 
    {
        decimal ret = 0m;

        if (!decimal.TryParse(value, out ret))
        {
            return null;
        }

        return ret;
    }

    /// <summary>
    /// 日付変換(文字⇒日付)
    /// </summary>
    /// <param name="value">対象文字列</param>
    /// <returns></returns>
    public DateTime? CnvStrToDate(string value)
    {
        DateTime ret;

        //想定する日付フォーマットを指定
        string[] ef = { "yyyyMM", "yyyyMMdd", "yyyy'/'MM'/'dd" };
        if (!DateTime.TryParseExact(
            value
            , ef
            , DateTimeFormatInfo.InvariantInfo
            , DateTimeStyles.None
            , out ret))
        {
            return null;
        }

        return ret;
    }

    public override string ToString()
    {
        string fm = "文字1:{0} 文字2:{1} 文字3:{2} 整数:{3} 浮動小数点:{4} 日付:{5}";

        return string.Format(fm
            , this.Csvitem_S1
            , this.Csvitem_S2
            , this.Csvitem_S3
            , Convert.ToString(this.Csvitem_I)
            , Convert.ToString(this.Csvitem_Dc)
            , this.Csvitem_Dt != null ? ((DateTime)this.Csvitem_Dt).ToString("yyyy/MM/dd") : string.Empty);
    }
}

次にCSVデータをさらに扱いやすくするため、CSV用の項目クラスを作ります。
string型のリストの状態ですと、何番目がなんだっけ?って状態になるので、クラスを作り、CSV用のクラスリストでCSVデータを管理するようにします。
定数で項目位置を管理しても良いのですが、作成したインスタンス名.で項目出てくる方が楽じゃないですか。

後ほど使用する「Linq」でデータを集計する時のためでもあります。

ヘッダテーブル登録用Linqクエリ

/// <summary>
/// LinqQuery(ヘッダテーブル登録用)
/// </summary>
/// <param name="csvitemlist"></param>
/// <returns></returns>
private static List<CsvItem> LinqQueryHeadTable(List<CsvItem> csvitemlist) 
{
    return (from q in csvitemlist
            group q by new
            {
                q.Csvitem_S1,
                q.Csvitem_S2
            } into g
            select new CsvItem
            {
                Csvitem_S1 = g.Key.Csvitem_S1,
                Csvitem_S2 = g.Key.Csvitem_S2,
                Csvitem_Dc = g.Sum(x => x.Csvitem_Dc)
            }).ToList();

}

Linqとはデータリストを任意の条件で取得したり、グループ化したりして集計できる機能です。
普段DBを触れSQLを使用した事がある方ならばピンとくる機能ですね。
DBでの集計とかですとSQLコマンドでサクッとできるので楽ですね。
それがC#でもできるのですから「Linq」は便利です。
クラスを作成しなくても匿名型で使用する事もできるのですが、わかりずらいのでクラスを作成して使用した方が良いかと私は思います。

今回のLinqクエリは「Csvitem_S1」、「Csvitem_S2」をグループで「Csvitem_Dc」を集計する。と言ったクエリになっています。
SQLだと良くやるような事ですね。

今回の仕様ですとCSVは明細単位の入力となるので、ヘッダテーブルに登録するためには下記状態にしないといけません。

CSVデータ:

ヘッダ登録用データ:

この程度でしたら明細テーブルを先にDBへ登録して後ほどSQLを使用しSELECT、INSERTでヘッダテーブル作るのもありなんですけどね。
今回はC#でデータを扱いやすくするプログラムが目的なので効率は気にしないでください笑

はい。
上記Linqクエリを使用すると上記のデータ状態になるという事です。
このように私はCSVを扱う時にC#上でデータを加工して、どこで何をやっているかをわかりやすくします。
ループは一つでヘッダ、明細テーブルを登録するとなるとゴチャゴチャしますしね。
ゴチャゴチャするプログラムはバグが出た時に対処するのが大変です。

CSV取込処理全体

static void Main(string[] args)
{
    try
    {
        //CSV読込
        List<List<string>> csvrows = GetCsvRows("D:\\test.csv");

        List<string> errmsg = new List<string>();

        List<CsvItem> csvitemlist = new List<CsvItem>();

        for (int cnt = 0; cnt < csvrows.Count; cnt++)
        {
            List<string> csvrow = csvrows[cnt];
            
            //基本チェック
            if (csvrow.Count != CSV_ITEM_CNT)
            {
                //エラー処理
                errmsg.Add("列数");
                continue;
            }

            CsvItem csvitem = new CsvItem(csvrow);

            //必須チェックなど
            if (string.IsNullOrEmpty(csvitem.Csvitem_S1))
            {
                //エラー処理
                errmsg.Add("列数");
                continue;
            }

            //その他必要なチェック
            //……
            //…

            csvitemlist.Add(new CsvItem(csvrow));
        }

        if (errmsg.Count != 0)
        {
            foreach (string err in errmsg)
            {
                Console.WriteLine(err);
            }

            return;
        }

        //DB登録処理
        Console.WriteLine("ヘッダテーブル登録データ");
        foreach (CsvItem item in LinqQueryHeadTable(csvitemlist))
        {
            Console.WriteLine(item.ToString());
        }

        Console.WriteLine("明細テーブル登録データ");
        foreach (CsvItem item in csvitemlist)
        {
            Console.WriteLine(item.ToString());
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }

    Console.ReadKey();

}

はい。
今まで作成したメソッドを組み合わせると上記状態になります。
CSV管理用クラスのインスタンスを作成する前に基本となるチェックなどは終わらせます。
項目数が足りないのようにそもそも対象外データを事前にはじきます。

基本チェックが完了したらCSV管理用クラスのインスタンスを作成し、今度は項目毎のチェックを行います。
主には必須チェックや業務的に必要なるチェックですね。

チェックが完了した時にエラーがなければDB登録処理となります。
後ほどの修正しやすさを考え、ヘッダと明細の登録処理は別としています。

今回はDBへ実際に登録する処理は記載していませんが、想定される登録データとしては下記となります。

CSV管理用クラスのプロパティを全て出しているので少し見にくいですが、想定した通りにヘッダ用の登録データが作成されています。
明細はCSVデータそのままなので、特に加工はしていません。
登録するテーブルが増えたら、DBへ登録する単位でLinqクエリメソッドを作成します。
後々修正しやすいからです。
業務プログラムは後での修正しやすさも大切です。

C#でCSVデータを扱う時のまとめ

・ダブルコーテーションで1項目単位か確認だ!
・CSV管理用クラスを作成しよう!
・C#ならLinqクエリが便利だよ!
・どうすれば後で見やすく、修正しやすいかを考えよう!

はい。
私が考えるCSV取込のサンプルプログラムは以上となります。

本日はこの辺で

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です