コネヒト開発者ブログ

コネヒト開発者ブログ

CakePHP3用のMaster/Replica接続管理プラグインをOSS化しました

こんにちは、サーバーサイドやっております 金城 (@o0h_)です。
なんとな〜〜〜くKindleのライブラリを見ていたら、スキエンティアがあって「とても美しくて良い話だなぁ。。。」と思った次第です。

スキエンティア (ビッグコミックススペシャル)

スキエンティア (ビッグコミックススペシャル)

たまに読み返したいな。

さて、掲題のとおりですが、以前にママリのマスプロモーションを実施した際の負荷対策として作成した機構をプラグインとして公開しました。
・・・という書き出しで以前に書いたのが、CakePHP2.x用のMaster/Replica接続管理プラグインです。

tech.connehito.com

それから暫く経ちましたが、この度CakePHP3用の同様のプラグインを公開しました。

packagist.org

この記事では、以下の3点について紹介したいと思います。

  1. プラグインの利用方法についての簡単な説明
  2. 設計について
  3. 「CakePHP3.xのプラグイン」を公開する際に工夫したこと

①利用方法について

CakePHP2用のプラグインと同様に、「複数の接続を管理する」「単一のモデルからそれらを任意に使い分けられる」ことを目的としています。例えば「参照しか走らないリクエストは参照用DBに接続して、更新系はマスターDBに接続する」といったような使い方を想定しています。

簡単に利用方法を紹介します。

  1. Pluginをcomposer installで導入する
  2. 「複数の接続先」をconfig(デフォルトではconfig/app.php)に書き込む
  3. (Controller等で)必要に応じて接続先情報を変更するメソッドを実行する

1. Pluginの配置

Composerによるinstallに対応しています。

composer require connehito/cakephp-master-replica

単体クラスの名前空間解決による読み込みだけで十分なので、Pluginのロード等の処理は必要としません。

2. 接続情報の設定

ConnectionManagerに喰わせる設定を変更します。
標準的な構成では、これは config/app.php にある Datasourcesの内容となります。

通常のConnectionクラス用の設定と同様に記述したあとに、「接続先の差分」を roles に書き込んでください。 共通する接続情報を記述した上で、それぞれの接続先ごとに異なる部分を記述するようになります。

例えば、以下のような3つの接続先を扱いたいという需要があったとします*1
(MySQLを使うものとします)

  1. master
    • Host: db-host
    • DB: app_db
    • Username: root
    • Password: password
  2. replica(1)
    • Host: db-host
    • DB: app_db
    • Username: read-only-user
    • Password: another-password
  3. replica(2)
    • Host: replica-host
    • DB: app_db
    • Username: read-only-user
    • Password: another-password

これに「master接続だけ利用する」場合は、CakePHP標準のConnectionを利用して以下のように記述できるかと思います。

<?php
// config/app.php
use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;

return [
    'Datasources' => [
        'default' => [
                'className' => Connection::class,
                'driver' => Mysql::class,
                'persistent' => false,
                'host' => 'db-host',
                'username' => 'root',
                'password' => 'password',
                'database' => 'app_db',
                'timezone' => 'UTC',
                'flags' => [],
                'cacheMetadata' => true,
                'log' => false,
                'quoteIdentifiers' => false,
        ],
    ],
];

これを、master + replica + replica2という構成に対応できるように書き換えてみます

<?php
// config/app.php
use Cake\Database\Driver\Mysql;
use Connehito\CakephpMasterReplica\Database\Connection\MasterReplicaConnection;

return [
    'Datasources' => [
        'default' => [
                'className' => MasterReplicaConnection::class,
                'driver' => Mysql::class,
                'persistent' => false,
                'host' => 'db-host',
                'database' => 'app_db',
                'timezone' => 'UTC',
                'flags' => [],
                'cacheMetadata' => true,
                'log' => false,
                'quoteIdentifiers' => false,
                'roles' => [
                    'master' => [
                        'username' => 'root',
                        'password' => 'password',
                    ],
                    'replica' => [
                        'username' => 'read-only-user',
                        'password' => 'another-password',
                    ],
                    'replica2' => [
                        'host' => 'replica-host',
                        'username' => 'read-only-user',
                        'password' => 'another-password',
                    ],
                ],
        ],
    ],
];

このように、classNameをMasterReplicaConnectionに書き換えた上で「差分だけroles に書く」ことによって全ての接続を扱えるようになります。

3. 接続の切り替え

デフォルトでは、 master という名前の設定を用いて接続されます。
switchRole() というAPIを用いて簡単に接続先を切り替えることが可能です。

例えばTableインスタンスが手元にある場合は、Tableインスタンスを介してアクセスするのが手軽だと思います。

<?php
$this->UsersTable->getConnection()->switchRole('replica');

それ以外の場合は、ConnectionManagerを利用することになるでしょうか。

<?php
use \Cake\Datasource\ConnectionManager;

ConnectionManager::get('default')->switchRole('replica2');

比較的容易にアクセスができるので、リトライ機構の導入や局所的にアクセス先を変更したいというニーズにも対応が簡単です。

4. 発展的な利用例: CQRS的なもの

多くのWebアプリケーションで、「書き込み・更新を伴うページやエンドポイント」「参照しか用いないもの」というのが比較的はっきりと分かれるのではないでしょうか。
エンドポイント単位をいわゆるCommand/Query的に分類し、それぞれで接続先を設定できると実用できる場面が広がるように思います。

社内のプロダクトでは、Routingの設定と絡めて「どっちにつなげるか」を管理できるようにしました。 例えば「商品の個別ページ」として、 item/show というエンドポイントがあり、ここではDBの更新が走らないものとします。item/edit では情報の更新を行うため、DBの更新を行います。

まず、 routes.phpでエンドポイント個別の設定として独自のオプションであるreadOnly フラグを立てます。

<?php
//routes.php
$routes->scope('item/', ['controller' => 'Items'], function (RouteBuilder $routes) {
    $routes->connect(
        'show',
        [
            'action' => 'show',
            '_method' => 'GET',
        ]
    );
    $routes->connect(
        'edit',
        [
            'action' => 'edit',
            '_method' => 'POST',
            'readOnly' => false,
        ]
    );
});

ルーティング情報と一緒に渡ってきた内容に対して、 AppController::beforeFilter() などの「最初の方に通る共通処理」で設定を反映させます。

<?php
/**
 * routesからパラメータを読み取り、接続先のDBを切り替える
 *
 * @return void
 */
private function setDefaultDbRole()
{
    $readOnly = $this->getRequest()->getParam('readOnly', true);
    $dbRole = readOnly ? 'replica' : 'master';

    /** @var MasterReplicaConnection $connection */
    $connection = ConnectionManager::get('default');
    $connection->switchRole($dbRole);
}

これだけで、「明示的にreadOnlyフラグを折ったエンドポイント以外はレプリカを見る」機能が実現されました。

②設計について

ここからは、実装面の話を紹介させていただきます。

CakePHP3の「ORM」「DB接続」

CakePHP2と比べて3.xは「ORM周りの機能が大幅に強化・変更された」というのは主要なトピックの1つですが、ORM/Database周りに関する内部構造も複雑になっています。

主要な登場人物として「ORM」「Datasource」「Database」の3レイヤーが出てきます。
ざっとまとめると以下のようになります。

f:id:o0h:20191014023707p:plain f:id:o0h:20191014023609p:plain

通常のアプリケーション開発を進めている時に直接触るのはORM層のみで、ほぼ事足りると思います。
この記事では詳細については割愛します*2が、「Connection」と「Driver」に着目してください

Driverクラスは、ClassDocを見ると以下のようなサマリーがついています。

/**
 * Represents a database driver containing all specificities for
 * a database engine including its SQL dialect.
 */

(cakeの中では)これが最もDBに近い層で、PDOインスタンスを保持します。
Connectionクラスはこれらを使役するクラスで、Driverを生成・取得し保持します。
今回作成したかったのは「複数の接続先を管理する」機能なので、「接続を切り替える」場としてConnectionクラスを改変することにしました。

接続の生成と管理・切り替え

PHP上で実際に「DBに接続している」のは「PDOインスタンスを生成(保持)している」箇所になります。
当プラグイン = Connectionクラスでは、「クラスのインスタンス化時に、注入されている全ての設定に応じた PDOインスタンス(との仲介役であるDriverインスタンス)を生成し、保持する」という戦術を取りました。

CakePHP2用プラグインでは、Datasource\Databaseレイヤーへのハックを行いMysqlを前提としていました。そのためにPDOとも密結合になっています。
これに対してCakePHP3用のプラグインでは、接続管理が抽象化されたことで、過度な設計を必要とせずに接続用のDBドライバは付け替え可能です。その点に注目しました。

デメリットとしては、例えば「replica接続しか使わないのにmaster接続のインスタンスも生成されてしまう」といったオーバーヘッドがあります。
実際、当初は「呼び出された時点でPDOインスタンスを作り、不要になったら破棄する」という方法を実現できるか?と模索もしました。
生成自体は遅延読み込み的に生成すれば実現できそうな気はします。問題は「破棄する」タイミングです。トランザクション管理など、どうしても「DriverないしConnectionから向き合わなければいけない関心事が増える」ことで、実装上の複雑さが増しそうな懸念がありました。また、「呼び出されるたびに再接続する」ことで生じるDB接続確立は大きな負担になりそうです。

総合して、「接続するのは最初に済ませてしまう」「インスタンスを内部に保持し続ける」ことで得られる、システムリソース的にもアプリケーションコード的にも魅力を感じました。 また、これらの機能をたった70行ちょっとの単一クラスで実現できたというのは「簡潔な記述ができた」成果だとも言えるのではないでしょうか。

③「CakePHP3.xのプラグイン」を公開する際に工夫したこと

当プラグインは、元々プロダクトコードの一部として実装していたものをライブラリとして切り出したものです。
実際のプロダクトなら「コントローラーもデータベースもすべてが揃っている!」テスト環境があるのですが、スタンドアロンなライブラリでは、そうも行きません。環境構築を含む事前準備は本質的ではないストレスになると考えています。もしテストがすぐに実行できれば、どれだけ開発体験が良くなるか・・・・

この問題は個人的には毎回頭を抱える部分なので、自分なりに「こうしたら楽かな?」と思える工夫をいくつか施してみました。
なお、今もなお試行錯誤している部分なので、ぜひ皆さんのご意見も聞いてみたいです。

docker-composeの梱包

このプラグインは理屈上はDBを使わなくても実装したロジックの内容を検査が可能だと思います。しかしながら、DB周りの機能を提供するものでもあるため、テストの時点で「実際にDBに触ってみる」事ができると安心です。
そこで、docker-composeを用いてPHP+MySQLの開発土台を配布できるようにしました。
(sourceはコチラ)

これによって、例えばPhpStormユーザーであれば手軽にIDE上からのテスト実行を提供できることになります🎉

  • stormの設定
    f:id:o0h:20191014032552p:plain
    f:id:o0h:20191014032654p:plain
  • 実際にテストを実行している光景🗻
    f:id:o0h:20191014032937p:plain

(localの)テスト時にローカルからプラグインを読み込む

Composer配布前提のライブラリなので、最終的にはpackagist経由で喰わせることになります。しかし、開発中はわざわざpushするというのは面倒な話です。
そこで、 docker-composeのvolumesと(テスト実行側アプリの)composer.jsonを組み合わせることで、「テスト実行時にローカルからライブラリを読み込む」ようにしました。

ホストからDockerコンテナにライブラリのsrcを喰わせる

全体構成としては

  • src: ライブラリ本体
  • tests/test_app/composer.json: テスト実行アプリのcomposer.json
  • tests/test_app/docker-compose.yaml: テスト実行アプリのdocker-compose

となります。

docker-compose上では、次のようにして「PJ全体は /app に喰わせる」「それとは別に、 /dist 上にライブラリの本体とパッケージ情報(composer.json)を喰わせる」ようにします。

services:
  test-app:
    volumes:
      - ../../src:/dist/src
      - ../../composer.json:/dist/composer.json
      - ../..:/app
ローカルのパスをレポジトリとして設定する

composer.json上には、ローカルのパッケージを参照させるようにレポジトリ情報を追加します

"repositories": [
      {
          "type": "path",
          "url": "/dist"
      }
  ],

これで、 app/tests/test_app/vendor/connehito/cakephp-master-replica/dist を指すシンボリックになります🎉

CakePHP4を見据えて・・

次期バージョンであるCakePHP4は、すでにβ4まで進んでおり、段々と全貌が見えてきています。
CakePHP3との互換性については意識されているということで、cakephp-master-replicaプラグインにおいても可能であれば低コストに移行したいと考えています。

そこで、4.xから入る規約等への対応を実施しました。
主だったところでは以下の4点です

  1. cakephp-codesnifferを利用してPSR-12対応
  2. 厳密な型チェック(strict_types=1)
  3. 引数・戻り値の型宣言
  4. PHPStanの対応レベル引き上げ(Lv5)

strict_typesの宣言漏れについてはcodesnifferでチェックできるので、そこまでナーバスになる必要もありません(CIでコケるため)。
PHPStanのレベルについては「コーディング規約」とは異なるようにも思いますが、本体の動向に追従しようというものです。
最終的にはstableのリリースを待って移行ガイド他ドキュメントをチェックし対応することになりますが、今の時点での「変更点」としてはこんなものだと思っています。

最後に

ごく僅かなコードで機能を実現できたのは、「フレームワークに乗っかった旨味だ」と感じています!
また、実現したいコードを如何にしてフィットさせるか?という観点でのコードリーディングは、モチベーションも湧きやすく良いものです。今回の機構の開発にあたり、自分なりにCakePHP3のORM,Databaseレイヤーについて理解が深まりました。

PDOインスタンスを複数持たせるというアイディア自体についても、結果的にIlluminateの接続管理でも同様の手法を取っているものです。・・・もっとも、これは実装してから気づいたので、「もっと早く見ればよかった」と項垂れもしました。が、個人的には「悪くないやり方と思っていいのかな〜〜」と、同時に自信も深められたと言えます✨Illuminateの方が高機能な実装をしているようにも思うので、こちらもまだ改善の余地がありそうです。

今回触れた「master replica切り替え機構」の設計や詳細については、もっと詳しい資料が社内にございます🌅
コネヒトではサーバーサイドエンジニアを募集していますので、是非お気軽に遊びに来てくださいね! www.wantedly.com

また、11月に開催されるCakeFestでは弊社CTOも登壇しますので、応援してください!!

*1:実際にこんな構成が使いたいか?は別として、あくまで「こういう事ができるよ」というのを説明するための内容です

*2:手前味噌ですが、以前にこの辺りを調べてみた記事があります。よろしければ御覧ください https://cake.nichiyoubi.land/posts/10-orm-database/