Help us understand the problem. What is going on with this article?

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

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

背景

もともとは仕事で書いたコード。先日開催された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はファイルに値を書き込むことだけに専念します
    • ジェネレータはこのように反復して呼び出される処理にも実はぴったりフィットします
    • 継続は力なり。継続万歳!
  • この実装だとユニットテストを書くにはちょっとだけ抽象化が足りてない感ありますね
    • 特にファイルに書き込むあたり
    • 動作確認できれば十分な書き捨てのスクリプトならこんなもんかな、と…
  • 無限ループって怖くね?
    • だって同じことが繰り返されるんだぜ?
      • それはどんなコードでもそう
      • 無限ループに落ちないように終了条件は十分検討が必要
  • 書き捨てじゃないコードならクラスで定義すればいいのでは?
    • それはそうかもしれない。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした