明滅するプログラマの思索

WEBエンジニアとして勤務している一介の男が、日々気づいたことをまとめるブログです

ジェネレータを利用した MySQL データの抽出

DBテーブル上にレコードが大量にあり、これをプログラムでループさせながら処理したい、というケースは非常に多くあります。
この場合、大量にあるレコードを配列に格納することで、よく問題になるのが使用メモリの肥大化です。
PHP5.5以降で実装されたジェネレータを使えば、メモリを節約しつつ大きなレコードのDBテーブルに対し SELECT できるようになります。

ジェネレータとは

ジェネレータは、ループ処理に必要なデータを配列で持たなくても済むように実装できるようにするものです。
一般的なループ処理を行う場合、その回数分のデータを配列に格納してそれを foreach 構文に引き渡すことで行いますが、非常に大きな回数を行う場合メモリを圧迫してしまいます。
ジェネレータを使うと、ループ処理1回ずつに対して必要なデータを生成して渡すことでメモリを節約することができます。
これを実装するためには、必要なデータを1回ずつ生成するためのジェネレータ関数を用意することになります。
ジェネレータ関数では、データの返却に return は使いません。代わりに yield (産出)を使用します。

本記事では、ジェネレータによってMySQLサーバよりデータを1行ずつ抽出し、処理を行うためのクラスを実装してみます。

実装環境

ソフトウェア バージョン
PHP 5.6.30

コード

※ PDO を利用しています。

<?php
class DB
{
    public static $DATABASE_PORT = '3306';
    public static $DB_NAME = 'test';
    public static $DB_HOST = 'localhost':
    public static $USER = 'root';
    public static $PASS = 'pass';
    public static $CHARSET = 'utf8';

    private $pdo;
    private $statement;

    public function __construct($db_name)
    {
        if (class_exists("\PDO") === false) {
            throw new Exception("PDO Class is undefined");
        }
        try {
            $pdo = new \PDO(
                sprintf("mysql:dbname=%s;host=%s;port=%s;charset=%s",
                    self::$DB_NAME,
                    self::$DB_HOST,
                    self::$DATABASE_PORT,
                    self::$CHARSET),
            self::$USER, self::$PASS);
            $pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_NATURAL);
            $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        } catch (\PDOException $e) {
            throw new Exception("MySQL-Server Connect Failed. PDO Error is listed below: ".$e->getMessage());
        }
        $this->pdo = $pdo;
    }

    public function execQuery($query)
    {
        try {
            $this->statement = $this->pdo->query($query);
        } catch (\PDOException $e) {
            $this->pdo->query("rollback");
            throw new Exception($query, $e->getMessage());
        }
        return $this;
    }

    public function getFetchGenerator()
    {
        if (is_null($this->statement)) {
            yield [];
        }
        while($row = $this->statement->fetch(\PDO::FETCH_ASSOC)) {
            yield $row;
        }
    }
}

DB クラスを用意し、__construct() でDB接続を行います。
execQuery() メソッドに SQL 文字列を引数として渡すと、クエリを実行できます。
その後、getFetchGenerator() でデータを1行ずつストリーミングします。
実際の利用は以下のようになります。

<?php
$db = new DB();

$db->execQuery("SELECT * FROM address");
$generator = $db->getFetchGenerator();
foreach ($generator as $row) {
    echo $row['mail_address'];
    echo "\n";
}

address テーブルには mail_address カラムがあり、それを1行ずつ出力するコードです。
$db->getFetchGenerator() の返却値はジェネレータオブジェクトであり、foreach 構文に渡すことで1行ずつ yield されたデータを受け取って処理できます。
配列に格納する処理がないため、メモリを圧迫することがありません。

非常に大きなレコード数のテーブルに対しループ処理を行いたい場合、ジェネレータは大きな力を発揮します。