webman 中间件与依赖注入实战:洋葱模型、PHP-DI 容器与 AOP
webman 的请求处理链路看似简单(路由 → 中间件 → 控制器),但中间的洋葱模型和 PHP-DI 容器藏着不少中级开发者没吃透的细节。这篇文章把中间件的执行顺序、全局/路由级/方法级三种挂载方式、依赖注入的边界一次性讲清楚,最后再聊一个常驻内存框架特有的话题——AOP。
一、洋葱模型:代码顺序与执行顺序是两回事
class AuthMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
// ── 前置逻辑(进入时执行)──
$userId = $this->jwt->parseToken($request);
$request->userId = $userId;
$response = $handler($request); // ← 下一层洋葱
// ── 后置逻辑(返回时执行)──
$response->withHeader('X-Trace-Id', $request->header('x-trace-id'));
return $response;
}
}三层中间件的执行顺序:
请求 → [A前置] → [B前置] → [C前置] → Controller
响应 ← [A后置] ← [B后置] ← [C后置] ←最容易踩的坑:异常发生时的执行路径。 如果控制器抛异常,中间件的后置代码默认不会执行——除非你在中间件里 try/catch 包住 $handler($request)。跨域中间件经常因此"时灵时不灵":异常响应没带 CORS 头,浏览器报的不是后端错误而是跨域错误。所以 CORS 中间件要这样写:
try {
$response = $handler($request);
} catch (\Throwable $e) {
$response = response($e->getMessage(), 500);
}
return $response->withHeaders([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
]);二、三种挂载粒度
1. 全局中间件(config/middleware.php)
return [
// 每个请求都会经过
app\middleware\AccessLog::class,
app\middleware\Cors::class,
];适合:日志、CORS、限流。注意全局中间件每个请求都会跑,别放重逻辑。
2. 路由/应用级中间件
// config/route.php
Route::group('/admin', function () {
Route::resource('/users', app\admin\controller\UserController::class);
})->middleware([
app\middleware\AdminAuth::class,
]);
// 或整个应用目录生效:app/admin/config/middleware.php
return [
app\middleware\AdminAuth::class,
];多应用(多入口目录)模式下,app/{应用名}/config/middleware.php 只对该应用生效——这是做多租户后台、API 版本隔离的天然工具。
3. 控制器方法级(中间件别名)
// config/middleware.php 里注册别名
return [
'auth' => [app\middleware\Auth::class],
];
// 控制器里按方法指定
class OrderController
{
#[Middleware(['auth'])] // 整个控制器生效
public function index() {}
#[Middleware(['auth', 'throttle:60,1'])]
public function create() {}
}执行优先级:全局 → 应用级 → 路由级 → 方法级。同层内按注册顺序。
三、实战:三个高频中间件的正确写法
限流中间件(基于 Redis 滑动窗口)
class Throttle implements MiddlewareInterface
{
public function __construct(private Redis $redis) {}
public function process(Request $request, callable $handler): Response
{
$key = 'throttle:' . $request->getRealIp() . ':' . date('YmdHi');
$current = $this->redis->incr($key);
if ($current === 1) {
$this->redis->expire($key, 60);
}
if ($current > 120) {
return json(['code' => 429, 'msg' => 'too many requests'], 429);
}
return $handler($request);
}
}注意 incr 后再 expire 的写法:只在第一次设置过期,避免每请求都重置 TTL。
请求日志(含慢请求标记)
class AccessLog implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$start = microtime(true);
$response = $handler($request);
$cost = round((micro(true) - $start) * 1000, 2);
Log::info('access', [
'ip' => $request->getRealIp(),
'path' => $request->path(),
'cost' => $cost,
'slow' => $cost > 500 ? '⚡' : '',
]);
return $response;
}
}JWT 认证 + 用户上下文注入
class Auth implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$token = $request->header('authorization', '');
try {
$payload = JWT::decode(trim(str_replace('Bearer ', '', $token)), new Key(config('jwt.secret'), 'HS256'));
} catch (\Throwable $e) {
return json(['code' => 401, 'msg' => 'unauthorized'], 401);
}
// 挂到 request 上传递(请求级隔离,不用静态属性)
$request->uid = $payload->uid;
return $handler($request);
}
}控制器里 $request->uid 直接取——请求结束自动销毁,没有跨请求污染。
四、依赖注入:webman 的 PHP-DI 容器
webman 内置 PHP-DI。最常见的三个用法:
1. 构造器注入(推荐)
class OrderController
{
public function __construct(
private OrderService $orderService, // 自动解析
private LoggerInterface $logger,
) {}
}常驻内存的特性让注入几乎零成本:控制器和依赖都在进程启动阶段实例化一次,之后每个请求直接复用。这也意味着——注入的依赖必须是"无状态"或"请求安全"的。Service 里存了请求相关的属性就会串数据。
2. 接口绑定
// config/container.php
return [
PayInterface::class => \DI\create(WechatPay::class),
];
// 之后注入 PayInterface 拿到的就是 WechatPay切支付渠道只改这一行。多环境(沙箱/生产)也可以在这里做条件绑定。
3. 自动装配的边界
PHP-DI 按类型提示递归解析依赖,但碰到标量参数就无能为力,需要显式定义:
// config/container.php
return [
\DI\create(SmsClient::class)
->constructor(\DI\value(config('sms.appkey'))),
];五、AOP:常驻内存框架的专属福利
AOP(面向切面编程)在 FPM 下性能代价大,但 webman 的进程常驻让织入成本只付一次。webman 通过 php-di/AOP 代理实现,最常见的场景是缓存注解:
#[Cacheable(prefix: 'user:', ttl: 300)]
public function getUserProfile(int $uid): array
{
return $this->userDao->findWithRelations($uid);
}切面拦截方法调用:先查 Redis,命中直接返回;未命中执行原方法并写缓存。类似的还有 #[Transactional](自动事务包裹)、#[Retry](失败重试)。
两个注意点:
- AOP 只对容器管理的对象生效——
new OrderService()出来的裸对象没有代理 - 拦截逻辑发生在方法级,粒度比中间件(请求级)细,适合业务横切(缓存/事务/重试),不适合请求横切(认证/日志)
六、一张图理清请求全链路
请求
→ 全局中间件(日志/CORS/限流)
→ 应用/路由中间件(认证)
→ 方法级中间件(权限)
→ 中间件层容器解析控制器(构造器注入,进程级单例)
→ 方法内 AOP 切面(缓存/事务)
→ Service(业务逻辑,保持无状态)
→ Model/Dao每一层解决不同粒度的横切问题:请求级用中间件,业务级用 AOP,对象组装用容器——三者各司其职,别把认证写进 Service,也别把缓存注解挂在中间件上。
写在最后
中间件和依赖注入是 webman 工程化的骨架。写中间件时时刻记住两件事:异常会跳过后置逻辑、常驻内存下禁止请求级状态进静态属性;用容器时记住注入对象是进程级复用的,Service 必须无状态。下一篇讲数据库层——连接池怎么配、事务怎么包、大数据怎么跑,那是常驻内存框架最容易翻车的第二现场。
评论 (0)