PDOでコネクションロスト後にDBに再接続する

## 要約
アプリケーションサーバが重くなって、DBにコネクションが溜まって、サービス全体がダウン、なんてことにならないように、DBコネクションにwait_timeoutを設定するのは良いことです。
しかし、PDOではコネクションが切れてしまったときに自動で再接続する手段が提供されていません。
そこで、PDOのラッパークラスを作って、再接続できるようにしました。

## 環境
* PHP7.0以上
* MySQL(MariaDB10.1)

## ハマりどころ
PDOをラップして、`PDO::exec()`の時に例外が発生したらcachして、ロストコネクションだったら再接続する、っていうだけなら簡単なんですが、このやりかただと`PDO::prepare()->execute()`されたときにcatchできなくなります。
`execute()`は`PDOStatement`のメソッドだからです。

## 解決策
PDOのラッパークラス自身にタイマーを持たせて、wait_timeoutが経過したら無駄にクエリを1回発行して例外を発生させることで、かならずラッパークラス自身で再接続するようにしました。
ダサいとか言わないで。

## サンプルコード
~~~php
<?php

class ReConnectablePdo
{
    /** @var \PDO $pdo */
    private $pdo;

    private $dsn;
    private $username;
    private $password;
    private $options;
    private $timeout;
    private $lastExecMicroTime;

    /**
     * ReConnectablePdo constructor.
     * @param string $dsn
     * @param string $username
     * @param string $password
     * @param array $options
     * @param int|null $timeout
     */
    public function __construct($dsn, $username, $password, $options, $timeout)
    {
        $this->dsn = $dsn;
        $this->username = $username;
        $this->password = $password;
        $this->options = $options;
        $this->timeout = $timeout;

        $this->connect();
    }

    /**
     * @param $statement
     * @return int
     * @throws RuntimeException
     */
    public function exec($statement)
    {
        return $this->callPDOMethod('exec', [$statement]);
    }

    // こんな感じで各メソッドをラップする

    /**
     * @param string $methodName
     * @param array $params
     * @return int|mixed
     * @throws RuntimeException|\Exception
     */
    private function callPDOMethod($methodName, $params = [])
    {
        $retried = false;
        while (true) {
            try {
                // PDOから直接DBにアクセスせずPDOStatement経由でアクセスする場合があるので
                // クラス内でもTimeoutを監視しておく
                // 前回実行時からTimeout以上経過していた場合は
                // PDO経由でダミーのクエリを発行し再接続を促す
                if ($this->timeout && $this->lastExecMicroTime + $this->timeout - 0.1 <= microtime(true)) {
                    $this->pdo->exec('SELECT 1');
                }
                $ret = call_user_func_array([$this->pdo, $methodName], $params);
                $this->lastExecMicroTime = microtime(true);
                return $ret;
            } catch (\Exception $exception) {
                // ２連続でロストコネクション、またはロストコネクション以外の場合は例外を投げる
                if ($retried || !self::causedByLostConnection($exception)) {
                    throw $exception;
                }
                $this->connect();
                $retried = true;
            }
        }
        // ここには来ない
        return 0;
    }

    /**
     * @return void
     */
    private function connect()
    {
        if (isset($this->pdo)) {
            unset($this->pdo);
        }

        $this->pdo = new \PDO($this->dsn, $this->username, $this->password, $this->options);

        if ($this->timeout) {
            $this->pdo->exec("SET wait_timeout = {$this->timeout}");
        }

        $this->lastExecMicroTime = microtime(true);
    }

    /**
     * @param \Exception $exception
     * @return bool
     */
    private static function causedByLostConnection(\Exception $exception)
    {
        $errorMessagePatterns = [
            'server has gone away',
            'no connection to the server',
            'Lost connection',
            'is dead or not enabled',
            'Error while sending',
            'decryption failed or bad record mac',
            'server closed the connection unexpectedly',
            'SSL connection has been closed unexpectedly',
            'Error writing data to the connection',
            'Resource deadlock avoided',
        ];

        $message = $exception->getMessage();

        foreach ($errorMessagePatterns as $pattern) {
            if (stripos($message, $pattern) !== false) {
                return true;
            }
        }
        return false;
    }
}

~~~