はじめに 「分割したなら、いつか必ず結合する」
前回は「ファイル分割」でしたね。
大きすぎるファイルを扱いやすくするために分けたなら、
どこかのタイミングで「元に戻したい」「まとめて扱いたい」というニーズが必ず出てきます。
ログを日別・サイズ別に分けて保存しているけれど、調査のときは一つにまとめて見たい。
分割して送られてきたバイナリファイル(バックアップ、アーカイブなど)を元の一つのファイルに戻したい。
分割した CSV を再度一つの CSV にまとめて、別システムに渡したい。
ここで必要になるのが「ファイル結合」のユーティリティです。
ただ「くっつければいい」だけではなく、順番、エンコーディング、ヘッダー行など、地味に気を付けるポイントが多いところでもあります。
ここでは、プログラミング初心者向けに、
バイナリファイルの結合とテキストファイル(特に CSV)の結合を、
実務でそのまま使える形でかみ砕いて解説していきます。
バイナリファイルの結合 「分割した順に、ただひたすら連結する」
考え方:中身の意味は気にせず、バイト列としてつなぐ
画像、PDF、ZIP、バックアップファイルなどの「バイナリファイル」を分割した場合、
結合するときは基本的に「分割した順に、バイト列をそのまま連結する」だけです。
ここで一番大事なのは「順番」です。file.part0000 → file.part0001 → file.part0002 のように、
分割時と同じ順番で結合しないと、元のファイルには戻りません。
シンプルなバイナリ結合ユーティリティ
まずは、「指定した複数ファイルを、指定した順番で結合する」ユーティリティを書いてみます。
using System;
using System.IO;
public static class BinaryFileJoiner
{
public static void JoinFiles(string[] partFiles, string outputPath)
{
if (partFiles is null || partFiles.Length == 0)
{
throw new ArgumentException("結合するファイルが指定されていません。", nameof(partFiles));
}
string? outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
const int bufferSize = 1024 * 1024;
byte[] buffer = new byte[bufferSize];
using var output = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None);
foreach (var partFile in partFiles)
{
if (!File.Exists(partFile))
{
throw new FileNotFoundException("分割ファイルが見つかりません。", partFile);
}
using var input = new FileStream(partFile, FileMode.Open, FileAccess.Read, FileShare.Read);
int read;
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
}
}
}
}
C#使い方のイメージはこうです。
string[] parts =
{
@"C:\data\backup.bin.part0000",
@"C:\data\backup.bin.part0001",
@"C:\data\backup.bin.part0002",
};
string output = @"C:\data\backup_merged.bin";
BinaryFileJoiner.JoinFiles(parts, output);
C#ここでの重要ポイントは、「結合順を呼び出し側が決める」設計にしていることです。
ファイル名に連番が付いているなら、Directory.GetFiles で取得してソートしてから渡す、という形にすればよいです。
順番を自動で決めるバージョン
もし「同じフォルダに xxx.part0000〜が並んでいる」という前提があるなら、
ユーティリティ側でファイルを集めてソートすることもできます。
using System.Linq;
public static class BinaryFileJoiner
{
public static void JoinPartsInDirectory(string directory, string baseFileName, string outputPath)
{
if (!Directory.Exists(directory))
{
throw new DirectoryNotFoundException($"ディレクトリが存在しません: {directory}");
}
var partFiles = Directory
.GetFiles(directory, baseFileName + ".part*")
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
JoinFiles(partFiles, outputPath);
}
// JoinFiles は先ほどと同じ
}
C#これで、次のように書けます。
BinaryFileJoiner.JoinPartsInDirectory(
directory: @"C:\data",
baseFileName: "backup.bin",
outputPath: @"C:\data\backup_merged.bin");
C#順番を間違えるとファイルが壊れるので、
「どうやって順番を決めているか」をコード上で明示しておくことが大事です。
テキストファイル(ログ・CSV)の結合 「行をつなぐ」と「ヘッダーをどうするか」
考え方:行単位でつなぐが、ヘッダー行に注意
テキストファイル、特に CSV やログの結合では、
「ファイル A の全行 → ファイル B の全行 → …」という形で行をつなげていきます。
ここで気を付けるべきポイントが二つあります。
一つ目は「エンコーディング」です。
UTF-8 のファイルと Shift_JIS のファイルを混ぜて結合すると、ほぼ確実に文字化けします。
結合するファイルは、すべて同じエンコーディングであることが前提です。
二つ目は「ヘッダー行」です。
CSV の場合、各ファイルの先頭に同じヘッダー行があることが多いので、
結合するときに「最初のファイルのヘッダーだけ残し、残りのヘッダーはスキップする」必要があります。
ヘッダー付き CSV を結合するユーティリティ
using System;
using System.IO;
using System.Text;
public static class CsvFileJoiner
{
public static void JoinCsvFiles(
string[] inputFiles,
string outputPath,
Encoding encoding)
{
if (inputFiles is null || inputFiles.Length == 0)
{
throw new ArgumentException("結合するファイルが指定されていません。", nameof(inputFiles));
}
string? outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
using var writer = new StreamWriter(outputPath, false, encoding);
bool isFirstFile = true;
foreach (var file in inputFiles)
{
if (!File.Exists(file))
{
throw new FileNotFoundException("入力ファイルが見つかりません。", file);
}
using var reader = new StreamReader(file, encoding, detectEncodingFromByteOrderMarks: true);
string? line;
bool isFirstLine = true;
while ((line = reader.ReadLine()) is not null)
{
if (isFirstFile)
{
writer.WriteLine(line);
}
else
{
if (isFirstLine)
{
isFirstLine = false;
continue;
}
writer.WriteLine(line);
}
isFirstLine = false;
}
isFirstFile = false;
}
}
}
C#使い方の例です。
string[] parts =
{
@"C:\data\sales_0000.csv",
@"C:\data\sales_0001.csv",
@"C:\data\sales_0002.csv",
};
string output = @"C:\data\sales_merged.csv";
CsvFileJoiner.JoinCsvFiles(parts, output, Encoding.UTF8);
C#ここでの重要ポイントを整理します。
最初のファイルだけヘッダーをそのまま書くisFirstFile が true のときは、1 行目も含めて全部書き出しています。
2 ファイル目以降は、最初の 1 行(ヘッダー)をスキップするisFirstFile が false のとき、isFirstLine が true の最初の行は continue で飛ばしています。
読み込みと書き込みで同じエンコーディングを使うStreamReader と StreamWriter に同じ Encoding を渡しています。
ここを揃えないと、結合後のファイルが壊れる原因になります。
巨大テキストの結合 「ストリーミングでひたすら書き足す」
考え方:1 行ずつ読み、1 行ずつ書く
ログファイルなど、サイズが大きいテキストを結合するときも、
基本は「1 行ずつ読み、1 行ずつ書く」ストリーミング方式で十分です。
ヘッダーがないログなら、もっとシンプルに書けます。
using System;
using System.IO;
using System.Text;
public static class LogFileJoiner
{
public static void JoinLogFiles(
string[] inputFiles,
string outputPath,
Encoding encoding)
{
if (inputFiles is null || inputFiles.Length == 0)
{
throw new ArgumentException("結合するファイルが指定されていません。", nameof(inputFiles));
}
string? outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
using var writer = new StreamWriter(outputPath, false, encoding);
foreach (var file in inputFiles)
{
if (!File.Exists(file))
{
throw new FileNotFoundException("入力ファイルが見つかりません。", file);
}
using var reader = new StreamReader(file, encoding, detectEncodingFromByteOrderMarks: true);
string? line;
while ((line = reader.ReadLine()) is not null)
{
writer.WriteLine(line);
}
}
}
}
C#この方法なら、結合対象のファイルが何 GB あっても、
メモリ使用量は「1 行分+α」で済みます。
実務ユーティリティとしての設計ポイント
「順番」と「対象ファイルの選び方」をどう決めるか
ファイル結合で一番事故りやすいのが「順番」です。
例えば、日付付きログを結合する場合、
ファイル名に日付が入っているなら、ファイル名でソートすればよいですが、
そうでない場合は「更新日時順」など、別のルールが必要になります。
ユーティリティ側でやるのか、呼び出し側で順番を決めて渡すのか、
どちらに責任を持たせるかを最初に決めておくと、設計がぶれません。
初心者向けには、「ユーティリティは“渡された順に結合する”だけ」と割り切り、
順番の決定は呼び出し側に任せるほうが分かりやすいです。
エンコーディングを曖昧にしない
テキスト結合でのトラブルの多くは、「エンコーディングが混ざっている」ことが原因です。
結合前に、
「すべて UTF-8 か」「すべて Shift_JIS か」
を確認しておくか、
前に作った「エンコーディング判定ユーティリティ」と組み合わせて、
「違うエンコーディングが混ざっていたら警告する」といった工夫もできます。
途中で失敗したときの扱い
結合中にエラーが起きた場合、
中途半端な状態の出力ファイルが残ることがあります。
重要な処理であれば、
一時ファイルに書き出してから最後にリネームする、
ログに「どこまで結合できたか」を残す、
といった工夫も検討できます。
例題:分割した CSV を結合して再インポートするイメージ
例えば、前回の「ファイル分割」で、sales.csv を 5 万行ずつに分割して sales_0000.csv〜sales_0003.csv を作ったとします。
これを再度一つにまとめて、別システムに渡したい場合は、こう書けます。
string[] parts = Directory
.GetFiles(@"C:\data\sales_split", "sales_*.csv")
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
CsvFileJoiner.JoinCsvFiles(
inputFiles: parts,
outputPath: @"C:\data\sales_merged.csv",
encoding: Encoding.UTF8);
C#これで、
ヘッダーは最初の 1 回だけ、
データ行はすべて連結された 1 つの sales_merged.csv ができます。
まとめ 実務で使える「ファイル結合」ユーティリティの考え方
ファイル結合は、「分割の逆操作」ですが、
ただ逆再生すればいいわけではなく、順番・エンコーディング・ヘッダーなど、
いくつかのポイントをきちんと押さえる必要があります。
押さえておきたいのは、次のような考え方です。
バイナリファイルは「分割した順に、バイト列をそのまま連結する」。
テキストファイルは「行単位でつなぐ」が基本で、CSV のようなヘッダー付きは「ヘッダーをどう扱うか」を最初に決める。
結合順はとても重要なので、「どう決めているか」をコードで明示する(ファイル名ソート、渡された順など)。
テキスト結合では、エンコーディングを揃えることが必須。読み書きで同じ Encoding を使う。
巨大ファイルでも安定して動くように、「ストリーミング(少しずつ読み書き)」を前提に設計する。
ここまで押さえておけば、「とりあえず cat でくっつけた」みたいな世界から卒業して、
業務で安心して使えるファイル結合ユーティリティを、自分の手で設計・実装できるようになります。
