Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions src/Aop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 20 additions & 29 deletions src/Aop/Framework/DynamicTraitAliasMethodInvocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 `<method>OriginalAlias` alias.
* - For inherited methods without a trait alias: the prototype method from the parent class.
*/
private readonly ReflectionMethod $originalMethodToCall;

/**
* @param array<Interceptor> $advices List of advices for this invocation
* @param class-string<T> $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
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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());
}
}
17 changes: 9 additions & 8 deletions src/Aop/Framework/ReflectionFunctionInvocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Interceptor> $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);
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -135,7 +136,7 @@ public function __toString(): string
{
return sprintf(
'execution(%s())',
$this->reflectionFunction->getName(),
$this->getFunction()->getName(),
);
}
}
12 changes: 11 additions & 1 deletion tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/Aop/Framework/ReflectionConstructorInvocationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
14 changes: 14 additions & 0 deletions tests/Aop/Framework/ReflectionFunctionInvocationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading