Zend Framework 1 でテストごとにDB接続してコネクションが枯渇する

はじめに

最近 Qiita に↓このようなものを投稿しましたが、どちらも Zend Framework 2 を使っていて気づいたものです。

Zend Framework 2 でテストの書き方を調べていたところ、普通にテストを書くとテストの数だけ DB 接続が行われ、最終的に MaxConnections に達してテストがコケます。

PHPUnit の方は Zend Framework 1 にも影響します。まずは Zend Framework 1 について書きます。

QuickStart で問題を発生させる

アプリケーションのコードには、↓にある QuickStart のソースコードを使います。

そのままだと DB が sqlite なので、schema.sqlite.sql を参考に MySQL にテーブルを作成します。

scripts/schema.mysql.sql

CREATE TABLE guestbook (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(32) NOT NULL DEFAULT 'noemail@test.com',
    comment TEXT NULL,
    created DATETIME NOT NULL
);

application.ini で DB を MySQL に変更します。お試しなので production/testing/development は同じでも構いません。

application/configs/application.ini

[production]
 :
resources.db.adapter = "PDO_MYSQL"
resources.db.params.dbname = "zf1 "
resources.db.params.host = "localhost"
resources.db.params.charset = "utf8"
resources.db.params.username = "test"
resources.db.params.password = "pass"

 :

[testing : production]
 :
;resources.db.adapter = "PDO_SQLITE"
;resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-testing.db"

[development : production]
 :
;resources.db.adapter = "PDO_SQLITE"
;resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-dev.db"

テストのための bootstrap を作ります。

tests/application/bootstrap.php

<?php
define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/../../application'));
define('APPLICATION_ENV', 'testing');

set_include_path(implode(PATH_SEPARATOR, array(
    realpath(APPLICATION_PATH . '/../library'),
    get_include_path(),
)));

require_once 'Zend/Application.php';
require_once 'Zend/Test/PHPUnit/ControllerTestCase.php';

次のように GuestbookController のテストを作ります。

tests/application/controllers/GuestbookControllerTest.php

<?php
class GuestbookControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function setUp()
    {
        $this->bootstrap = new Zend_Application(
            APPLICATION_ENV,
            APPLICATION_PATH . '/configs/application.ini'
        );
        parent::setUp();
    }

    public function test()
    {
        $this->dispatch('/guestbook');
        //$this->assertResponseCode(200); // E_DEPRECATED
        $this->assertEquals(200, $this->getResponse()->getHttpResponseCode());
    }
}

assertResponseCode を使っていないのは PHPUnit の最新版だと E_DEPRECATED が発生するためです。Zend Framework 1 はもうオワコンですね。

次に、一度テストを実行して、テストが OK なこと、MySQL のログにアプリケーションからの接続が記録されていること、を確認します。

$ phpunit --bootstrap tests/application/bootstrap.php tests/application/controllers/
PHPUnit 3.7.27 by Sebastian Bergmann.

.

Time: 513 ms, Memory: 4.75Mb

OK (1 test, 1 assertion)

$ sudo tail -n 5 /var/log/mysql/query.log
131005 17:00:40  1763 Connect   test@127.0.0.1 on zf1
                 1763 Query     SET NAMES 'utf8'
                 1763 Query     DESCRIBE `guestbook`
                 1763 Query     SELECT `guestbook`.* FROM `guestbook`
                 1763 Quit

テストを次のように書き換えます。@dataProvider アノテーションを使ってテストを 200 回実行させます。

tests/application/controllers/GuestbookControllerTest.php

<?php
class GuestbookControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function setUp()
    {
        $this->bootstrap = new Zend_Application(
            APPLICATION_ENV,
            APPLICATION_PATH . '/configs/application.ini'
        );
        parent::setUp();
    }

    /**
     * @dataProvider data
     */
    public function test()
    {
        $this->dispatch('/guestbook');
        //$this->assertResponseCode(200); // E_DEPRECATED
        $this->assertEquals(200, $this->getResponse()->getHttpResponseCode());
    }

    public function data()
    {
        return array_fill(0, 200, array());
    }
}

テストを実行します。

$ phpunit --bootstrap tests/application/bootstrap.php tests/application/controllers/
PHPUnit 3.7.27 by Sebastian Bergmann.

...............................................................  63 / 200 ( 31%)
............................................................... 126 / 200 ( 63%)
..........................FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 189 / 200 ( 94%)
FFFFFFFFFFF

Time: 1.19 minutes, Memory: 18.00Mb

There were 48 failures:

1) GuestbookControllerTest::test
Failed asserting that 500 matches expected 200.

 :
 :
 :

FAILURES!
Tests: 200, Assertions: 200, Failures: 48.

MySQL の設定によりますが、途中で MaxConnections に達してテストがコケます。

解決方法:tearDown で解放

Zend_Application_Resource_Dbインスタンス化した Zend_Db_Adapter を、Zend_Application(Zend_Application_Bootstrap_Bootstrap)が掴んでいて、かつ、Zend_Test_PHPUnit_ControllerTestCaseZend_Application をテストの完了まで掴んで離さないことが原因なので、次のように tearDown を書いてやれば GC のタイミングで DB 接続も解放されます。

<?php
class GuestbookControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function setUp()
    {
        $this->bootstrap = new Zend_Application(
            APPLICATION_ENV,
            APPLICATION_PATH . '/configs/application.ini'
        );
        parent::setUp();
    }

    public function tearDown()
    {
        parent::tearDown();
        $this->reset();
        $this->bootstrap = null;
        gc_collect_cycles();
    }

    /**
     * @dataProvider data
     */
    public function test()
    {
        $this->dispatch('/guestbook');
        //$this->assertResponseCode(200); // E_DEPRECATED
        $this->assertEquals(200, $this->getResponse()->getHttpResponseCode());
    }

    public function data()
    {
        return array_fill(0, 200, array());
    }
}

次のとおりです。テストの都度、接続~切断を繰り返します。

$ phpunit --bootstrap tests/application/bootstrap.php tests/application/controllers/
PHPUnit 3.7.27 by Sebastian Bergmann.

...............................................................  63 / 200 ( 31%)
............................................................... 126 / 200 ( 63%)
............................................................... 189 / 200 ( 94%)
...........

Time: 1.18 minutes, Memory: 6.00Mb

OK (200 tests, 200 assertions)

$ sudo tail -n 15 /var/log/mysql/query.log
131005 17:10:12   210 Connect   test@127.0.0.1 on zf1
                  210 Query     SET NAMES 'utf8'
                  210 Query     DESCRIBE `guestbook`
                  210 Query     SELECT `guestbook`.* FROM `guestbook`
                  210 Quit
                  211 Connect   test@127.0.0.1 on zf1
                  211 Query     SET NAMES 'utf8'
                  211 Query     DESCRIBE `guestbook`
                  211 Query     SELECT `guestbook`.* FROM `guestbook`
                  211 Quit
                  212 Connect   test@127.0.0.1 on zf1
                  212 Query     SET NAMES 'utf8'
                  212 Query     DESCRIBE `guestbook`
                  212 Query     SELECT `guestbook`.* FROM `guestbook`
                  212 Quit

解決方法:DB接続をグローバルに持つ

そもそもテストの都度 DB に接続する方がバカげているので、Bootstrap で db リソースの初期化を変えた方が良いです。

<?php
class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
    :

    protected function _initDb()
    {
        if (!Zend_Db_Table::getDefaultAdapter())
        {
            $options = $this->getOptions();

            $adapter = $options['resources']['db']['adapter'];
            $params = $options['resources']['db']['params'];

            $db = Zend_Db::factory($adapter, $params);
            Zend_Db_Table::setDefaultAdapter($db);
        }

        return Zend_Db_Table::getDefaultAdapter();
    }
}

毎回接続しない分、この方がテストの実行もだいぶ早いです。

$ phpunit --bootstrap tests/application/bootstrap.php tests/application/controllers/
PHPUnit 3.7.27 by Sebastian Bergmann.

...............................................................  63 / 200 ( 31%)
............................................................... 126 / 200 ( 63%)
............................................................... 189 / 200 ( 94%)
...........

Time: 5.54 seconds, Memory: 11.25Mb

OK (200 tests, 200 assertions)

$ cat /var/log/mysql/query.log

 :

131005 17:14:16   216 Connect   test@127.0.0.1 on zf1
                  216 Query     SET NAMES 'utf8'
                  216 Query     DESCRIBE `guestbook`
                  216 Query     SELECT `guestbook`.* FROM `guestbook`
                  216 Query     DESCRIBE `guestbook`
                  216 Query     SELECT `guestbook`.* FROM `guestbook`
 :
131005 17:14:21   216 Query     DESCRIBE `guestbook`
                  216 Query     SELECT `guestbook`.* FROM `guestbook`
                  216 Query     DESCRIBE `guestbook`
                  216 Query     SELECT `guestbook`.* FROM `guestbook`
                  216 Quit

↑の例では Zend_Db_Table::setDefaultAdapter() を使いましたが、Zend_Registry を使ってもいいと思います。

<?php
class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
    :

    protected function _initDb()
    {
        if (!Zend_Registry::isRegistered('db'))
        {
            $options = $this->getOptions();
            $adapter = $options['resources']['db']['adapter'];
            $params = $options['resources']['db']['params'];

            $db = Zend_Db::factory($adapter, $params);
            Zend_Registry::set('db', $db);
            Zend_Db_Table::setDefaultAdapter($db);
        }

        $db = Zend_Registry::get('db');
        return $db;
    }
}

ちなみに私は Zend_Registry に近い方法をこれまで使ってきました(Zend_Registry ではなく独自のシングルトンでしたけど)。

さいごに

普通に Web アプリで実行する分には、リクエストの終了時にすべてのリソースが解放されるので問題にはなりませんが、Zend_ApplicationZend_Application_Resource_DbZend_Test_PHPUnit_ControllerTestCase など、なんとなく標準っぽいやり方でむしろ問題にぶち当たってしまうのが罠っぽいなーと思います。

なお、Zend Framework 2 だと SplPriorityQueue の問題も絡んできます。近いうちに Zend Framework 2 についても書きます。→ Zend Framework 2 でテストごとにDB接続してコネクションが枯渇する - ngの日記