PHP8.x 注解使用指南

在使用 PHP 构建 Web 应用时,有时我们希望为代码添加一些可以被程序其他部分读取的元数据用于标注。例如,如果你用 PHPUnit 为 PHP 代码编写测试,你很可能在 DocBlock 中使用过 @test 注解来标记某个方法是一个测试。

传统上,这类注解是通过 DocBlock 添加的。然而,从 PHP 8.0 开始,你可以使用注解以更结构化的方式为代码添加注解。

本文将介绍什么是注解、它们在代码中的作用以及如何使用它们。我们还会讲解如何创建你自己的注解以及如何为使用注解的代码编写测试。

读完本文后,你应当能够自信地在自己的 PHP 应用中开始使用注解。

什么是注解?

注解最早在 PHP 8.0(2020 年 11 月发布)中引入。借助注解,你可以为代码添加机器可读、结构化的元数据。注解可以用于类、类方法、函数、匿名函数、属性(property)以及常量。其语法为 #[AttributeNameHere],其中 AttributeNameHere 是注解名。例如:#[Test]#[TestWith('data')]#[Override] 等等。

要理解注解是什么以及它解决了什么问题,把它和 DocBlock 里的「标签」(tags)对比会很有帮助。先从一个使用 DocBlock 的 PHPUnit 测试看起:

php
/**
 * @test
 * @dataProvider permissionsProvider
 */
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

public static function permissionsProvider(): array
{
    return [
        ['superadmin'],
        ['admin'],
        ['user'],
        // and so on...
    ];
}

上面的代码里有两个方法:

  • access_is_denied_if_the_user_does_not_have_permission:实际的测试方法。它接收一个 $permission 参数,我们假定它会断言当用户没有对应权限时访问被拒绝。
  • permissionsProvider:数据提供器方法。返回的字符串数组会作为 $permission 参数传入测试方法。

在测试方法的 DocBlock 中应用 @test 标签后,PHPUnit 会识别该方法为可运行的测试。同理,在 DocBlock 中使用 @dataProvider 标签后,PHPUnit 会使用 permissionsProvider 作为该测试方法的数据提供器。

由于 permissionsProvider 返回了一个包含三个元素的数组,这个测试会执行三次(每个权限一次)。

虽然 DocBlock 标签对标注代码很有帮助,但它们的本质只是纯文本,因此缺乏结构,也很容易写错。比如,我们故意把 DocBlock 里的 @test 拼错:

php
/**
 * @tests
 * @dataProvider permissionsProvider
 */
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

上例中我们把 @test 改成了 @tests。在一个包含大量测试的方法集合里,这样的拼写错误很难一眼看出。如果运行该测试,它将不会被执行,因为 PHPUnit 不会识别它为测试。

这正是注解可以发挥作用的地方。继续用 PHPUnit 为例,我们把测试方法改为使用注解而不是 DocBlock。从 PHPUnit 10 开始,你可以使用 #[\PHPUnit\Framework\Attributes\Test] 来标记一个方法为测试;使用 #[\PHPUnit\Framework\Attributes\DataProvider()] 指定数据提供器方法。更新后的代码如下:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

#[Test]
#[DataProvider('permissionsProvider')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

如上,我们为测试方法添加了两个注解:

  • #[Test]:标记该方法为测试。
  • #[DataProvider('permissionsProvider')]:指定 permissionsProvider 方法为该测试方法的数据提供器。

你或许也注意到,这些注解都有命名空间,并且是像导入类一样被 use 导入的。这是因为注解本身其实就是类;稍后我们会详细说明。

现在如果把 #[Test] 改成 #[Tests],你的 IDE 会把它当作错误(通常红色下划线)高亮出来,方便你尽快发现和修复。因为 PHP 会尝试在当前命名空间中寻找名为 Tests 的注解。如果我们假设测试位于 Tests\Feature\Authorization 命名空间,那么 PHP 会尝试解析 Tests\Feature\Authorization\Tests 注解。在本例中,#[Tests] 并不存在。这类似于在代码中引用了一个不存在的类。不过,不同点在于:引用不存在的类会在运行时抛出异常,而使用一个不存在的注解通常不会。因此测试依然不会运行(和 DocBlock 类似),但你的 IDE 能更容易帮你发现问题。

重要的是:除非你在运行时显式地对注解进行校验,否则 PHP 不会因为注解不存在而抛异常。

另一个注解的优点是它们可以接收参数(如上例中的 #[DataProvider])。由于注解是结构化的,你的 IDE 通常能为注解的参数提供自动补全提示,这在不确定注解接受哪些参数时很有帮助。

如何使用注解

既然已经对注解有了大致了解,我们来看如何在代码中使用它们。

把注解应用到代码

注解可以用于类、类方法、函数、匿名函数、属性和常量。下面以一个示例注解 #[MyAwesomeAttribute] 展示不同用法。

给类应用注解:

php
#[MyAwesomeAttribute]
class MyClass
{
    // Class code goes here...
}

给类方法应用注解:

php
class MyClass
{
    #[MyAwesomeAttribute]
    public function myMethod()
    {
        // Method code goes here...
    }
}

给函数应用注解:

php
#[MyAwesomeAttribute]
function myFunction()
{
    // Function code goes here...
}

给匿名函数应用注解:

php
$myFunction = #[MyAwesomeAttribute] function () {
    // Anonymous function code goes here...
};

给属性(property)应用注解:

php
class MyClass
{
    #[MyAwesomeAttribute]
    public string $myProperty;
}

给常量应用注解:

php
class MyClass
{
    #[MyAwesomeAttribute]
    public const MY_CONSTANT = 'my-constant';
}

你也可以把注解应用到函数参数上。例如,从 PHP 8.2 开始,可以用内置的 #[\SensitiveParameter] 注解防止某个参数值出现在或被记录到栈追踪中。如果一个方法接收密码之类的敏感字段,你不希望该值出现在栈追踪里,可以这样标注:

php
function authenticate(
    string $username,
    #[\SensitiveParameter] string $password,
) {
    // Function code goes here...
}

向注解传参

如前所示,注解可以接收参数供读取它们的代码使用。沿用先前的 PHPUnit 示例,我们给 #[DataProvider] 传入数据提供器方法名:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

#[Test]
#[DataProvider('permissionsProvider')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

与 PHP 中的函数一样,注解也支持命名参数。我们可以改写为:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

#[Test]
#[DataProvider(method: 'permissionsProvider')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

如上,我们使用了命名参数 method:,在语义不够清晰时能提升可读性。

如果注解需要多个参数,也可以一次性传入。比如,我们可以移除 #[DataProvider],直接用 #[TestWith] 把数据硬编码在注解参数里:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;

#[Test]
#[TestWith('superadmin', 'admin', 'user')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
): void {
    // Test goes here...
}

同时使用多个注解

当你在同一目标(类、方法、函数等)上使用多个注解时,可以把它们写在同一个 #[...] 块里,也可以为每个注解单独使用一个 #[...] 块。

分别写成独立的块:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;

#[Test]
#[TestWith('superadmin', 'admin', 'user')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
):
{
    // Test goes here...
}

写在同一个块里:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;

#[
    Test,
    TestWith('superadmin', 'admin', 'user')
]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
):
{
    // Test goes here...
}

如何创建你自己的注解

了解了注解的用法后,我们来实现一个自定义注解,并演示如何在代码中读取它。

我们的目标:给枚举(enum)的各个 case 添加可读性更好的「友好标签」(friendly label),用于在界面显示(例如表单下拉框)。

假设我们在做一个博客平台,有一个代表文章状态的枚举 PostStatus。某些场景下,我们希望把 PostStatus 的枚举项展示给用户。枚举定义如下:

php
namespace App\Enums;

enum PostStatus: string
{
    case Draft = 'draft';

    case Published = 'published';

    case InReview = 'in-review';

    case Scheduled = 'scheduled';
}

如上,我们定义了包含四个 case 的 PostStatus。目前这些 case 是字符串枚举,也就是说可以通过 PostStatus::InReview->value 得到 in-review。但我们可能希望返回更友好的文本,比如把 in-review 显示为 In Review

为此,我们创建一个新的 Friendly 注解,允许把一个字符串标签应用到枚举 case 上。先创建注解类。在 PHP 中,注解就是一个用 #[\Attribute] 标注过的普通类。我们把 Friendly 放到 App\Attributes 命名空间:

php
namespace App\Attributes;

use Attribute;

#[Attribute]
final readonly class Friendly
{
    public function __construct(
        public string $friendly,
    ) {
        //
    }
}

上面我们定义了一个带构造函数的 Friendly 类,它接收一个字符串参数,作为枚举 case 的友好标签。

然后在枚举 case 上应用该注解:

php
namespace App\Enums;

use App\Attributes\Friendly;

enum PostStatus: string
{
    #[Friendly('Draft')]
    case Draft = 'draft';

    #[Friendly('Published')]
    case Published = 'published';

    #[Friendly('In Review')]
    case InReview = 'in-review';

    #[Friendly('Scheduled to Publish')]
    case Scheduled = 'scheduled';
}

如上,我们给每个枚举 case 赋了一个 Friendly 标签。例如 PostStatus::Scheduled 的标签是 Scheduled to Publish

接下来需要在代码中读取这些标签。我们在 PostStatus 上新增一个实例方法 friendly(),以便可以通过 PostStatus::InReview->friendly() 获取标签。

完整的枚举如下,之后再解释关键点:

php
namespace App\Enums;

use App\Attributes\Friendly;
use ReflectionClassConstant;

enum PostStatus: string
{
    #[Friendly('Draft')]
    case Draft = 'draft';

    #[Friendly('Published')]
    case Published = 'published';

    #[Friendly('In Review')]
    case InReview = 'in-review';

    #[Friendly('Scheduled to Publish')]
    case Scheduled = 'scheduled';

    public function friendly(): string
    {
        // 为当前 case 创建一个 ReflectionClassConstant,尝试读取 Friendly 注解
        $attributes = (new ReflectionClassConstant(
            class: self::class,
            constant: $this->name,
        ))->getAttributes(
            name: Friendly::class,
        );

        // 如果没有找到 Friendly 注解,抛出异常
        if ($attributes === []) {
            throw new \RuntimeException(
                message: 'No friendly attribute found for ' . $this->name,
            );
        }

        // 创建 Friendly 实例并返回其 friendly 值
        return $attributes[0]->newInstance()->friendly;
    }
}

上例中,我们新增了返回字符串的 friendly() 方法。其通过 ReflectionClassConstant 来检查当前枚举 case,并用 getAttributes() 读取 Friendly 注解。注意 getAttributes() 返回 ReflectionAttribute 的数组;如果未找到注解(例如开发者忘了在某个 case 上添加),就会返回空数组。

我们用是否为空数组来判断是否找到注解,如果没找到就抛出 \RuntimeException。这么做可以强制要求每个 case 都必须有 Friendly。当然,实际使用中你也可以改为返回默认值,例如对枚举值做格式化后返回。

如果找到了注解,$attributes[0] 就是一个 ReflectionAttribute,可以用 newInstance() 创建 Friendly 实例并返回其 friendly 属性。

于是就可以写出:

php
echo PostStatus::InReview->friendly(); // In Review
echo PostStatus::Scheduled->friendly(); // Scheduled to Publish

由于给枚举 case 添加「友好标签」可能很常见,我们可以把逻辑提炼成一个 trait,供其他枚举复用。例如创建 App\Traits\HasFriendlyEnumLabels

php
namespace App\Traits;

use App\Attributes\Friendly;
use ReflectionClassConstant;

trait HasFriendlyEnumLabels
{
    public function friendly(): string
    {
        // 为当前 case 创建 ReflectionClassConstant,读取 Friendly 注解
        $attributes = (new ReflectionClassConstant(
            class: self::class,
            constant: $this->name,
        ))->getAttributes(
            name: Friendly::class,
        );

        if ($attributes === []) {
            throw new \RuntimeException(
                message: 'No friendly attribute found for ' . $this->name,
            );
        }

        return $attributes[0]->newInstance()->friendly;
    }
}

然后在 PostStatus 中使用该 trait:

php
namespace App\Enums;

use App\Attributes\Friendly;
use App\Traits\HasFriendlyEnumLabels;

enum PostStatus: string
{
    use HasFriendlyEnumLabels;

    #[Friendly('Draft')]
    case Draft = 'draft';

    #[Friendly('Published')]
    case Published = 'published';

    #[Friendly('In Review')]
    case InReview = 'in-review';

    #[Friendly('Scheduled to Publish')]
    case Scheduled = 'scheduled';
}

可以看到,使用 trait 后,枚举更简洁、更易读。

声明注解的目标(target)

前面提到,注解可以用在很多地方,比如类、类方法、属性等。但有时候你可能希望某个注解只能用于特定场景。例如,我们希望 Friendly 只用于枚举的 case;它不应该应用在类或类方法上。

PHP 允许在声明注解时可选地指定目标。既然我们只希望 Friendly 用在枚举 case 上,可以这么写:

php
namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
final readonly class Friendly
{
    public function __construct(
        public string $friendly,
    ) {
        //
    }
}

我们给 #[Attribute] 传入了参数,指定它只允许应用到「类常量」(从而也适用于枚举 case)。

可用的目标包括:

  • Attribute::TARGET_CLASS:只能用于类
  • Attribute::TARGET_FUNCTION:只能用于函数
  • Attribute::TARGET_METHOD:只能用于类方法
  • Attribute::TARGET_PROPERTY:只能用于类属性(property)
  • Attribute::TARGET_CLASS_CONSTANT:只能用于类常量和枚举
  • Attribute::TARGET_PARAMETER:只能用于函数或方法的参数
  • Attribute::TARGET_ALL:可用于任意位置

虽然声明目标是可选的,但我更倾向于在创建注解时就明确目标。这能更清楚地表达意图,也方便其他开发者正确使用。当然,这只是个人偏好。

需要注意的是,如果你把注解应用到了错误的目标上,PHP 通常不会在运行时抛异常,除非你去与之交互。例如,如果我们把 Friendly 的目标设为 Attribute::TARGET_CLASS(仅用于类),如下代码依然可以运行,因为我们没有与该注解交互:

php
echo PostStatus::InReview->value; // in-review

但运行下面的代码将会抛出 Error

php
echo PostStatus::InReview->friendly();

错误信息类似:

Attribute "App\Attributes\Friendly" cannot target class constant (allowed targets: class)

声明可重复(repeatable)注解

有时你希望某个注解可以在同一目标上重复出现多次。比如 PHPUnit 的 #[TestWith]:你既可以写一次传多个参数,也可以多次书写、每次传一个参数。例如:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;

#[Test]
#[TestWith('superadmin', 'admin', 'user')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
):
{
    // Test goes here...
}

或者:

php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;

#[Test]
#[TestWith('superadmin')]
#[TestWith('admin')]
#[TestWith('user')]
public function access_is_denied_if_the_user_does_not_have_permission(
    string $permission
):
{
    // Test goes here...
}

这两种写法都会让测试运行三次(分别针对三个权限)。

要声明某个注解可重复,可以在 #[Attribute] 中使用 Attribute::IS_REPEATABLE 标志。例如 PHPUnit 的 #[TestWith] 声明如下:

php
namespace PHPUnit\Framework\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class TestWith
{
    // ...
}

如上,#[TestWith] 仅用于方法(TARGET_METHOD),并且可以在同一目标上重复使用(IS_REPEATABLE),两者通过按位或组合。

如果不使用 Attribute::IS_REPEATABLE,而你却在同一目标上重复使用该注解,IDE 通常会把它标记为错误。

测试使用了注解的代码

和应用中的其他部分一样,为与注解交互的代码编写测试同样重要。测试可以帮助你确认注解是否被正确应用、并被正确读取。

仍以前面的 Friendly 注解为例,我们给 PostStatus 枚举和 HasFriendlyEnumLabels trait 编写测试。

先为 trait 写测试,我们要覆盖两种场景:

  1. 如果枚举 case 应用了 Friendly,应返回友好标签;
  2. 如果未应用 Friendly,应抛出异常。

示例测试类如下:

php
namespace Tests\Feature\Traits\HasFriendlyEnumLabels;

use App\Attributes\Friendly;
use App\Traits\HasFriendlyEnumLabels;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class FriendlyTest extends TestCase
{
    #[Test]
    public function friendly_label_can_be_returned(): void
    {
        $this->assertSame(
            'Has Friendly Value',
            MyTestEnum::HasFriendlyValue->friendly(),
        );
    }

    #[Test]
    public function error_is_thrown_if_the_friendly_attribute_is_not_applied(): void
    {
        $this->expectException(\RuntimeException::class);

        $this->expectExceptionMessage(
            'No friendly attribute found for HasNoFriendlyValue',
        );

        MyTestEnum::HasNoFriendlyValue->friendly();
    }
}

enum MyTestEnum: string
{
    use HasFriendlyEnumLabels;

    #[Friendly('Has Friendly Value')]
    case HasFriendlyValue = 'has-friendly-value';

    case HasNoFriendlyValue = 'has-no-friendly-value';
}

如上,我们针对两个场景分别写了测试,从而对 HasFriendlyEnumLabels 的行为更有信心。

你可能注意到,为了测试该 trait,我们在测试类后面定义了一个新的 MyTestEnum 枚举,其中包含一个未应用 Friendly 的 case,以便验证异常分支。虽然一般不建议在一个文件里放多个类,但在测试中为了就地构造特定场景,这样做是可以接受的。如果你不喜欢这种方式,也可以单独建立一个测试用枚举文件。

我们还可以为 PostStatus 写一个测试,确保每个 case 都应用了 Friendly。示例:

php
namespace Tests\Feature\Enums\PostStatus;

use App\Enums\PostStatus;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class FriendlyTest extends TestCase
{
    #[Test]
    #[DataProvider('casesProvider')]
    public function each_case_has_a_friendly_label(
        PostStatus $postStatus
    ): void {
        $this->assertNotNull($postStatus->friendly());
    }

    public static function casesProvider(): array
    {
        return array_map(
            callback: static fn (PostStatus $case): array => [$case],
            array: PostStatus::cases(),
        );
    }
}

这里我们使用了 PHPUnit 的 #[DataProvider] 标注来提供测试数据。在 casesProvider 中,我们把 PostStatus::cases() 转换为形如 [[PostStatus::Draft], [PostStatus::Published], ...] 的数组,便于 PHPUnit 逐个传给测试方法。这样测试会对每个枚举 case 运行一次。测试里我们只断言 friendly() 的结果非空。虽然它并没有断言具体字符串,但足以保证每个 case 都应用了 Friendly。这样如果将来新增了一个 case 而忘记添加 Friendly,测试会失败,从而在上线前暴露问题。

当然,具体到你的业务,你可能还需要更精确的断言。但对我们的示例来说,这样的测试已经足够提供信心。

结语

本文介绍了注解是什么、它们在代码中的作用以及如何使用;我们还学习了如何自定义注解并为使用注解的代码编写测试。

现在你应当已经具备在自己的 PHP 应用中使用注解的信心。

JaguarJack
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接