PHPで大量の行数のCSVファイルを分割する際にはまったポイント

表題の通りなのですが、数MBもの CSVファイルをインポートすることになり、その過程の分割スクリプトではまりました。

そもそも分割せずにデフォルトの nginx 環境に まず 1MB 以上のものファイルをアップロードすると、 以下の 413 エラーが出てしまいます。

413 Request Entity Too Large

単純に、サーバー全体でアップロードする容量を大きくしたい場合は、nginx の conf をいじるなどを検討する必要があると思うのですが、常にそうしたいわけでなく今回だけと言うことだったので、数MBのCSVファイルを1MB 未満になるようにいくつかのCSVファイルに分割したいと考えました。

安易な気持ちでスクリプトを書いていたんですが、実際に分割されたデータを見ると変なところで次の行に入ってしまうことがありました。

要は1万行のファイルを3000行ごとに分割する場合

分割ファイル1… 1 ~ 3000 行
分割ファイル2… 3001 ~ 6000 行
分割ファイル3… 6001 ~ 9000 行
分割ファイル4… 9001 ~ 10000 行

が入ると想定してたんですが、変なところで次の行に入っているので、分割ファイル4が 1037行になるみたいな形で1000行を超えてました。

以下のケースがあると発生しました。

  • データの中に改行やカンマなどの記号があった場合
  • 分割時に文字コードを変換した場合


文字コードの方はあまり条件がわかってないですが…変換の際に記号と相性が悪かったりするのかなと思っています。

初めは SplFileObject の fgets() メソッドを使っていたのですが、使わなければ「データの中にカンマがあった場合」以外は解決しました。
setFlags() で うまいこと回避できたかもしれませんが…。

カンマがあった場合については、別の文字(今回は半角スペース)に置き換えました。これでいいかはユースケースに依りますが、今回はこれでよかったのでそうしています。

ということで長々しゃべってしまいましたが、コードです

$filePath = '分割前のファイルのパス';
$rowsPerFile = 3000; // 3000行ごとに分割する
$outputDir = '分割したファイルを置く場所のパス';
$hasHeader = true; // 分割前のファイルがヘッダーを持っているか

$splitter = new FileSplitter();
$splitter->split($filePath, $rowsPerFile, $outputDir, $hasHeader);

class FileSplitter {
    private ?array $rows;
    private int $fileCount;
    private ?string $header;

    public function split($filePath, $rowsPerFile, $outputDir, bool $hasHeader) {
        $this->fileCount = 0;
        $this->rows = null;

        $file = new \SplFileObject($filePath);
        $file->setFlags(\SplFileObject::READ_CSV);
        $rowCount = 0;

        // fgets ではなく $fileを foreach で回している
        foreach ($file as $key => $data) {
            if (!empty($data)) {
                if ($hasHeader && $key === 0) {
                    $this->header  = implode(',', $data);
                    continue;
                }

                if($rowCount % $rowsPerFile === 0) {
                    $this->writeToFile($this->generateOutputFilePath($outputDir, $file));
                }

                // カンマがあったら半角スペースに変換しているので注意
                foreach ($data as $key2 => $datum) {
                    $data[$key2] = str_replace(',', ' ', $datum);
                }

                $this->rows[] = implode(',', $data);
                $rowCount++;
            }
        }

        $this->writeToFile($this->generateOutputFilePath($outputDir, $file));
        $file = null;
    }

    private function generateOutputFilePath($outputDir, \SplFileObject $file) {
        $baseName = $file->getBasename('.'.$file->getExtension());
        $ext = $file->getExtension();
        $num = $this->fileCount + 1;
        return $outputDir.$baseName.'_'.$num.'.'.$ext;
    }

    private function writeToFile($filePath) {
        if($this->rows) {
            $file = new SplFileObject($filePath, 'w');
            
            if (!empty($this->header)) {
                $header_array = explode(',', $this->header);
                $file->fputcsv($header_array);
            }

            foreach ($this->rows as $line) {
                $contents = explode(',', $line);
                $file->fputcsv($contents);
            }
            $this->fileCount++;
            $this->rows = null;
        }
    }
}

大量データを扱うと想定外のことが起きますね。
今回たまたま見つからなかっただけのものも全然ありそうです。

カテゴリーPHP

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です