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

はじめに

この記事 の続きです。

Zend Framework 1 で発生した、テストの実行時にDBのコネクションを使い果たしてしまう問題は Zend Framework 2 でも同じように発生します。

この問題に気づいたのは Zend Framework 2 のチュートリアル で Album モジュールを作成した後に AlbumController のテストを書いていたときですが、この記事では簡単化のために Application モジュールの IndexController で DBアダプタを直接使って問題を再現させています。

SkeletonApplication で問題を発生させる

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

$ composer create-project --repository-url="https://packages.zendframework.com" -s dev zendframework/skeleton-application skeleton-application
$ cd skeleton-application

DB接続用のコンフィグを作成します。

config/autoload/global.php

<?php
return array(
    'db' => array(
        'driver'   => 'pdo',
        'dsn'      => 'mysql:dbname=zf2;host=localhost;charset=utf8',
        'username' => 'test',
        'password' => 'pass',
    ),
    'service_manager' => array(
        'factories' => array(
            'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
        ),
    ),
);

テスト用の bootstrap.php を作成します。

tests/bootstrap.php

<?php
require __DIR__ . '/../vendor/autoload.php';
chdir(dirname(__DIR__));

テストケースを作成します。

tests/Test/Application/Controller/IndexControllerTest.php

<?php
namespace Test\Application\Controller;

use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

class IndexControllerTest extends AbstractHttpControllerTestCase
{
    protected $traceError = true;

    protected function setUp()
    {
        $this->setApplicationConfig(
            require __DIR__ . '/../../../../config/application.config.php'
        );
        parent::setUp();
    }

    function test()
    {
        $this->dispatch('/');
        $this->assertResponseStatusCode(200);
    }
}

Application モジュールの IndexController で適当にDBアダプタを使います。

module/Application/src/Application/Controller/IndexController.php

<?php
namespace Application\Controller;

use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;

class IndexController extends AbstractActionController
{
    public function indexAction()
    {
        /* @var $db \Zend\Db\Adapter\Adapter */
        $db = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');
        $stmt = $db->query("select 1");
        $result = $stmt->execute();

        return new ViewModel();
    }
}

テストが通ることと、アプリケーションから MySQL に接続していることを確認します。

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

.

Time: 597 ms, Memory: 5.50Mb

OK (1 test, 1 assertion)

$ sudo tail -n 3 /var/log/mysql/query.log
131005 19:55:07   403 Connect   test@127.0.0.1 on zf2
                  403 Query     select 1
                  403 Quit

@dataProvider アノテーションでテストの回数を増やします。

tests/Test/Application/Controller/IndexControllerTest.php

<?php
namespace Test\Application\Controller;

use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

class IndexControllerTest extends AbstractHttpControllerTestCase
{
    protected $traceError = true;

    protected function setUp()
    {
        $this->setApplicationConfig(
            require __DIR__ . '/../../../../config/application.config.php'
        );
        parent::setUp();
    }

    /**
     * @dataProvider data
     */
    function test()
    {
        $this->dispatch('/');
        $this->assertResponseStatusCode(200);
    }

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

テストを実行すると、途中で DB 接続が MaxConnections に達してコケます。

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

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

Time: 1.22 minutes, Memory: 54.50Mb

There were 48 errors:

1) Test\Application\Controller\IndexControllerTest::test
Zend\Db\Adapter\Exception\RuntimeException: Connect Error: SQLSTATE[08004] [1040] Too many connections

 :

FAILURES!
Tests: 200, Assertions: 152, Errors: 48.

解決方法:tearDown で解放 → 失敗

Zend Framework 1 のときと同じように AbstractHttpControllerTestCase がいろいろとメンバを持っていることが原因だと思ったので、tearDown で解放するようにしてみました。

tests/Test/Application/Controller/IndexControllerTest.php

<?php
namespace Test\Application\Controller;

use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

class IndexControllerTest extends AbstractHttpControllerTestCase
{
    protected $traceError = true;

    protected function setUp()
    {
        $this->setApplicationConfig(
            require __DIR__ . '/../../../../config/application.config.php'
        );
        parent::setUp();
    }

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

    /**
     * @dataProvider data
     */
    function test()
    {
        $this->dispatch('/');
        $this->assertResponseStatusCode(200);
    }

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

が、この方法では改善しませんでした。

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

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

Time: 1.24 minutes, Memory: 54.50Mb

There were 48 errors:

1) Test\Application\Controller\IndexControllerTest::test
Zend\Db\Adapter\Exception\RuntimeException: Connect Error: SQLSTATE[08004] [1040] Too many connections

 :

FAILURES!
Tests: 200, Assertions: 152, Errors: 48.

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

DBアダプタのインスタンスを静的に使いまわすために、DBアダプタの ServiceFactory を次のように作成します。

module/Application/src/Application/Db/Adapter/StaticAdapterFactory.php

<?php
namespace Application\Db\Adapter;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Zend\Db\Adapter\AdapterServiceFactory;

class StaticAdapterFactory implements FactoryInterface
{
    private static $adapter;

    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        if (!isset(self::$adapter))
        {
            self::$adapter = (new AdapterServiceFactory)->createService($serviceLocator);
        }

        return self::$adapter;
    }
}

コンフィグを新たに作成した ServiceFactory を使うように修正します。

config/autoload/global.php

<?php
return array(
    'db' => array(
        'driver'   => 'pdo',
        'dsn'      => 'mysql:dbname=zf2;host=localhost;charset=utf8',
        'username' => 'test',
        'password' => 'pass',
    ),
    'service_manager' => array(
        'factories' => array(
            //'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
            'Zend\Db\Adapter\Adapter' => 'Application\Db\Adapter\StaticAdapterFactory',
        ),
    ),
);

これでDBアダプタは静的なインスタンスが使いまわされるようになります。

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

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

Time: 7.55 seconds, Memory: 47.50Mb

OK (200 tests, 200 assertions)

$ cat /var/log/mysql/query.log
 :
131006 19:08:23  1070 Connect   test@127.0.0.1 on zf2
                 1070 Query     select 1
                 1070 Query     select 1
 :
131006 19:08:30  1070 Query     select 1
                 1070 Query     select 1
                 1070 Quit

なお、一見上手くいきそうな下記の方法だと上手くいきません。

<?php
use Zend\Db\Adapter\AdapterServiceFactory;

return array(
    'db' => array(
        'driver'   => 'pdo',
        'dsn'      => 'mysql:dbname=zf2;host=localhost;charset=utf8',
        'username' => 'test',
        'password' => 'pass',
    ),
    'service_manager' => array(
        'factories' => array(
            //'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
            'Zend\Db\Adapter\Adapter' => function ($serviceLocator) {
                static $adapter;
                if (!isset($adapter))
                {
                    $adapter = (new AdapterServiceFactory)->createService($serviceLocator);
                }
                return $adapter;
            },
        ),
    ),
);

クロージャー内の static 変数はクロージャーの定義毎のインスタンスとなるため、これでは結局テストの都度DBアダプタがインスタンス化されてしまうためです。

次のようにDBアダプタをグローバル変数にしても上手くいきません。

<?php
use Zend\Db\Adapter\AdapterServiceFactory;

return array(
    'db' => array(
        'driver'   => 'pdo',
        'dsn'      => 'mysql:dbname=zf2;host=localhost;charset=utf8',
        'username' => 'test',
        'password' => 'pass',
    ),
    'service_manager' => array(
        'factories' => array(
            //'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
            'Zend\Db\Adapter\Adapter' => function ($serviceLocator) {
                global $g_adapter;
                if (!isset($g_adapter))
                {
                    $g_adapter = (new AdapterServiceFactory)->createService($serviceLocator);
                }
                return $g_adapter;
            },
        ),
    ),
);

これは PHPUnit によるテスト毎にグローバル変数を元に戻す機能によって、$g_adapter がテストの開始時の状態に復元されるためです。

次のように --no-globals-backup オプションを追加すればグローバル変数が復元されなくなるので、意図した通りにDBアダプタのインスタンスが使いまわされるようになります。

$ phpunit --bootstrap tests/bootstrap.php --no-globals-backup tests/
PHPUnit 3.7.27 by Sebastian Bergmann.

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

Time: 7.55 seconds, Memory: 47.50Mb

OK (200 tests, 200 assertions)

tearDown で解放したのでは上手くいかない理由

なぜ tearDown で解放したのでは上手くいかなかったのでしょうか?

理由は下記の SplPriorityQueue を絡めた循環参照が原因でした。

Zend Framework 2 の EventManager は内部でイベントリスナのコンテナとして SplPriorityQueue を使用しています。また、フレームワークの内部でかなりの勢いで循環参照しているため、この問題が原因で未到達なオブジェクトでもGCで回収されることがありません。

EventManager の中の SplPriorityQueue を起点に、Zend Framework 2 の内部の多くのオブジェクトが解放されることなくテストの都度生成されっぱなしになります。

その他の気付いたこと

tearDown で明示的に disconnect すればいいかとも思いましたが、この方法でも上手くいきません。

tests/Test/Application/Controller/IndexControllerTest.php

<?php
namespace Test\Application\Controller;

use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

class IndexControllerTest extends AbstractHttpControllerTestCase
{
    protected $traceError = true;

    protected function setUp()
    {
        $this->setApplicationConfig(
            require __DIR__ . '/../../../../config/application.config.php'
        );
        parent::setUp();
    }

    protected function tearDown()
    {
        /* @var $db \Zend\Db\Adapter\Adapter */
        $db = $this->getApplicationServiceLocator()->get('Zend\Db\Adapter\Adapter');
        $db->getDriver()->getConnection()->disconnect();
    }

    /**
     * @dataProvider data
     */
    function test()
    {
        $this->dispatch('/');
        $this->assertResponseStatusCode(200);
    }

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

Zend\Db\Adapter\Driver\Pdo\Connection::disconnect() の実装は、単に PDO のインスタンスをクリアするだけです。

Zend/Db/Adapter/Driver/Pdo/Connection.php#L310

<?php
     :
    /**
     * Disconnect
     *
     * @return Connection
     */
    public function disconnect()
    {
        if ($this->isConnected()) {
            $this->resource = null;
        }
        return $this;
    }

一方、Zend\Db\Adapter\Adapter は最後に実行したステートメントを保持しており、

Zend/Db/Adapter/Adapter.php#L61 Zend/Db/Adapter/Adapter.php#L180

<?php
     :
    /**
     * @var Driver\StatementInterface
     */
    protected $lastPreparedStatement = null;
<?php
     :
    public function query($sql, $parametersOrQueryMode = self::QUERY_MODE_PREPARE)
    {
         :
        $this->lastPreparedStatement = null;
        $this->lastPreparedStatement = $this->driver->createStatement($sql);
        $this->lastPreparedStatement->prepare();
         :
    }

Zend\Db\Adapter\Driver\Pdo\Statement は PDO のインスタンスを保持しているため、

Zend/Db/Adapter/Driver/Pdo/Statement.php#L23

<?php
     :
    /**
     * @var \PDO
     */
    protected $pdo = null;

Zend\Db\Adapter\Driver\Pdo\Connection::disconnect() だけでは PDO のインスタンスが解放されないのです。