diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2c69af..e20620f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide. * [Performance] [BC BREAK] **Advisor cache compiled to plain PHP** — the advisor cache is no longer a `serialize()`d blob under `{cacheDir}/_aspect/sha1(FQCN)`. Each aspect's pointcut/advisor graph is now compiled into an includable plain-PHP file returning `['version' => 1, 'advisors' => [...]]` built from nested static constructor expressions (advice leaves are pure-static-data `Interceptor::before(SomeAspect::class, 'method')` lazy facade calls, so including a cache file constructs no aspects and no advice closures), shadowing the aspect source below the cache directory: `{appDir}/src/Aspect/LoggingAspect.php` is cached as `{cacheDir}/src/Aspect/LoggingAspect.cache.php`. Loading a warm advisor cache is a single opcache-friendly `include` with no unserialization. **Cache format bump: clear your AOP cache directory when upgrading** (or re-run `cache:warmup:aop`); stale-format files are rebuilt automatically outside `PREBUILT_CACHE` mode. Aspects whose source is not below `appDir` are loaded directly and never cached. * [Performance] **Debug mode lets opcache cache untransformed files** — in debug mode the autoloader includes a file the cache records as untransformed by its original path when the record is fresh (same source size and mtime, tracked resources not newer), so opcache caches it instead of compiling it through `php://filter` on every request; woven files, stale records and misses keep the filter (XDebug breakpoints and magic constants of woven files rely on it, a changed source is woven again). Both the filter and the autoloader use one freshness rule, `CachePathManager::queryFreshCacheState()`. `AspectKernel::init()` tracks the aspect files without loading the aspects: their files are resolved through the wrapped composer loaders (`AopComposerLoader::findOriginalFile()`). `AspectContainer::onRegistration()` never autoloads any more: an id whose class is not loaded yet reaches the listener as a candidate. Warm debug request of the fixture project: `init()` ~1.3 ms → ~0.25 ms, an untransformed file ~38 µs → ~10 µs, the whole request 27–40% faster. * [Performance] **Proxy bodies built as AST** — the proxy generators build every intercepted method, function and (static) initialization body directly as syntax tree nodes (`static $__joinPoint` with its `@var` docblock, the `InterceptorInjector::for*()` call and the `__invoke()`), instead of printing each body to a string and parsing it back; the proxy is printed once. Intercepted methods are looked up in a name index built once per woven class, declared types are converted from the syntax tree without a round trip through reflection types, and attribute arguments are cloned by one shared traverser. Generated proxies are byte-identical; proxy generation takes about a third of its former time (weaving case of the transformation benchmark: −43%). `ClassProxyGenerator::getJoinpointInvocationBody()` is replaced by `getJoinpointInvocationStatements()` and `createOriginalMethodCallable()`. +* [Performance] **Joinpoints reflect on first use** — like `ClassFieldAccess`, the method and function joinpoints no longer build reflection in their constructors. `getFunction()` creates it when an advice first asks, and the reflection that `DynamicTraitAliasMethodInvocation` needs to call the original method is created on the first call that reaches it. Joinpoints whose advices never ask for reflection (e.g. an around advice serving a cached result) build none. A callable without a scope class or an unknown function name is now reported on first use instead of at construction. **Removed** * [BC BREAK] Removed `Features::INTERCEPT_FUNCTIONS` and `Features::INTERCEPT_INITIALIZATIONS`: the framework enables function and initialization interception from the registered pointcuts themselves. `execution(Namespace\function(*))` pointcuts weave function proxies without a flag, and the rewriting of `new` turns itself on while an `initialization(...)` pointcut is registered, lazily registered aspects included (the new internal `AdvisorIndex` loads them and tracks the pointcut kinds of every advisor). Before, an `initialization(...)` pointcut without the flag wove `__initialization()` but never ran its advice. diff --git a/src/Aop/AGENTS.md b/src/Aop/AGENTS.md index 1725c11a..20dc5c08 100644 --- a/src/Aop/AGENTS.md +++ b/src/Aop/AGENTS.md @@ -27,14 +27,19 @@ Proxy generators use TypeGenerator::renderTypeForPhpDoc() to emit V as 2nd gener ## Implementations (src/Aop/Framework/) | Class | Implements | Key behavior | |-----------------------------------|-------------------------|------------------------------------------------------------------------------------------------------------------------| -| AbstractMethodInvocation | MethodInvocation | Base; protected readonly Closure $closureToCall (FCC); TRAIT_ALIAS_SUFFIX='OriginalAlias'; keeps method reflection | -| DynamicTraitAliasMethodInvocation | DynamicMethodInvocation | receives $this->mOriginalAlias(...) or parent::m(...); proceed() via ReflectionMethod::invokeArgs (handles by-ref correctly) | +| AbstractMethodInvocation | MethodInvocation | Base; protected readonly Closure $closureToCall (FCC); TRAIT_ALIAS_SUFFIX='OriginalAlias'; getMethod() reflection created on first use | +| DynamicTraitAliasMethodInvocation | DynamicMethodInvocation | receives $this->mOriginalAlias(...) or parent::m(...); proceed() via ReflectionMethod::invokeArgs (handles by-ref correctly), resolved on the first call reaching the body | | StaticTraitAliasMethodInvocation | StaticMethodInvocation | FCC shim: static fn(array $args) => forward_static_call_array(...); bindTo(null, $scope) per call | -| ReflectionConstructorInvocation | ConstructorInvocation | newInstanceWithoutConstructor() then call constructor (`new` rewritten while an initialization pointcut is registered) | -| ReflectionFunctionInvocation | FunctionInvocation | receives FCC to global fn (e.g. \strlen(...) with leading \ to avoid recursive proxy call) | +| ReflectionConstructorInvocation | ConstructorInvocation | newInstanceWithoutConstructor() then call constructor (`new` rewritten while an initialization pointcut is registered); class/constructor reflection built eagerly: nearly every call needs it, and lazy creation measured slower | +| ReflectionFunctionInvocation | FunctionInvocation | receives FCC to global fn (e.g. \strlen(...) with leading \ to avoid recursive proxy call); getFunction() reflection created on first use | | ClassFieldAccess | FieldAccess | Property hooks call read()/write() by value; readByReference() only for the &get hook of arrays (binding released after) | | StaticInitializationJoinpoint | ClassJoinpoint | Fired once after proxy class loaded via `Proxy::__staticInitialization()` | +Reflection is created on first use (`$this->x ??= new Reflection...`) where a call can finish without it: joinpoints +are built once per member, and many advices (an around advice that never proceeds) never ask for reflection. +Exception: ReflectionConstructorInvocation builds its reflection eagerly, because creating the object always needs it +and the lazy version measured 20–25% slower on the first call. + HOT PATH: `__invoke()`/`proceed()` of these classes (`read()`/`readByReference()`/`write()` of ClassFieldAccess) run on every intercepted call. Code there is embedded on purpose: never extract parts into methods or a base class, never add object allocations, reflection or extra method calls (copy-paste of the frame handling across the classes is intentional). Joinpoint objects are shared by every call of diff --git a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php index 7866da83..b37b323b 100644 --- a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php +++ b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php @@ -12,10 +12,8 @@ namespace Go\Aop\Framework; -use Closure; use Go\Aop\Exception\InvocationException; use Go\Aop\Intercept\DynamicMethodInvocation; -use Go\Aop\Intercept\Interceptor; use ReflectionFunction; use ReflectionMethod; @@ -54,35 +52,12 @@ final class DynamicTraitAliasMethodInvocation extends AbstractMethodInvocation i private object $instance; /** - * ReflectionMethod pointing to the original method body: + * ReflectionMethod pointing to the original method body, resolved on the first call that reaches it + * (an around advice may never proceed): * - For methods with a trait alias: the private `OriginalAlias` alias. * - For inherited methods without a trait alias: the prototype method from the parent class. */ - private readonly ReflectionMethod $originalMethodToCall; - - /** - * @param array $advices List of advices for this invocation - * @param class-string $className Class, containing method to invoke - * @param non-empty-string $methodName Name of the method to invoke - * @param Closure $closureToCall First-class callable to the original method body, - * e.g. `$this->methodOriginalAlias(...)` for trait-aliased - * methods or `parent::method(...)` for inherited ones. - */ - public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) - { - parent::__construct($advices, $className, $methodName, $closureToCall); - - // Logic with reflection is used as workaround for PHP bug https://bugs.php.net/bug.php?id=72326 - $reflectionClosure = new ReflectionFunction($closureToCall); - $closureScopeClass = $reflectionClosure->getClosureScopeClass(); - if ($closureScopeClass === null) { - throw InvocationException::closureScopeUnresolvable(); - } - $this->originalMethodToCall = new ReflectionMethod( - $closureScopeClass->getName(), - $reflectionClosure->getName(), - ); - } + private ReflectionMethod $originalMethodToCall; /** * Hot path: runs on every intercepted call. The code is inlined on purpose and the frame handling is copied @@ -127,7 +102,7 @@ public function proceed(): mixed return $this->advices[$this->current++]->invoke($this); } - return $this->originalMethodToCall->invokeArgs($this->instance, $this->arguments); + return ($this->originalMethodToCall ??= $this->resolveOriginalMethod())->invokeArgs($this->instance, $this->arguments); } /** @@ -150,4 +125,20 @@ public function getScope(): string { return $this->instance::class; } + + /** + * Resolves the method behind the original-body callable. + * + * Reflection is used as workaround for PHP bug https://bugs.php.net/bug.php?id=72326 + */ + private function resolveOriginalMethod(): ReflectionMethod + { + $reflectionClosure = new ReflectionFunction($this->closureToCall); + $closureScopeClass = $reflectionClosure->getClosureScopeClass(); + if ($closureScopeClass === null) { + throw InvocationException::closureScopeUnresolvable(); + } + + return new ReflectionMethod($closureScopeClass->getName(), $reflectionClosure->getName()); + } } diff --git a/src/Aop/Framework/ReflectionFunctionInvocation.php b/src/Aop/Framework/ReflectionFunctionInvocation.php index 4d90f60e..2cc71ed0 100644 --- a/src/Aop/Framework/ReflectionFunctionInvocation.php +++ b/src/Aop/Framework/ReflectionFunctionInvocation.php @@ -44,25 +44,23 @@ final class ReflectionFunctionInvocation extends AbstractInvocation implements F private array $stackFrames = []; /** - * Instance of reflection function + * Reflection of the intercepted function, created on first use: only advices ask for it */ - private readonly ReflectionFunction $reflectionFunction; + private ReflectionFunction $reflectionFunction; /** * Constructor for function invocation * * @param array $advices List of advices for this invocation - * - * @throws ReflectionException */ public function __construct( array $advices, - string $functionName, + /** Name of the intercepted function */ + private readonly string $functionName, /** First-class callable to the original global function (e.g. `\file_get_contents(...)`) */ private readonly Closure $closureToCall, ) { parent::__construct($advices); - $this->reflectionFunction = new ReflectionFunction($functionName); } /** @@ -83,9 +81,12 @@ public function proceed(): mixed return ($this->closureToCall)(...$this->arguments); } + /** + * @throws ReflectionException If the function does not exist + */ public function getFunction(): ReflectionFunction { - return $this->reflectionFunction; + return $this->reflectionFunction ??= new ReflectionFunction($this->functionName); } /** @@ -135,7 +136,7 @@ public function __toString(): string { return sprintf( 'execution(%s())', - $this->reflectionFunction->getName(), + $this->getFunction()->getName(), ); } } diff --git a/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php b/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php index adb015cb..f5988e88 100644 --- a/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php +++ b/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php @@ -56,7 +56,17 @@ public function testRejectsClosureWithoutScopeClass(): void $this->expectException(InvocationException::class); $this->expectExceptionMessage('Cannot determine the scope class of the closure'); - new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'publicMethod', strlen(...)); + // The original method is resolved lazily, on the first call that reaches the method body + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'publicMethod', strlen(...)); + $invocation(new TraitAliasProxy()); + } + + public function testOriginalMethodIsNotResolvedWhenAdviceDoesNotProceed(): void + { + $around = new AroundInterceptor(static fn(): string => 'cached'); + $invocation = new DynamicTraitAliasMethodInvocation([$around], TraitAliasProxy::class, 'publicMethod', strlen(...)); + + $this->assertSame('cached', $invocation(new TraitAliasProxy())); } public function testAdviceIsCalledBeforeProceeding(): void diff --git a/tests/Aop/Framework/ReflectionConstructorInvocationTest.php b/tests/Aop/Framework/ReflectionConstructorInvocationTest.php index 2d716d17..09db98f8 100644 --- a/tests/Aop/Framework/ReflectionConstructorInvocationTest.php +++ b/tests/Aop/Framework/ReflectionConstructorInvocationTest.php @@ -48,6 +48,14 @@ public function testReturnsConstructor(): void $this->assertEquals('__construct', $ctor->name); } + public function testReturnsNullForClassWithoutConstructor(): void + { + $invocation = new ReflectionConstructorInvocation([], \stdClass::class); + + $this->assertNull($invocation->getConstructor()); + $this->assertSame(\stdClass::class, $invocation->__invoke()::class); + } + public function testReturnsThis(): void { $observed = []; diff --git a/tests/Aop/Framework/ReflectionFunctionInvocationTest.php b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php index f8068cda..53a073aa 100644 --- a/tests/Aop/Framework/ReflectionFunctionInvocationTest.php +++ b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php @@ -34,6 +34,20 @@ public function testInvokeCallsFirstClassCallableDirectly(): void $this->assertSame(5, $result); } + /** + * The function is reflected on first use only: an around advice that does not proceed never needs it. + */ + public function testFunctionIsNotReflectedUntilRequested(): void + { + $around = new AroundInterceptor(static fn(): string => 'cached'); + $invocation = new ReflectionFunctionInvocation([$around], 'go_aop_missing_function', \strlen(...)); + + $this->assertSame('cached', $invocation(['hello'])); + + $this->expectException(\ReflectionException::class); + $invocation->getFunction(); + } + /** * With a first-class callable, proceed() calls the callable directly, bypassing reflection. */