PHPでDbCの真似事

PHPにはAOP(Weaving)を実現するGo! AOP PHPという素敵なライブラリがあるそうなのでこれでDbC(契約プログラミング)的なことをやる。

実行サンプル

例として正の整数しか扱えないスタック、PositiveIntStackというのを用意する。

<?php

namespace Samples\Sample;

use Samples\DbC\Postcondition;
use Samples\DbC\Precondition;

/**
 * 正の整数のみを扱うスタック
 */
class PositiveIntStack
{
    /**
     * @param array $stack
     */
    public function __construct(private array $stack = [])
    {
    }

    /**
     * 渡された整数をスタックに積む
     * @param int $value 整数
     * @return void なし
     */
    #[Precondition('value > 0', '正の整数のみ使用できます。')]
    public function push(int $value): void
    {
        array_push($this->stack, $value);
    }

    /**
     * 整数をスタックから取り出して戻す。
     * @return int 取り出した整数
     */
    #[Precondition('not this.empty()', '空のスタックはpop出来ません。')]
    #[Postcondition('return > 0', '正の整数のみ戻されます。')]
    public function pop(): int
    {
        return array_pop($this->stack);
    }

    /**
     * @return bool スタックが空かどうかを戻す
     */
    public function empty(): bool
    {
        return empty($this->stack);
    }
}

使用例(テスト) PositiveIntStackTest.php

<?php

namespace Samples\Sample;

use Samples\AOP\DbCAspectKernel;
use Samples\DbC\Exception\PostconditionException;
use Samples\DbC\Exception\PreconditionException;

beforeAll(function (): void {
    $kernel = DbCAspectKernel::getInstance();

    $kernel->init([
        'debug' => true,
        'appDir' => __DIR__ . '/..',
        'cacheDir' => __DIR__ . '/cache',
        'includePaths' => []
    ]);
});

test('test PositiveIntStack push normal', function (): void {
    $stack = new PositiveIntStack();
    expect(fn() => $stack->push(1))->not->toThrow(\Throwable::class);
});
test('test PositiveIntStack push with negative number', function (): void {
    $stack = new PositiveIntStack();
    expect(fn() => $stack->push(-1))->toThrow(
        PreconditionException::class,
        '正の整数のみ使用できます。'
    );
});
test('test PositiveIntStack pop with positive number', function (): void {
    $stack = new PositiveIntStack();
    $stack->push(1);
    expect($stack->pop())->toBe(1);
});
test('test PositiveIntStack pop though empty', function (): void {
    $stack = new PositiveIntStack();
    expect(fn() => $stack->pop())->toThrow(
        PreconditionException::class,
        '空のスタックはpop出来ません'
    );
});
test('test PositiveIntStack pop negative value', function (): void {
    $stack = new PositiveIntStack([-1]);
    expect(fn() => $stack->pop())->toThrow(
        PostconditionException::class,
        '正の整数のみ戻されます。'
    );
});

環境

goaop/frameworkの安定版3.0.0はPHP7でしか使えないのでdevのを使う。お覚悟はよろしくて?
composer.json抜粋

{
  "require": {
    "php": "^8.4.2",
    "goaop/framework": "4.0.x-dev",
    "symfony/expression-language": "^v7.2.0"
  },
  "require-dev": {
    "pestphp/pest": "^3.7"
  },
  "minimum-stability": "dev",
  "prefer-stable": true
}

実装

事前条件と事後条件を記述するためのAttributeを作成する。条件を書く式言語にSymfony ExpressionLanguageを借りました。
例外は適当に定義したもの

<?php

namespace Samples\DbC;

use Samples\DbC\Exception\PreconditionException;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

#[\Attribute]
class Precondition
{
    public function __construct(
        private readonly string $condition,
        private readonly string $message = 'Precondition failed.'
    ) {
    }

    public function validate($object, array $args): void
    {
        $language = new ExpressionLanguage();
        $result = $language->evaluate($this->condition, ['this' => $object, ...$args]);
        if (!$result) {
            throw new PreconditionException($this->message);
        }
    }
}
<?php

namespace Samples\DbC;

use Samples\DbC\Exception\PostconditionException;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

#[\Attribute]
class Postcondition
{
    public function __construct(
        private readonly string $condition,
        private readonly string $message = 'Precondition failed.'
    ) {
    }

    public function validate($object, $returnValue): void
    {
        $language = new ExpressionLanguage();
        $result = $language->evaluate($this->condition, ['this' => $object, 'return' => $returnValue]);
        if (!$result) {
            throw new PostconditionException($this->message);
        }
    }
}

Aspectを定義する。戻り値を使用したいのでAroundとして定義。
ポイントカットは@executionを使用。これで指定したアトリビュートが付けられたメソッドが対象になる。
(メモ:@が付くポイントカットはアスペクト/アトリビュート用)

<?php

namespace Samples\AOP;

use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Attribute\Around;
use Samples\DbC\Postcondition;
use Samples\DbC\Precondition;

class ContractAspect implements Aspect
{
    #[Around("@execution(Samples\DbC\Precondition) || @execution(Samples\DbC\Postcondition)")]
    public function aroundExecutionForContract(MethodInvocation $invocation)
    {
        $object = $invocation->getThis();
        $reflectionMethod = $invocation->getMethod();
        $args = $invocation->getArguments();

        // 事前条件の検証
        $preconditionAttributes = $reflectionMethod->getAttributes(Precondition::class);
        if (!empty($preconditionAttributes)) {
            // 引数の名前と値を使って連想配列にする
            $names = array_column($reflectionMethod->getParameters(), 'name');
            $namedArgs = array_combine($names, $args);
            // 検証を実行
            foreach ($preconditionAttributes as $attribute) {
                $attributeInstance = $attribute->newInstance();
                $attributeInstance->validate($object, $namedArgs);
            }
        }

        // メソッド実行(戻り値を取得)
        $returnValue = $invocation->proceed();

        // 事後条件の検証
        $postconditionAttributes = $reflectionMethod->getAttributes(Postcondition::class);
        if (!empty($postconditionAttributes)) {
            foreach ($postconditionAttributes as $attribute) {
                $attributeInstance = $attribute->newInstance();
                $attributeInstance->validate($object, $returnValue);
            }
        }

        return $returnValue;
    }
}

これを登録するAspectKernelの実クラスを実装

<?php
<?php

namespace Samples\AOP;

use Go\Core\AspectContainer;
use Go\Core\AspectKernel;

class DbCAspectKernel extends AspectKernel
{
    /**
     * @inheritDoc
     */
    protected function configureAop(AspectContainer $container): void
    {
        $container->registerAspect(new ContractAspect());
    }
}

これで冒頭の実行サンプルが動く

解説

Go! AOP PHPのweavingの仕組み

Go! AOP PHPは特定のpointcutに対してAspectを登録することで関数やメソッドの呼び出し前後に処理を追加することができる。
Aspectに指定できる実行タイミングの種類にはBefore(呼び出し前に実行)、After(呼び出し後に実行)、Around(呼び出す代わりに実行)がある。
どうやって実現しているかというと、PHPのautoloadの機構を使ってクラスローディングのタイミングで読み込まれるクラスを解析して処理を追加(weaving)するらしい。(なのでAspectKernelのinitの前に読み込まれたクラスはweavingされない。)
どの関数やどのクラスのメソッドに処理を追加するのかを指定するのがポイントカットで、今回だとContractAspectのAroundアトリビュートに指定した "@execution(Samples\DbC\Precondition)"という文字列がそれにあたる。アトリビュート/アノテーション以外に直接メソッドや関数を対象とすることもできる。
AspectKernelのinit時にconfigureAop()が呼ばれるのでそこでAspectを登録→Aspectに指定されたポイントカットが記録される→クラス読み込み時に該当する関数/メソッドがあれば処理を追加、という流れ。

Precondition/Postconditionに処理を渡す仕組み

Precondition/Postconditionを付けたメソッドが呼ばれると、そのタイミングでContractAspectの該当メソッドが呼ばれるので、実行時の情報(対象オブジェクトや引数の値や戻り値)をPrecondition/Postconditionに渡して検証している。
Preconditionには、対象オブジェクトと引数を渡す。引数は引数名をキーとする連想配列にしている。
Postconditionには、対象オブジェクトと戻り値を渡す。

Precondition/Postcondition内での処理の仕組み

Precondition/Postconditionはコンストラクタで第1引数を条件($condition)、第2引数を違反時のメッセージ($message)としてプロパティに保存している。ここにはPrecondition/Postconditionアトリビュートに定義した値が渡ってくる。(サンプルでいえば'value > 0'と'正の整数のみ使用できます。'など)
実行時はContractAspectからvalidate()が呼び出されるので、$conditionをSymphony ExpressionLanguageの式、thisとパラメータ(PostConditionの場合はreturn)を変数(環境)として渡して評価し、falseだったら条件違反として例外を投げる。
式内では引数名の$を取ったものが変数として使える。
ここの仕組みはextractとevalでも書けるけど何でも出来ると危ないからね。式言語を使います。