Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
7
Help us understand the problem. What is going on with this article?
@tadsan

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

More than 1 year has passed since last update.

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

背景

もともとは仕事で書いたコード。先日開催された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はファイルに値を書き込むことだけに専念します
    • ジェネレータはこのように反復して呼び出される処理にも実はぴったりフィットします
    • 継続は力なり。継続万歳!
  • この実装だとユニットテストを書くにはちょっとだけ抽象化が足りてない感ありますね
    • 特にファイルに書き込むあたり
    • 動作確認できれば十分な書き捨てのスクリプトならこんなもんかな、と…
  • 無限ループって怖くね?
    • だって同じことが繰り返されるんだぜ?
      • それはどんなコードでもそう
      • 無限ループに落ちないように終了条件は十分検討が必要
  • 書き捨てじゃないコードならクラスで定義すればいいのでは?
    • それはそうかもしれない。
7
Help us understand the problem. What is going on with this article?
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
tadsan
僕に警備する自宅をください。Emacs初心者。Rubyist。 全ての投稿された記事は別段の表記がない限りはCC 3.0 BY-SA https://creativecommons.org/licenses/by-sa/3.0/deed.ja で二次利用できます。 記事中に含まれる全てのコードスニペットの著作権は抛棄するので、煮るなり焼くなりお好きにどうぞ。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
7
Help us understand the problem. What is going on with this article?