C# Tips | ファイル・ディレクトリ操作:ファイル結合

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

はじめに 「分割したなら、いつか必ず結合する」

前回は「ファイル分割」でしたね。
大きすぎるファイルを扱いやすくするために分けたなら、
どこかのタイミングで「元に戻したい」「まとめて扱いたい」というニーズが必ず出てきます。

ログを日別・サイズ別に分けて保存しているけれど、調査のときは一つにまとめて見たい。
分割して送られてきたバイナリファイル(バックアップ、アーカイブなど)を元の一つのファイルに戻したい。
分割した CSV を再度一つの CSV にまとめて、別システムに渡したい。

ここで必要になるのが「ファイル結合」のユーティリティです。
ただ「くっつければいい」だけではなく、順番、エンコーディング、ヘッダー行など、地味に気を付けるポイントが多いところでもあります。

ここでは、プログラミング初心者向けに、
バイナリファイルの結合とテキストファイル(特に CSV)の結合を、
実務でそのまま使える形でかみ砕いて解説していきます。


バイナリファイルの結合 「分割した順に、ただひたすら連結する」

考え方:中身の意味は気にせず、バイト列としてつなぐ

画像、PDF、ZIP、バックアップファイルなどの「バイナリファイル」を分割した場合、
結合するときは基本的に「分割した順に、バイト列をそのまま連結する」だけです。

ここで一番大事なのは「順番」です。
file.part0000file.part0001file.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 で飛ばしています。

読み込みと書き込みで同じエンコーディングを使う
StreamReaderStreamWriter に同じ 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.csvsales_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 でくっつけた」みたいな世界から卒業して、
業務で安心して使えるファイル結合ユーティリティを、自分の手で設計・実装できるようになります。

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