简介

Laravel Pennant 是一个简单轻量的功能开关包——去除了冗余部分。功能开关可让您满怀信心地逐步发布应用的新功能、对新的界面设计进行 A/B 测试、配合基于主干的开发策略,还能实现更多操作。

安装

首先,使用 Composer 包管理器将 Pennant 安装到您的项目中:

composer require laravel/pennant

接着,您应当使用 vendor:publish Artisan 命令发布 Pennant 的配置文件和迁移文件:

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"

最后,您应当运行应用的数据库迁移。这将创建一个 features 表,Pennant 会利用该表来驱动其 database 驱动:

php artisan migrate

配置

发布 Pennant 的资源文件后,其配置文件将位于 config/pennant.php。通过该配置文件,您可以指定 Pennant 存储已解析的功能开关值时所采用的默认存储机制。

Pennant 支持通过 array 驱动将已解析的功能开关值存储在内存数组中。此外,Pennant 还可以通过 database 驱动将已解析的功能开关值持久化存储在关系型数据库中,这也是 Pennant 默认使用的存储机制。

定义功能

若要定义一个功能,您可以使用 Feature 门面提供的 define 方法。您需要为该功能指定一个名称,同时提供一个闭包,用于解析该功能的初始值。

通常,功能会在服务提供者中使用 Feature 门面进行定义。该闭包会接收功能检查对应的“作用域”。最常见的情况下,作用域就是当前已认证的用户。在本示例中,我们将定义一个功能,以便向我们的应用用户逐步发布新的 API:

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 引导应用服务。
     */
    public function boot(): void
    {
        Feature::define('new-api', fn (User $user) => match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        });
    }
}

如您所见,我们的功能具有以下规则:

  • 所有内部团队成员都应使用新 API。
  • 任何高流量客户都不应使用新 API。
  • 其他情况下,该功能会随机分配给用户,有 1/100 的概率处于启用状态。

针对特定用户首次检查 new-api 功能时,闭包的结果会被存储驱动记录下来。下一次对该用户检查该功能时,将从存储中获取该值,不会再次调用该闭包。

为方便起见,如果功能定义仅返回一个抽签结果,您可以完全省略闭包:

Feature::define('site-redesign', Lottery::odds(1, 1000));

基于类的功能

Pennant 还允许您定义基于类的功能。与基于闭包的功能定义不同,基于类的功能无需在服务提供者中进行注册。若要创建基于类的功能,您可以调用 pennant:feature Artisan 命令。默认情况下,该功能类会放置在应用的 app/Features 目录下:

php artisan pennant:feature NewApi

编写功能类时,您只需定义一个 resolve 方法,该方法会被调用,用于解析特定作用域下该功能的初始值。同样,作用域通常为当前已认证的用户:

<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * 解析功能的初始值。
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

若您想手动解析基于类的功能实例,可以调用 Feature 门面上的 instance 方法:

use App\Features\NewApi;
use Laravel\Pennant\Feature;

$instance = Feature::instance(NewApi::class);

功能类是通过 容器 解析的,因此您可以在需要时将依赖项注入到功能类的构造函数中。

自定义存储的功能名称

默认情况下,Pennant 会存储功能类的全限定类名。若您希望将存储的功能名称与应用的内部结构解耦,可以在功能类上添加 Name 特性。该特性的值将被存储,用于替代类名:

<?php

namespace App\Features;

use Laravel\Pennant\Attributes\Name;

#[Name('new-api')]
class NewApi
{
    // ...
}

检查功能

若要判断某个功能是否启用,您可以使用 Feature 门面上的 active 方法。默认情况下,功能会针对当前已认证的用户进行检查:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * 展示资源列表。
     */
    public function index(Request $request): Response
    {
        return Feature::active('new-api')
            ? $this->resolveNewApiResponse($request)
            : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

尽管默认会针对当前已认证的用户检查功能,但您也可以轻松针对其他用户或作用域检查该功能。要实现这一点,请使用 Feature 门面提供的 for 方法:

return Feature::for($user)->active('new-api')
    ? $this->resolveNewApiResponse($request)
    : $this->resolveLegacyApiResponse($request);

Pennant 还提供了一些额外的便捷方法,在判断功能是否启用时可能非常有用:

// 判断所有给定的功能是否都启用……
Feature::allAreActive(['new-api', 'site-redesign']);

// 判断任意一个给定的功能是否启用……
Feature::someAreActive(['new-api', 'site-redesign']);

// 判断某个功能是否未启用……
Feature::inactive('new-api');

// 判断所有给定的功能是否都未启用……
Feature::allAreInactive(['new-api', 'site-redesign']);

// 判断任意一个给定的功能是否未启用……
Feature::someAreInactive(['new-api', 'site-redesign']);

在 HTTP 上下文之外使用 Pennant 时(例如在 Artisan 命令或队列任务中),您通常应该显式指定功能的作用域。或者,您可以定义一个默认作用域,同时适配已认证的 HTTP 上下文和未认证的上下文。

检查基于类的功能

对于基于类的功能,在检查功能时您应当提供类名:

<?php

namespace App\Http\Controllers;

use App\Features\NewApi;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * 展示资源列表。
     */
    public function index(Request $request): Response
    {
        return Feature::active(NewApi::class)
            ? $this->resolveNewApiResponse($request)
            : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

条件执行

可以使用 when 方法,在功能启用时以流式方式执行给定的闭包。此外,还可以提供第二个闭包,当功能未启用时执行该闭包:

<?php

namespace App\Http\Controllers;

use App\Features\NewApi;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;

class PodcastController
{
    /**
     * 展示资源列表。
     */
    public function index(Request $request): Response
    {
        return Feature::when(NewApi::class,
            fn () => $this->resolveNewApiResponse($request),
            fn () => $this->resolveLegacyApiResponse($request),
        );
    }

    // ...
}

unless 方法与 when 方法的作用相反,当功能未启用时会执行第一个闭包:

return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

HasFeatures Trait

可以将 Pennant 的 HasFeatures 特性添加到应用的 User 模型(或任何其他具有功能特性的模型)中,从而提供一种流畅、便捷的方式,直接从模型中检查功能特性:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;

    // ...
}

一旦将该特性添加到模型中,就可以通过调用 features 方法轻松检查功能特性:

if ($user->features()->active('new-api')) {
    // ...
}

当然,features 方法还提供对许多其他用于与功能特性交互的便捷方法的访问:

// Values...
$value = $user->features()->value('purchase-button');
$values = $user->features()->values(['new-api', 'purchase-button']);

// State...
$user->features()->active('new-api');
$user->features()->allAreActive(['new-api', 'server-api']);
$user->features()->someAreActive(['new-api', 'server-api']);

$user->features()->inactive('new-api');
$user->features()->allAreInactive(['new-api', 'server-api']);
$user->features()->someAreInactive(['new-api', 'server-api']);

// Conditional execution...
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

$user->features()->unless('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

Blade 指令

为了在 Blade 中无缝检查功能特性,Pennant 提供了 @feature 和 @featureany 指令:

@feature('site-redesign')
    <!-- 'site-redesign' 处于启用状态 -->
@else
    <!-- 'site-redesign' 处于未启用状态 -->
@endfeature

@featureany(['site-redesign', 'beta'])
    <!-- 'site-redesign' 或 `beta` 处于启用状态 -->
@endfeatureany

中间件

Pennant 还包含一个中间件,可用于在调用路由之前,验证当前已认证的用户是否具有访问某项功能特性的权限。你可以将该中间件分配给路由,并指定访问该路由所需的功能特性。如果对于当前已认证的用户,任何指定的功能特性都未启用,路由将返回 400 Bad Request HTTP 响应。可将多个功能特性传递给静态的 using 方法。

use Illuminate\Support\Facades\Route;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/api/servers', function () {
    // ...
})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));

自定义响应

如果你想在列出的功能特性之一未启用时,自定义中间件返回的响应,可以使用 EnsureFeaturesAreActive 中间件提供的 whenInactive 方法。通常,该方法应在应用的某个服务提供者的 boot 方法中调用:

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

/**
 * 引导任何应用服务。
 */
public function boot(): void
{
    EnsureFeaturesAreActive::whenInactive(
        function (Request $request, array $features) {
            return new Response(status: 403);
        }
    );

    // ...
}

拦截功能特性检查

有时,在检索给定功能特性的存储值之前进行一些内存中的检查会很有用。假设你正在开发一个受功能开关控制的新 API,并且希望在不丢失存储中任何已解析的功能特性值的情况下禁用该新 API。如果在新 API 中发现错误,你可以轻松地为除内部团队成员之外的所有人禁用它,修复错误,然后为之前有权访问该功能特性的用户重新启用新 API。

你可以使用基于类的功能特性的 before 方法来实现这一点。当存在该方法时,before 方法总是会在从存储中检索值之前,先在内存中运行。如果该方法返回非 null 值,该值将在请求期间替代功能特性的存储值使用:

<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * 在检索存储值之前,运行始终在内存中执行的检查。
     */
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    /**
     * 解析功能特性的初始值。
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

你也可以使用该功能来安排之前受功能开关控制的功能特性的全局发布:

<?php

namespace App\Features;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;

class NewApi
{
    /**
     * 在检索存储值之前,运行始终在内存中执行的检查。
     */
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }

        if (Carbon::parse(Config::get('features.new-api.rollout-date'))->isPast()) {
            return true;
        }
    }

    // ...
}

内存缓存

在检查功能特性时,Pennant 会创建结果的内存缓存。如果你使用 database 驱动,这意味着在单个请求内重新检查同一个功能开关不会触发额外的数据库查询。这也确保了功能特性在请求期间具有一致的结果。

如果你需要手动清空内存缓存,可以使用 Feature 门面提供的 flushCache 方法:

Feature::flushCache();

作用域

指定作用域

如前所述,功能特性通常是针对当前已认证的用户进行检查。然而,这并不总是符合你的需求。因此,可以通过 Feature 门面的 for 方法指定你想要检查给定功能特性的作用域:

return Feature::for($user)->active('new-api')
    ? $this->resolveNewApiResponse($request)
    : $this->resolveLegacyApiResponse($request);

当然,功能特性的作用域并不局限于“用户”。假设你构建了一个新的计费功能,该功能是面向整个团队而非单个用户进行发布的。也许你希望成立时间较早的团队的发布速度比新成立的团队更慢。你的功能特性解析闭包可能如下所示:

use App\Models\Team;
use Illuminate\Support\Carbon;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) {
        return true;
    }

    if ($team->created_at->isAfter(new Carbon('1st Jan, 2019'))) {
        return Lottery::odds(1 / 100);
    }

    return Lottery::odds(1 / 1000);
});

你会注意到,我们定义的闭包期望接收的不是 User,而是 Team 模型。若要判断某个功能特性对于用户的团队是否处于启用状态,应将团队传递给 Feature 门面提供的 for 方法:

if (Feature::for($user->team)->active('billing-v2')) {
    return redirect('/billing/v2');
}

// ...

全局作用域

若要使用全局作用域检查或与功能特性交互,无论配置的默认作用域解析器是什么,都可以使用 globally 方法。这对于应用范围内的功能开关很有用,例如临时启用维护行为或向所有用户发布一项功能特性:

Feature::globally()->active('new-api');

Feature::globally()->activate('new-api');

默认作用域

还可以自定义 Pennant 用于检查功能特性的默认作用域。例如,也许所有的功能特性都是针对当前已认证用户的团队而非用户进行检查。无需在每次检查功能特性时都调用 Feature::for($user->team),你可以将团队指定为默认作用域。通常,这应在应用的某个服务提供者中完成:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 引导任何应用服务。
     */
    public function boot(): void
    {
        Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);

        // ...
    }
}

如果未通过 for 方法显式提供作用域,功能检查现在将使用当前已认证用户的队伍作为默认作用域:

Feature::active('billing-v2');

// Is now equivalent to...

Feature::for($user->team)->active('billing-v2');

可空作用域

如果在检查功能时提供的作用域为 null,且功能定义未通过可空类型或在联合类型中包含 null 来支持 null,Pennant 将自动返回 false 作为功能的结果值。

因此,如果你传递给功能的 scope 可能为 null,并且希望调用功能的值解析器,你应在功能定义中对此进行处理。在 Artisan 命令、排队任务或未认证路由中检查功能时,可能会出现 null 作用域。由于在这些上下文中通常没有已认证用户,默认作用域将为 null。

如果你并非始终显式指定功能作用域,那么应确保作用域的类型为“可空”,并在功能定义逻辑中处理 null 作用域值:


use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-api', fn (User $user) => match (true) {
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

标识作用域

Pennant 内置的 array 和 database 存储驱动知道如何正确存储所有 PHP 数据类型以及 Eloquent 模型的作用域标识符。然而,如果你的应用使用了第三方 Pennant 驱动,该驱动可能不知道如何正确存储 Eloquent 模型或其他自定义类型的作用域标识符。

鉴于此,Pennant 允许你在应用中用作 Pennant 作用域的对象上实现 FeatureScopeable 契约,以格式化用于存储的作用域值。

例如,假设你在一个应用中使用了两个不同的功能驱动:内置的 database 驱动和第三方“Flag Rocket”驱动。“Flag Rocket”驱动不知道如何正确存储 Eloquent 模型,它需要一个 FlagRocketUser 实例。通过实现 FeatureScopeable 契约定义的 toFeatureIdentifier,我们可以自定义提供给应用所使用的每个驱动的可存储作用域值:

<?php

namespace App\Models;

use FlagRocket\FlagRocketUser;
use Illuminate\Database\Eloquent\Model;
use Laravel\Pennant\Contracts\FeatureScopeable;

class User extends Model implements FeatureScopeable
{
    /**
     * Cast the object to a feature scope identifier for the given driver.
     */
    public function toFeatureIdentifier(string $driver): mixed
    {
        return match($driver) {
            'database' => $this,
            'flag-rocket' => FlagRocketUser::fromId($this->flag_rocket_id),
        };
    }
}

序列化作用域

默认情况下,Pennant 在存储与 Eloquent 模型关联的功能时会使用全限定类名。如果你已经使用了Eloquent 形态映射(morph map),你可以选择让 Pennant 也使用该形态映射,从而将存储的功能与你的应用结构解耦。

为实现这一点,在服务提供者中定义 Eloquent 形态映射之后,你可以调用 Feature 外观的 useMorphMap 方法:

use Illuminate\Database\Eloquent\Relations\Relation;
use Laravel\Pennant\Feature;

Relation::enforceMorphMap([
    'post' => 'App\Models\Post',
    'video' => 'App\Models\Video',
]);

Feature::useMorphMap();

富功能值

到目前为止,我们主要将功能展示为二元状态,即它们是“启用”或“未启用”,但 Pennant 也允许你存储富值。

例如,假设你正在测试应用中“立即购买”按钮的三种新颜色。你可以在功能定义中返回一个字符串,而不是返回 true 或 false:

use Illuminate\Support\Arr;
use Laravel\Pennant\Feature;

Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));

你可以使用 value 方法来获取 purchase-button 功能的值:

$color = Feature::value('purchase-button');

Pennant 自带的 Blade 指令还使根据功能的当前值条件渲染内容变得十分简单:

@feature('purchase-button', 'blue-sapphire')
    <!-- 'blue-sapphire' is active -->
@elsefeature('purchase-button', 'seafoam-green')
    <!-- 'seafoam-green' is active -->
@elsefeature('purchase-button', 'tart-orange')
    <!-- 'tart-orange' is active -->
@endfeature

使用富值时,需要了解的重要一点是,当功能具有除 false 之外的任何值时,该功能即被视为“启用”。

调用条件 when 方法时,功能的富值会提供给第一个闭包:

Feature::when('purchase-button',
    fn ($color) => /* ... */,
    fn () => /* ... */,
);

同样,调用条件 unless 方法时,功能的富值会提供给可选的第二个闭包:

Feature::unless('purchase-button',
    fn () => /* ... */,
    fn ($color) => /* ... */,
);

检索多个功能

values 方法允许检索给定作用域下的多个功能:

Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]

或者,你可以使用 all 方法来检索给定作用域下所有已定义功能的值:

Feature::all();

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]

然而,基于类的功能是动态注册的,在显式检查之前,Pennant 并不知晓它们。这意味着如果应用的基于类的功能在当前请求期间尚未被检查,它们可能不会出现在 all 方法返回的结果中。

如果你想确保在使用 all 方法时始终包含功能类,可以使用 Pennant 的功能发现功能。为此,请在应用的某个服务提供者中调用 discover 方法:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::discover();

        // ...
    }
}

discover 方法会注册应用 app/Features 目录下的所有功能类。all 方法现在会将这些类纳入结果中,无论它们在当前请求期间是否已被检查:

Feature::all();

// [
//     'App\Features\NewApi' => true,
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
//     'site-redesign' => true,
// ]

预加载

尽管 Pennant 会为单个请求保留所有已解析功能的内存缓存,但仍可能遇到性能问题。为缓解此问题,Pennant 提供了预加载功能值的能力。

为说明这一点,假设我们在循环中检查功能是否启用:

use Laravel\Pennant\Feature;

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

假设我们使用的是数据库驱动,此代码将为循环中的每个用户执行一次数据库查询——可能会执行数百次查询。不过,通过使用 Pennant 的 load 方法,我们可以预加载一组用户或作用域的功能值,从而消除这一潜在的性能瓶颈:

Feature::for($users)->load(['notifications-beta']);

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

如果只想在功能值尚未加载时才加载,可以使用 loadMissing 方法:

Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

你可以使用 loadAll 方法加载所有已定义的功能:

Feature::for($users)->loadAll();

更新值

当首次解析某个功能的值时,底层驱动会将结果存储起来。这通常是为了确保用户在不同请求中都能获得一致的体验。不过,有时你可能需要手动更新该功能的存储值。

为此,你可以使用 activate 和 deactivate 方法来将功能切换为“开启”或“关闭”状态:

use Laravel\Pennant\Feature;

// 为默认作用域激活该功能...
Feature::activate('new-api');

// 为指定作用域停用该功能...
Feature::for($user->team)->deactivate('billing-v2');

也可以通过向 activate 方法传入第二个参数,为功能手动设置一个丰富的值:

Feature::activate('purchase-button', 'seafoam-green');

若要让 Pennant 忽略某个功能的存储值,可以使用 forget 方法。当再次检查该功能时,Pennant 会根据其功能定义重新解析该功能的值:

Feature::forget('purchase-button');

批量更新

若要批量更新存储的功能值,可以使用 activateForEveryone 和 deactivateForEveryone 方法。

例如,假设你已对 new-api 功能的稳定性充满信心,并且为结算流程确定了最佳的 'purchase-button' 颜色——你可以据此更新所有用户的存储值:

use Laravel\Pennant\Feature;

Feature::activateForEveryone('new-api');

Feature::activateForEveryone('purchase-button', 'seafoam-green');

或者,你可以为所有用户停用该功能:

Feature::deactivateForEveryone('new-api');

这只会更新 Pennant 存储驱动中已存储的解析后的功能值。你还需要更新应用程序中的功能定义。

清除功能

有时,从存储中清除整个功能会很有用。如果你已从应用程序中移除了某项功能,或者对功能定义进行了调整并希望将其推广给所有用户,通常就需要进行此操作。

你可以使用 purge 方法移除某项功能的所有存储值:

// 清除单个功能...
Feature::purge('new-api');

// 清除多个功能...
Feature::purge(['new-api', 'purchase-button']);

若要清除存储中的所有功能,可以在不传入任何参数的情况下调用 purge 方法:

Feature::purge();

由于在应用程序的部署流水线中清除功能会很有用,Pennant 内置了 pennant:purge Artisan 命令,该命令将从存储中清除指定的功能:

php artisan pennant:purge new-api

php artisan pennant:purge new-api purchase-button

也可以清除指定功能列表之外的所有功能。例如,假设你想清除所有功能,但保留“new-api”和“purchase-button”功能的值。为此,你可以将这些功能名称传入 --except 选项:

php artisan pennant:purge --except=new-api --except=purchase-button

为方便起见,pennant:purge 命令还支持 --except-registered 标志。该标志表示除在服务提供者中显式注册的功能外,所有功能都将被清除:

php artisan pennant:purge --except-registered

测试

在测试与功能开关交互的代码时,在测试中控制功能开关返回值的最简单方法就是重新定义该功能。例如,假设你在应用程序的某个服务提供者中定义了如下功能:

use Illuminate\Support\Arr;
use Laravel\Pennant\Feature;

Feature::define('purchase-button', fn () => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));

若要在测试中修改该功能的返回值,你可以在测试开始时重新定义该功能。以下测试将始终通过,即便服务提供者中仍然保留着 Arr::random() 的实现:

use Laravel\Pennant\Feature;

test('it can control feature values', function () {
    Feature::define('purchase-button', 'seafoam-green');

    expect(Feature::value('purchase-button'))->toBe('seafoam-green');
});
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values()
{
    Feature::define('purchase-button', 'seafoam-green');

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}

同样的方法也适用于基于类的功能:

use Laravel\Pennant\Feature;

test('it can control feature values', function () {
    Feature::define(NewApi::class, true);

    expect(Feature::value(NewApi::class))->toBeTrue();
});
use App\Features\NewApi;
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values()
{
    Feature::define(NewApi::class, true);

    $this->assertTrue(Feature::value(NewApi::class));
}

如果你的功能返回的是 Lottery 实例,还有一些有用的测试辅助方法可供选择。

存储配置

你可以在应用程序的 phpunit.xml 文件中定义 PENNANT_STORE 环境变量,以此配置 Pennant 在测试期间使用的存储:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <!-- ... -->
    <php>
        <env name="PENNANT_STORE" value="array"/>
        <!-- ... -->
    </php>
</phpunit>

添加自定义 Pennant 驱动

实现驱动

如果 Pennant 现有的存储驱动都无法满足你的应用需求,你可以自行编写存储驱动。你的自定义驱动应实现 Laravel\Pennant\Contracts\Driver 接口:

<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;

class RedisFeatureDriver implements Driver
{
    public function define(string $feature, callable $resolver): void {}
    public function defined(): array {}
    public function getAll(array $features): array {}
    public function get(string $feature, mixed $scope): mixed {}
    public function set(string $feature, mixed $scope, mixed $value): void {}
    public function setForAllScopes(string $feature, mixed $value): void {}
    public function delete(string $feature, mixed $scope): void {}
    public function purge(array|null $features): void {}
}

现在,我们只需通过 Redis 连接来实现这些方法。关于如何实现这些方法的示例,可参考Pennant 源代码中的 Laravel\Pennant\Drivers\DatabaseDriver

Laravel 并未附带用于存放扩展的目录。你可以将其放在任何你喜欢的位置。在本示例中,我们创建了一个 Extensions 目录来存放 RedisFeatureDriver。

注册驱动

驱动实现完成后,就可以将其注册到 Laravel 中了。若要为 Pennant 添加额外的驱动,可以使用 Feature 门面提供的 extend 方法。你应该在应用程序某个服务提供者的 boot 方法中调用 extend 方法:

<?php

namespace App\Providers;

use App\Extensions\RedisFeatureDriver;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 注册任何应用程序服务。
     */
    public function register(): void
    {
        // ...
    }

    /**
     * 引导启动任何应用程序服务。
     */
    public function boot(): void
    {
        Feature::extend('redis', function (Application $app) {
            return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
        });
    }
}

驱动注册完成后,就可以在应用程序的 config/pennant.php 配置文件中使用 redis 驱动了:

'stores' => [

    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],

    // ...

],

在外部定义功能

如果你的驱动是对第三方功能开关平台的封装,那么很可能是在该平台上定义功能,而非使用 Pennant 的 Feature::define 方法。若是这种情况,你的自定义驱动还应实现 Laravel\Pennant\Contracts\DefinesFeaturesExternally 接口:

<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;
use Laravel\Pennant\Contracts\DefinesFeaturesExternally;

class FeatureFlagServiceDriver implements Driver, DefinesFeaturesExternally
{
    /**
     * 获取为给定作用域定义的功能。
     */
    public function definedFeaturesForScope(mixed $scope): array {}

    /* ... */
}

definedFeaturesForScope 方法应返回为所提供的作用域定义的功能名称列表。

事件

Pennant 会触发多种事件,在应用程序中跟踪功能标志时,这些事件非常有用。

Laravel\Pennant\Events\FeatureRetrieved

每当检查功能时,都会触发此事件。此事件对于在整个应用程序中针对功能标志的使用情况创建并跟踪指标可能很有用。

Laravel\Pennant\Events\FeatureResolved

首次为特定作用域解析功能的值时,会触发此事件。

Laravel\Pennant\Events\UnknownFeatureResolved

首次为特定作用域解析未知功能时,会触发此事件。如果你本打算移除某个功能标志,却在应用程序中不小心留下了零散的引用,那么监听此事件可能会很有用:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Events\UnknownFeatureResolved;

class AppServiceProvider extends ServiceProvider
{
    /**
     * 引导任何应用程序服务。
     */
    public function boot(): void
    {
        Event::listen(function (UnknownFeatureResolved $event) {
            Log::error("Resolving unknown feature [{$event->feature}].");
        });
    }
}

Laravel\Pennant\Events\DynamicallyRegisteringFeatureClass

在一次请求中首次动态检查基于类的功能时,会触发此事件。

Laravel\Pennant\Events\UnexpectedNullScopeEncountered

当向一个不支持空值的功能定义传递 null 作用域时,会触发此事件。

这种情况会被妥善处理,功能会返回 false。不过,如果你想禁用该功能的默认容错行为,可以在应用程序的 AppServiceProvider 的 boot 方法中注册该事件的监听器:

use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Events\UnexpectedNullScopeEncountered;

/**
 * 引导任何应用程序服务。
 */
public function boot(): void
{
    Event::listen(UnexpectedNullScopeEncountered::class, fn () => abort(500));
}

Laravel\Pennant\Events\FeatureUpdated

在更新某个作用域的功能时(通常通过调用 activate 或 deactivate 实现),会触发此事件。

Laravel\Pennant\Events\FeatureUpdatedForAllScopes

在更新所有作用域的功能时(通常通过调用 activateForEveryone 或 deactivateForEveryone 实现),会触发此事件。

Laravel\Pennant\Events\FeatureDeleted

在删除某个作用域的功能时(通常通过调用 forget 实现),会触发此事件。

Laravel\Pennant\Events\FeaturesPurged

在清除特定功能时,会触发此事件。

Laravel\Pennant\Events\AllFeaturesPurged

在清除所有功能时,会触发此事件。