StartMVC开发手册

可以快速上手的开发文档

手册目录

中间件

中间件

中间件(Middleware)是一种「在请求到达控制器之前、以及响应返回之后」插入处理逻辑的机制。它把校验、日志、跨域等与业务无关的横切逻辑从控制器里抽出来,让控制器只关心业务本身。常见用途:

  • 登录鉴权、角色与权限校验
  • 请求日志、访问审计
  • CSRF 防护
  • 跨域(CORS)请求处理
  • 请求参数过滤与统一响应加工

StartMVC 的中间件由两个类协作完成,职责不要混:

  • MiddlewareBase —— 中间件基类。你要写的每个中间件都继承它,并实现 handle() 方法。它只规定「一个中间件长什么样」。
  • Middleware —— 中间件管理器。负责注册别名、组装管道、按注册顺序执行。它决定「中间件怎么被跑起来」。

一句话:写中间件继承 MiddlewareBase,跑中间件交给 Middleware。

1. 创建一个中间件

自定义中间件统一放在 app/middleware/ 目录下,命名空间为 app\middleware(与 composer.jsonapp\ 的自动载入映射一致)。继承 MiddlewareBase 并实现 handle() 即可:

// app/middleware/AuthMiddleware.php
namespace app\middleware;

use startmvc\core\MiddlewareBase;

class AuthMiddleware extends MiddlewareBase
{
 /**
 * @param object $request 请求对象(与全局管道共享的同一实例)
 * @param \Closure $next 下一个中间件
 * @return mixed
 */
 public function handle($request, \Closure $next)
 {
 // 1. 前置操作:请求到达控制器之前先做判断
 if (empty($_SESSION['user_id'])) {
 // 不放行:直接返回响应,$next 不再被调用(本次请求到此结束)
 $response = new \startmvc\core\Response();
 $response->setStatusCode(302)->setHeader('Location', '/user/login');
 return $response;
 }

 // 2. 放行:调用 $next 进入下一环,拿到控制器/后续中间件的返回值
 $response = $next($request);

 // 3. 后置操作:响应返回之后可以加工它(加响应头、改内容等)
 return $response;
 }
}

三条硬性约定:

  • 必须有返回值。返回 $next($request) 表示放行;直接返回自己的响应则表示短路,控制器与后续中间件都不会执行。
  • 不要自己 new Request()。管道会把当前请求实例依次传给每个中间件,自行新建会丢掉前面中间件写入的状态。
  • 短路时必须返回一个响应(字符串或 Response 对象),否则页面会一片空白。

2. 注册与使用

注册分三种粒度,全部在配置文件或路由定义里完成,不需要在控制器里手动调用。

2.1 全局中间件:对所有请求生效。写在 config/middleware.phpglobal 键里,框架启动时自动加载:

// config/middleware.php
return [
 // 中间件别名:注册后可用别名代替完整类名
 'aliases' => [
 'csrf' => 'app\\middleware\\CsrfMiddleware',
 'auth' => 'app\\middleware\\AuthMiddleware',
 'log' => 'app\\middleware\\LogMiddleware',
 ],

 // 全局中间件:每个请求都会执行(按数组顺序)。默认只挂了 CSRF 防护
 'global' => [
 'app\\middleware\\CsrfMiddleware',
 ],
];

2.2 路由中间件:只对指定路由生效。路由方法的第三个参数就是中间件数组,可传多个、按序执行:

// config/route.php
Router::get('/admin/dashboard', 'Admin/Index/dashboard', ['auth']);
Router::get('/admin/setting', 'Admin/Index/setting', ['log', 'auth']); // 多个中间件按序执行

2.3 路由组中间件:组内所有路由统一继承,组中间件排在路由自身中间件的前面:

// config/route.php
Router::group(['prefix' => 'admin', 'middleware' => ['auth']], function () {
 Router::get('/dashboard', 'Admin/Index/dashboard'); // 只走 auth
 Router::get('/setting', 'Admin/Index/setting', ['log']); // 先 auth,后 log
});

2.4 代码中手动注册:不想用配置文件时,也可以在路由文件或启动流程里直接调用静态方法:

use startmvc\core\Middleware;

Middleware::alias('auth', \app\middleware\AuthMiddleware::class); // 注册别名
Middleware::register(\app\middleware\LogMiddleware::class); // 注册全局中间件

需要注意路由目标的写法:应为「模块/控制器/方法」(如 'Admin/Index/dashboard'),方法名会自动补 Action 后缀,最终调用的是 dashboardAction()。框架不支持 AdminController@dashboard 这类 @ 写法。

3. 带参数的中间件

同一套校验逻辑,换个角色就要复制一个中间件类,是最常见的坏味道。中间件项支持「别名:参数」写法,参数会作为额外实参传给 handle(),一套代码即可覆盖多种配置:

// app/middleware/AuthMiddleware.php
namespace app\middleware;

use startmvc\core\MiddlewareBase;

class AuthMiddleware extends MiddlewareBase
{
 // 多声明一个形参用来接收参数,建议给出默认值
 public function handle($request, \Closure $next, $role = null)
 {
 $user = $_SESSION['user'] ?? [];

 if ($role !== null && ($user['role'] ?? '') !== $role) {
 $response = new \startmvc\core\Response();
 $response->setStatusCode(403)->setContent('无权限访问');
 return $response;
 }

 // 把校验结果挂到请求上,控制器可直接读取
 $request->user = $user;

 return $next($request);
 }
}

注册时按需要传参,不必为每种角色各写一个类:

// config/route.php
Router::get('/admin/dashboard', 'Admin/Index/dashboard', ['auth:admin']);
Router::get('/editor/panel', 'Admin/Index/edit', ['auth:admin,editor']); // 多个参数用英文逗号分隔
Router::get('/profile', 'Home/User/profile', ['auth']); // 不传参,走形参默认值

参数解析规则如下:

  • 只按第一个冒号切分,因此 'auth:a:b' 的参数是 'a:b',适合传递 URL 这类本身含冒号的值。
  • 多个参数用英文逗号分隔,每个参数两端的空白会自动去掉,空项会被忽略('auth:admin,,editor,' 等价于 ['admin', 'editor'])。
  • 'auth:' 冒号后为空时视为不带参数
  • 完整类名同样可以带参数'app\middleware\AuthMiddleware:admin'
  • 老式中间件无需任何改动。只声明 $request$next 两个形参的中间件照常工作,多传的实参会被 PHP 忽略(必要时可用 func_get_args() 取到)。

全局中间件也支持带参数写法:

// config/middleware.php
'global' => [
 'auth:admin', // 全局要求 admin 角色
],

4. 执行顺序与短路

中间件按「洋葱模型」执行:请求由外向内穿过每一层的前置操作,抵达控制器;响应再反过来由内向外穿过每一层的后置操作。执行顺序为:

全局中间件 → 分组中间件 → 路由中间件 → 控制器方法 → 路由中间件(后置) → 分组中间件(后置) → 全局中间件(后置)

也就是说:先注册的先进入、后返回。同一层内按数组顺序执行,组中间件排在路由自身中间件之前。

如果某个中间件不调用 $next 就直接返回响应,即为「短路」:排在它后面的中间件与控制器都不会执行,但排在它前面的中间件的后置代码仍会正常执行。短路点之后的中间件连实例化都不会发生(中间件是执行到它时才创建的),因此用短路拒绝非法请求不会有额外开销。

5. 在中间件与控制器之间传递数据

一次请求的生命周期内只有一个请求实例:App::run() 创建它并绑定到容器后,中间件管道、路由闭包、控制器方法签名注入拿到的都是同一个对象。因此中间件直接在请求上写属性,控制器就能读到:

// 中间件里:把解析结果挂到请求上
$request->user = $user;
$request->locale = 'zh-cn';

return $next($request);

控制器有两种读法。推荐直接使用基类提供的 $this->requestController 构造时已从容器取出当前请求实例):

// app/home/controller/UserController.php
namespace app\home\controller;

use app\common\BaseController;

class UserController extends BaseController
{
 public function profileAction()
 {
 $user = $this->request->user; // 中间件写入的数据
 $locale = $this->request->locale;

 $this->assign(['user' => $user, 'locale' => $locale]);
 $this->display();
 }
}

也可以在方法签名里直接声明 Request 类型参数,由容器自动注入同一个实例(注意方法名要带 Action 后缀):

use startmvc\core\Request;

public function profileAction(Request $request)
{
 $user = $request->user;
 // ...
}

路由闭包同样支持这种注入写法:

Router::get('/me', function (Request $request) {
 return '你好,' . ($request->user['name'] ?? '访客');
}, ['auth']);

6. 内置的 CSRF 防护中间件

框架自带 app/middleware/CsrfMiddleware,并且在 config/middleware.php默认作为全局中间件挂载,开箱即用。它的行为是:

  • GET / HEAD / OPTIONS 等安全方法直接放行,并顺带确保 Token 已生成。
  • POST / PUT / DELETE / PATCH 校验 Token,失败返回 403,请求不会到达控制器。AJAX 请求返回 JSON,普通请求返回提示页。
  • 表单伪装字段 _method 只接受 PUT/DELETE/PATCH,不允许伪装成 GET,因此无法绕过校验。

页面里用助手函数输出 Token 即可:

// 表单中输出隐藏域(函数已自动做 HTML 转义)
<form method="post" action="/user/save">
 <?php echo csrf_field(); ?>
 ...
</form>

// 在 <head> 中输出 meta 标签,供 AJAX 读取
<?php echo csrf_meta(); ?>

// 只取 Token 值
$token = csrf_token();

AJAX 请求把 Token 放进请求头 X-CSRF-TOKENX-XSRF-TOKEN 即可:

var token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/user/save', {
 method: 'POST',
 headers: {'X-CSRF-TOKEN': token, 'Content-Type': 'application/json'},
 body: JSON.stringify({name: 'Tom'})
});

支付回调、对外开放 API 等无需校验的路径,在 config/common.php 中排除(支持 * 通配符,前缀匹配同样生效):

// config/common.php
'csrf' => [
 'token_lifetime' => 3600, // Token有效期(秒)
 'token_name' => 'csrf_token', // Token字段名
 'auto_delete' => false, // 验证后是否自动删除(true=一次性)
 'exclude' => [
 'api/webhook', // 精确匹配,同时匹配 api/webhook/xxx
 'api/open/*', // 通配符匹配
 ],
],

若不需要 CSRF 防护,把 config/middleware.phpglobal 数组清空即可。

7. 常见问题

别名写了但不生效:先确认别名已在 config/middleware.phpaliases 中登记。未注册的名字不会被静默跳过,而是抛出 RuntimeException:中间件不存在:xxx(别名未注册或类未定义),据此可直接定位问题。

访问某个 URL 时路由中间件没执行:路由级(含分组)中间件只在请求命中所定义的路由时生效。若该 URL 是框架按「模块/控制器/方法」自动解析的(config/route.php 中没有对应规则),则只有全局中间件会执行。需要在这类 URL 上也生效,请为它显式定义一条路由,或改为全局中间件。

中间件里改了请求,控制器读不到:检查中间件中是否自行 new \startmvc\core\Request()。状态应由管道传入的 $request 携带,控制器侧也统一从 $this->request 或签名注入的 Request 读取。

页面一片空白:中间件没有调用 $next($request),同时又没有返回任何内容。短路时必须返回响应(字符串或 Response 对象)。

中间件类找不到:确认文件位于 app/middleware/、命名空间为 app\middleware、文件名与类名一致(大小写敏感)。