以前接手过一个老项目,控制器里的验证代码是这么写的:
public function save(Request $request)
{
$data = $request->post();
if (empty($data['name'])) {
return json(['code' => 422, 'msg' => '姓名不能为空']);
}
if (!isset($data['age']) || $data['age'] < 0) {
return json(['code' => 422, 'msg' => '年龄不正确']);
}
// ... 还有七八个字段要检查
// 最后才真正写业务逻辑
}
这种代码短时间看着还行,但接口稍微一多,痛苦就来了。每个方法都堆着同样的验证逻辑,改一个规则要翻好几个控制器。上个月用 ThinkPHP8 起了个新项目,我决定把验证这块彻底收拾干净。
先定一个小目标
我想达到的效果是:在控制器里写接口,直接用一行代码完成参数验证,验证不通过就直接抛出异常,由全局异常处理器统一返回 JSON 错误信息。像这样:
public function save(Request $request)
{
$data = $request->post();
// 如果验证不过,会抛出 ValidateException
validate(CheckRequest::class)->scene('save')->check($data);
// 业务代码继续往下走...
}
这样每个方法只需要知道自己要验证哪些数据,至于如何返回错误,根本不关心。而且验证器本身可复用。
但 ThinkPHP8 自带的验证器抛出的是 thinkexceptionValidateException,默认异常处理虽然能捕获,但返回的错误格式并不符合我接口的约定。我需要自己处理这个异常。
第一步:写一个标准的验证器
按照 TP8 的官方文档,验证器类放在 appvalidate 目录下。我这里做一个注册接口的例子,它有两个场景:save 表示新增,update 表示更新。
<?php
declare(strict_types=1);
namespace appvalidate;
use thinkValidate;
class CheckRequest extends Validate
{
protected $rule = [
'name' => 'require|max:20',
'age' => 'require|integer|between:1,120',
'email' => 'email',
];
protected $message = [
'name.require' => '姓名必须填写',
'name.max' => '姓名不能超过20个字',
'age.require' => '年龄必须填写',
'age.integer' => '年龄必须是数字',
'age.between' => '年龄范围在1~120之间',
'email' => '邮箱格式不对',
];
protected $scene = [
'save' => ['name', 'age', 'email'],
'update' => ['age', 'email'],
];
}
这里我故意简单写几个字段,实际项目可以按需扩展。重点不在规则,而在后面的异常处理。
第二步:自定义一个接口异常类
我希望最终返回的 JSON 格式符合团队习惯:
{
"code": 422,
"message": "参数错误",
"data": {
"name": ["姓名必须填写"],
"age": ["年龄必须填写"]
}
}
所以我在 appexception 下新建了一个 ApiException 类,但它不是用来替代 TP8 自身的异常,而是用来处理验证异常转化后的数据。
<?php
declare(strict_types=1);
namespace appexception;
use Exception;
class ApiException extends Exception
{
protected $data;
public function __construct(string $message = "", array $data = [], int $code = 422)
{
parent::__construct($message, $code);
$this->data = $data;
}
public function getData()
{
return $this->data;
}
}
第三步:写一个公共函数,专门处理验证逻辑
我新建了一个 appcommon.php 文件(如果项目没有就自己建),里面写一个辅助函数:
<?php
// appcommon.php
use thinkfacadeValidate;
use appexceptionApiException;
if (!function_exists('api_validate')) {
/**
* 验证数据,失败抛 ApiException
* @param array $data 请求参数
* @param string $validateClass 验证器类名
* @param string $scene 场景名
* @return array 验证通过返回原始数据
* @throws ApiException
*/
function api_validate(array $data, string $validateClass, string $scene = '')
{
$validator = Validate::make();
// 加载验证器类中的规则和场景
if (class_exists($validateClass)) {
$validator = new $validateClass;
}
if ($scene !== '') {
$validator->scene($scene);
}
if (!$validator->check($data)) {
throw new ApiException('参数错误', $validator->getError(), 422);
}
return $data;
}
}
看到这里你可能会问:直接使用 TP8 的 validate() 不就好了?为什么要绕一层?因为 validate() 抛出的异常不是我们自定义的 ApiException,而且我想让它统一转换为上面的格式。
第四步:写全局异常处理
在 ThinkPHP8 中,自定义异常处理只需要重写 appExceptionHandle.php 的 render() 方法。我把它改为先处理 ApiException,其他的仍然交给父类。
<?php
declare(strict_types=1);
namespace app;
use thinkdbexceptionDataNotFoundException;
use thinkdbexceptionModelNotFoundException;
use thinkexceptionHandle;
use thinkexceptionHttpException;
use thinkexceptionHttpResponseException;
use thinkexceptionValidateException;
use thinkResponse;
use Throwable;
use appexceptionApiException;
class ExceptionHandle extends Handle
{
public function render($request, Throwable $e): Response
{
// 如果是自定义 ApiException
if ($e instanceof ApiException) {
return json([
'code' => $e->getCode(),
'message' => $e->getMessage(),
'data' => $e->getData()
])->code(200); // 业务状态码放在 body 里,HTTP 状态码统一 200
}
// 如果是 TP8 的验证异常,也转成同样的结构
if ($e instanceof ValidateException) {
return json([
'code' => 422,
'message' => '参数错误',
'data' => $e->getError()
])->code(200);
}
// 其他异常继续交给父类
return parent::render($request, $e);
}
}
注意我返回 HTTP 状态码固定为 200,业务状态码放在 body 里。这么做是为了让前端方便处理,不用区分 HTTP 层和业务层的不同。如果你有别的习惯,改成 422 也一样。
第五步:在控制器里使用验证
现在写注册接口:
<?php
declare(strict_types=1);
namespace appcontroller;
use thinkRequest;
use appvalidateCheckRequest;
use appexceptionApiException;
class User
{
public function register(Request $request)
{
$data = $request->post();
// 一行代码完成验证
api_validate($data, CheckRequest::class, 'save');
// 验证通过,假装创建用户
return json([
'code' => 200,
'message' => '注册成功',
'data' => ['id' => 123]
]);
}
}
如果你不想用辅助函数,直接写在控制器里也行,但那样每个控制器都要重复 try-catch。我还是喜欢辅助函数的方式,一行搞定,清清爽爽。
再更进一步:把验证器参数绑定到 Request 上
用了一段时间,我发现每个方法都要先去拿 $request->post(),再手动传给 api_validate,还是有点啰嗦。干脆我在控制器基类里做了一个统一入口,通过宏或者注解?其实不用那么复杂,直接在系统公共函数里再封装一个小助手:
function request_validate(string $validateClass, string $scene = '')
{
$request = request();
$data = $request->post();
// 如果需要 GET 参数,也可以 $request->get()
return api_validate($data, $validateClass, $scene);
}
然后控制器里可以直接:
$data = request_validate(CheckRequest::class, 'save');
这一行即使从控制器里拿出来也能用,测试也好写。
踩过的一个小坑
用 Validate::make() 的时候,我发现如果我用自定义的验证器类,系统会自动合并内置的规则,这没什么问题。但要注意:如果验证器里定义了 scene,并且你又调用了 scene() 方法,那么验证器只会验证该场景里定义的字段。一开始我没写 scene('save'),结果所有规则全被校验了,那些非必填的字段也会报错。所以记得一定要指定场景,或者有时候我干脆不写场景,在验证器里用 $this->scene 来控制。
另外一个坑是:getError() 返回的数据结构。我一开始以为是一个字符串,后来发现它默认是一个数组,比如 ['name' => '姓名必须填写']。但如果是多个字段错误,它是一个嵌套数组。为了统一给前端,我在 ApiException 里把 data 字段直接传成了 $validator->getError()。如果以后想改成字符串,还要自己拼接一下。
这套方案好在哪里
最大的好处是:所有接口的错误返回格式都一样,前端不用对接多种结构。而且验证器和控制器完全解耦,同一个验证器可以在不同的接口场景中复用。
另外,如果想加一个“或者”规则,或者“自定义验证方法”,也是在验证器类里加,不用动控制器。项目跑了一个多月,基本没有因为接口参数问题扯皮过。
最后的一点废话
我不喜欢为了用框架而用框架,更讨厌控制器里塞一堆判断。拿到一个 TP8 项目,第一件事就是把验证模块理顺。这套东西你花半小时搭好,后面能给你省下无数个半小时。如果你现在还在用老方法写 if,不妨试试,反正代码已经很烂了,改一改也不会更差。

