LoginSignup
9
12

More than 3 years have passed since last update.

ジェネレータで大量データをCSVにファイル分割して書き込む

Last updated at Posted at 2020-02-15

みんな大好きジェネレータ。クラスとか定義せずにジェネレータだけで綺麗に書けるぞ。

背景

もともとは仕事で書いたコード。先日開催されたPHPerKaigi 2020ジェネレータの事例紹介として出したので、ここで見せます。

要件

  • データベースから値を取り出してCSVにダンプしたい
    • レコード数 = とにかくいっぱい
    • 全レコードを1クエリで全部取得するのはメモリ量としてもDBの負荷としても許容できない
  • CSVは表計算ソフトで読み込ませる必要がある
    • 表計算ソフトという括りではExcelに限らず65535行以上のCSVを扱えるのは稀なので、ファイルには65534レコードごとにファイルを分割する。

実装

おいらは3重ループとか4重ループとか多重ネストさせるのは絶対に嫌なのでジェネレータで処理を分割しました。

#!/usr/bin/env php
<?php

/**
 * 実行すると記事一覧を CSV として吐き出すスクリプト
 */

// 無限ループしないように最大値を明示
$limit = 100000;
$base_dir = __DIR__ . '/csv/';

// DBコネクション: 今回は使わないのでダミー値
$con = null;

/**
 * データベースからとってきた値を無限に吐き出すよ
 *
 * @return \Generator<array>
 */
$articles_generator = function (int $step) use ($con, $limit) {
    while (true) {
        $min = $min ?? 0;
        $max = $min + $step;

        // $min から $max までの区間のレコードを全取得
        $articles = fetch_db($con, $min, $max);

        foreach ($articles as $article) {
            yield $article['id'] => $article;
        }

        // デバッグ出力は STDERR に書き込む
        fwrite(STDERR, "{$article['id']}\n");

        if ($max >= $limit) {
            return;
        }

        $min += $step;
    }
};

/**
 * 65534 レコードごとに別のCSVファイルに書き込むジェネレータ
 */
$csv_generator = function ($header) use ($base_dir) {
    while (true) {
        $i = $i ?? 0;
        $i++;

        $path = $base_dir . "records-{$i}.csv";

        echo $path, PHP_EOL;
        $fp = fopen($path, 'w');

        if ($fp === false) {
            throw new LogicException("Open failure: {$path}");
        }

        // CSV ヘッダ行
        fputcsv($fp, $header);

        // ヘッダ行を抜いて 65534 レコード書き込む
        foreach (range(2, 65535) as $_) {
            $row = yield;

            fputcsv($fp, $row);
        }

        fclose($fp);
    }
};

// ジェネレータ初期化時にCSVのファイルヘッダ(カラム)を注入
$csv = $csv_generator(['id', 'name']);

// 1回のDBリクエストで1000レコードづつ処理する
foreach ($articles_generator(1000) as $id => $article) {
    // CSV 書き込み
    $csv->send([
        $article['id'],
        $article['name'],
    ]);
}

/**
 * ダミーのDB取得関数
 *
 * @return array<array{id:int,name:string}>
 */
function fetch_db($con, int $min, int $max): array
{
    return array_map(
        function ($id) { return ['id' => $id, 'name' => "dummy value {$id}"]; },
        range($min, $max - 1)
    );
}

雑感

  • 雑にwhile (true)とか書いてるけど、ちゃんと停止します
  • $articles_generatorではDBから値を取り出すことだけに専念します
  • $csv_generatorはファイルに値を書き込むことだけに専念します
    • ジェネレータはこのように反復して呼び出される処理にも実はぴったりフィットします
    • 継続は力なり。継続万歳!
  • この実装だとユニットテストを書くにはちょっとだけ抽象化が足りてない感ありますね
    • 特にファイルに書き込むあたり
    • 動作確認できれば十分な書き捨てのスクリプトならこんなもんかな、と…
  • 無限ループって怖くね?
    • だって同じことが繰り返されるんだぜ?
      • それはどんなコードでもそう
      • 無限ループに落ちないように終了条件は十分検討が必要
  • 書き捨てじゃないコードならクラスで定義すればいいのでは?
    • それはそうかもしれない。
9
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
12