crell/attributeutils

一个健壮、灵活的属性处理框架

1.2.0 2024-07-11 17:15 UTC

This package is auto-updated.

Last update: 2024-09-11 17:40:44 UTC


README

Latest Version on Packagist Software License Total Downloads

AttributeUtils为PHP 8.1及以上版本提供了简化属性操作和读取的实用工具。

其主要工具是类分析器,允许您针对某些属性类分析给定的类或枚举。属性类可以通过实现各种接口来选择性地加入额外的行为,如下所述。总体目标是提供一个简单但强大的框架,用于从类中读取元数据,包括反射数据。

安装

通过Composer

$ composer require crell/attributeutils

用法

基本用法

系统中最重要的类是Analyzer,它实现了ClassAnalyzer接口。

#[MyAttribute(a: 1, b: 2)]
class Point
{
    public int $x;
    public int $y;
    public int $z;
}

$analyzer = new Crell\AttributeUtils\Analyzer();

$attrib = $analyzer->analyze(Point::class, MyAttribute::class);

// $attrib is now an instance of MyAttribute.
print $attrib->a . PHP_EOL; // Prints 1
print $attrib->b . PHP_EOL; // Prints 2

Analyzer将所有与反射系统的交互抽象化。

您可以针对任何属性分析任何类。如果找不到属性,将创建一个带有默认参数值的新属性类实例。如果需要任何参数,将抛出RequiredAttributeArgumentsMissing异常。

最终结果是,只要属性类没有必需的参数,您就可以分析任何属性类。

尽管如此,Analyzer最重要的部分是它允许属性选择性地加入额外的行为,成为一个完整的类分析和反射框架。

反射

如果类属性实现了Crell\AttributeUtils\FromReflectionClass,那么一旦属性实例化,将被分析类的ReflectionClass表示将传递给fromReflection()方法。属性可以保存它需要的任何反射信息,方式不限。例如,如果您想使属性对象知道它来自哪个类,可以将$reflection->getName()和/或$reflection->getShortName()保存到对象的非构造函数属性上。或者,只有当某些构造函数参数未提供时,才保存它们。

如果您直接保存反射值,强烈建议您使用与ReflectClass属性中一致的属性名称。这样,所有属性(甚至不同库)的名称都是一致的,从而使得代码更容易被其他开发者阅读和理解。(我们将在后面更多地介绍ReflectClass。)

在以下示例中,属性接受一个$name参数。如果没有提供,将使用类的短名称。

#[\Attribute]
class AttribWithName implements FromReflectionClass 
{
    public readonly string $name;
    
    public function __construct(?string $name = null) 
    {
        if ($name) {
            $this->name = $name;
        }
    }
    
    public function fromReflection(\ReflectionClass $subject): void
    {
        $this->name ??= $subject->getShortName();
    }
}

绝对不应将反射对象本身保存到属性对象中。反射对象无法缓存,因此保存它会使属性对象无法缓存。这也很浪费,因为您需要的任何数据都可以从反射对象中检索并单独保存。

还有类似的FromReflectionPropertyFromReflectionMethodFromReflectionClassConstantFromReflectionParameter接口,它们分别对其所属类的各个部分执行相同的操作。

额外的类组件

类属性也可以选择分析类的各个部分,例如其属性、方法和常量。它是通过实现ParsePropertiesParseStaticPropertiesParseMethodsParseStaticMethodsParseClassConstants接口来实现的,分别对应。它们的工作方式相同,所以我们将特别关注属性。

例子是最好的解释方式

#[\Attribute(\Attribute::TARGET_CLASS)]
class MyClass implements ParseProperties
{
    public readonly array $properties;

    public function propertyAttribute(): string
    {
        return MyProperty::class;
    }

    public function setProperties(array $properties): void
    {
        $this->properties = $properties;
    }

    public function includePropertiesByDefault(): bool
    {
        return true;
    }
}

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class MyProperty
{
    public function __construct(
        public readonly string $column = '',
    ) {}
}

#[MyClass]
class Something
{
    #[MyProperty(column: 'beep')]
    protected property $foo;
    
    private property $bar;
}

$attrib = $analyzer->analyze(Something::class, MyClass::class);

在这个例子中,首先会实例化MyClass属性。它没有参数,这是可以的。然而,接口方法指定分析器应解析Something的属性与MyProperty相关。如果一个属性没有这样的属性,它仍然应该被包含并实例化,不传递任何参数。

分析器将尽职尽责地创建一个包含两个MyProperty实例的数组,一个用于$foo,一个用于$bar;前者具有columnbeep,后者具有默认的空字符串值。然后,这个数组将被传递给MyClass::setProperties(),以便MyClass保存、解析、过滤或执行它想要的任何操作。

如果includePropertiesByDefault()返回false,则数组将只有一个值,来自$foo。将忽略$bar

注意:传递给setProperties的数组已经根据属性的名称进行了索引,因此您不需要自己这样做。

针对属性的目标属性(MyProperty)也可以实现FromReflectionProperty来获取传递给它的相应的ReflectionProperty,就像类属性可以一样。

分析器在ParseProperties中只包括对象级别的属性。如果您想要静态属性,请使用ParseStaticProperties接口,它的工作方式完全相同。这两个接口可以同时实现。

ParseClassConstant接口的工作方式与ParseProperties相同。

方法

ParseMethods的工作方式与ParseProperties相同(并且还有一个相应的ParseStaticMethods接口用于静态方法)。然而,一个方法目标属性也可以实现ParseParameters来检查该方法上的参数。ParseParameters重复了上面ParseProperties中的相同模式,方法名称相应地更改。

类引用组件

一个组件目标属性也可以实现ReadsClass。如果是这样,那么在所有其他设置完成后,类的属性将传递给fromClassAttribute()方法。这允许属性从类中继承默认值,或者根据在类属性上设置的属性改变其行为。

排除值

在解析类的组件时,它们是否包含取决于多个因素。各种 Parse* 接口上的 includePropertiesByDefault()includeMethodsByDefault() 等方法决定了缺少属性的组件是否应包含默认值,或者完全排除。

如果 include*() 方法返回 true,仍然可以按需排除特定的组件。该组件的属性可能实现了 Excludable 接口,该接口只有一个方法,即 exclude()

接下来会发生的情况是分析器将加载该类型的所有属性,然后过滤掉返回 true 的那些。这允许单个属性、方法等选择不进行解析。你可以为 exclude() 使用任何逻辑,尽管最常见的做法可能如下

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class MyProperty implements Excludable
{
    public function __construct(
        public readonly bool $exclude = false,
    ) {}
    
    public function exclude(): bool
    {
        return $this->exclude;
    }
}

class Something
{
    #[MyProperty(exclude: true)]
    private int $val;
}

如果你采用这种手动方法,强烈建议你使用这里的命名约定以保证一致性。

属性继承

默认情况下,PHP 中的属性不可继承。也就是说,如果类 A 上有一个属性,而 B 扩展了 A,那么询问反射 B 有什么属性将找不到。有时这很好,但有时必须重复值,这非常令人烦恼。

Analyzer 通过让属性选择是否继承来解决这个问题。任何属性——对于类、属性、方法、常量或参数——都可以实现 Inheritable 标记接口。该接口没有方法,但它向系统发出信号,如果找不到属性,则应检查父类和接口。

例如

#[\Attribute(\Attribute::TARGET_CLASS)]
class MyClass implements Inheritable
{
    public function __construct(public string $name = '') {}
}

#[MyClass(name: 'Jorge')]
class A {}

class B extends A {}

$attrib = $analyzer->analyze(B::class, MyClass::class);

print $attrib->name . PHP_EOL; // prints Jorge

由于 MyClass 可继承,分析器会注意到它在 B 上不存在,因此会检查类 A。如果需要,所有属性组件都可以通过实现接口来设置为可继承。

在检查继承属性时,首先检查祖先类,然后按 class_implements() 返回的顺序检查实现的接口。属性当然不会检查接口,因为接口不能有属性。

属性子类

在检查属性时,分析器使用反射中的 instanceof 检查。这意味着指定的子类或实现接口的类仍然会被找到并包含在内。这对所有属性类型都适用。

子属性

Analyzer 每个目标只能处理一个属性。然而,它也支持“子属性”的概念。子属性的工作方式类似于类可以选择解析属性或方法,但针对的是同级属性而不是子组件。这样,同一组件上的任何数量的属性都可以折叠成一个单一的属性对象。任何组件的任何属性都可以通过实现 HasSubAttributes 来选择子属性。

以下示例应该会使其更清晰

#[\Attribute(\Attribute::TARGET_CLASS)]
class MainAttrib implements HasSubAttributes
{
    public readonly int $age;

    public function __construct(
        public readonly string name = 'none',
    ) {}

    public function subAttributes(): array
    {
        return [Age::class => 'fromAge'];
    }
    
    public function fromAge(?Age $sub): void
    {
        $this->age = $sub?->age ?? 0;
    }
}

#[\Attribute(\Attribute::TARGET_CLASS)]
class Age
{
    public function __construct(public readonly int $age = 0) {}
}

#[MainAttrib(name: 'Larry')]
#[Age(21)]
class A {}

class B {}

$attribA = $analyzer->analyze(A::class, MainAttrib::class);

print "$attribA->name, $attribA->age\n"; // prints "Larry, 21"

$attribB = $analyzer->analyze(B::class, MainAttrib::class);

print "$attribB->name, $attribB->age\n"; // prints "none, 0"

subAttributes() 方法返回一个关联数组,其中包含属性类名称映射到要调用的方法。它们可以是字符串,或内联闭包,或闭包引用的方法,如果需要,可以是私有的。例如

#[\Attribute(\Attribute::TARGET_CLASS)]
class MainAttrib implements HasSubAttributes
{
    public readonly int $age;
    public readonly string $name;

    public function __construct(
        public readonly string name = 'none',
    ) {}

    public function subAttributes(): array
    {
        return [
            Age::class => $this->fromAge(...),
            Name::class => function (?Name $sub) {
                $this->name = $sub?->name ?? 'Anonymous';
            }
        ];
    }

    private function fromAge(?Age $sub): void
    {
        $this->age = $sub?->age ?? 0;
    }
}

加载 MainAttrib 后,分析器将寻找列出的任何子属性,然后将结果传递给对应的方法。主属性可以保存整个子属性,或从中提取部分内容保存,或执行其他操作。

属性可以有任意数量的子属性。

请注意,如果子属性缺失,则将传递 null 给方法。这是为了允许子属性在指定时才具有必要参数,同时保持子属性本身为可选。因此,你 必须 使回调方法的参数可空。

子属性也可能具有可继承的特性。

多值子属性

默认情况下,PHP属性只能在一个给定的目标上放置一次。然而,它们可以被标记为“可重复”,在这种情况下,相同的属性可以放置在同一个目标上。(类、属性、方法等。)

分析器不支持多值属性,但它支持多值子属性。如果子属性实现了Multivalue标记接口,那么将传递一个子属性数组的数组到回调中。

例如

#[\Attribute(\Attribute::TARGET_CLASS)]
class MainAttrib implements HasSubAttributes
{
    public readonly array $knows;

    public function __construct(
        public readonly string name = 'none',
    ) {}

    public function subAttributes(): array
    {
        return [Knows::class => 'fromKnows'];
    }
    
    public function fromKnows(array $knows): void
    {
        $this->knows = $knows;
    }
}

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class Knows implements Multivalue
{
    public function __construct(public readonly string $name) {}
}

#[MainAttrib(name: 'Larry')]
#[Knows('Kai')]
#[Knows('Molly')]
class A {}

class B {}

在这种情况下,可以包含任意数量的Knows属性,包括零个,但如果包含,则必须提供$name参数。将使用fromKnows()方法调用一个可能为空(在B的情况下)的Knows对象数组,并可以随意处理它。在这个例子中,对象将被完整保存,但也可以将它们压入一个数组或用于设置其他值。

注意,如果多值子属性是可继承的,只有在没有本地子属性的情况下,才会检查祖先类。如果至少有一个,则它将具有优先级,并且将忽略祖先。

注意:为了使用多值子属性,属性类本身必须被标记为“可重复”,如上面的例子所示,否则PHP将生成错误。然而,这并不足以使分析器将其解析为多值。这是因为属性在实现作用域时也可能是多值的,但从分析器的角度来看仍然是单值的。请参阅下面的作用域部分。

属性最终化

选择加入多个功能接口的属性可能不知道何时执行默认处理。属性设置何时“完成”可能并不明显。因此,属性类可以加入Finalizable接口。如果指定了它,则保证是最后一个在属性上调用的方法。属性可以执行适当的最终准备工作,以便将对象视为“准备就绪”。

缓存

主要的Analyzer类根本不做任何缓存。然而,它实现了ClassAnalyzer接口,这使得它可以很容易地被其他提供缓存层的实现包装。

例如,MemoryCacheAnalyzer类提供了一个简单的包装器,它将结果缓存到内存中的静态变量中。几乎总是应该使用这个包装器来提高性能。

$analyzer = new MemoryCacheAnalyzer(new Analyzer());

还包括一个PSR-6缓存桥接器,允许分析器与任何PSR-6兼容的缓存池一起使用。

$anaylzer = new Psr6CacheAnalyzer(new Analyzer(), $somePsr6CachePoolObject);

包装器也可以组合在一起,所以以下将是一个完全有效且可能很好的方法

$analyzer = new MemoryCacheAnalyzer(new Psr6CacheAnalyzer(new Analyzer(), $psr6CachePool));

高级功能

还有一些其他高级功能也可用。这些功能不太常用,但在适当的情况下,它们可能非常有帮助。

作用域

属性可以选择支持“作用域”。作用域允许您为不同的上下文指定相同属性的替代版本。例如,包括不同的序列化组或不同的语言。通常,作用域将隐藏在另一个库(如语言)的另一个名称后面,这是可以的。

如果一个属性实现了SupportsScopes,则在查找属性时将执行额外的过滤。确切的逻辑也与排除有关,以及类属性是否指定在缺失时默认加载组件,从而导致了高度健壮的潜在规则集,用于确定何时使用哪个属性。

作为一个例子,让我们考虑提供属性属性的替代语言版本。逻辑对于任何组件以及子属性都是相同的。

#[\Attribute(\Attribute::TARGET_CLASS)]
class Labeled implements ParseProperties
{
    public readonly array $properties;

    public function setProperties(array $properties): void
    {
        $this->properties ??= $properties;
    }

    public function includePropertiesByDefault(): bool
    {
        return true;
    }

    public function propertyAttribute(): string
    {
        return Label::class;
    }
}

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Label implements SupportsScopes, Excludable
{
    public function __construct(
        public readonly string $name = 'Untitled',
        public readonly ?string $language = null,
        public readonly bool $exclude = false,
    ) {}

    public function scopes(): array
    {
        return [$this->language];
    }

    public function exclude(): bool
    {
        return $this->exclude;
    }
}

#[Labeled]
class App
{
    #[Label(name: 'Installation')]
    #[Label(name: 'Instalación', language: 'es')]
    public string $install;

    #[Label(name: 'Setup')]
    #[Label(name: 'Configurar', language: 'es')]
    #[Label(name: 'Einrichten', language: 'de')]
    public string $setup;

    #[Label(name: 'Einloggen', language: 'de')]
    #[Label(language: 'fr', exclude: true)]
    public string $login;

    public string $customization;
}

类上的 Labeled 属性我们之前已经见过。属性上的 Label 属性既可排除也可支持作用域,尽管它以 language 的名称公开。

像之前看到的那样调用分析器将忽略作用域版本,并导致一个包含名称为 "Installation"、"Setup"、"Untitled" 和 "Untitled" 的 Label 数组。然而,它也可以使用特定的作用域调用。

$labels = $analyzer->analyze(App::class, Labeled::class, scopes: ['es']);

现在,$labels 将包含一个包含名称为 "Instalación"、"Configurar"、"Untitled" 和 "Untitled" 的 Label 数组。在 $stepThree 上,没有 es 作用域版本,所以它会回退到默认值。同样,de 作用域将导致 "Installation"、"Einrichten"、"Einloggen" 和 "Untitled"(因为 "Installation" 在英语和德语中拼写相同)。

fr 作用域将导致每个情况都使用默认的(英语),除了 $stepThree 将完全被省略。只有在该作用域中,exclude 指令才适用。因此,结果将是 "Installation"、"Setup"、"Untitled"。

(如果您真的在做这件事,最好通过 FromReflectionProperty 从属性名本身推导出一个默认的 name,而不是使用硬编码的 "Untitled"。)

相比之下,如果 Labeled::includePropertiesByDefault() 返回 false,那么 $customization 将不会包含在任何作用域中。只有 de 作用域中的 $login 将被包含,而在任何其他作用域中都不会被包含。这是因为没有指定默认作用域选项,所以在除 de 作用域之外的其他作用域中不会创建默认值。对于 fr 作用域的查找将是空的。

控制包含哪些属性的一个有用方法是使类级别的属性作用域感知,并通过参数控制 includePropertiesByDefault()。这样,例如,includePropertiesByDefault() 可以在无作用域的情况下返回 true,但在明确指定作用域时返回 false;这样,属性只有在明确选择进入该作用域时才会包含在作用域中,而在无作用域的情况下,所有属性都会被包含。

请注意,scopes() 方法返回一个数组。这意味着支持一个属性属于多个作用域。您如何填充该方法的返回值(是数组参数还是其他)取决于您。

此外,作用域被查找为一个或操作数组。也就是说,以下命令

$labels = $analyzer->analyze(SomeClass::class, AnAttribute::class, scopes: ['One', 'Two']);

将检索返回 OneTwo 的任何属性,这些属性从它们的 scopes() 方法返回。如果同一组件上的多个属性符合该规则(例如,一个返回 ['One'],另一个返回 ['Two']),则将使用词法上第一个。

传递性

传递性仅适用于属性上的属性,并且只有当问题属性可以针对属性和类时。它是一种继承的替代形式。具体来说,如果一个属性被类型化为一个类或接口,并且问题属性实现了 TransitiveProperty,并且属性上没有该属性,那么分析器将首先查看属性类型的类,而不是查找继承树。

这里有太多的条件,所以这里有一个例子来说明

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class MyClass implements ParseProperties
{
    public readonly array $properties;

    public function setProperties(array $properties): void
    {
        $this->properties = $properties;
    }
    
    public function includePropertiesByDefault(): bool { return true; }

    public function propertyAttribute(): string { return FancyName::class; }
}


#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)]
class FancyName implements TransitiveProperty
{
    public function __construct(public readonly string $name = '') {}
}

class Stuff
{
    #[FancyName('A happy little integer')]
    protected int $foo;

    protected string $bar;
    
    protected Person $personOne;
    
    #[FancyName('Her Majesty Queen Elizabeth II')]
    protected Person $personTwo;
}

#[FancyName('I am not an object, I am a free man!')]
class Person
{
}

$attrib = $analyzer->analyze(Stuff::class, MyClass::class);

print $attrib->properties['foo']->name . PHP_EOL; // prints "A happy little integer"
print $attrib->properties['bar']->name . PHP_EOL; // prints ""
print $attrib->properties['personOne']->name . PHP_EOL; // prints "I am not an object, I am a free man!"
print $attrib->properties['personTwo']->name . PHP_EOL; // prints "Her Majesty Queen Elizabeth II"

因为 $personTwo 有一个 FancyName 属性,所以它表现得像正常一样。然而,$personOne 没有,所以它会跳到 Person 类去查找属性,并发现它在那里。

如果一个属性实现了 InheritableTransitive,那么首先检查被分析类,然后是其祖先类,然后是其实现接口,然后是为其类型化的传递性类,然后是该类的祖先,直到找到合适的属性。

主属性和子属性都可以声明为 Transitive

自定义分析

作为最后的手段,一个属性也可以实现CustomAnalysis接口。如果它这样做,分析器本身将被传递到属性的customAnalysis()方法中,然后它可以执行任何它想要的操作。这个特性仅作为最后的手段,如果不小心可能会创建不愉快的无限循环。99%的情况下你应该使用其他任何机制。但如果你需要,它就在那里。

依赖注入

分析器被设计成无需任何配置即可使用。通过依赖注入容器提供它是推荐的。在DI配置中还应包括适当的缓存包装器。

函数分析

还支持通过单独的分析器(基本上以相同的方式工作)检索函数上的属性。FuncAnalyzer类实现了FunctionAnalyzer接口。

use Crell\AttributeUtils\FuncAnalyzer;

#[MyFunc]
function beep(int $a) {}

$closure = #[MyClosure] fn(int $a) => $a + 1;

// For functions...
$analyzer = new FuncAnalyzer();
$funcDef = $analyzer->analyze('beep', MyFunc::class);

// For closures
$analyzer = new FuncAnalyzer();
$funcDef = $analyzer->analyze($closure, MyFunc::class);

子属性ParseParametersFinalizable以及作用域都在函数上工作,就像在类和方法上一样。还有一个对应的FromReflectionFunction接口用于接收ReflectionFunction对象。

FuncAnalyzer也有可用的缓存包装器。它们的工作方式与类分析器相同。

# In-memory cache.
$analyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer());

# PSR-6 cache.
$anaylzer = new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $somePsr6CachePoolObject);

# Both caches.
$analyzer = new MemoryCacheFunctionAnalyzer(
    new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $psr6CachePool)
);

与类分析器一样,最好在DI容器中连接这些。

Reflect库

Analyzer的一个用途是从类中提取反射信息。有时你可能只需要其中的一部分,但没有任何理由你不能获取所有信息。结果是,一个可以携带所有与反射相同信息的属性,如果需要,可以缓存,而反射对象不能。

Attributes/Reflect目录中提供了一个这样的属性集。它们涵盖了类的所有组件。由于它们都没有任何参数,所以没有必要将它们放在任何类上。每个默认的“空”版本都将被使用,然后通过使用FromReflection*接口来自我填充。

最终结果是,可以通过调用来获得任何任意类的完整反射摘要

use Crell\AttributeUtls\Attributes\Reflect\ReflectClass;

$reflect = $analyzer->analyze($someClass, ReflectClass::class);

$reflect现在包含了一个完整的类、属性、常量、方法和参数反射信息的副本,在定义良好、易于缓存的对象中。请参阅每个类的docblocks以获取所有可用信息的完整列表。

要分析枚举,请使用ReflectEnum::class

即使你不需要使用整个Reflect树,也值得作为如何充分利用分析器的示例来研究。此外,如果你将任何反射值直接保存到你的属性中,鼓励你使用与那些类相同的命名约定,以保证一致性。

还包括了一些处理收集给定类组件的常见情况的特质。如果你愿意,可以在自己的类中使用它们。

高级技巧

以下是一系列高级和复杂的分析器用法,主要用于展示当适当使用时它有多么强大。

多值属性

如前所述,分析器只支持每个组件上的单个主要属性。然而,子属性可以是多值的,省略的属性可以用默认的“空”属性填充。这导致以下模拟多值属性的方式。它适用于任何组件,但为了简单起见,我们将它在类上展示。

#[\Attribute(Attribute::TARGET_CLASS)]
class Names implements HasSubAttributes, IteratorAggregate, ArrayAccess
{
    protected readonly array $names;

    public function subAttributes(): array
    {
        return [Alias::class => 'fromAliases'];
    }

    public function fromAliases(array $aliases): void
    {
        $this->names = $aliases;
    }

    public function getIterator(): \ArrayIterator
    {
        return new ArrayIterator($this->names);
    }

    public function offsetExists(mixed $offset): bool
    {
        return array_key_exists($offset, $this->names);
    }

    public function offsetGet(mixed $offset): Alias
    {
        return $this->names[$offset];
    }

    public function offsetSet(mixed $offset, mixed $value): void
    {
        throw new InvalidArgumentException();
    }

    public function offsetUnset(mixed $offset): void
    {
        throw new InvalidArgumentException();
    }
}

#[\Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class Alias implements Multivalue
{
    public function __construct(
        public readonly string $first,
        public readonly string $last,
    ) {}

    public function fullName(): string
    {
        return "$this->first $this->last";
    }
}

#[Alias(first: 'Bruce', last: 'Wayne')]
#[Alias(first: 'Bat', last: 'Man')]
class Hero
{
    // ...
}

$names = $analyzer->analyze(Hero::class, Names::class);

foreach ($names as $name) {
    print $name->fullName() . PHP_EOL;
}

// Output:
Bruce Wayne
Bat Man

IteratorAggregateArrayAccess 接口是可选的;我这里包括它们只是为了展示如果需要的话你可以这样做。在这里,Names 属性永远不会直接放在类上。然而,通过分析一个“相对于”Names 的类,你可以收集它所拥有的所有多值子属性,从而给人一种多值属性的感觉。

请注意,Alias 需要实现 Multivalue 接口,这样分析器就知道预期会有多个。

接口属性

通常,属性不继承。这意味着接口上的属性对实现该接口的类没有影响。然而,属性可以通过分析器选择继承。

其中一个好用法是子属性,子属性也可以指定为接口。例如,考虑上面示例的修改版本

#[\Attribute(\Attribute::TARGET_CLASS)]
class Names implements HasSubAttributes, IteratorAggregate, ArrayAccess
{
    protected readonly array $names;

    public function subAttributes(): array
    {
        return [Name::class => 'fromNames'];
    }

    public function fromNames(array $names): void
    {
        $this->names = $names;
    }

    // The same ArrayAccess and IteratorAggregate code as above.
}

interface Name extends Multivalue
{
    public function fullName(): string;
}

#[\Attribute(\Attribute::TARGET_CLASS)]
class RealName implements Name
{
    public function __construct(
        public readonly string $first,
        public readonly string $last,
    ) {}

    public function fullName(): string
    {
        return "$this->first $this->last";
    }
}

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class Alias implements Name
{
    public function __construct(public readonly string $name) {}

    public function fullName(): string
    {
        return $this->name;
    }
}

#[RealName(first: 'Bruce', last: 'Wayne')]
#[Alias('Batman')]
#[Alias('The Dark Knight')]
#[Alias('The Caped Crusader')]
class Hero
{
    // ...
}

你现在可以在同一个类上混合使用 RealNameAlias。只允许一个 RealName,但允许任意数量的 Alias 属性。所有这些都是根据 Names 主属性下的 Name,因此所有这些都将被拾取并可用。

请注意,接口必须标记为 Multivalue,这样 Analyzer 才允许该类型的多个属性。然而,RealName 属性未标记为可重复的,因此 PHP 会防止同时使用多个 RealName,而 Alias 可以使用任意次数。

众多选项之一

类似地,你可以使用子属性来声明一个组件可以标记为几个属性中的一个,但只能是一个。

interface DisplayType {}

#[\Attribute(\Attribute::TARGET_CLASS)]
class Screen implements DisplayType
{
    public function __construct(public readonly string $color) {}
}

#[\Attribute(\Attribute::TARGET_CLASS)]
class Audio implements DisplayType
{
    public function __construct(public readonly int $volume) {}
}

#[\Attribute(Attribute::TARGET_CLASS)]
class DisplayInfo implements HasSubAttributes
{
    public readonly ?DisplayType $type;

    public function subAttributes(): array
    {
        return [DisplayType::class => $this->fromDisplayType(...)];
    }

    public function fromDisplayType(?DisplayType $type): void
    {
        $this->type = $type;
    }
}

#[Screen('#00AA00')]
class A {}

#[Audio(10)]
class B {}

class C {}

$displayInfoA = $analyzer->analzyer(A::class, DisplayInfo::class);
$displayInfoB = $analyzer->analzyer(B::class, DisplayInfo::class);
$displayInfoC = $analyzer->analzyer(C::class, DisplayInfo::class);

在这种情况下,一个类可以被标记为 ScreenAudio,但不能同时两者。如果两者都指定,则只使用列出的第一个;其他的将被忽略。

在这个例子中,$displayInfoA->type 将是 Screen 的实例,$displayInfoB->type 将是 Audio 的实例,而 $displayInfoC->type 将是 null

变更日志

有关最近更改的更多信息,请参阅CHANGELOG

测试

$ composer test

贡献

有关详细信息,请参阅CONTRIBUTINGCODE_OF_CONDUCT

安全性

如果你发现任何安全相关的问题,请使用GitHub 安全报告表而不是问题队列。

鸣谢

该库的初始开发由TYPO3 GmbH赞助。

许可证

Lesser GPL 版本 3 或更高版本。请参阅许可证文件获取更多信息。