のらぬこの日常を描く

ノージャンルのお役立ち情報やアニメとゲームの話、ソフトウェア開発に関する話などを中心としたブログです。

俺のPHPがこんなに遅いわけがない 〜メソッドキャッシュのお話〜

どうものらぬこです。

最近仕事でPHPを使い始めました。

というわけで、今回はPHPの話でもしようかと思います。

さて、2年ほど前に在籍していた会社で開発していたシステムの話になるのですが、そのシステムが抱えるDBが、規模が巨大だったりだとか、それなりに複雑なクエリーをいくつも投げなきゃいけないとかありまして、パフォーマンスを出すのに割と苦労していたことがありました。

その時は、一緒に仕事をしていた「自称」天才の某中国人様に、『「クラス名+メソッド名+引数の値をつなげたもの」をキーとして、「戻り値の値」をメソッド呼び出しを丸ごとキャッシュとして保持してくれる仕組み』なるものを作ってもらいまして各種諸問題を解決していただいていたのは今では、今では良い思い出です。

ちなみに、当時はJAVAで開発していたのですが、今回、それと同じような仕組みをPHPで作ってみたいと思います。

今回作るもの

クラス内のインスタンスメソッドの呼び出しをフックして、「クラス名+メソッド名+引数の値をつなげたもの」をキーとして、「戻り値の値」をキャッシュする仕組みを作ります。

既存のシステムになるべく簡単に組み込めるものにします。

キャッシュする部分、キャッシュからデータを取得する部分の実装は適当です(Redisなどのミドルウェア使うなりPHPのプロセス内に Hashなどで保持しとくなりご自由にということで)。

お題として、DBに保存された何らかのマスターデータを問い合わせるというシーンを想定して、DBへの問い合わせ部分(IDを渡すとIDに対応したレコードを返してくれるメソッド)をキャッシュしてみます。

前提となる開発・実行環境の話

僕の環境は下記のような感じです。

  • mac mini 2014
  • PHP 7.1系(5.6以降なら多分動く)
  • postgreSQL 9.6系(サンプルコード動かすのに使ってます)

それほど特殊なことをしているわけではないので、PHP5.6以降(多分もうちょっと古いバージョンでも大丈夫)な環境であれば動作すると思います。

また、組み込み先のプロジェクトは、キャッシュ化したいモジュールが class として定義されていることが前提です。

.phtml 等の htmlなんだかphpなんだか良くわからないコードや、関数という概念が存在しない残念なコード等には組み込むことができません。

早速作ってみる

取り敢えず、コード載せときます。説明とかは後回しで。

キャッシュの仕組みを実装しているコード

まずは、メソッドキャッシュの仕組みを実装しているコードを載せておきます。

コードの中身は後で説明するので、中身わかる方は眺めていただいてもいいですし、よくわからない方は読み飛ばしていただいて大丈夫です。

<?php 
class MethodCache {

    private $_instance;
    private static $_className;


    public function __construct($instance) {
        $this->_instance = $instance;
        $ref = new ReflectionClass($instance);
        self::$_className = $ref->getName();
    }

    private static $cached = Array();
    private function get_cache($key, &$value) {
        if (isset(self::$cached[$key])) {
            $value = unserialize(self::$cached[$key]);
            return true;
        } else {
            return false;
        }
    }

    private function put_cache($key, $value) {
        self::$cached[$key] = serialize($value);
    }

    public function __call($method, $arguments) {

        $calledMethod = self::$_className . '->' . $method . ':' . json_encode($arguments, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $ret = null;

        if (!$this->get_cache($calledMethod, $ret)) {
            $ret = call_user_func_array(array($this->_instance, $method), $arguments);
            $this->put_cache($calledMethod, $ret);
        }
        return $ret;
    }

    public function __get($key) {

        return $this->_instance->$key;
    }

    public function __set($key, $value) {

        $this->_instance->$key = $value;
    }
}

なお、get_cache() がキャッシュ済データを取得するメソッド, put_cache()がキャッシュに値を追加するメソッドです。

これらのメソッドの中身はサンプル用に適当に作っただけなので(一応、キャッシュするという機能は持っていますが)、Redisなどのミドルウェアを使うように適宜置き換えていただければと思います。 また、実運用ではキャッシュの破棄タイミングなども考慮する必要があるかと思います。

サンプルを作って動かしてみる

ということで、取り敢えずサンプル的なものを作って、実際に動かしてみたいと思います。

なお、phpの5.6以上と、遊び用途で使える適当なRDBが使えることが前提です。

僕の環境は、上の方でも一回書きましたが、php7.1、postgreSQL9.6です。

RDBの準備

まずは、RDBに「id」という名前のprimary key を持つテスト用のテーブルを適当に用意して、データを100件ほどインサートしておきます。 列名などはなんでも構いません。

作業環境を整える

RDBの準備ができたら、ソースの作成に入ります。

まずは適当な作業ディレクトリに移動して、以下のコマンドを打ち込んでください。

mkdir intercepter
mkdir dao
curl -sS https://getcomposer.org/installer | php

続いて、以下のファイルを順に作成、保存してください。

intercepter/MethodCache.php

まずは、前項で載せた、メソッドキャッシュの仕組みを実装しているコードを intercepter/MethodCache.php という名前で保存してください。

composer.json

PHPな方にはおなじみのcomposer.json です。 メソッドキャッシュの仕組み自体には不要なのですが、サンプルとしてDBにつなぐために illuminate-database というモジュールを使用しています。

{
    "autoload": {
        "psr-4": {
            "Dao\\"      : "dao/",
            ""           : "intercepter/"
        }
    },
    "require": {
        "illuminate/database": "^5.4",
        "illuminate/events": "^5.4"
    }
}

./dao/ExampleDao.php

illuminate-databaseのモジュールを利用して、DBから値を取ってくるクラス(いわゆるDao)クラスです。

<?php

namespace Dao;

use Illuminate\Database\Capsule\Manager as Capsule;

class ExampleDao {
    private static $instance = null;

    private function __construct() {
    }

    public static function instance() {
        if (is_null(self::$instance)) {
            self::$instance = 
                new \MethodCache(new ExampleDao());
        }
        return self::$instance;
    }

    public function findBy($id) {
        return Capsule::table('test.my_data')->where('id', $id)->first();
    }
}

./test.php

最後のファイルは、サンプルの本体です。

<?php
require_once __DIR__ . "/vendor/autoload.php";

use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;

$capsule = new Capsule;

$capsule->addConnection([
    'driver'    => 'pgsql',
    'host'      => 'localhost',
    'database'  => 'example',
    'username'  => 'noranuk0',
    'password'  => 'password',
    'charset'   => 'utf8',
    'collation' => 'utf8_unicode_ci',
    'prefix'    => '',
]);
$capsule->setEventDispatcher(new Dispatcher(new Container));
$capsule->setAsGlobal();
$capsule->bootEloquent();

$dao = Dao\ExampleDao::instance();
for ($times = 1; $times < 10; $times++) {
    $i = 0;
    $timeStart = microtime(true);
    for($i = 1; $i <= 100; $i++) {
        $dao->findBy($i);
    }
    $timePassed = microtime(true) - $timeStart;
    $timePassed *= 1000;
    print("{$times}回目:{$timePassed}ms\n");
}

なお、DB接続の部分のパラメータは環境に合わせて適切に設定してください。

composer の実行

実際にサンプルを動かす前に、composerを使いautoloadの設定と必要なモジュールのインストールを行います。

以下のコマンドをタイプしてください。

composer installer
composer upate

動かしてみようか

ここまでの作業で、サンプルは完成(のはず)です。

早速動かしてみます。

環境により実行時間は異なってくると思いますが、キャッシュが効いている2回目以降は爆速みたいな結果が出てくると思います。

1回目:47.780990600586ms
2回目:0.20217895507812ms
3回目:0.1981258392334ms
4回目:0.22983551025391ms
5回目:0.22292137145996ms
6回目:0.24795532226562ms
7回目:0.1990795135498ms
8回目:0.20503997802734ms
9回目:0.20503997802734ms

既存コードへの取り込み方

高速化したいクラスのインスタンスを生成する際、そのクラスのインスタンスを直接生成するのではなく、new MethodCache(...) で囲います。

self::$instance = 
    new \MethodCache(new ExampleDao());

こんな感じです。

これで、class内に定義されたメソッドは全てキャッシュ対象となります。

クラスのインスタンスメソッドが呼び出されたときに、過去に同一の引数で呼び出されたことがあり(かつキャッシュが残っていれば)元のメソッドは呼び出さず、キャッシュされた値を返します。

原理

例えば、こんなコードが合ったとします。

$dao = new MethodCache(new ExampleDao());
$dao->findBy($id);

findBy()メソッドは ExampleDao に定義されたメソッドで、MethodCache class には該当メソッドがありません。

PHPで、存在しないメソッドが呼ばれると、 __call() というマジックメソッドが呼ばれます。

PHP: マジックメソッド - Manual

今回は、このメソッドの中で、キャッシュがあればそれを返し、なければ元のclassのメソッドを呼び出す、ということをやっております。

課題

class内のメソッドを何でもかんでもキャッシュしたら都合が悪い場合も勿論あると思います。

例えば、取得系のメソッドはキャッシュしたいけど、追加、更新系のメソッドはキャッシュしたくない、などです。

メソッド名で、キャッシュする、しないを判断する仕組みを間に挟んだり、メソッドアノテーション的なもの(PHPにあったっけ・・・?)でなんとかする等、方法はあると思います。

取り敢えず、そろそろ書くのも飽きてきたのと眠くなってきたので、この辺のことはまた今度気が向いたら考えることにします。

取り敢えず、今日の話は、PHPでもAOP的な仕組みを使って、メソッドキャッシュみたいなこともできるよっていう部分の紹介までということで。

最後に

今回の記事の参考にさせていただいた記事の紹介です。

PHPでAOP的にメソッドの実行時間を計測 - Qiita

こちらの記事でメソッドの実行時間を取得する目的で使われている仕組みを、メソッドキャッシュのために使ってみました。

大変参考になりました。どうもありがとうございましたm(__)m

ということで、今回のお話は以上となります。

この記事が、PHPな方々にとって何かの参考になれば幸いです。

詳細! PHP 7+MySQL 入門ノート

詳細! PHP 7+MySQL 入門ノート