什么是 Laravel Pennant
Laravel Pennant 是一个轻量、简洁的功能开关(Feature Flag)扩展包。功能开关可以让你分阶段发布新功能、进行 A/B 测试,或者补充主干开发流程。
什么是功能开关
借助功能开关,可以将代码部署与功能发布分离。代码可以先部署到生产环境,通过配置来打开或关闭功能。
安装扩展包
通过 Composer 安装 Pennant。 composer require laravel/pennant
发布配置文件与迁移
通过 vendor:publish Artisan 命令发布文件。 php artisan vendor:publish --provider= "Laravel\Pennant\PennantServiceProvider"
这会在 config/pennant.php 与 database/migrations 中生成对应文件。
执行迁移
创建 Pennant 用来保存功能开关值的 features 表。
在 config/pennant.php 中可以配置要使用的存储驱动,Pennant 支持两种。
驱动 说明 database将值持久化到关系型数据库(默认) array保存在内存中(适合测试或临时使用)
// config/pennant.php
'default' => env ( 'PENNANT_STORE' , 'database' ),
定义功能
基于闭包的定义
功能通过 Feature Facade 的 define 方法定义,通常写在服务提供者的 boot 方法中。闭包会接收一个“作用域(scope)”参数(一般是已认证用户)。
<? 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 ),
});
}
}
上面这个功能的逻辑是:
团队内部成员始终开启
高流量客户始终关闭
其余用户按 1% 的概率开启
当功能首次被检查时,闭包返回的结果会写入存储驱动,后续判断都直接使用已保存的值。
如果定义体只是返回 Lottery,可以省略闭包。 Feature :: define ( 'site-redesign' , Lottery :: odds ( 1 , 1000 ));
基于类的定义
Pennant 也支持基于类的功能定义。基于类时无需在服务提供者中注册。
php artisan pennant:feature NewApi
生成的类位于 app/Features 目录,只需实现 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 ),
};
}
}
自定义保存名
默认使用完全限定类名保存。可以通过 Name Attribute 自定义名字。
use Laravel\Pennant\Attributes\ Name ;
#[ Name ( 'new-api' )]
class NewApi
{
// ...
}
拦截判断(before 方法)
基于类的功能可以定义 before 方法。它会在读取存储值之前在内存中执行,若返回非 null 值就直接使用该值。
class NewApi
{
public function before ( User $user ) : mixed
{
if ( Config :: get ( 'features.new-api.disabled' )) {
return $user -> isInternalTeamMember ();
}
}
public function resolve ( User $user ) : mixed
{
// ...
}
}
before 方法适合在出现 bug 时紧急关闭功能,或在指定时间点做灰度发布。
判断功能状态
Feature::active() / Feature::inactive()
active 方法可以判断功能是否启用。默认使用当前已认证用户作为作用域。
use Laravel\Pennant\ Feature ;
if ( Feature :: active ( 'new-api' )) {
// 使用新版 API 的处理
}
基于类的功能则传入类名。
use App\Features\ NewApi ;
if ( Feature :: active ( NewApi :: class )) {
// ...
}
其他常用方法:
// 是否所有功能都启用
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' ]);
条件执行(when / unless)
when 方法只在功能启用时执行闭包。
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
在 User 模型上添加 HasFeatures trait 后,就可以直接从模型上检查功能。
use Laravel\Pennant\Concerns\ HasFeatures ;
class User extends Authenticatable
{
use HasFeatures ;
}
if ( $user -> features () -> active ( 'new-api' )) {
// ...
}
// 获取值
$value = $user -> features () -> value ( 'purchase-button' );
// 条件执行
$user -> features () -> when ( 'new-api' ,
fn () => /* ... */ ,
fn () => /* ... */ ,
);
Blade 指令
Blade 模板中可以使用 @feature 指令。
@feature ( 'site-redesign' )
{{-- 'site-redesign' 启用时 --}}
@else
{{-- 'site-redesign' 未启用时 --}}
@endfeature
@featureany ([ 'site-redesign' , 'beta' ])
{{-- 任一功能启用时 --}}
@endfeatureany
中间件
EnsureFeaturesAreActive 中间件可以为路由声明必须启用的功能。若未启用会返回 400 Bad Request。
use Laravel\Pennant\Middleware\ EnsureFeaturesAreActive ;
Route :: get ( '/api/servers' , function () {
// ...
}) -> middleware ( EnsureFeaturesAreActive :: using ( 'new-api' , 'servers-api' ));
要自定义响应,使用 whenInactive 方法。
EnsureFeaturesAreActive :: whenInactive (
function ( Request $request , array $features ) {
return new Response ( status : 403 );
}
);
内存缓存
Pennant 会在同一请求内把功能结果缓存到内存中。即使多次判断同一个功能,也不会产生额外的数据库查询。
要手动清空缓存,使用 flushCache。
作用域(Scope)
指定作用域
默认情况下作用域是已认证用户,可以通过 for 方法指定任意作用域。
// 检查特定用户
Feature :: for ( $user ) -> active ( 'new-api' );
// 检查团队
Feature :: for ( $user -> team ) -> active ( 'billing-v2' );
按团队管理功能开关的示例:
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 );
});
自定义默认作用域
可以通过 Feature::resolveScopeUsing 修改默认作用域。
Feature :: resolveScopeUsing ( fn ( $driver ) => Auth :: user () ?-> team );
设置后不加 for 时会使用默认作用域。
Feature :: active ( 'billing-v2' );
// 等同于
Feature :: for ( $user -> team ) -> active ( 'billing-v2' );
Nullable 作用域
当作用域为 null(未认证路由、Artisan 命令等)时,如果功能定义不支持 null,会自动返回 false。若需要支持 null,请在定义中使用可空类型。
Feature :: define ( 'new-api' , fn ( User | null $user ) => match ( true ) {
$user === null => true ,
$user -> isInternalTeamMember () => true ,
$user -> isHighTrafficCustomer () => false ,
default => Lottery :: odds ( 1 / 100 ),
});
富功能值(Rich Feature Values)
功能不只可以返回布尔值。例如 A/B 测试中控制按钮颜色:
Feature :: define ( 'purchase-button' , fn ( User $user ) => Arr :: random ([
'blue-sapphire' ,
'seafoam-green' ,
'tart-orange' ,
]));
取值时使用 value 方法。
$color = Feature :: value ( 'purchase-button' );
Blade 中可以基于值进行分支:
@feature ( 'purchase-button' , 'blue-sapphire' )
{{-- blue-sapphire 启用 --}}
@elsefeature ( 'purchase-button' , 'seafoam-green' )
{{-- seafoam-green 启用 --}}
@elsefeature ( 'purchase-button' , 'tart-orange' )
{{-- tart-orange 启用 --}}
@endfeature
使用富值时,除 false 之外的所有值都视为启用。
将富值传入 when 时,第一个闭包会收到该值。
Feature :: when ( 'purchase-button' ,
fn ( $color ) => /* $color 是当前值 */ ,
fn () => /* 未启用 */ ,
);
一次性获取多个功能
values 方法可以一次性获取多个功能的值。
Feature :: values ([ 'billing-v2' , 'purchase-button' ]);
// [
// 'billing-v2' => false,
// 'purchase-button' => 'blue-sapphire',
// ]
all 方法返回已定义的所有功能的值。
若希望 all 的结果包含基于类的功能,在服务提供者中调用 discover。
这样 app/Features 目录下的所有功能类都会被注册。
Eager Loading
在循环里检查功能可能带来性能问题。可以通过 load 提前一次性取值。
// NG:每次循环都会产生数据库查询
foreach ( $users as $user ) {
if ( Feature :: for ( $user ) -> active ( 'notifications-beta' )) {
$user -> notify ( new RegistrationSuccess );
}
}
// OK:提前批量获取
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' ,
]);
更新值
手动更新
activate / deactivate 用于打开或关闭功能。
// 在默认作用域下启用
Feature :: activate ( 'new-api' );
// 在特定作用域下禁用
Feature :: for ( $user -> team ) -> deactivate ( 'billing-v2' );
// 设置富值
Feature :: activate ( 'purchase-button' , 'seafoam-green' );
若要让保存的值“被遗忘”,使用 forget。下次判断时会重新从定义计算。
Feature :: forget ( 'purchase-button' );
批量更新
activateForEveryone / deactivateForEveryone 会一次性作用于存储中的所有作用域。
Feature :: activateForEveryone ( 'new-api' );
Feature :: activateForEveryone ( 'purchase-button' , 'seafoam-green' );
Feature :: deactivateForEveryone ( 'new-api' );
清除功能(purge)
当你把某功能从代码中移除或修改了定义时,可以从存储中清除对应值(purge)。
// 清除单个功能
Feature :: purge ( 'new-api' );
// 清除多个功能
Feature :: purge ([ 'new-api' , 'purchase-button' ]);
// 清除全部
Feature :: purge ();
也可以通过 Artisan 命令 purge,非常适合部署流水线。
php artisan pennant:purge new-api
# 多个功能
php artisan pennant:purge new-api purchase-button
# 除指定功能外全部 purge
php artisan pennant:purge --except=new-api --except=purchase-button
# 除已在服务提供者注册的功能外全部 purge
php artisan pennant:purge --except-registered
重新定义功能
测试中可以通过 Feature::define 重新定义,控制返回值。
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 () : void
{
Feature :: define ( 'purchase-button' , 'seafoam-green' );
$this -> assertSame ( 'seafoam-green' , Feature :: value ( 'purchase-button' ));
}
基于类的功能同样如此。
test ( 'it can control feature values' , function () {
Feature :: define ( NewApi :: class , true );
expect ( Feature :: value ( NewApi :: class )) -> toBeTrue ();
});
use App\Features\ NewApi ;
public function test_it_can_control_feature_values () : void
{
Feature :: define ( NewApi :: class , true );
$this -> assertTrue ( Feature :: value ( NewApi :: class ));
}
测试用存储配置
可以在 phpunit.xml 中通过环境变量指定测试用存储。
<? xml version = "1.0" encoding = "UTF-8" ?>
< phpunit colors = "true" >
< php >
< env name = "PENNANT_STORE" value = "array" />
</ php >
</ phpunit >
自定义驱动
如果已有驱动不能满足需求,可以自定义驱动。实现 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 {}
}
在服务提供者的 boot 方法中通过 extend 注册。
Feature :: extend ( 'redis' , function ( Application $app ) {
return new RedisFeatureDriver ( $app -> make ( 'redis' ), $app -> make ( 'events' ), []);
});
注册后即可在 config/pennant.php 中使用。
'stores' => [
'redis' => [
'driver' => 'redis' ,
'connection' => null ,
],
],
想做的事 方法 安装 composer require laravel/pennant定义功能 Feature::define('name', fn ($user) => ...)判断功能 Feature::active('name')Blade 中判断 @feature('name') ... @endfeature更新值 Feature::activate('name') / deactivate对所有作用域生效 Feature::activateForEveryone('name')在测试中控制 用 Feature::define('name', true) 重定义 从存储中 purge Feature::purge('name')
下一步
Laravel Pulse 引入应用性能监控仪表盘。