以前、WindowsFromを使ったCSV結合ツールの作り方をテーマに、全6回に分けて詳しく解説した記事を公開していましたが、本記事はその中で最も人気が高かった内容をリニューアルしたものになります。
本記事で紹介しているプログラムは、指定フォルダに存在するCSVファイルを全て1つのファイルとして結合するものであり、ヘッダが複数行ある場合はそれを考慮して結合します。
また、CSVの文字コードを変換することも可能なので、EXCELで文字化けするようなCSVであっても、Shift-jis に変換することで文字化けを無くすことが出来ます。
プログラム自体はシンプルなので、皆さんが必要な機能をソースコードに追加し、趣味や仕事に役立てて頂ければと思います。
尚、本記事からダウンロード可能なプロジェクトファイルは Visual Studio 2022 で動作を確認しております。
プロジェクトファイルの中に実行ファイル(CsvJoin.exe)も同梱しているので、単にプログラムを使うだけであれば、ダウンロードファイルを解凍後、CsvJoin.exeだけを任意のフォルダに解凍し、実行して頂くことも可能です。
画面レイアウト
プログラムを起動すると下記の画面が表示されます。シンプルなので解説は不要だと思いますが、結合対象フォルダと出力ファイル名の入力欄には、ファイルやフォルダをドラッグ&ドロップにより指定することが可能になっています。
ヘッダ行数は0~4まで選択可能ですが、任意の数値を手入力することができます。例えば、5を入力するとヘッダが5行あることを前提に結合します。
「ファイル名でソートした順に結合」にチェックを入れておくと、ファイル名でソートした順番にファイルを結合してくれます。
試用しているコントロール名
画面で使っているコントロールには以下の名前を付けています。

CSV結合ツールのアルゴリズム
指定されたフォルダにあるファイル名を1つづつオープンし、1行づつ取り出して出力ファイルに書き出すという処理を繰り返します。CSVファイルを一旦メモリに読み込むようなことはしないので、どれだけ巨大なCSVであっても、メモリオーバーすることなく結合できるのが特徴です。
CSV結合ツールのアルゴリズム(処理手順)方は次と通りです。
- 出力ファイルを書き込みモードでオープンする
- 結合対象フォルダから1個取り出す
- 取り出したCSVファイルから順次1行読んでは出力ファイルに書き出すことを繰り返す
- 取り出したCSVファイルが2個目以降の場合、ヘッダは読み飛ばす(読み込んだヘッダを無視)
- 出力ファイルをクローズする
1個目か2個目以上かを判定するため、 file_no という変数を使用しています。
また、ヘッダ行を読み飛ばすため、CSVファイルの読み込んだ行数を数え、画面から入力されたヘッダ行数に達するまで読み飛ばすという処理を行っています。
line_cnt を0から始めているので、line_cnt < head_cnt (読み込んだ行数がヘッダ数未満)という条件判断にしています。
line_cnt = 1 にすると、line_cnt <= head_cnt(読み込んだ行数がヘッダ数以下)という書き方ができるのですが、多くのプログラミング言語はカウントを0から開始する習慣があるので、あえてline_cnt=0にしています。
以上のことを念頭に、フローチャートをざっくり確認してみて下さい。

ソースコード
それでは、全体のソースコードを掲載しておきます。
Visual Studio 2022 の プロジェクトファイルは、下記からダウンロード可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using System.IO; namespace CsvJoiner { public partial class MainForm : Form { /// <summary> /// コンストラクタ /// </summary> public MainForm() { InitializeComponent(); //文字コードの選択項目を配列として保持 var charcode = new string[] { "shift-jis", "utf-8" }; //文字コード選択用コンボボックスに選択項目を登録 uxTargeCharCode.Items.AddRange(charcode); uxOutputCharCode.Items.AddRange(charcode); //文字コード選択用コンボボックスに対して最初の項目を選択済みにしておく uxTargeCharCode.SelectedIndex = 0; uxOutputCharCode.SelectedIndex = 0; //ヘッダー数コンボボックスに対して1を選択済みにしておく uxHeaderCount.SelectedIndex = 1; //フォルダとファイル名入力用テキストボックスをドラッグ&ドロップ対応にする。 EnableDragDrop(uxTargetFolder); EnableDragDrop(uxOutputFile); } /// <summary> /// 結合対象フォルダ選択ボタンクリック処理 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void UxSelectTargetFolder_Click(object sender, EventArgs e) { //フォルダ選択ダイアログのインスタンスを生成 FolderBrowserDialog folder = new FolderBrowserDialog() { SelectedPath = uxTargetFolder.Text }; //フォルダ選択ダイアログの表示と戻り値のチェック uxTargetFolder.Text = (folder.ShowDialog() == DialogResult.OK) ? folder.SelectedPath : uxTargetFolder.Text; } /// <summary> /// 出力ファイル名選択ボタンクリック処理 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void UxSelectOutputFile_Click(object sender, EventArgs e) { //入力されている出力ファイル名からディレクトリ名を取得 var init_dir = (uxOutputFile.Text.Trim() == "") ? System.Environment.GetFolderPath(Environment.SpecialFolder.Personal) : Path.GetDirectoryName(uxOutputFile.Text); //ファイル選択ダイアログのインスタンスを生成 SaveFileDialog folder = new SaveFileDialog() { FileName = uxOutputFile.Text, Filter = "csv|*.csv|全てのファイル|*.*",InitialDirectory= init_dir}; //ファイル選択ダイアログの表示と戻り値のチェック uxOutputFile.Text = (folder.ShowDialog() == DialogResult.OK) ? folder.FileName : uxOutputFile.Text; } /// <summary> /// 結合実行ボタンクリック処理 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void UxExecJoin_Click(object sender, EventArgs e) { try { //ヘッダ行数の取得 var head_cnt = (int.TryParse(uxHeaderCount.Text, out int val)) ? val : 0; //CSVを結合する JoinCsv(uxTargetFolder.Text, uxOutputFile.Text, head_cnt, uxTargeCharCode.Text, uxOutputCharCode.Text); } catch(Exception exp) { MessageBox.Show(exp.Message,"エラー",MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// <summary> /// 指定したフォルダ内のファイルを1つのファイルに結合する /// </summary> /// <param name="folder"></param> /// <param name="outputName"></param> /// <param name="headerCount"></param> /// <param name="targetEnc"></param> /// <param name="outputEnc"></param> private void JoinCsv(string folder,string outputName,int headerCount,string targetEnc,string outputEnc) { //結合対象CSVファイルの文字コード取得 var target_enc = Encoding.GetEncoding(targetEnc); //出力CSVファイルの文字コード取得 var output_enc = Encoding.GetEncoding(outputEnc); //出力ファイルを上書きモードでオープン using (StreamWriter sw = new StreamWriter(outputName, false, output_enc)) { //ファイル番号用変数を定義 int file_no = 0; //ファイル名でソートにチェックが入っているとソートする var files = (uxIsSort.Checked) ? Directory.GetFiles(folder) : Directory.GetFiles(folder).OrderBy(i => i).ToArray(); //結合対象フォルダからファイル名を全て取り出す foreach (var file in files) { //対象ファイルを読み込みモードでオープン using (StreamReader sr = new StreamReader(file, target_enc)) { //行数カウント用変数を定義 int line_cnt = 0; //ファイルの終わりに達するまでループ while (!sr.EndOfStream) { //1行読み込み var line = sr.ReadLine(); //2個目以降のファイル(ファイル番号が0より大きい)で、 //且つ行数がヘッダ行より小さい場合は読み飛ばす if (file_no > 0 && line_cnt++ < headerCount) { continue; } //出力ファイルに書き込む sw.WriteLine(line); } } //ファイル番号を1つ加算 file_no++; } } } /// <summary> /// 指定したコントロールをドラッグ&ドロップ対応にする /// </summary> /// <param name="control"></param> private void EnableDragDrop(Control control) { //ドラッグ&ドロップを受け付けられるようにする control.AllowDrop = true; //ドラッグが開始された時のイベント処理(マウスカーソルをドラッグ中のアイコンに変更) control.DragEnter += (s, e) => { // ドラッグドロップ時にカーソルの形状を変更 // e.Effect = DragDropEffects.All; //ファイルがドラッグされたとき、カーソルをドラッグ中のアイコンに変更し、そうでない場合は何もしない。 e.Effect = (e.Data.GetDataPresent(DataFormats.FileDrop)) ? DragDropEffects.Copy : e.Effect = DragDropEffects.None; }; //ドラッグ&ドロップが完了した時の処理(ファイル名を取得し、ファイルの中身をTextプロパティに代入) control.DragDrop += (s, e) => { if (e.Data.GetDataPresent(DataFormats.FileDrop)) // ドロップされたものがファイルかどうか確認する。 { string[] paths = ((string[])e.Data.GetData(DataFormats.FileDrop)); control.Text = paths[0]; } }; } } } |
ファイルを1行ずつ読み書きするクラス
これまで作ってきたプログラムでファイルを読み書きする場合、Fileクラスのメソッド(ReadAllLInes、 WriteAllLines、AppendAllLines)を使ってきました。
これらのメソッドは1行でファイルの読み書きが出来ますが、それは対象となるファイルを一旦メモリに格納する必要がありました。
ことのとにより、「巨大なサイズのファイルはメモリに収まりきらなくなるため扱えない」という制限が生じていました。
こういう場合は、1行ごとに読み書きすることでメモリ量を大幅に減らすことができますが、この時に使うクラスが StreamReader や StreamWrite クラスです。
ファイルのオープン
ファイルに対して読み書きする場合、OSに対して「今からこのファイルを読み込むよ」とか「今からこのファイルに書き込みするよ」という宣言を行い、その許可をもらう必要があります。
この行為をファイルをオープンすると呼びます。
オープンすることで、OSは特定のプログラムにだけ参照や書き込みを許可し、他のプログラムからの要求を跳ね除けたりするのですが、この状態を「ロックされている」とか「掴まれている」という風に表現します。
ファイルに対しては、「読み込みのみ」、「書き込み(追加書き込み含む)のみ」、「読み書き両方」の3パターンが選べるのですが、それぞれにクラスが用意されています。
今回は「読み込みのみ」と「書き込みのみ」の2つ、つまりStreamReader と StreamWriteを使っています。
ファイルのクローズ
プログラムはファイルをオープンした後で、必ずクローズする必要があります。
クローズとは、OSに対して「もう処理が終わりました」という宣言をすることです。
これによりOSは、他のプログラムからアクセスできるような状態に戻すと共に、ファイルスタンプを変更します。
もし、プログラムがオープンしたままクローズせず終了したらどうなるでしょう?
OSがファイルを掴んだままになるため、他のプログラムがアクセスしようとしても出来くなってしまいます。
また、 StreamReaderやStreamWriteを new した時に確保されたメモリも暫くは残ったままになるため、ループ処理の中で数多くのファイルを処理する場合、メモリ不測に陥る可能性も否めません。
以上のことから、ファイルをオープンしたら、必ずクローズするという処理が必要になります。
クローズするには Closeメソッドを呼ぶだけなので、ファイルを扱う場合は是非このメソッドを呼ぶことを心がけてください。
自動でCloseしてくれる using 文
C#には便利な機能があって、明示的にCloseメソッドを呼ばなくても、自動的にクローズするように記述が出来ます。
それが using です。
ソースコードの冒頭に using System; とかいう記述が数行続きますが、これとは全く違います。
その点が少しややこしいのですが、全く別物です。
書き方は
using( クラスのインスタンス生成 ) { 処理 }
です。
では、実際の例を見てみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//一般的な書き方(最後にCloseが必要) StreamReader sr = new StreamReader(file, target_enc); while (!sr.EndOfStream) { Console.WriteLine(sr.ReadLine()); } sw.Close(); //using を使った書き方(最後にCloseは不要) using (StreamReader sr = new StreamReader(file, target_enc)) { while (!sr.EndOfStream) { Console.WriteLine(sr.ReadLine()); } } |
以上の様に、using( ) { } で括ることで、明示的に Closeメソッドを呼ばなくとも、自動的にクローズ処理をしてくれるようになります。
ファイルの終わりまでループ
ファイルを1行ずつ、最後まで読み込むには次のように記述します。
これは こちらの記事の最後にも登場しましたね。
1 2 3 4 5 6 |
while (!sr.EndOfStream) { //1行読み込み Console.WriteLine(sr.ReadLine()); } |
ファイル読み込みの処理をGoogle検索すると、次の様な書き方を目にすることもあります。
1 2 3 4 5 6 7 |
string line; while ((line = sr.ReadLine()) != null) { //1行読み込み Console.WriteLine(line); } |
こちらは、ファイルの終わりに達した状態でReadLineメソッドを呼ぶと null が返ってくるので、それを利用した方法になります。
これは好みの問題なので、どちらでも良いと思います。
2個目以降とヘッダ行数の判定
最後に要となるヘッダを読み飛ばす際の判定条件です。
1個目のファイルか2個目以降のファイルかを判定するために、ファイル1個を処理するたびに file_noをカウントアップしています。
これを利用すると、file_no が0の時は1個目、0以外の時(0より大きい時)は2個目以降であると判断できます。
そして、line_cnt は1行読む度にカウントアップしていますので、行数が head_cnt より小さい場合はヘッダだと判断して、読み飛ばし(continue により、forループの先頭に無条件にジャンプ)を行っている訳です。
1 2 3 4 5 6 |
//2個目以降のファイル(ファイル番号が0より大きい)で、 //且つ行数がヘッダ行より小さい場合は読み飛ばす if(file_no > 0 && line_cnt ++ < head_cnt) { continue; } |
ちょっとだけ注意する点としては、line_cnt は int 型で定義しているので 、 -2147483648~2147483648(プラスマイナス2億1千)の値が扱える範囲になります。
つまり、2億1千行を超えるCSVを扱う場合、途中でline_cntの値がマイナスになって挙動がおかしくなるという危険性はあります。
実質そんな巨大なファイルを結合することは無いと思いますが、もし気になるようでしたら long line_cnt とすれば -9223372036854775808~9223372036854775808というくらいとてつもない行数に対応できるようになります。
ちなみに、file_no > 0 と書いていますが、 file_no != 0 と書いても同じです。
それから、 line_cnt ++ と書いていますが、これはC言語っぽい書き方です。
++演算子
++ は演算子で、変数の値に1加えるという記述です。
C言語には次のような記述方法が用意されていて、 1を加えるだけなら ++ を使います。
1 2 3 4 5 6 7 8 9 |
//1を加える書き方 line_cnt = line_cnt + 1 line_cnt += 1 line_cnt ++ //1を引く書き方 line_cnt = line_cnt -1 line_cnt -= 1 line_cnt -- |
++ は変数の後だけではなく、前にも記述できます。
1 2 3 4 5 |
//変数の後に記述 line_cnt ++ //変数の前に記述 ++ line_cnt |
1行の中に単独で書く場合、どちらの記述でも結果は同じです。
しかし条件判断の中で使う場合は意味が変わってきます。
++を変数の後に書く場合、条件判断を実行した後で変数を加算します。
逆に、変数の前に記述すると、変数に値を加算してから条件判断します
1 2 3 4 5 |
//変数の後だと、line_cnt < head_cnt を評価した後で、line_cnt を1加算する if(line_cnt ++ < head_cnt) //変数の前だと、line_cnt に1加算した結果を使って、line_cnt < head_cntを評価する if(++ line_cnt < head_cnt) |
条件判断で用いる場合、この違いは大きいのでご注意ください。
まとめ
如何でしたでしょうか。
StreadReader とStreamWrite を使って1行毎に読み込みと書き込みを繰り返すことで、メモリ消費量を減らしました。
これでメモリにファイルを丸ごと読み込む必要がなくなったので、実質メモリ不測という事は無くなります。
数G単位のファイルを扱う場合はこの方法が有効になりますので、機会があれば是非ご活用下さい。
コメント