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

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

はじめに 「ファイル分割」は“逃げ”ではなく戦略

業務をやっていると、だいたい一度はこうなります。

  • ログがバカみたいに大きくなって、テキストエディタで開けない。
  • 外部システムから「1 ファイル 10MB までで送って」と言われる。
  • 巨大 CSV をそのまま処理するとメモリが死ぬので、小分けにしたい。

ここで必要になるのが「ファイル分割」です。
大きなファイルを「サイズごと」「行数ごと」などのルールで、複数の小さなファイルに切り分ける処理です。

ただ「適当に分ける」だけだと、後で結合できなかったり、ヘッダー行が消えたり、文字化けしたりと、地味に事故りやすいところでもあります。
ここでは、プログラミング初心者向けに、バイナリファイルの分割とテキストファイル(特に CSV)の分割を、実務目線でかみ砕いて解説します。


バイナリファイルの分割 「サイズで機械的に切る」

考え方:中身の意味は気にせず、バイト単位で切る

画像、PDF、ZIP、バックアップファイルなど、「中身の構造は意識せず、とにかくサイズで分けたい」ケースでは、
バイト単位で機械的に分割するのが基本です。

例えば、「1 ファイルを 10MB ごとに分割する」イメージです。

シンプルなバイナリ分割ユーティリティ

using System;
using System.IO;

public static class BinaryFileSplitter
{
    public static void SplitBySize(string sourcePath, string outputDirectory, long partSizeBytes)
    {
        if (!File.Exists(sourcePath))
        {
            throw new FileNotFoundException("元ファイルが存在しません。", sourcePath);
        }

        if (!Directory.Exists(outputDirectory))
        {
            Directory.CreateDirectory(outputDirectory);
        }

        const int bufferSize = 1024 * 1024;

        byte[] buffer = new byte[bufferSize];

        using var input = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read);

        int partIndex = 0;
        long bytesWrittenInPart = 0;
        FileStream? currentOutput = null;

        void OpenNextPart()
        {
            currentOutput?.Dispose();

            string partFileName = Path.Combine(
                outputDirectory,
                $"{Path.GetFileName(sourcePath)}.part{partIndex:D4}");

            currentOutput = new FileStream(partFileName, FileMode.Create, FileAccess.Write, FileShare.None);
            bytesWrittenInPart = 0;
            partIndex++;
        }

        OpenNextPart();

        int read;
        while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
        {
            int offset = 0;

            while (offset < read)
            {
                long remainingInPart = partSizeBytes - bytesWrittenInPart;
                int toWrite = (int)Math.Min(remainingInPart, read - offset);

                currentOutput!.Write(buffer, offset, toWrite);

                offset += toWrite;
                bytesWrittenInPart += toWrite;

                if (bytesWrittenInPart >= partSizeBytes)
                {
                    OpenNextPart();
                }
            }
        }

        currentOutput?.Dispose();
    }
}
C#

重要ポイントの解説

「バッファを使って少しずつ読む」
巨大ファイルを一気に読み込まず、1MB ずつ読みながら書き出しています。
これにより、ファイルサイズが何 GB でもメモリ使用量は一定に抑えられます。

「partSizeBytes を超えないように書き分ける」
1 回の Read で読んだデータを、そのまま 1 つの分割ファイルに書くのではなく、
「今の分割ファイルにあとどれだけ書けるか」を計算しながら、必要に応じて次のファイルを開いています。

「ファイル名に連番を付ける」
元ファイル名.part0000, 元ファイル名.part0001 のように、
後で結合しやすい規則的な名前にしています。


テキストファイル(CSVなど)の分割 「行数で切る」

考え方:行の途中で切らない、ヘッダーをどうするか決める

テキストファイル、特に CSV のような「行単位のデータ」の場合は、
バイト単位ではなく「行数単位」で分割するのが自然です。

ここで大事になるのが、ヘッダー行の扱いです。

  • 各分割ファイルの先頭にヘッダーを付けるのか。
  • 最初のファイルだけヘッダーを持ち、残りはデータだけにするのか。

業務でよく使うのは「各ファイルにヘッダーを付ける」パターンです。
どのファイルを単体で渡しても、そのまま CSV として扱えるからです。

ヘッダー付き CSV を行数で分割するユーティリティ

using System;
using System.IO;
using System.Text;

public static class CsvFileSplitter
{
    public static void SplitByLines(
        string sourcePath,
        string outputDirectory,
        int maxDataLinesPerFile,
        Encoding encoding)
    {
        if (!File.Exists(sourcePath))
        {
            throw new FileNotFoundException("元ファイルが存在しません。", sourcePath);
        }

        if (!Directory.Exists(outputDirectory))
        {
            Directory.CreateDirectory(outputDirectory);
        }

        using var reader = new StreamReader(sourcePath, encoding, detectEncodingFromByteOrderMarks: true);

        string? header = reader.ReadLine();
        if (header is null)
        {
            return;
        }

        int partIndex = 0;
        int dataLinesInCurrentFile = 0;
        StreamWriter? currentWriter = null;

        StreamWriter OpenNextWriter()
        {
            currentWriter?.Dispose();

            string partFileName = Path.Combine(
                outputDirectory,
                $"{Path.GetFileNameWithoutExtension(sourcePath)}_{partIndex:D4}{Path.GetExtension(sourcePath)}");

            var writer = new StreamWriter(partFileName, false, encoding);

            writer.WriteLine(header);

            partIndex++;
            dataLinesInCurrentFile = 0;

            return writer;
        }

        currentWriter = OpenNextWriter();

        string? line;
        while ((line = reader.ReadLine()) is not null)
        {
            if (dataLinesInCurrentFile >= maxDataLinesPerFile)
            {
                currentWriter = OpenNextWriter();
            }

            currentWriter.WriteLine(line);
            dataLinesInCurrentFile++;
        }

        currentWriter?.Dispose();
    }
}
C#

重要ポイントの解説

「最初の 1 行をヘッダーとして読む」
ReadLine で 1 行目を読み、それを header として保持しています。
その後に開くすべての分割ファイルの先頭に、このヘッダーを書き出しています。

「データ行数で分割する」
maxDataLinesPerFile は「ヘッダーを除いたデータ行数」です。
例えば 10,000 を指定すると、「ヘッダー 1 行+データ 10,000 行」の CSV が順に生成されます。

「エンコーディングを明示する」
読み込みと書き込みで同じ Encoding を使っています。
CSV のエンコーディング(UTF-8, Shift_JIS など)が決まっているなら、必ず指定しましょう。


分割ファイルの命名ルールをどうするか

規則的な名前にしておくと後処理が楽になる

分割ファイルの名前は、後で結合したり、別システムに渡したりするときに効いてきます。

よく使うパターンは次のような形です。

  • 元ファイル名+.part0000 のような拡張子追加型(バイナリ向き)
  • 元ファイル名の末尾に _0000 のようなサフィックスを付ける(CSV など拡張子を残したい場合)

例えば、sales.csv を 3 つに分割した場合、

  • sales_0000.csv
  • sales_0001.csv
  • sales_0002.csv

のようにしておくと、ソート順もそのまま結合順になります。

命名ルールはチームで決めて、ユーティリティ側で統一してしまうと、後々楽です。


分割サイズ・行数の決め方

「上限ギリギリ」ではなく「余裕を持った値」にする

外部システムから「1 ファイル 10MB まで」と言われたとき、
本当に 10MB ぴったりを狙う必要はありません。

  • 文字コードや改行コードの違いで微妙にサイズが変わる
  • 後で列が増えて 1 行あたりのサイズが増える

といったことを考えると、例えば「8MB くらいを目安にする」ほうが安全です。

行数で分割する場合も同様で、「Excel で開きたいから 100 万行以下にしたい」なら、
90 万行など、少し余裕を持たせておくと安心です。


例題:巨大ログを日別・サイズ別に分割するイメージ

例えば、1 つの巨大ログファイル app.log があり、
これを「1 ファイル 50MB 以内」で分割したいとします。

バイナリ分割ユーティリティを使うと、次のように書けます。

string source = @"C:\logs\app.log";
string outputDir = @"C:\logs\split";

long partSize = 50L * 1024 * 1024;

BinaryFileSplitter.SplitBySize(source, outputDir, partSize);
C#

これで、

  • app.log.part0000
  • app.log.part0001

のようなファイルができます。

一方、CSV の売上データ sales.csv を「1 ファイル 5 万行(ヘッダー除く)」で分割したいなら、こうです。

string source = @"C:\data\sales.csv";
string outputDir = @"C:\data\sales_split";

CsvFileSplitter.SplitByLines(
    sourcePath: source,
    outputDirectory: outputDir,
    maxDataLinesPerFile: 50_000,
    encoding: Encoding.UTF8);
C#

まとめ 実務で使える「ファイル分割」ユーティリティの考え方

ファイル分割は、「大きすぎて扱いづらいものを、扱いやすい単位にする」ための道具です。
ただ切るだけでなく、「後でどう使うか」「どう結合するか」までイメージして設計するのがポイントです。

押さえておきたいのは次のようなことです。

  • バイナリファイルは「サイズ単位」、テキストファイルは「行数単位」で分割するのが基本。
  • CSV などヘッダー付きのテキストは、「ヘッダーをどう扱うか」を最初に決める(各ファイルに付けるのが無難)。
  • 読み書きのエンコーディングを揃えることで、文字化けやデータ破損を防ぐ。
  • 分割ファイルの命名ルールを決めておくと、後処理(結合・転送)が楽になる。
  • サイズや行数の上限は、仕様ギリギリではなく、少し余裕を持たせる。

ここまで押さえておけば、「とりあえず手で分ける」「エディタが固まるまで頑張る」といった世界から抜けて、
「業務で通用するファイル分割ユーティリティ」を自分の武器として使い回せるようになります。

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