6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PHPでメソッドの挙動を柔軟に変更するプロキシ設計

Posted at

概要

プロキシを挟んでメソッドを実行することで処理の主体を切り替え、状況に応じたメソッドのラッピングを簡単にします。

// 普通に走る
echo $dog->run(); // => "1"

// 脚の性能 ($dog固有の値) に依存して走る
echo $dog->withLegs()->run(); // => "4"

// 吠える
$dog->bark(); // => "わん"

// 3回吠える
$dog->repeat(3)->bark(); // => "わんわんわん"

// 別のオブジェクト、別のメソッドでも使える
$cat->repeat(2)->mew(); // => "にゃーにゃー"

// お腹が空いているのなら食べる
$dog->when(function ($dog) {
    return $dog->isHungry();
})->eat(); // => "ばくばく"

// もうお腹がいっぱいになったので食べない
$dog->when(function ($dog) {
    return $dog->isHungry();
})->eat(); // => eat() is not executed.

// public でないメソッド (Dog::grow()) を実行
echo $dog->getAge(); // => "0"
try {
    $dog->grow();
} catch (\Throwable $e) {
    echo $e->getMessage(); // => "Call to private method Dog::grow() from context ''"
}
$dog->unprotect()->grow();
echo $dog->getAge(); // => "1"

サンプルコード

ここで紹介するコードのソースは GitHub に上げてあります。動作を確かめたい場合は git clone して composer install してから実行してください (PHP 7.1以上) 。

git clone git@github.com:KNJ/php-object-proxy.git
cd php-object-proxy
composer install
./vendor/bin/phpunit tests

サンプルの実行コマンド

  • php running.php
  • php repeating.php
  • php judgement.php
  • php unprotecting.php
  • ./vendor/bin/phpunit tests

※ 序論が長いので手っ取り早く実装を知りたい方は応用例とサンプルのソースコードを参照してください。

メソッドの挙動を変更するときの普通の方法

あるメソッドのふるまいを少しだけ変えたい場合には引数を与えてパラメータの変更ができるようにするでしょう。

例: Animal::run() はスピードを変更して実行できる。

sample/Running/Animal.php
<?php

namespace Sample\Running;

class Animal
{
    public function run($speed = 1)
    {
        // run at a speed of $speed
        return $speed;
    }
}

また、継承したクラスに依存するふるまいを加えたい場合にはオーバーライドをすることでしょう。

例: Dog は足の数だけ速くなる (現実はそうではないと思いますが) 。

sample/Running/Dog.php
<?php

namespace Sample\Running;

class Dog extends Animal
{
    const LEGS = 4;

    public function run($speed = 1)
    {
        $speed = $speed * self::LEGS;
        
        return parent::run($speed);
    }
}

では、同じように Human というクラスも Animal を継承して作ってみましょう。

例: Human は足の数だけ速くなる。

sample/Running/Human.php
<?php

namespace Sample\Running;

class Human extends Animal
{
    const LEGS = 2;

    public function run($speed = 1)
    {
        $speed = $speed * self::LEGS;
        
        return parent::run($speed);
    }
}

おや? Dog のコピペみたいですね。この調子で Cat とか Bear とか作ったらえらいことになりそうです…。であれば Animal::run() は最初から LEGS に依存させましょうか?しかし、 Animal が哺乳類以外も含めるのであれば Snake はどうすれば… (そもそも"走る"のでしょうか?) 。よし、 Animal を継承した Mammal を作って run() をオーバーライドし、 Mammal を継承した DogHuman を作りましょう!でも多重継承って、本当にいいんでしょうか?う~ん…。あー。 (ここで腕を組んで目をつむる)

メソッドをまったく別のオブジェクトに委譲してしまうという方法

悩んでいても埒が明かないので、 run()LEGS を利用する場合と利用しない場合があると視点を変えてみましょう。脚があってもそれを使わずに移動する動物もいます (小学生の時に観察したハナムグリの幼虫には大変な関心を抱いたものです) 。 LEGS を利用する・しないは選択できるようにしましょう。そして、 LEGS を利用して run() する場合、 Legs というクラスに run() を代理させます。

もう少しわかりやすく説明しましょう。犬がただ走るのであればそれは「犬が走る」となります。しかし、犬が脚を使って走るのであれば主体は脚に切り替わり「脚が走る」ということになります。このことをコードに書き出してみましょう。

まず、脚があるやつとないやつを区別しなければならないので次のようなインターフェイスを用意します。

sample/Running/WithLegs.php
<?php

namespace Sample\Running;

interface WithLegs
{
    // legs() は脚の数を返します.
    public function legs(): int;
}

脚は__主体を代理する__ことと、__走る__ことができるのでその2つのメソッドを用意します。当然主体は脚を有していることが前提なので WithLegs を実装したオブジェクトしか代理できません。このような代理をするオブジェクトをプロキシと呼ぶことにします。

sample/Running/Legs.php
<?php

namespace Sample\Running;

final class Legs
{
    private $animal;

    // represent は主体のオブジェクトを代理します.
    public function represent(WithLegs $animal)
    {
        $this->animal = $animal;

        return $this;
    }

    // これが実質 Animal::run() のオーバーライドになります.
    public function run($speed = 1)
    {
        $legs = $this->animal->legs();

        return $this->animal->run($speed * $legs);
    }
}

脚を使って走れるやつは CanRunWithLegs というトレイトを導入します。 withLegs() というメソッドによりプロキシ化します。

sample/Running/CanRunWithLegs.php
<?php

namespace Sample\Running;

trait CanRunWithLegs
{
    public function withLegs()
    {
        $proxy = new Legs;

        return $proxy->represent($this);
    }
}

そしてインターフェイスを実装した Dog に変更します。

sample/Running/Dog.php
<?php

namespace Sample\Running;

class Dog extends Animal implements WithLegs
{
    use CanRunWithLegs;

    const LEGS = 4;

    public function legs(): int
    {
        return self::LEGS;
    }
}

さあ、実際に使ってみましょう!

running.php
<?php

require_once __DIR__.'/vendor/autoload.php';

$dog = new \Sample\Running\Dog;

// 犬が走る
echo $dog->run() // => "1"
    .PHP_EOL;

// 犬の脚が走る
echo $dog->withLegs()->run() // => "4"
    .PHP_EOL;

メソッドの挙動を変更する方法として、以上のように引数の変更や継承してオーバーライドする以外の方法を示しました。こうすることのメリットは、オリジナルのメソッドも呼び出せたり、オリジナルを上書きしていることがわかりやすいことです。

プロキシ化するとプロキシに定義されているメソッド以外は使えなくなってしまいます (まったく別のオブジェクトなのだから当然) 。以下の応用例ではマジックメソッド __call() を使ってそれを解決します。

応用例

指定した回数だけ繰り返しメソッドを実行する

repeating.php
<?php

require_once __DIR__.'/vendor/autoload.php';

$dog = new \Sample\Repeating\Dog;
$cat = new \Sample\Repeating\Cat;

echo $dog->bark() // => "わん"
    .PHP_EOL;

echo $dog->repeat(3)->bark() // => "わんわんわん"
    .PHP_EOL;

echo $cat->repeat(2)->mew() // => "にゃーにゃー"
    .PHP_EOL;

プロキシに引数を持たせて挙動を細かく制御し、さらに __call() を実装してあらゆるメソッドをプロキシが実行できるようにしています。またトレイトさえ導入すればどんなオブジェクトでもプロキシ化できます1

プロキシの実装:

sample\repeating\repeater.php
<?php

namespace Sample\Repeating;

final class Repeater
{
    private $original;

    private $times;

    // represent は主体のオブジェクトを代理します.
    public function represent($original, $times)
    {
        $this->original = $original;
        $this->times = $times;

        return $this;
    }

    public function __call($method, $args)
    {
        for ($i = 0; $i < $this->times; $i++) {
            $this->original->$method(...$args);
        }
    }
}

条件にマッチしたらメソッドを実行する

judgement.php
<?php

require_once __DIR__.'/vendor/autoload.php';

$dog = new \Sample\Judgement\Dog;

$dog->when(function ($dog) {
    return $dog->isHungry();
})->eat(); // => ばくばく

$dog->when(function ($dog) {
    return $dog->isHungry();
})->eat(); // => eat() is not executed.

if を使いたくない人向け。上記の例では示せていませんが、一時的な変数を使用してもそのスコープをクロージャ内に閉じ込められるというメリットがあります。

プロキシの実装:

sample/Judgement/Judge.php
<?php

namespace Sample\Judgement;

use Closure;

final class Judge
{
    private $original;

    private $closure;

    // represent は主体のオブジェクトを代理します.
    public function represent($original, Closure $closure)
    {
        $this->original = $original;
        $this->closure = $closure;

        return $this;
    }

    public function __call($method, $args)
    {
        $closure = $this->closure;

        if ($closure($this->original)) {
            $this->original->$method(...$args);
        }
    }
}

public でないメソッドを外部から実行する

unprotecting.php
<?php

require_once __DIR__.'/vendor/autoload.php';

$dog = new \Sample\Unprotecting\Dog;
echo $dog->getAge().PHP_EOL; // => "0"

try {
    $dog->grow();
} catch (\Throwable $e) {
    echo $e->getMessage() // => "Call to private method Sample\Unprotecting\Dog::grow() from context ''"
        .PHP_EOL;
}

$dog->unprotect()->grow();
echo $dog->getAge().PHP_EOL; // => "1"
$dog->unprotect()->grow(2);
echo $dog->getAge().PHP_EOL; // => "3"

ユニットテストとかで private or protected なメソッドをテストしたいときの黒魔術詠唱をプロキシで簡略化しちゃいます。

プロキシの実装:

sample/Unprotecting/Unprotector.php
<?php

namespace Sample\Unprotecting;

final class Unprotector
{
    private $original;

    // represent は主体のオブジェクトを代理します.
    public function represent($original)
    {
        $this->original = $original;

        return $this;
    }

    public function __call($method, $args)
    {
        $closure = function ($original, $method, $args) {
            $original->$method(...$args);
        };
        $fn = $closure->bindTo($this->original, $this->original);

        return $fn($this->original, $method, $args);
    }
}

参考: PHP: Closure::bindTo - Manual

ただしこれをテストで使いたい場合、 トレイトが導入されていることが前提になるため、テストのためだけに多くのクラスで use trait 宣言をすることになってしまいます。このような場合はヘルパー関数でプロキシ化する手段を用意しておくとよいでしょう。

tests/bootstrap.php
<?php

require_once __DIR__.'/../vendor/autoload.php';

// public でないメソッドにアクセス可能にするプロキシ化
function unprotect($original)
{
    $proxy = new class($original)
    {
        public function __construct($original)
        {
            $this->original = $original;
        }

        public function __call($method, $args)
        {
            $closure = function ($original, $method, $args) {
                $original->$method(...$args);
            };
            $fn = $closure->bindTo($this->original, $this->original);

            return $fn($this->original, $method, $args);
        }
    };

    return $proxy;
}

テストコード: unprotect($this->dog)->grow() という形で使います。

tests/Unprotecting/DogTest.php
<?php

declare(strict_types=1);

namespace Tests\Unprotecting;

use PHPUnit\Framework\TestCase;

final class DogTest extends TestCase
{
    public function setUp()
    {
        $this->dog = new \Sample\Unprotecting\Dog;
    }

    public function testGrowMethod()
    {
        // Dog::$age を参照可能にする.
        $age = new \ReflectionProperty(\Sample\Unprotecting\Dog::class, 'age');
        $age->setAccessible(true);

        // private なメソッド Dog::grow() を3回実行して
        // Dog::$age が 0 -> 1 -> 2 -> 3 と増えることをテストする.
        for ($i = 0; $i < 3; $i++) {
            $this->assertSame($i, $age->getValue($this->dog));
            unprotect($this->dog)->grow();
        }

        $this->assertSame(3, $age->getValue($this->dog));
    }
}

他の応用アイデア

  • メソッドの返り値をいろいろなフォーマットに変換する (JSON, シリアル化, ハッシュ値, 暗号化など)
  • メソッド実行中に発行されたSQLを出力する
  • メソッドの実行時間やメモリ使用量を計測する

など、応用できる範囲はとても広いと思います。他にもアイデアがあったらコメントをください!

余談

Laravel には HigherOrderCollectionProxy という、今回解説したプロキシ設計に似たような概念が Collection 操作のために実装されています。eachmap などのメソッドをプロパティとして呼び出すとプロキシ化し、要素の1つ1つが次に呼び出すメソッドの主体になり反復処理が行われます。

実例を見たほうが早い:

次回予告 (近日予定)

さらに実用的な応用例として次回は Laravel における Repository パターンとキャッシュの連携を紹介したいと思います。

// UserRepository から "KNJ" という名前のユーザー情報を取得する.
$user = $userRepo->findByName('KNJ');

// UserRepository から "KNJ" という名前のユーザー情報を取得したときの結果をキャッシュする.
$userRepo->saveCache()->findByName('KNJ');

// UserRepository のキャッシュから "KNJ" という名前のユーザー情報を取得する.
$user = $userRepo->useCache()->findByName('KNJ');

// UserRepository から "KNJ" という名前のユーザー情報を取得したときの結果のキャッシュを消去する.
$userRepo->deleteCache()->findByName('KNJ');
  1. 制限を設けたい場合は sample/Running/Legs.php のように represent() の仮引数をインターフェイスでタイプヒントすればよいでしょう。

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?