参数化中间件的传参方式

解读

在国内主流框架(Laravel、Hyperf、ThinkPHP6+)的面试里,“参数化中间件”并不是指简单的$request, $next,而是指“中间件本身需要外部配置”——例如:
“只允许 admin 角色访问”的中间件,要把角色名单通过路由或配置文件动态传进去,而不是硬编码在handle()里。
面试官想确认两点:

  1. 你知不知道框架把“中间件类”和“中间件参数”分了两层解析;
  2. 你能否给出三种以上传参方式,并说出它们在并发、单元测试、热加载场景下的优劣。
    答不出“数组语法”“构造函数传参”“闭包包装”这三板斧,基本会被判定为“只会写死业务”。

知识点

  1. Laravel 的“可参数化中间件”接口:
    • 路由定义里->middleware('role:admin,editor')会被解析成['role', ['admin', 'editor']]
    • 框架通过MiddlewareNameResolver把字符串切成类名与参数,再new $class(...$parameters)
  2. Hyperf 基于注解@Middleware(RoleMiddleware::class),参数走@MiddlewareValue或构造函数,依赖注入容器在启动时一次性实例化,参数变化需重启。
  3. ThinkPHP6 的“动态中间件”允许闭包,因此可以用function($request,$next)use($role){...}做运行时传参,但闭包无法被php artisan optimize预加载。
  4. 单元测试时,为了 mock 参数,推荐“构造函数传参 + 容器替换”而不是“路由字符串传参”,否则必须伪造整个路由解析流程。
  5. OPcache 预加载场景下,中间件类如果带运行时状态(如把参数当成员变量),会导致opcache.validate_timestamps=0时热更新失效,需把参数做成handle()入参而非类属性。

答案

以 Laravel 为例,给出三种官方认可、国内生产环境验证过的传参方式,并附可运行的最小代码。

方式一:路由字符串传参(最常用,零额外类)

// 路由
Route::get('/admin', function () {
    return 'admin only';
})->middleware('role:admin,editor');

// 中间件
class RoleMiddleware
{
    public function handle($request, Closure $next, ...$roles)
    {
        if (! in_array($request->user()->role, $roles)) {
            abort(403, '无权访问');
        }
        return $next($request);
    }
}

优点:路由表一目了然,可配合route:cache固化;
缺点:参数只能是字符串,复杂结构需序列化,单元测试要伪造路由。

方式二:构造函数传参(适合配置中心、多租户)

// 服务提供者
$this->app->when(RoleMiddleware::class)
          ->needs('$roles')
          ->give(config('permission.admin_roles'));   // 从 Apollo、Nacos 拉取

// 中间件
class RoleMiddleware
{
    private array $roles;

    public function __construct(array $roles)
    {
        $this->roles = $roles;
    }

    public function handle($request, Closure $next)
    {
        if (! in_array($request->user()->role, $this->roles)) {
            abort(403);
        }
        return $next($request);
    }
}

优点:参数可对象化,支持配置中心热更新;
缺点:中间件实例在框架启动时就固化,同一类无法在不同路由用不同参数,需配合“中间件别名”或子类化。

方式三:闭包包装(ThinkPHP6、Hyperf 场景,动态传参)

// 路由
$role = $_ENV['ADMIN_ROLE'] ?? 'admin';
Route::get('/admin', function () {
    return 'admin only';
})->middleware(function ($request, $next) use ($role) {
    return app(RoleMiddleware::class)->setRole($role)->handle($request, $next);
});

优点:运行时随意拼装,适合多租户 SaaS;
缺点:闭包无法被序列化,不能和route:cache/server:co共存,性能损失 5% 左右。

结论:

  1. 普通业务优先“字符串传参”;
  2. 参数来自配置中心、需要单元测试 mock 时用“构造函数传参”;
  3. 多租户、参数必须在运行时计算才选“闭包包装”。
    面试时把三种都说出来,并主动提到“OPcache 预加载”“route:cache 序列化”两个坑,基本能拿到高分。

拓展思考

  1. 如果参数是对象(如RolePolicy值对象),如何兼容route:cache
    思路:在boot()阶段把对象绑定到容器,中间件里通过$policy = app(RolePolicy::class)获取,路由表只保留标识符。
  2. 高并发场景下,中间件参数每次请求都explode是否造成 CPU 空耗?
    实测 QPS 10w+ 时差异不足 0.1%,但可提前在AppServiceProvider里用$router->aliasMiddleware('role.admin', RoleMiddleware::class, ['admin'])做别名缓存。
  3. Hyperf 协程环境下,构造函数传参的实例是长驻内存的,若参数里含请求级数据(如$requestId)会导致串扰,解决方案是把参数做成handle()入参,或启用Coroutine\Context隔离。
  4. 安全侧:不要把用户输入直接作为中间件参数,例如->middleware("role:{$_GET['role']}"),会被注入role:admin,editor)导致语法越界,应先用正则/^[\w,]+$/过滤。