CakePHP + MySQL アプリのテスト時間を 72% 削減した話

この記事は CakePHP Advent Calendar 2018 の23日目の記事です。

CakePHP アプリケーションのユニットテストでボトルネックになりがちなのがデータベースへのディスク I/O 時間ですが、そういったプロジェクトでは FriendsOfCake/fixturize プラグイン を導入すると大幅に高速化できる可能性があります。

実例として、テストに18分も掛かっていた MySQL を使ったプロジェクトありましたが、導入後なんと5分にまで短縮されました。(約 72% の高速化🎉)

結果として早いサイクルで CI を回せるようになり開発スピードも上がりました。

何がボトルネックになっていたか?

テストケースごとに実行されるテストデータのリロードクエリがボトルネックになっていました。リロードクエリとは TRUNCATE TABLE クエリと INSERT クエリのセットのことで、積み重なると高コストなクエリになります。

テストデータのリロード時間はテストケースが増えるほど支配的なものになる

CakePHP のユニットテストは、テストデータを Fixture クラスとして定義しておき、テストクラスの側で使用したい Fixture 名の一覧を記述します。

例として、テストクラスは以下のようになります。

<?php
namespace App\Test\TestCase;
use Cake\TestSuite\TestCase;

class ArticlesTest extends TestCase
{
    // 3テーブル分のテストデータの使用を宣言
    public $fixtures = [
        'app.articles',
        'app.comments',
        'app.users',
    ];

    public function testCase1() {
        // テストコード
    }

    public function testCase2() {
        // テストコード
    }
}

このテストを実行すると testCase1()testCase2() のテストケースの実行前に、それぞれ3テーブル分のリロードクエリが実行されます。同じように、このクラスに100個のテストケースがあったとしたらリロードクエリは最低で300回実行されます。

極端な例を挙げましたが、リロード回数はテスト数とフィクスチャ数の掛け算で増えていくため、簡単に無視できない I/O コストになることは想像に難くないと思います。

毎回テストデータをリロードする理由

無意味に何度もリロードしている訳ではありません。毎回リロードすることで、テストが書きやすくなるというメリットがあります。

テスト中にいくらテストデータを書き換えたとしても、次のテストでは元の状態にリセットされているので、テストケース間でテストデータの状態について副作用を気にする必要がなくなります。

この高速化プラグインがすること

FriendsOfCake/fixturize のソースを見ると分かりますが、このプラグインのすることは非常にシンプルです。上述の「毎回同じテストデータの状態でテストを開始したい!」という要求を満たしつつ、必要なときだけリロードするようにし、リロードクエリの実行回数を削減します。

具体的には、初回のフィクスチャロード後に CHECKSUM TABLE クエリ でテーブルのチェックサム値を取っておき、次以降のリロードは差分があったときだけ行うようにします。

チェックサム値に差分がなければテストデータが変更されていないということなので、リロードもスキップして問題ないということになります。

プラグインで高速化できる可能性のあるプロジェクト

ということで、以下に該当するプロジェクトであればプラグインを導入するだけで大幅にテスト時間を削減できる可能性があります。

  • CakePHP 3.x 製アプリ
  • MySQL / MariaDB / Percona のいずれかを使用している
  • テストケース数とテーブル数が多いプロジェクト
  • 複数テーブルを参照する結合的なテストをしているプロジェクト

また、もしこのプラグインが使えない環境のプロジェクトであっても、不要なロードを減らすというアプローチ自体は有効かもしれません。

おまけ: その他のアプローチ

他のアドベントカレンダーになりますが、素晴らしい記事を見かけましたのでそちらも紹介しておきます。

DB ストレージを tmpfs というオンメモリのファイルシステムに差し替え、高コストなディスク IO を無くすることで高速化するというアプローチです。私も手元のプロジェクトで試してみましたが、実際に 40% ほど高速化できました。FriendsOfCake/fixturize プラグイン と併用することで更に高速化が見込めます。

CakePHP4.0 時代への展望とこれまでの振り返り

2015年に CakePHP3.0 がリリースされたことは記憶に新しいですが、先日 CakePHP の Core Developer の @mark_story 氏により CakePHP4.0 時代への展望とこれまでの歩みの振り返りがスライドとして公開されていた ので、内容を要約してみたいと思います。

※ 以下の内容は自分が CakePHP を触っていて感じたことを元に補足している内容も含まれますのでニュアンスが異なる部分もあるかもしれません。

2.x – 3.00 アップデートの振り返り

  • 3.0.0 はとても大規模で困難なアップデートだった
  • PHP7 対応に伴うモダン化
    • フレームワークのインストール方法が composer になった
    • コアコード・モデル周りの大幅な改修
      • 連想配列地獄からの脱却・オブジェクト化
      • モデルクラスが Table と Entity クラスに分離された
  • 2.x もまだメンテンナンスされている
    • CakePHP2.7 で PHP7 にも対応した

現行版の 3.2.x – 3.3.x について

  • getter / setter 分離のため移行中の段階 (詳細は後述)
  • Middleware 対応 (PSR-7互換)
    • Request / Response の構築処理に任意の処理を挟み込める
    • PSR-7 対応に伴う Request & Response オブジェクトの不変化
      • with から始まるメソッド名のメソッドは新しいインスタンスを返す
<?php
// $response オブジェクトの内容は変わらない
$response->withHeader('X-yes', 'yes');

// 戻り値に変更が反映された状態で clone instance が返ってくるので
// もし変更したい場合は代入する。
$response = $response->withHeader('X-yes', 'yes');

3.5 で提供される新機能

  • 新しいミドルウェアの提供
    • CSRF
    • Cookies
    • Authentication
  • ルーティング設定とミドルウェアの紐付けが可能に

3.6 について

  • 4.0 リリースに向けたバージョンになる
  • 3.6 未満と下位互換性は保たれる

4.0 はどんな感じになる?

  • ベースはあくまで 3.x のまま
  • PHP7.1 以上の環境が必須に ( ! )
  • 全ての非推奨メソッドの削除
    • getter と setter の両方を兼ねたメソッドも削除される

進められている getter / setter 分離対応について

より簡単なインターフェースにしてコードの複雑さを軽減することが目的。

これまで:

  • Entity::errors()

これから:

  • Entity::getErrors()
  • Entity::setErrors()

リリース予定日は?

まだ未定だが 2017年末 〜 2018年初頭になりそう。

所感

CakePHP3 はかなり大掛かりなアップデートでしたが、CakePHP4 は PHP7.1 以上が要求されること以外は比較的シンプルなアップデートになるのではないでしょうか。

近年のモダン PHP の流れを汲みつつ堅実に進化していっているなという印象を受けました。

最近の PHP フレームワークのトレンドを見ていると Laravel に押され気味な感はありますが、CakePHP も古株のフレームワークとして安心感のあるフレームワークであることは今後も変わらないと思います。がんばれ CakePHP。

CakePHP3のMigrationsでDB接続先を切り替える方法

CakePHP3 の Migrations コマンドを使ってマイグレーションする際、DB 接続先を明示的に指定することが出来ます。

この記事の内容は、以下のように CakePHP の設定ファイルに複数の Datasource が設定されていることが前提になります。

config/app.php:

    /**
     * Connection information used by the ORM to connect
     * to your application's datastores.
     * Do not use periods in database name - it may lead to error.
     * See https://github.com/cakephp/cakephp/issues/6471 for details.
     * Drivers include Mysql Postgres Sqlite Sqlserver
     * See vendor\cakephp\cakephp\src\Database\Driver for complete list
     */
    'Datasources' => [
        'default' => [
            // 省略
        ],
        'my_other_connection' => [
            // 省略
        ]
    ],

Datasource を指定するオプション

例えば config/app.php で設定した my_other_connection の Datasource に接続する場合、以下のように -c オプションにデータソース名を指定します。

./bin/cake migrations migrate -c my_other_connection

また、以下のように接続先を指定しないとデフォルトの Datasource が使用されます。

./bin/cake migrations migrate

ただし、この方法は全てのマイグレーションファイルで指定した接続先が使用されます。マイグレーションファイルごとに接続先を切り替えたい場合は以下を参照してください。

マイグレーションファイルごとに接続先を切り替える方法

少しアナログな方法にはなりますが、少し手を加えることで実現できます。

まず、以下のように接続先ごとにフォルダを分けて、その中にマイグレーションファイルを入れておきます。

  • config/Migrations/default (default コネクション用フォルダ)
  • config/Migrations/other (my_other_connection コネクション用フォルダ)

この状態でマイグレーションをするときに -s (–source=SOURCE) オプションで上記のディレクトリを指定してマイグレーションします。

./bin/cake migrations migrate -c default -s Migrations/default

./bin/cake migrations migrate -c my_other_connection -s Migrations/other

マイグレーションの実行履歴はコネクションごとの phinxlog テーブル上で管理されるため、バッティングせずにマイグレーション出来ます。

コマンドオプション指定の組み合わせは間違えないよう注意する必要があります。