.
This commit is contained in:
commit
a5ce423afe
30 changed files with 1807 additions and 0 deletions
19
src/Aspect.php
Normal file
19
src/Aspect.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_METHOD)]
|
||||
class Aspect
|
||||
{
|
||||
public function before(mixed ...$args): void
|
||||
{
|
||||
echo "before\n";
|
||||
}
|
||||
|
||||
public function after(mixed $result): void
|
||||
{
|
||||
echo "after\n";
|
||||
}
|
||||
}
|
||||
40
src/AspectBuilder.php
Normal file
40
src/AspectBuilder.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
class AspectBuilder
|
||||
{
|
||||
public array $classes = [];
|
||||
public array $namespaces = [];
|
||||
|
||||
public static function begin(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,class-string> $classes
|
||||
*/
|
||||
public function withClasses(array $classes): self
|
||||
{
|
||||
$this->classes = $classes;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $namespaces
|
||||
*/
|
||||
public function withNamespaces(array $namespaces): self
|
||||
{
|
||||
$this->namespaces = $namespaces;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,class-string> $aspects
|
||||
*/
|
||||
public function build(AspectWeaver $weaver): AspectLoader
|
||||
{
|
||||
return new AspectLoader($weaver, $this->classes, $this->namespaces);
|
||||
}
|
||||
}
|
||||
18
src/AspectException.php
Normal file
18
src/AspectException.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class AspectException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $aspectClass,
|
||||
public readonly string $method,
|
||||
?Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
77
src/AspectLoader.php
Normal file
77
src/AspectLoader.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
class AspectLoader
|
||||
{
|
||||
/**
|
||||
* @param array<int,class-string> $classes
|
||||
* @param array<int,string> $namespaces
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly AspectWeaver $weaver,
|
||||
public readonly array $classes,
|
||||
public readonly array $namespaces,
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(): self
|
||||
{
|
||||
spl_autoload_register([$this, 'load'], true, true);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function load(string $className): bool
|
||||
{
|
||||
if (!$this->shouldProcessClass($className)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = $this->findClassFile($className);
|
||||
|
||||
if ($file === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->weaver->weave($className, $file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function shouldProcessClass(string $className): bool
|
||||
{
|
||||
if (!empty($this->classes)) {
|
||||
return in_array($className, $this->classes, true);
|
||||
}
|
||||
|
||||
if (!empty($this->namespaces)) {
|
||||
foreach ($this->namespaces as $namespace) {
|
||||
if (str_starts_with($className, $namespace)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function findClassFile(string $className): ?string
|
||||
{
|
||||
$autoloaders = spl_autoload_functions();
|
||||
|
||||
foreach ($autoloaders as $autoloader) {
|
||||
if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) {
|
||||
$classLoader = $autoloader[0];
|
||||
$file = $classLoader->findFile($className);
|
||||
|
||||
if ($file !== false) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
446
src/AspectWeaver.php
Normal file
446
src/AspectWeaver.php
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use ReflectionNamedType;
|
||||
use ReflectionType;
|
||||
use ReflectionUnionType;
|
||||
use ReflectionIntersectionType;
|
||||
|
||||
class AspectWeaver
|
||||
{
|
||||
/**
|
||||
* @param array<int,class-string> $aspects
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $aspects,
|
||||
public readonly string $cacheDir,
|
||||
public readonly bool $useCache,
|
||||
public readonly LoggerInterface $logger,
|
||||
) {
|
||||
if (!is_dir($this->cacheDir)) {
|
||||
mkdir($this->cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function weave(string $className, string $filePath): ?string
|
||||
{
|
||||
$cacheKey = str_replace('\\', '_', $className);
|
||||
$cachedFile = $this->cacheDir . '/' . $cacheKey . '.php';
|
||||
|
||||
if ($this->useCache && file_exists($cachedFile)) {
|
||||
require $cachedFile;
|
||||
return $cachedFile;
|
||||
}
|
||||
|
||||
$sourceCode = file_get_contents($filePath);
|
||||
|
||||
if (!$this->hasAspectAttribute($sourceCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->generateProxyWithReflection($className, $sourceCode);
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
file_put_contents($cachedFile, $result);
|
||||
|
||||
$this->logger->debug('Generated proxy class file', [
|
||||
'class' => $className,
|
||||
'path' => $cachedFile,
|
||||
]);
|
||||
|
||||
return $cachedFile;
|
||||
}
|
||||
|
||||
private function hasAspectAttribute(string $sourceCode): bool
|
||||
{
|
||||
foreach ($this->aspects as $aspect) {
|
||||
$useStatement = '/use\s+' . preg_quote($aspect, '/') . '\s*(?:as\s+\w+)?\s*;/';
|
||||
if (preg_match($useStatement, $sourceCode) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strpos($sourceCode, '#[\\' . $aspect . ']') !== false ||
|
||||
strpos($sourceCode, '#[\\' . $aspect . '(') !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function generateProxyWithReflection(string $className, string $sourceCode): ?string
|
||||
{
|
||||
$parts = explode('\\', $className);
|
||||
$shortName = array_pop($parts);
|
||||
$namespace = implode('\\', $parts);
|
||||
|
||||
$renamedClassName = '__AopOriginal_' . $shortName;
|
||||
$fullRenamedClassName = $namespace ? "{$namespace}\\{$renamedClassName}" : $renamedClassName;
|
||||
|
||||
$modifiedSource = $this->renameClass($sourceCode, $shortName, $renamedClassName, $namespace);
|
||||
|
||||
if (!class_exists($fullRenamedClassName)) {
|
||||
eval($modifiedSource);
|
||||
|
||||
if (!class_exists($fullRenamedClassName)) {
|
||||
$this->logger->error('Failed to load renamed class after eval', [
|
||||
'originalClass' => $className,
|
||||
'renamedClass' => $fullRenamedClassName,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fullRenamedClassName);
|
||||
|
||||
$methods = $this->getMethodsWithAspects($reflection);
|
||||
|
||||
if (empty($methods)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$proxyOnlyCode = $this->buildProxyClass($namespace, $shortName, $renamedClassName, $methods);
|
||||
eval($proxyOnlyCode);
|
||||
|
||||
return $this->buildMergedProxyAndOriginalFile(
|
||||
$namespace,
|
||||
$shortName,
|
||||
$renamedClassName,
|
||||
$methods,
|
||||
$modifiedSource,
|
||||
);
|
||||
}
|
||||
|
||||
private function renameClass(string $sourceCode, string $originalName, string $newName, string $namespace): string
|
||||
{
|
||||
$modified = preg_replace('/<\?php\s*/', '', $sourceCode, 1);
|
||||
|
||||
if ($namespace) {
|
||||
$modified = preg_replace('/namespace\s+[\w\\\\]+\s*;/', '', $modified, 1);
|
||||
}
|
||||
|
||||
$modified = preg_replace(
|
||||
'/\bclass\s+' . preg_quote($originalName, '/') . '\b/',
|
||||
'class ' . $newName,
|
||||
$modified,
|
||||
1
|
||||
);
|
||||
|
||||
if ($namespace) {
|
||||
$modified = "namespace {$namespace};\n\n" . $modified;
|
||||
}
|
||||
|
||||
return $modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map methods to their aspect classes and attributes for stacking support
|
||||
* @param ReflectionClass<object> $reflection
|
||||
* @return array<int, MethodMetadata>
|
||||
*/
|
||||
private function getMethodsWithAspects(ReflectionClass $reflection): array
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED) as $method) {
|
||||
if ($method->getDeclaringClass()->getName() !== $reflection->getName()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$methodAspects = array_map(
|
||||
fn ($attribute) => new AttributeMetadata($attribute->getName(), $attribute),
|
||||
array_filter(
|
||||
$method->getAttributes(),
|
||||
fn ($attribute) => in_array($attribute->getName(), $this->aspects, true)
|
||||
)
|
||||
);
|
||||
|
||||
if (!empty($methodAspects)) {
|
||||
$metadata[] = new MethodMetadata($method, $methodAspects);
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, MethodMetadata> $methodAspectsMap
|
||||
*/
|
||||
private function buildMergedProxyAndOriginalFile(
|
||||
string $namespace,
|
||||
string $shortName,
|
||||
string $renamedClassName,
|
||||
array $methodAspectsMap,
|
||||
string $modifiedSource
|
||||
): string {
|
||||
$code = "<?php\n\n";
|
||||
$code .= trim($modifiedSource) . "\n\n";
|
||||
$code .= "class {$shortName} extends {$renamedClassName}\n{\n";
|
||||
|
||||
foreach ($methodAspectsMap as $methodMetadata) {
|
||||
$code .= $this->generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects);
|
||||
}
|
||||
|
||||
$code .= "}\n";
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MethodMetadata> $methodAspectsMap
|
||||
*/
|
||||
private function buildProxyClass(
|
||||
string $namespace,
|
||||
string $shortName,
|
||||
string $renamedClassName,
|
||||
array $methodAspectsMap
|
||||
): string {
|
||||
$code = '';
|
||||
|
||||
if ($namespace) {
|
||||
$code .= "namespace {$namespace};\n";
|
||||
}
|
||||
|
||||
$code .= "class {$shortName} extends {$renamedClassName}\n{\n";
|
||||
|
||||
foreach ($methodAspectsMap as $methodMetadata) {
|
||||
$code .= $this->generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects);
|
||||
}
|
||||
|
||||
$code .= "}\n";
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, AttributeMetadata> $aspects
|
||||
*/
|
||||
private function generateProxyMethodFromReflection(ReflectionMethod $method, array $aspects): string
|
||||
{
|
||||
$visibility = $method->isPublic() ? 'public' : 'protected';
|
||||
$static = $method->isStatic() ? 'static ' : '';
|
||||
$isStatic = $method->isStatic();
|
||||
$name = $method->getName();
|
||||
$className = $method->getDeclaringClass()->getName();
|
||||
|
||||
$this->logger->info('Generating proxy method', [
|
||||
'class' => $className,
|
||||
'method' => $name,
|
||||
'aspects' => array_map(fn ($a) => $a->aspectClass, $aspects),
|
||||
]);
|
||||
|
||||
$params = $this->buildParameterList($method);
|
||||
$paramNames = $this->buildParameterCallList($method);
|
||||
|
||||
$returnType = $this->buildReturnType($method);
|
||||
|
||||
$code = " {$visibility} {$static}function {$name}({$params}){$returnType}\n";
|
||||
$code .= " {\n";
|
||||
|
||||
// Instantiate all aspects using their constructor arguments from attributes
|
||||
foreach ($aspects as $index => $aspectMetadata) {
|
||||
$aspectClass = $aspectMetadata->aspectClass;
|
||||
$attribute = $aspectMetadata->attribute;
|
||||
|
||||
// Get constructor arguments from the attribute
|
||||
$args = $attribute->getArguments();
|
||||
$aspectConstructorArguments = $this->buildConstructorArguments($args);
|
||||
|
||||
$code .= " \$aspect{$index} = new \\{$aspectClass}({$aspectConstructorArguments});\n";
|
||||
}
|
||||
|
||||
// Prepare target argument for aspect methods
|
||||
$targetArg = $isStatic ? "static::class" : "\$this";
|
||||
|
||||
// Call all before methods in a single try-catch
|
||||
$code .= " \$currentAspect = '';\n";
|
||||
$code .= " try {\n";
|
||||
foreach ($aspects as $index => $aspectMetadata) {
|
||||
$aspectClass = $aspectMetadata->aspectClass;
|
||||
$aspectReflection = new ReflectionClass($aspectClass);
|
||||
if ($aspectReflection->hasMethod('before')) {
|
||||
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
|
||||
$beforeParams = $paramNames ? "{$targetArg}, {$paramNames}" : $targetArg;
|
||||
$code .= " \$aspect{$index}->before({$beforeParams});\n";
|
||||
}
|
||||
}
|
||||
$code .= " } catch (\\Throwable \$e) {\n";
|
||||
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
|
||||
$code .= " 'Exception in before() method of aspect',\n";
|
||||
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
|
||||
$code .= " 'before',\n";
|
||||
$code .= " \$e\n";
|
||||
$code .= " );\n";
|
||||
$code .= " }\n";
|
||||
|
||||
if ($returnType && $returnType !== ': void') {
|
||||
$code .= " \$result = parent::{$name}({$paramNames});\n";
|
||||
|
||||
// Call all after methods in a single try-catch (in reverse order)
|
||||
$code .= " \$currentAspect = '';\n";
|
||||
$code .= " try {\n";
|
||||
for ($i = count($aspects) - 1; $i >= 0; $i--) {
|
||||
$aspectMetadata = $aspects[$i];
|
||||
$aspectClass = $aspectMetadata->aspectClass;
|
||||
$aspectReflection = new ReflectionClass($aspectClass);
|
||||
if ($aspectReflection->hasMethod('after')) {
|
||||
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
|
||||
$code .= " \$result = \$aspect{$i}->after({$targetArg}, \$result);\n";
|
||||
}
|
||||
}
|
||||
$code .= " } catch (\\Throwable \$e) {\n";
|
||||
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
|
||||
$code .= " 'Exception in after() method of aspect',\n";
|
||||
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
|
||||
$code .= " 'after',\n";
|
||||
$code .= " \$e\n";
|
||||
$code .= " );\n";
|
||||
$code .= " }\n";
|
||||
|
||||
$code .= " return \$result;\n";
|
||||
} else {
|
||||
$code .= " parent::{$name}({$paramNames});\n";
|
||||
|
||||
$code .= " \$currentAspect = '';\n";
|
||||
$code .= " try {\n";
|
||||
for ($i = count($aspects) - 1; $i >= 0; $i--) {
|
||||
$aspectMetadata = $aspects[$i];
|
||||
$aspectClass = $aspectMetadata->aspectClass;
|
||||
$aspectReflection = new ReflectionClass($aspectClass);
|
||||
if ($aspectReflection->hasMethod('after')) {
|
||||
$code .= " \$currentAspect = \\{$aspectClass}::class;\n";
|
||||
$code .= " \$aspect{$i}->after({$targetArg}, null);\n";
|
||||
}
|
||||
}
|
||||
$code .= " } catch (\\Throwable \$e) {\n";
|
||||
$code .= " throw new \\IceFox\\Aspect\\AspectException(\n";
|
||||
$code .= " 'Exception in after() method of aspect',\n";
|
||||
$code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n";
|
||||
$code .= " 'after',\n";
|
||||
$code .= " \$e\n";
|
||||
$code .= " );\n";
|
||||
$code .= " }\n";
|
||||
}
|
||||
|
||||
$code .= " }\n\n";
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function buildParameterList(ReflectionMethod $method): string
|
||||
{
|
||||
$params = [];
|
||||
|
||||
foreach ($method->getParameters() as $param) {
|
||||
$paramStr = '';
|
||||
|
||||
if ($param->hasType()) {
|
||||
$paramStr .= $this->formatType($param->getType()) . ' ';
|
||||
}
|
||||
|
||||
if ($param->isPassedByReference()) {
|
||||
$paramStr .= '&';
|
||||
}
|
||||
|
||||
if ($param->isVariadic()) {
|
||||
$paramStr .= '...';
|
||||
}
|
||||
|
||||
$paramStr .= '$' . $param->getName();
|
||||
|
||||
if ($param->isDefaultValueAvailable()) {
|
||||
$defaultValue = $param->getDefaultValue();
|
||||
$paramStr .= ' = ' . var_export($defaultValue, true);
|
||||
} elseif ($param->isOptional() && $param->allowsNull()) {
|
||||
$paramStr .= ' = null';
|
||||
}
|
||||
|
||||
$params[] = $paramStr;
|
||||
}
|
||||
|
||||
return implode(', ', $params);
|
||||
}
|
||||
|
||||
private function buildParameterCallList(ReflectionMethod $method): string
|
||||
{
|
||||
$params = [];
|
||||
|
||||
foreach ($method->getParameters() as $param) {
|
||||
$name = '';
|
||||
|
||||
if ($param->isVariadic()) {
|
||||
$name .= '...';
|
||||
}
|
||||
|
||||
$name .= '$' . $param->getName();
|
||||
$params[] = $name;
|
||||
}
|
||||
|
||||
return implode(', ', $params);
|
||||
}
|
||||
|
||||
private function buildReturnType(ReflectionMethod $method): string
|
||||
{
|
||||
if (!$method->hasReturnType()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ': ' . $this->formatType($method->getReturnType());
|
||||
}
|
||||
|
||||
private function formatType(\ReflectionType $type): string
|
||||
{
|
||||
if ($type instanceof ReflectionNamedType) {
|
||||
$name = $type->getName();
|
||||
|
||||
// Don't add ? for nullable built-in types that already allow null
|
||||
if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') {
|
||||
return '?' . $name;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionUnionType) {
|
||||
$types = array_map(fn ($t) => $this->formatType($t), $type->getTypes());
|
||||
return implode('|', $types);
|
||||
}
|
||||
|
||||
if ($type instanceof ReflectionIntersectionType) {
|
||||
$types = array_map(fn ($t) => $this->formatType($t), $type->getTypes());
|
||||
return implode('&', $types);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build constructor arguments code from attribute arguments
|
||||
* @param array<int|string,mixed> $args
|
||||
*/
|
||||
private function buildConstructorArguments(array $args): string
|
||||
{
|
||||
if (empty($args)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$argParts = [];
|
||||
foreach ($args as $key => $value) {
|
||||
$valueCode = var_export($value, true);
|
||||
|
||||
if (is_string($key)) {
|
||||
$argParts[] = "{$key}: {$valueCode}";
|
||||
} else {
|
||||
$argParts[] = $valueCode;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $argParts);
|
||||
}
|
||||
}
|
||||
21
src/AttributeMetadata.php
Normal file
21
src/AttributeMetadata.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
use ReflectionAttribute;
|
||||
|
||||
/**
|
||||
* Metadata about an aspect attribute applied to a method
|
||||
*/
|
||||
final readonly class AttributeMetadata
|
||||
{
|
||||
/**
|
||||
* @param class-string $aspectClass
|
||||
* @param ReflectionAttribute<object> $attribute
|
||||
*/
|
||||
public function __construct(
|
||||
public string $aspectClass,
|
||||
public ReflectionAttribute $attribute,
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
src/MethodMetadata.php
Normal file
21
src/MethodMetadata.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace IceFox\Aspect;
|
||||
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* Metadata about a method and its associated aspects
|
||||
*/
|
||||
final readonly class MethodMetadata
|
||||
{
|
||||
/**
|
||||
* @param ReflectionMethod $method
|
||||
* @param array<int, AttributeMetadata> $aspects
|
||||
*/
|
||||
public function __construct(
|
||||
public ReflectionMethod $method,
|
||||
public array $aspects,
|
||||
) {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue