いままでのCSV結合ツールでは、結合させたいCSVファイルの容量に制限がありました。
それは、結合対象ファイル1つ1つを一旦丸ごとメモリに読み込み、出力対象ファイルに書き出すというアルゴリズムが原因でした。
そこで、今回はバージョンアップとして、その制限をなくしてみたいと思います。
具体的には、結合対象ファイルを読み込んでは書き出すという処理に変えたいと思います。
バージョンアップのアルゴリズム
ちょっとややこしくなりましたが、考え方は次と通りです。
- 出力ファイルを書き込みモードでオープンしておく
- 結合対象フォルダから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 2019 プロジェクトファイルは、下記からダウンロード可能です。
UxExecJoin_Clickイベントハンドラの部分しか変更していないので、このイベントハンドラのソースコードを、お手元のソースコードに置き換えて頂ければVerUp完了です。
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 |
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; } /// <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) { //ファイル選択ダイアログのインスタンスを生成 SaveFileDialog folder = new SaveFileDialog() { FileName = uxOutputFile.Text, Filter = "csv|*.csv|全てのファイル|*.*" }; //ファイル選択ダイアログの表示と戻り値のチェック 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) { //ヘッダ行数の取得 var head_cnt = (int.TryParse(uxHeaderCount.Text, out int val)) ? val : 0; //結合対象CSVファイルの文字コード取得 var target_enc = Encoding.GetEncoding(uxTargeCharCode.Text); //出力CSVファイルの文字コード取得 var output_enc = Encoding.GetEncoding(uxOutputCharCode.Text); //出力ファイルを上書きモードでオープン using (StreamWriter sw = new StreamWriter(uxOutputFile.Text,false,output_enc)) { //ファイル番号用変数を定義 int file_no = 0; //結合対象フォルダからファイル名を全て取り出す foreach (var file in Directory.GetFiles(uxTargetFolder.Text)) { //対象ファイルを読み込みモードでオープン 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 ++ < head_cnt) { continue; } //出力ファイルに書き込む sw.WriteLine(line); } } //ファイル番号を1つ加算 file_no++; } } } } } |
ファイルを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単位のファイルを扱う場合はこの方法が有効になりますので、機会があれば是非ご活用下さい。