デコレーター
OxPHP のデコレーターは、PHP 8 の属性を使って PHP の関数呼び出しとメソッド呼び出しをインターセプトします。任意の関数やメソッドに属性を付けるだけで、元のコードを変更することなく、OxPHP が呼び出しのたびにデコレーターの before() メソッドと after() メソッドをその前後で呼び出します。
仕組み
OxPHP\Decorator\AttributeInterfaceを実装し、#[Attribute]を付けたデコレータークラスを定義します。- ブートストラップ時に
oxphp_register_decorator(ClassName::class)で一度だけ登録します。 - 任意の関数、メソッド、クラスにその属性を適用します。
- デコレートされた関数への最初の呼び出しで、OxPHP が属性を検出し、インターセプト用のフックをインストールします。
- それ以降のすべての呼び出しで、関数の前に
before()が実行され、関数がリターンした後にafter()が実行されます。
デコレーターを書く
デコレータークラスには 2 つのものが必要です。#[Attribute] アノテーションと、AttributeInterface の実装です。
<?php
use OxPHP\Decorator\AttributeInterface;
use OxPHP\Decorator\Context;
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
class Timer implements AttributeInterface
{
private float $start;
public function __construct(
public readonly string $label = '',
) {}
public function before(Context $ctx): void
{
$this->start = hrtime(true);
}
public function after(Context $ctx): void
{
$elapsed = (hrtime(true) - $this->start) / 1e6;
error_log(sprintf('[Timer] %s: %.2fms', $this->label ?: $ctx->target, $elapsed));
}
}デコレートされた関数が呼び出される前、ブートストラップ時にデコレーターを登録します。
<?php
require __DIR__ . '/../vendor/autoload.php';
oxphp_register_decorator(Timer::class);関数やメソッドに適用します。
<?php
#[Timer]
function processOrder(int $orderId): void
{
// Timer::before() runs before this
// Timer::after() runs after this
}
#[Timer(label: 'db-query')]
function fetchUser(int $id): array
{
return $db->query('SELECT * FROM users WHERE id = ?', [$id]);
}
class PaymentService
{
#[Timer(label: 'payment')]
public function charge(float $amount): bool
{
// ...
}
}クラスレベルのデコレーター
クラスに属性を適用すると、そのすべてのメソッドをデコレートできます。
<?php
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Audit implements AttributeInterface
{
public function before(Context $ctx): void
{
error_log("Calling {$ctx->target}");
}
public function after(Context $ctx): void
{
$status = $ctx->hasResult() ? 'ok' : 'error';
error_log("Finished {$ctx->target}: {$status}");
}
}
// Register
oxphp_register_decorator(Audit::class);
// Every method in this class is now audited
#[Audit]
class OrderService
{
public function create(array $data): int { /* ... */ }
public function cancel(int $id): void { /* ... */ }
}Context オブジェクト
before() と after() はどちらも、デコレートされた呼び出しに関する情報を持つ OxPHP\Decorator\Context オブジェクトを受け取ります。
プロパティ
| プロパティ | 型 | 説明 |
|---|---|---|
$target |
string |
完全なターゲット名。App\Service::method または my_function |
$class |
string |
クラス名。単独の関数の場合は "" |
$method |
string |
メソッド名。単独の関数の場合は "" |
$function |
string |
単独の関数の場合は関数名。メソッドの場合は "" |
$objectId |
int |
呼び出されたオブジェクトの spl_object_id()。関数および静的メソッドの場合は 0 |
$requestId |
string |
現在のリクエストID |
$traceId |
string |
現在の W3C トレースID。分散トレーシングが有効でない場合は空文字列 |
メソッド
| メソッド | 利用可能な箇所 | 説明 |
|---|---|---|
getParams(): array |
before() と after() |
デコレートされた関数に渡された引数 |
getResult(): mixed |
after() のみ |
デコレートされた関数の戻り値。before() 内、または例外の発生後は null を返す |
hasResult(): bool |
after() のみ |
関数が例外を投げずに正常にリターンした場合は true |
引数を調べる
getParams() は引数を数値インデックスの配列として返します。
<?php
#[Attribute(Attribute::TARGET_FUNCTION)]
class ValidateArgs implements AttributeInterface
{
public function before(Context $ctx): void
{
$params = $ctx->getParams();
foreach ($params as $i => $value) {
if ($value === null) {
throw new \InvalidArgumentException(
"Argument {$i} of {$ctx->target} must not be null"
);
}
}
}
public function after(Context $ctx): void {}
}複数のデコレーター
同じ関数に複数のデコレーターを重ねられます。before() は宣言順に、after() は逆順に実行されます。
<?php
#[RateLimit(maxCalls: 100, windowSeconds: 60)]
#[Timer]
#[Cache(ttl: 300)]
function getProduct(int $id): array
{
// Execution order:
// 1. RateLimit::before()
// 2. Timer::before()
// 3. Cache::before()
// 4. getProduct() executes
// 5. Cache::after()
// 6. Timer::after()
// 7. RateLimit::after()
}before() が例外を投げた場合、OxPHP はその例外を記録し、すでに before() を完了していたすべてのデコレーターに対して逆順に after() を呼び出します。呼び出し側は通常の PHP の try/catch を通じてその例外を観測できますが、before() が例外を投げたデコレーターについては after() は呼び出されません。
OxPHP が before() の呼び出しに使う PHP の zend_observer_fcall_begin API には、呼び出しそのものをキャンセルする手段がありません。before() が例外を投げても、VM が最も近い例外ハンドラーまで巻き戻される前に、デコレートされた関数本体がいくつかのオペコードを実行してしまう可能性があります。関数本体内での副作用をスキップするために RejectedException に頼らないでください。 デコレーターによる拒否は「呼び出し側が例外を受け取る」ものとして扱い、厳格な認可のゲートは(デコレーターではなく)関数本体の内部、またはその手前に実装してください。
実行の停止
デコレーターは before() から OxPHP\Decorator\RejectedException を投げることで拒否を伝えられます。例外は呼び出し側に伝播しますが、(前述のとおり)これは呼び出し前の拒否権ではありません。
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class RequireRole implements AttributeInterface
{
public function __construct(
public readonly string $role,
) {}
public function before(Context $ctx): void
{
if (!current_user_has_role($this->role)) {
throw new \OxPHP\Decorator\RejectedException(
"Access denied: requires role '{$this->role}'"
);
}
}
public function after(Context $ctx): void {}
}ワーカーモードでの挙動
ワーカーモードでは PHP プロセスがリクエストをまたいで存続しますが、デコレーターのインスタンスは存続しません。ワーカーごとのインスタンスキャッシュは、リクエストのたびにその終了時にクリアされます。これは次のことを意味します。
- コンストラクターのロジックは、リクエストごとに、デコレートされた各関数につき一度だけ実行されます。それはそのリクエスト内でその関数が最初に呼び出されたタイミングであり、ワーカーの生存期間中に一度だけではありません
- プロパティに保持されたインスタンスの状態は、単一のリクエスト内でデコレートされた関数のすべての呼び出しで共有されますが、次のリクエストには引き継がれません
- 各リクエストは、新しいインスタンス(および属性の引数を再評価する新しいコンストラクターの実行)で始まります
デコレーターは、リクエスト間でステートレスになるように設計してください。リクエストごとの状態が必要な場合は、before() で設定し、after() で読み取ってください。
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class RequestTimer implements AttributeInterface
{
// Per-request state: set in before(), read in after()
private float $start;
public function before(Context $ctx): void
{
$this->start = hrtime(true);
}
public function after(Context $ctx): void
{
$elapsed = (hrtime(true) - $this->start) / 1e6;
// Safe: $this->start is always set fresh in before()
}
}組み込みのデコレーター
#[OxPHP\Apm\Trace]
APM プラグインが有効(OTEL_APM_ENABLED=true)な場合、OxPHP は #[OxPHP\Apm\Trace] 属性に対する組み込みのデコレーターを登録します。これは関数の入口で自動的にスパンを作成し、出口でそれをクローズします。手動での oxphp_apm_start() / oxphp_apm_end() の呼び出しは不要です。
<?php
use OxPHP\Apm\Trace;
#[Trace]
function processOrder(int $orderId): void
{
// A span named "processOrder" is created automatically.
// If this function throws, the span is marked as error
// and an "exception" event is recorded with the class name.
}
class PaymentService
{
#[Trace]
public function charge(float $amount): bool
{
// Span named "PaymentService::charge"
return true;
}
}#[Trace] 属性は関数とメソッドの両方を対象にできます。これはユーザー定義の PHP コードで動作します(内部の C 関数では動作しません。それらは APM の自動計装フックによって処理されます)。
oxphp_register_decorator() の呼び出しは不要です。APM プラグインがサーバーの初期化時にこのデコレーターを自動的に登録します。このデコレーターは標準モードとワーカーモードの両方で利用できます。
APM トレーシングの詳細については、分散トレーシングと APM を参照してください。
制限事項
- ユーザー関数のみ — 組み込みの PHP 関数はデコレートできません。インターセプトできるのは PHP コードで定義された関数とメソッドのみです
- 最初の呼び出しより前に登録 — デコレーターは、対象とするいずれかの関数が最初に呼び出される前に登録する必要があります。ブートストラップ時に登録してください
- スカラーのコンストラクター引数 — 属性のコンストラクター引数は最初の呼び出し時に一度だけ評価されます。属性内の複雑な式や実行時の値はサポートされません
- 最大 256 ネストレベル — デコレーターのコンテキストスタックは、ネストされたデコレート済み関数呼び出しを最大 256 レベルまでサポートします。それを超えると、呼び出しはデコレーターのコンテキストを暗黙のうちに破壊するのではなく、
OxPHP\Decorator\StackOverflowExceptionを投げます
トラブルシューティング
デコレーターが呼び出しをインターセプトしない
デコレーターが、関数がすでに呼び出された後に登録されているか、または属性が認識されていません。
確認事項: oxphp_register_decorator() が、デコレートされたいずれかの関数が呼び出される前に呼ばれていることを確認してください。ワーカーモードでは、oxphp_worker() より前の外側のスコープで登録してください。
登録時に「Class not found」となる
oxphp_register_decorator() が呼び出された時点で、デコレータークラスが読み込まれていません。
対処: オートローダーが先に登録されていることを確認してください。
<?php
require __DIR__ . '/../vendor/autoload.php';
// Now the class can be found
oxphp_register_decorator(Timer::class);コンストラクター引数がリクエスト間で更新されない
属性の引数はソースレベルのリテラルであり、デコレーターのインスタンスが構築されるとき(各リクエストの最初の呼び出し)に評価されます。これらは実行時の値ではないため、変化するアプリケーションのデータでそれらを変えることはできません。コードで属性を編集したときにのみ変化します。
対処: リクエストごとの初期化にはコンストラクターではなく before() を使ってください。コンストラクターは、属性で宣言された静的な設定のみを受け取るべきです。
PHP の例
キャッシュデコレーター
<?php
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
class Cache implements AttributeInterface
{
private static array $store = [];
public function __construct(
public readonly int $ttl = 60,
) {}
public function before(Context $ctx): void
{
$key = $ctx->target . ':' . serialize($ctx->getParams());
if (isset(self::$store[$key]) && self::$store[$key]['expires'] > time()) {
// Skip function execution — return cached value
// Note: you cannot short-circuit execution from PHP decorators.
// Use this pattern with an external cache check in the function itself.
}
}
public function after(Context $ctx): void
{
if ($ctx->hasResult()) {
$key = $ctx->target . ':' . serialize($ctx->getParams());
self::$store[$key] = [
'value' => $ctx->getResult(),
'expires' => time() + $this->ttl,
];
}
}
}リクエストコンテキスト付きのロギングデコレーター
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class LogCall implements AttributeInterface
{
public function before(Context $ctx): void
{
error_log(json_encode([
'event' => 'call_start',
'target' => $ctx->target,
'request_id' => $ctx->requestId,
'params' => $ctx->getParams(),
]));
}
public function after(Context $ctx): void
{
error_log(json_encode([
'event' => 'call_end',
'target' => $ctx->target,
'request_id' => $ctx->requestId,
'success' => $ctx->hasResult(),
]));
}
}