Lumen 数据验证规范FormRequest
Lumen 数据验证规范
Author: EmmaShao
Version: 1.2
版本 | 时间 | 内容变更 |
---|---|---|
v1.0 | 2019-05-09 | - |
v1.1 | 2019-05-20 | - |
v1.2 | 2019-09-23 | FormRequest中统一处理rules方法,子类不再写rules方法,每个验证规则名为 ruleMethod,添加after钩子 |
前言
Laravel 提供了多种方法来验证请求输入数据,默认情况下,Laravel 的控制器基类使用 ValidateRequests trait, 该trait 提供了便捷方法通过各种功能强大验证规则来验证输入的HTTP请求。
Laravel 文档和网上的各种教程,会教授我们一个任务可以使用好几种方法来完成。对于框架设计来说,灵活是件好事,能提供给开发者不同的选项,能让框架适用更多的用户场景。但是对于团队的协同开发来说,大部分时候,更多的选项反而是累赘。
验证规范,使用场景有两种,http 请求和 excel 导入
目的意义
规范 兼备开发效率、程序执行效率、扩展性和安全性
- 高效编码 - 避免了过多的选择造成的『决策时间』浪费;
- 风格统一 - 最大程度统一了开发团队成员代码书写风格和思路,代码阅读起来如出一辙;
- 减少错误 - 减小初级工程师的犯错几率。
laravel 提供的验证规53种,覆盖范围非常广
参考手册地址:https://laravelacademy.org/post/8798.html
参考规范:https://learnku.com/docs/laravel-specification/5.5/form-validation/507
参考package: https://github.com/urameshibr/lumen-form-request
代码实践
controller
原则:
在controller中进行参数验证,并没有错,但这并不是最好的实现方法,而且会让controller看起来很混乱,这种不满足单一职责原则,不推荐。controller应该只做一件事情,就是处理从route路由过来的数据,并且有一个合适的返回。
推荐写法与步骤
1. 创建一个 GrantRequest 类,路径 app/Http/Requests/GrantRequest.php
<?php
/**
* created by emma
* @2019-09-24
*/
namespace App\Http\Requests;
use App\Http\Requests\FormRequest;
class GrantRequest extends FormRequest
{
public function ruleCreate()
{
$rule = [
'code' => 'required|string|min:1|max:50',
'num' => 'required|int|min:0', // 数量
'name' => 'required|string|min:2', // 员工姓名
'user_id' => 'required|string|min:1', // 员工号
'id_code' => 'required|string|min:2', // 证件号
];
return $rule;
}
// 单个新增授予后的规则处理
public function ruleCreateAfter($validator)
{
$vestList = $this->input('vest_schedule_detail');
$grantNum = $this->input('num');
$pendingNum = $this->input('pending_num');
if ($grantNum == 0 && $pendingNum == 0) {
$validator->errors()->add("num", '授予数量与已归属待行权数量不能同时为0');
}
$sum = 0;
if ($vestList) {
foreach ($vestList as $key => $detail) {
$sum += intval($detail['value']);
}
}
if ($sum != $grantNum) {
$validator->errors()->add("num", "归属规则数量之和{$sum}与授予数量{$grantNum}不等");
}
}
public function ruleQuery()
{
$rule = [
'key' => 'string|min:1|max:50', // 搜索员工关键字
'code' => 'string|min:1|max:50', // 搜索编号
'page_num' => 'required|integer|min:1', // 页数
'page_size' => 'required|integer|min:1|max:500', // 单页显示最多条数
'start_date' => 'date|date_format:Y-m-d', // 开始日期
'end_date' => 'date|date_format:Y-m-d', // 截止日期
'status' => 'required|in:' . RsuGrant::DISABLE . ',' . RsuGrant::ENABLE, // 状态
];
return $rule;
}
public function ruleQueryAfter($validator)
{
$startDate = $this->input('start_date');
$endDate = $this->input('end_date');
if ($startDate > $endDate) {
$validator->errors()->add("end_date", "结束日期不能早于开始日期");
}
}
}
2. 在 app/Http/Controllers/Support/CompanyController.php
/**
* 新增单条授予数据
*/
public function create(GrantRequest $request)
{
try {
$param = $request->validated();
// ....
} catch (\Exception $e) {
}
}
3. 引入validate,会担忧 $request 不能trim,表慌,我们在routes中启用中间件 TrimStrings 即可, 在 /app/Http/routes.php
//提供给中台系统的数据接口-需要有登陆态
$app->group(
[
'namespace' => 'Support',
'middleware' => [ 'TrimStrings']
],
function () use ($app) {
$app->get('/modify-middle/get-company-list', 'ModifyController@getCompanyList');
}
4. 验证错误的语言提示包问题
语言包设计有些麻烦,数据验证前端本就拦截过一遍,大约后端的中英文翻译显得就没那么必要了。一旦发现有这样的问题在后端被拦截下来,应通知前端修正并拦截。
5. request中的rule规则不能覆盖的校验规则,请在Request类中添加after钩子函数
6. 接口数据格式定义时要规范,要么是json格式,要么是键值对格式。不混合格式传输
有时候发现无法转发到service,大约是头部没有添加 'Content-Type' => 'application/json'
rule 规则使用
推荐使用:
- required
- in
- string | numeric | integer
- min|max
- date|date_format|before|before_or_equal|after|after_or_equal
- required_if
- required_with
- required_without
- required_unless
- before
- lt|lte
- gt|gte
- regex
- distinct
- json | array
- different
- digits
- ip
禁止使用与数据库查询结合的规则:
-
exists
-
unique
查询数据表的,不能使用,数据加密存储,这种写法能用到的地方不多,另外,接下来的逻辑处理还需要用到此次查询的结果,所以放在controller或Logic或Trait 处理这种数据库查询操作
Request 基类添加
本规范,是参考 Laravel 框架推荐的规范,FormRequest 在Laravel中天然支持,但在 Lumen 中移除了所以参考了Laravel框架,与composer包 urameshibr/lumen-form-request,添加了文件 如下:
适用于 lumen5.3, lumen5.5
- 添加文件
app/Http/Requests/FormRequest.php
<?php
namespace App\Http\Requests;
use App\Exceptions\ExceptionCode;
use App\Http\Common\UlsLog;
use FutuWeb\Monitor\Reporter;
use Illuminate\Container\Container;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Illuminate\Contracts\Validation\ValidatesWhenResolved;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exception\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\UnauthorizedException;
use Illuminate\Validation\ValidatesWhenResolvedTrait;
use Laravel\Lumen\Http\Redirector;
class FormRequest extends Request implements ValidatesWhenResolved
{
use ValidatesWhenResolvedTrait;
/**
* The container instance.
*
* @var \Illuminate\Container\Container
*/
protected $container;
/**
* The redirector instance.
*
* @var \Laravel\Lumen\Http\Redirector
*/
protected $redirector;
/**
* The route to redirect to if validation fails.
*
* @var string
*/
protected $redirectRoute;
/**
* The controller action to redirect to if validation fails.
*
* @var string
*/
protected $redirectAction;
/**
* The key to be used for the view error bag.
*
* @var string
*/
protected $errorBag = 'default';
/**
* The input keys that should not be flashed on redirect.
*
* @var array
*/
protected $dontFlash = ['password', 'password_confirmation'];
/**
* Get the validator instance for the request.
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function getValidatorInstance()
{
$factory = $this->container->make(ValidationFactory::class);
if (method_exists($this, 'validator')) {
return $this->container->call([$this, 'validator'], compact('factory'));
}
return $factory->make(
$this->validationData(), $this->container->call([$this, 'rules']), $this->messages(), $this->attributes()
)->after(function($validator){
$this->after($validator);
});
}
/**
* Get data to be validated from the request.
*
* @return array
*/
protected function validationData()
{
return $this->all();
}
/**
* Handle a failed validation attempt.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return void
*
* @throws \Illuminate\Http\Exceptions\HttpResponseException
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException($this->response([
"result" => ExceptionCode::ERROR_PARAMETER,
"msg" => $firstErrorMsg
]
));
}
/**
* Determine if the request passes the authorization check.
*
* @return bool
*/
protected function passesAuthorization()
{
if (method_exists($this, 'authorize')) {
return $this->container->call([$this, 'authorize']);
}
return false;
}
/**
* Handle a failed authorization attempt.
*
* @return void
*
* @throws \Illuminate\Http\Exceptions\HttpResponseException
*/
protected function failedAuthorization()
{
// throw new HttpResponseException($this->forbiddenResponse());
throw new UnauthorizedException($this->forbiddenResponse());
}
/**
* Get the proper failed validation response for the request.
*
* @param array $errors
* @return \Illuminate\Http\JsonResponse
*/
public function response(array $errors)
{
return new JsonResponse($errors, 200);
}
/**
* Get the response for a forbidden operation.
*
* @return \Illuminate\Http\Response
*/
public function forbiddenResponse()
{
return new Response('Forbidden', 403);
}
/**
* Format the errors from the given Validator instance.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return array
*/
protected function formatErrors(Validator $validator)
{
return $validator->getMessageBag()->toArray();
}
/**
* Set the Redirector instance.
*
* @param \Laravel\Lumen\Http\Redirector $redirector
* @return $this
*/
public function setRedirector(Redirector $redirector)
{
$this->redirector = $redirector;
return $this;
}
/**
* Set the container implementation.
*
* @param \Illuminate\Container\Container $container
* @return $this
*/
public function setContainer(Container $container)
{
$this->container = $container;
return $this;
}
/**
* Get custom messages for validator errors.
*
* @return array
*/
public function messages()
{
return [];
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes()
{
return [];
}
public function validated()
{
$rules = $this->container->call([$this, 'rules']);
return $this->only(collect($rules)->keys()->map(function ($rule) {
return explode('.', $rule)[0];
})->unique()->toArray());
}
public function setJson($json)
{
$this->json = $json;
return $this;
}
public function authorize()
{
return true;
}
public function rules()
{
list($controllerName, $actionName) = explode('@', substr(strrchr($this->route()[1]['uses'], '\\'), 1));
$actionName = 'rule' . ucfirst($actionName);
return $this->$actionName();
}
public function after($validator)
{
list($controllerName, $actionName) = explode('@', substr(strrchr($this->route()[1]['uses'], '\\'), 1));
$actionName = 'rule' . ucfirst($actionName) . 'After';
if (method_exists($this, $actionName)) {
$this->$actionName($validator);
}
}
}
- 修改文件
bootstrap/app.php
, 追加 FormRequestServiceProvider
$app->register(\App\Providers\AppServiceProvider::class);
// emmashao 2019-05-09 加入
$app->register(App\Providers\FormRequestServiceProvider::class);
TrimStrings 中间件添加
参考 Laravel 框架,将 TrimStrings 复制到 lumen5.3中。lumen5.5天然支持,无需自己添加
- 添加文件
app/Http/Middleware/TransformsRequest.php
<?php
namespace App\Http\Middleware;
use Closure;
use Symfony\Component\HttpFoundation\ParameterBag;
class TransformsRequest
{
/**
* The additional attributes passed to the middleware.
*
* @var array
*/
protected $attributes = [];
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next, ...$attributes)
{
$this->attributes = $attributes;
$this->clean($request);
return $next($request);
}
/**
* Clean the request's data.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
protected function clean($request)
{
$this->cleanParameterBag($request->query);
if ($request->isJson()) {
$this->cleanParameterBag($request->json());
} else {
$this->cleanParameterBag($request->request);
}
}
/**
* Clean the data in the parameter bag.
*
* @param \Symfony\Component\HttpFoundation\ParameterBag $bag
* @return void
*/
protected function cleanParameterBag(ParameterBag $bag)
{
$bag->replace($this->cleanArray($bag->all()));
}
/**
* Clean the data in the given array.
*
* @param array $data
* @return array
*/
protected function cleanArray(array $data)
{
return collect($data)->map(function ($value, $key) {
return $this->cleanValue($key, $value);
})->all();
}
/**
* Clean the given value.
*
* @param string $key
* @param mixed $value
* @return mixed
*/
protected function cleanValue($key, $value)
{
if (is_array($value)) {
return $this->cleanArray($value);
}
return $this->transform($key, $value);
}
/**
* Transform the given value.
*
* @param string $key
* @param mixed $value
* @return mixed
*/
protected function transform($key, $value)
{
return $value;
}
}
- 添加文件
app/Http/Middleware/TrimStrings.php
<?php
namespace App\Http\Middleware;
class TrimStrings extends TransformsRequest
{
/**
* The attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
/**
* Transform the given value.
*
* @param string $key
* @param mixed $value
* @return mixed
*/
protected function transform($key, $value)
{
if (in_array($key, $this->except, true)) {
return $value;
}
return is_string($value) ? trim($value) : $value;
}
}
Job
ESOP有大量的业务需要导入excel,excel导入均使用的异步导入。步骤如下:
- 上传excel,直接存入excel存储 表,返回id,生产此id的消息,入队列
- 前端根据此 id ,查询处理结果
- 后端消费消息,数据校验与存储,校验失败要输出错误详细信息,完全通过需要进行数据存储
Excel的数据校验内容繁多,包括
- 是否缺失表单(表单数量是否正确)
- 表单内容是否可以为空(多表单数据,往往第二个及以后的表单内容可以为空,如上传授予含历史数据)
- 是否缺列(查看excel第一行数据与定义的头部信息是否一致)
- 基本的单表单格式验证(比如必填项,整数、小数位等的校验,不耗时)
- 多表单的联合校验(比如授予信息与归属信息的对应数量校验)
- 与数据库的匹配校验
据过往写导入的经验,往往数据校验极有可能花费代码千行,如果按照一天150行代码的产出,大约需要六天的样子,难调试,bug多,此时使用 Validator 就显得非常必要,据开发者反馈,三天的工作量可以降到一天。代码可读性、灵活性、健壮性都得到了提高。
异步导入 job 必须定义的函数:
// 获取表头
abstract protected function getMeta();
// 数据格式校验规则
abstract protected function getValidateFormatRule();
// 获取自定义错误信息
abstract protected function getValidateCustomMessages();
// 数据逻辑校验,与数据库匹配
abstract protected function validateLogic();
// 验证通过的数据做的处理,一般是落库保存
abstract protected function storeData();
Ps: 查询数据库才能校验的一定放后单独处理,因为比较耗时,运营经常反馈excel导入太过缓慢,如果数据有问题,比如那种不查数据库都能看到的问题,要优先反馈出来,让他们做修正,而不是逐行与数据库匹配,然后到了最后一行发现少了一个必填项。