Skip to content
全部文档

Laravel Pennant

介绍

Laravel Pennant 是一个简洁轻量的功能开关(feature flag)包,没有多余负担。功能开关让你能够自信地逐步发布新功能、对新界面设计进行 A/B 测试、配合主干开发策略,以及做更多事情。

安装

首先,使用 Composer 将 Pennant 安装到项目中:

shell
composer require laravel/pennant

接下来,使用 vendor:publish Artisan 命令发布 Pennant 的配置与迁移文件:

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

最后,运行应用的数据库迁移。这将创建 features 表,供 Pennant 的 database 驱动使用:

shell
php artisan migrate

配置

发布 Pennant 资源后,配置文件位于 config/pennant.php。该文件可指定 Pennant 存储已解析功能开关值的默认存储机制。

Pennant 支持通过 array 驱动将已解析的功能开关值存入内存数组;也可通过 database 驱动持久化到关系型数据库,后者是 Pennant 的默认存储机制。

定义功能

定义功能时,可使用 Feature Facade 提供的 define 方法。需提供功能名称,以及用于解析功能初始值的闭包。

通常,功能在服务提供者中使用 Feature Facade 定义。闭包会接收功能检查的「作用域」(scope)。最常见的是当前已认证用户。本例将为应用用户逐步发布新 API 定义一个功能:

php
<?php

namespace App\Providers;

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

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    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 功能时,闭包结果会由存储驱动保存。下次对同一用户再次检查时,将从存储读取该值,不再调用闭包。

为方便起见,若功能定义仅返回 lottery,可完全省略闭包:

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

基于类的功能

Pennant 也支持定义基于类的功能。与基于闭包的定义不同,基于类的功能无需在服务提供者中注册。可执行 pennant:feature Artisan 命令创建;默认情况下,功能类会放在应用的 app/Features 目录中:

shell
php artisan pennant:feature NewApi

编写功能类时,只需定义 resolve 方法,用于为给定作用域解析功能的初始值。作用域通常仍是当前已认证用户:

php
<?php

namespace App\Features;

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

class NewApi
{
    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

若要手动解析基于类的功能实例,可调用 Feature Facade 的 instance 方法:

php
use Illuminate\Support\Facades\Feature;

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

INFO

功能类通过容器解析,因此可在需要时向功能类构造函数注入依赖。

自定义存储的功能名称

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

php
<?php

namespace App\Features;

use Laravel\Pennant\Attributes\Name;

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

检查功能

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

php
<?php

namespace App\Http\Controllers;

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

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::active('new-api')
            ? $this->resolveNewApiResponse($request)
            : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

默认针对当前已认证用户检查功能,但也可轻松针对其他用户或作用域检查。使用 Feature Facade 的 for 方法即可:

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

Pennant 还提供其他便捷方法,用于判断功能是否启用:

php
// Determine if all of the given features are active...
Feature::allAreActive(['new-api', 'site-redesign']);

// Determine if any of the given features are active...
Feature::someAreActive(['new-api', 'site-redesign']);

// Determine if a feature is inactive...
Feature::inactive('new-api');

// Determine if all of the given features are inactive...
Feature::allAreInactive(['new-api', 'site-redesign']);

// Determine if any of the given features are inactive...
Feature::someAreInactive(['new-api', 'site-redesign']);

INFO

在 HTTP 上下文之外使用 Pennant(例如在 Artisan 命令或队列任务中)时,通常应显式指定功能作用域。也可定义默认作用域,同时覆盖已认证与未认证的 HTTP 上下文。

检查基于类的功能

检查基于类的功能时,应传入类名:

php
<?php

namespace App\Http\Controllers;

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

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::active(NewApi::class)
            ? $this->resolveNewApiResponse($request)
            : $this->resolveLegacyApiResponse($request);
    }

    // ...
}

条件执行

when 方法可在功能启用时流畅地执行给定闭包;还可提供第二个闭包,在功能未启用时执行:

php
<?php

namespace App\Http\Controllers;

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

class PodcastController
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request): Response
    {
        return Feature::when(NewApi::class,
            fn () => $this->resolveNewApiResponse($request),
            fn () => $this->resolveLegacyApiResponse($request),
        );
    }

    // ...
}

unless 方法是 when 的反向版本,在功能未启用时执行第一个闭包:

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

HasFeatures Trait

可将 Pennant 的 HasFeatures trait 添加到应用的 User 模型(或任何带功能的模型),以便直接从模型流畅、便捷地检查功能:

php
<?php

namespace App\Models;

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

class User extends Authenticatable
{
    use HasFeatures;

    // ...
}

将 trait 添加到模型后,可调用 features 方法轻松检查功能:

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

当然,features 方法还提供许多其他便捷方法用于与功能交互:

php
// 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 指令:

blade
@feature('site-redesign')
    <!-- 'site-redesign' is active -->
@else
    <!-- 'site-redesign' is inactive -->
@endfeature

@featureany(['site-redesign', 'beta'])
    <!-- 'site-redesign' or `beta` is active -->
@endfeatureany

中间件

Pennant 还提供中间件,可在路由被调用前验证当前已认证用户是否拥有某功能访问权限。可将中间件分配给路由,并指定访问该路由所需的功能。若当前用户任一指定功能未启用,路由将返回 400 Bad Request HTTP 响应。可向静态 using 方法传入多个功能。

php
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 方法中调用:

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

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    EnsureFeaturesAreActive::whenInactive(
        function (Request $request, array $features) {
            return new Response(status: 403);
        }
    );

    // ...
}

拦截功能检查

有时,在读取某功能的存储值之前先做内存检查会很有用。假设你在功能开关后开发新 API,希望在不禁用存储中已解析功能值的情况下关闭新 API。若发现新 API 有缺陷,可轻松对除内部团队成员外的所有人禁用,修复后再为此前有权限的用户重新启用。

可通过基于类的功能before 方法实现。若存在该方法,会在从存储读取值之前始终在内存中执行。若方法返回非 null 值,则在本次请求期间将替代功能的存储值使用:

php
<?php

namespace App\Features;

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

class NewApi
{
    /**
     * Run an always-in-memory check before the stored value is retrieved.
     */
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    /**
     * Resolve the feature's initial value.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

也可利用该能力,为此前受功能开关控制的功能安排全局发布:

php
<?php

namespace App\Features;

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

class NewApi
{
    /**
     * Run an always-in-memory check before the stored value is retrieved.
     */
    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 Facade 的 flushCache 方法:

php
Feature::flushCache();

作用域

指定作用域

如前所述,功能通常针对当前已认证用户检查。但这未必总符合需求,因此可通过 Feature Facade 的 for 方法指定要检查的作用域:

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

当然,功能作用域不限于「用户」。假设你构建了新的计费体验,面向整个团队而非单个用户发布。你可能希望较老的团队比新团队更慢地灰度。功能解析闭包可能如下:

php
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);
});

注意,我们定义的闭包期望的是 Team 模型而非 User。要判断用户团队是否启用该功能,应将团队传给 Feature Facade 的 for 方法:

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

// ...

全局作用域

无论默认作用域解析器如何配置,都可使用 globally 方法以全局作用域检查或操作功能。这适用于应用级功能开关,例如临时启用维护行为或向所有用户发布功能:

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

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

默认作用域

也可自定义 Pennant 检查功能时使用的默认作用域。例如,所有功能都针对当前用户的团队而非用户本人检查。这样就不必每次检查都调用 Feature::for($user->team),可将团队设为默认作用域。通常应在应用某个服务提供者中完成:

php
<?php

namespace App\Providers;

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

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);

        // ...
    }
}

若未通过 for 方法显式提供作用域,功能检查将默认使用当前已认证用户的团队:

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

// Is now equivalent to...

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

可空作用域

若检查功能时提供的作用域为 null,且功能定义未通过可空类型或联合类型中的 null 支持 null,Pennant 会自动将功能结果设为 false

因此,若传入的作用域可能为 null 且你希望仍调用功能值解析器,应在功能定义中处理该情况。在 Artisan 命令、队列任务或未认证路由中检查功能时,作用域可能为 null;这些场景通常没有已认证用户,默认作用域即为 null

若并非总是显式指定功能作用域,应确保作用域类型为「可空」,并在功能定义逻辑中处理 null 作用域值:

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

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

标识作用域

Pennant 内置的 arraydatabase 存储驱动能正确存储所有 PHP 数据类型及 Eloquent 模型的作用域标识符。但若应用使用第三方 Pennant 驱动,该驱动可能无法正确存储 Eloquent 模型或其他自定义类型的标识符。

为此,Pennant 允许在作为作用域的对象上实现 FeatureScopeable 合约,以格式化用于存储的作用域值。

例如,同一应用中同时使用内置 database 驱动与第三方「Flag Rocket」驱动。「Flag Rocket」驱动无法正确存储 Eloquent 模型,而需要 FlagRocketUser 实例。通过实现 FeatureScopeable 合约定义的 toFeatureIdentifier,可为应用使用的各驱动自定义可存储的作用域值:

php
<?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),
        };
    }
}

序列化作用域

默认情况下,存储与 Eloquent 模型关联的功能时会使用完全限定类名。若已使用 Eloquent morph map,也可让 Pennant 使用 morph map,使存储的功能与应用结构解耦。

在服务提供者中定义 Eloquent morph map 后,可调用 Feature Facade 的 useMorphMap 方法:

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

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

Feature::useMorphMap();

富功能值

此前我们主要将功能展示为二元状态(「启用」或「未启用」),但 Pennant 也支持存储富值。

例如,你在测试应用「立即购买」按钮的三种新颜色。功能定义可返回字符串,而非 truefalse

php
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 功能的值:

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

Pennant 内置的 Blade 指令也便于根据功能当前值条件渲染内容:

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

INFO

使用富值时需注意:只要值不是 false,功能即视为「启用」。

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

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

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

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

获取多个功能

values 方法可获取给定作用域下的多个功能:

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

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

也可使用 all 方法获取给定作用域下所有已定义功能的值:

php
Feature::all();

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

但基于类的功能是动态注册的,在显式检查之前 Pennant 并不知道它们。因此,若当前请求中尚未检查,基于类的功能可能不会出现在 all 方法的返回结果中。

若希望使用 all 方法时始终包含功能类,可使用 Pennant 的功能发现能力。在应用某个服务提供者中调用 discover 方法即可开始:

php
<?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 方法的结果将始终包含这些类,无论当前请求是否已检查过它们:

php
Feature::all();

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

预加载

尽管 Pennant 会在单次请求内缓存所有已解析功能,仍可能出现性能问题。为此,Pennant 支持预加载功能值。

例如,在循环中检查功能是否启用:

php
use Laravel\Pennant\Feature;

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

假设使用 database 驱动,上述代码会为循环中的每个用户执行一次数据库查询,可能多达数百次。使用 Pennant 的 load 方法,可为用户或作用域集合预加载功能值,消除该性能瓶颈:

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

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

仅在尚未加载时加载功能值,可使用 loadMissing 方法:

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

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

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

更新值

功能值首次解析时,底层驱动会将结果写入存储,这通常用于在多次请求间为用户保持一致体验。但有时你可能需要手动更新功能的存储值。

可使用 activatedeactivate 方法将功能切换为「开」或「关」:

php
use Laravel\Pennant\Feature;

// Activate the feature for the default scope...
Feature::activate('new-api');

// Deactivate the feature for the given scope...
Feature::for($user->team)->deactivate('billing-v2');

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

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

要让 Pennant 忘记功能的存储值,可使用 forget 方法。再次检查该功能时,Pennant 会从功能定义重新解析其值:

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

批量更新

要批量更新存储的功能值,可使用 activateForEveryonedeactivateForEveryone 方法。

例如,你已确信 new-api 功能稳定,并为结账流程确定了最佳的 'purchase-button' 颜色——可据此为所有用户更新存储值:

php
use Laravel\Pennant\Feature;

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

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

也可为所有用户停用该功能:

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

INFO

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

清除功能

有时需要从存储中清除整个功能。这通常发生在已从应用中移除该功能,或调整了功能定义并希望向所有用户重新发布时。

可使用 purge 方法移除某功能的全部存储值:

php
// Purging a single feature...
Feature::purge('new-api');

// Purging multiple features...
Feature::purge(['new-api', 'purchase-button']);

若要清除存储中的_全部_功能,可不带参数调用 purge 方法:

php
Feature::purge();

将功能清除纳入部署流水线会很有用,因此 Pennant 提供 pennant:purge Artisan 命令,用于从存储中清除指定功能:

shell
php artisan pennant:purge new-api

php artisan pennant:purge new-api purchase-button

也可清除除给定功能列表外的所有功能。例如,你想清除全部功能但保留存储中「new-api」与「purchase-button」的值,可将这些功能名传给 --except 选项:

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

为方便起见,pennant:purge 还支持 --except-registered 标志,表示除在服务提供者中显式注册的功能外,其余全部清除:

shell
php artisan pennant:purge --except-registered

测试

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

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

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

要在测试中修改功能返回值,可在测试开头重新定义该功能。以下测试将始终通过,即使服务提供者中仍存在 Arr::random() 实现:

php
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');
});
php
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'));
}

基于类的功能也可采用相同方式:

php
use Laravel\Pennant\Feature;

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

    expect(Feature::value(NewApi::class))->toBeTrue();
});
php
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
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <!-- ... -->
    <php>
        <env name="PENNANT_STORE" value="array"/>
        <!-- ... -->
    </php>
</phpunit>

添加自定义 Pennant 驱动

实现驱动

若现有存储驱动都不满足需求,可自行编写存储驱动。自定义驱动应实现 Laravel\Pennant\Contracts\Driver 接口:

php
<?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

INFO

Laravel 不会自带用于放置扩展的目录,你可自由放置。本例中我们创建了 Extensions 目录来存放 RedisFeatureDriver

注册驱动

实现驱动后,即可向 Laravel 注册。要向 Pennant 添加额外驱动,可使用 Feature Facade 的 extend 方法。应在应用某个服务提供者boot 方法中调用:

php
<?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
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // ...
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Feature::extend('redis', function (Application $app) {
            return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
        });
    }
}

注册驱动后,可在 config/pennant.php 中使用 redis 驱动:

php
'stores' => [

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

    // ...

],

在外部定义功能

若驱动是对第三方功能开关平台的封装,功能更可能在平台上定义,而非使用 Pennant 的 Feature::define 方法。此时自定义驱动还应实现 Laravel\Pennant\Contracts\DefinesFeaturesExternally 接口:

php
<?php

namespace App\Extensions;

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

class FeatureFlagServiceDriver implements Driver, DefinesFeaturesExternally
{
    /**
     * Get the features defined for the given scope.
     */
    public function definedFeaturesForScope(mixed $scope): array {}

    /* ... */
}

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

事件

Pennant 会派发多种事件,便于在应用中跟踪功能开关。

Laravel\Pennant\Events\FeatureRetrieved

每当检查功能时派发此事件,可用于创建并跟踪功能开关在应用中的使用指标。

Laravel\Pennant\Events\FeatureResolved

首次为特定作用域解析功能值时派发此事件。

Laravel\Pennant\Events\UnknownFeatureResolved

首次为特定作用域解析未知功能时派发此事件。若你打算移除功能开关却误留引用,监听此事件会很有用:

php
<?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
{
    /**
     * Bootstrap any application services.
     */
    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 作用域传给不支持 null的功能定义时派发此事件。

该情况会被优雅处理,功能将返回 false。但若想退出该默认行为,可在 AppServiceProviderboot 方法中为此事件注册监听器:

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

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Event::listen(UnexpectedNullScopeEncountered::class, fn () => abort(500));
}

Laravel\Pennant\Events\FeatureUpdated

为某作用域更新功能时派发此事件,通常通过调用 activatedeactivate

Laravel\Pennant\Events\FeatureUpdatedForAllScopes

为所有作用域更新功能时派发此事件,通常通过调用 activateForEveryonedeactivateForEveryone

Laravel\Pennant\Events\FeatureDeleted

为某作用域删除功能时派发此事件,通常通过调用 forget

Laravel\Pennant\Events\FeaturesPurged

清除特定功能时派发此事件。

Laravel\Pennant\Events\AllFeaturesPurged

清除所有功能时派发此事件。