CakePHP2でのデータベースレプリケーション

[XTechグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/xtech-group)の8日目は、iXIT株式会社 エンジニアの蝦名（@ebi_）がお送りします。

## はじめに
今更ですが担当しているプロジェクトのDB移管によりCakePHP2でRead/Write構成のデータベースレプリケーションに対応する必要がありましたので、そのときのDoをご紹介します。

※対応進行中ですので、結果が出たら記事を更新します。

## 環境
- PHP 7.2.32
- CakePHP2.10.13
- Amazon Aurora MySQL

## 参考にしたサイト
- CakePHPでSELECTの時だけリードレプリカから情報を取得するようにする
 - https://blog.supersonico.info/?p=704
- CakePHP から味わう データベース・レプリケーション - 職業プログラマの休日出勤
 - https://tmotooka.hatenablog.jp/entry/2019/12/07/203000

## 具体的なやりたいこと
元々のソースコードの規模が大きいので、DB接続している箇所の前で一々コネクションの向き先を変えるのは現実的ではありません。
また、できる限り汎用的な作りにしたいですね。
そこで、以下の用件でAppModel（もしくはその周辺とか）だけを改修して対応させる方法を模索しました。

- 1.更新系SQLの場合はWriteに接続する
- 2.参照系SQLの場合はReadに接続する
- 3.ただし、コネクションにトランザクションが張られている場合は参照SQLであってもWriteに接続する

参考に挙げさせて頂いたサイトの内容では、1,2は出来ているのですが3に対応していません。
ですので今回は参考サイトの方法をベースに、3についての対応をさらに盛り込んでの実装を行いました。

## 実装について
クラス名、関数名については簡略化して記載します。

#### 1. CakePHPのDataSource/MySQLを継承して、ReplicaMysqlを作成する
CakePHPはデフォルトではトランザクションの状態を取得出来ません。
cakephp/lib/Cake/Model/Datasource/DataSource.phpのソースを見ると$_transactionStartedという変数がありますから、DataSourceを継承してgetter関数を作ってしまいましょう。

```php:cakephp/app/Model/Datasource/Database/ReplicaMysql.php
App::uses("Mysql", "Model/Datasource/Database");

class ReplicaMysql extends Mysql {

    /**
     * get DataSource->$_TransactionStarted
     * @return bool
     */
    public function getTransactionStarted() {
        return $this->_transactionStarted;
    }

    /**
     * 使っていませんが、これでネスト数を取得出来ます。
     * get DboSource->$_transactionNesting
     * @return int
     */
    public function getTransactionNesting() {
        return $this->_transactionNesting;
    }

}
```

#### 2. database.phpのDataSourceを先ほど作成したReplicaMysqlに設定する

```php:cakephp/app/Config/database.php
class DATABASE_CONFIG {

    // write
    public $default = array(
        'datasource' => 'Database/ReplicaMysql',
        'persistent' => false,
        'host' => '',
        'login' => '',
        'password' => '',
        'database' => '',
        'prefix' => '',
        'encoding' => 'utf8',
    );

    // read
    public $default_read = array(
        'datasource' => 'Database/ReplicaMysql',
        'persistent' => false,
        'host' => '',
        'login' => '',
        'password' => '',
        'database' => '',
        'prefix' => '',
        'encoding' => 'utf8',
   );
}
```
これでModel->getDataSource()で取得するクラスがReplicaMysqlになります。

#### 3. ReplicaBehaviorを作成し、Model->find()の際にRead接続用の設定を見るようにする
参考サイトを元に、トランザクションの判定を追加しています。

```php:cakephp/app/Model/Behavior/ReplicaBehavior.php
class ReplicaBehavior extends ModelBehavior
{

    /**
     * @param Model $model
     * @param array $query
     * @return array|bool
     */
    public function beforeFind(Model $model, $query) {
        $transactionStarted = $model->getDataSource()->getTransactionStarted();
        if (!$transactionStarted) {
            $model->useDbConfig = 'default_read';
            foreach ($model->belongsTo as $btModelName => $btModelData) {
                $model->{$btModelName}->useDbConfig = 'default_read';
            }
        }
        return true;
    }

    /**
     * @param Model $model
     * @param mixed $results
     * @param bool $primary
     * @return mixed
     */
    public function afterFind(Model $model, $results, $primary = false) {
        $model->useDbConfig = 'default';
        foreach ($model->belongsTo as $btModelName => $btModelData) {
            $model->{$btModelName}->useDbConfig = 'default';
        }
    }

}
```

#### 4. AppModelで作成したReplicaBehaviorを読み込む

```php:cakephp/app/Model/AppModel.php
class AppModel extends Model {
    public $actsAs = array('Replica');
}
```
Behaviorにすることで「今は不要」みたいなとき、Model->Behaviors->unload('Replica')ではずせます。

## この方法を使う上で留意すべきこと
- Database.phpのDataSourceにReplicaMysqlが設定されていないとき、関数がないのでエラーとなる
 - 多分method_exists()で回避するのが一番楽で確実

- Model->query(＄sql)に対応出来ない
 - AppModelでquery(＄sql)をオーバーライドすれば出来るかも

## 最後に

参考にさせて頂いた先人の方々にはお世話になりました。ありがとうございます。
レアケースかもしれませんが、この記事も誰かのお役に立てたら嬉しいです。
また、不備等ありましたら、ご教示ください。

XTechグループ Advent Calendar 2020
明日の執筆担当は@h-sakamotoさんです。よろしくお願いいたします。