装饰器

OxPHP 装饰器使用 PHP 8 属性拦截 PHP 函数和方法调用。为任意函数或方法添加一个属性,OxPHP 便会在每次调用前后调用你的装饰器的 before()after() 方法,而无需改动原始代码。

工作原理

  1. 定义一个装饰器类,实现 OxPHP\Decorator\AttributeInterface 接口并用 #[Attribute] 标注。
  2. 在引导阶段用 oxphp_register_decorator(ClassName::class) 注册一次。
  3. 将该属性应用到任意函数、方法或类上。
  4. 首次调用被装饰的函数时,OxPHP 检测到该属性并安装拦截钩子。
  5. 之后每次调用时,before() 在函数执行前运行,after() 在函数返回后运行。

编写装饰器

一个装饰器类需要两样东西:#[Attribute] 标注以及对 AttributeInterface 的实现。

php
<?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
<?php require __DIR__ . '/../vendor/autoload.php'; oxphp_register_decorator(Timer::class);

将它应用到函数和方法上:

php
<?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
<?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::methodmy_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
<?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
<?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 观察到该异常,而抛出异常的那个装饰器的 after() 不会被调用。

关于中止执行的重要注意事项

PHP 的 zend_observer_fcall_begin API——OxPHP 用它来调用 before()——并不提供取消调用本身的途径。当 before() 抛出异常时,在 VM 展开到最近的异常处理器之前,被装饰的函数体可能仍会执行少量操作码。不要依赖 RejectedException 来跳过函数体内的副作用。 应将装饰器的拒绝视为“调用方看到一个异常”,并在函数体内部(或它之前)实现任何硬性授权关卡,而不是在装饰器里实现。

中止执行

装饰器可以通过在 before() 中抛出 OxPHP\Decorator\RejectedException 来发出拒绝信号。该异常会传播给调用方,但(如上所述)它并不是一个调用前的否决:

php
<?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
<?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] 属性注册一个内置装饰器。它会在函数进入时自动创建一个 span,并在退出时关闭它——无需手动调用 oxphp_apm_start() / oxphp_apm_end()

php
<?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
<?php require __DIR__ . '/../vendor/autoload.php'; // Now the class can be found oxphp_register_decorator(Timer::class);
构造函数参数在请求之间不更新

属性参数是源代码级别的字面量,在装饰器实例被构造时(每次请求中的首次调用)求值。它们不是运行时值,因此变化的应用数据无法改变它们——它们只有在你编辑代码中的属性时才会改变。

修复: 使用 before() 进行每次请求的初始化,而不是构造函数。构造函数应只接受属性中声明的静态配置。

PHP 示例

缓存装饰器

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
<?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(), ])); } }

另请参阅