ThinkPHP迁移到Laravel不完全指北:多场景验证器

前言

ThinkPHP提供了内置的多场景验证器,可以通过scene方法指定验证场景,而Laravel并没有类似的方法,官方文档中给到的方案是使用FormRequest来使得验证规则和控制器逻辑分离,但同一个控制器中的每一个不同方法都需要定义一个FormRequest类,这样过于麻烦,我们希望把验证器逻辑统一到同一个类文件中。接下来我们将封装一个多场景验证器。

ThinkPHP多场景验证器

下面是Likeadmin框架(基于ThinkPHP 8.0)中的一个多场景验证器,他负责校验管理员账号的增删改查API请求入参,先看下面的代码:

<?php

namespace app\adminapi\validate\auth;

use app\common\validate\BaseValidate;
use app\common\model\auth\Admin;

/**
 * 管理员验证
 */
class AdminValidate extends BaseValidate
{
    protected $rule = [
        'id' => 'require|checkAdmin',
        'account' => 'require|length:1,32|unique:'.Admin::class,
        'name' => 'require|length:1,16|unique:'.Admin::class,
        'password' => 'require|length:6,32|edit',
        'password_confirm' => 'requireWith:password|confirm',
        'role_id' => 'require',
        'disable' => 'require|in:0,1|checkAbleDisable',
        'multipoint_login' => 'require|in:0,1',
    ];

    protected $message = [
        'id.require' => '管理员id不能为空',
        'account.require' => '账号不能为空',
        'account.length' => '账号长度须在1-32位字符',
        'account.unique' => '账号已存在',
        'password.require' => '密码不能为空',
        'password.length' => '密码长度须在6-32位字符',
        'password_confirm.requireWith' => '确认密码不能为空',
        'password_confirm.confirm' => '两次输入的密码不一致',
        'name.require' => '名称不能为空',
        'name.length' => '名称须在1-16位字符',
        'name.unique' => '名称已存在',
        'role_id.require' => '请选择角色',
        'disable.require' => '请选择状态',
        'disable.in' => '状态值错误',
        'multipoint_login.require' => '请选择是否支持多处登录',
        'multipoint_login.in' => '多处登录状态值为误',
    ];

    public function sceneAdd()
    {
        return $this->remove(['password', 'edit'])
            ->remove('id', true)
            ->remove('disable', true);
    }

    public function sceneDetail()
    {
        return $this->only(['id']);
    }

    public function sceneEdit()
    {
        return $this->remove('password', 'require|length')
            ->append('id', 'require|checkAdmin')
            ->remove('role_id', 'require')
            ->append('role_id', 'checkRole');
    }

    public function sceneDelete()
    {
        return $this->only(['id']);
    }

    public function edit($value, $rule, $data)
    {
        if (empty($data['password']) && empty($data['password_confirm'])) {
            return true;
        }
        $len = strlen($value);
        if ($len < 6 || $len > 32) {
            return '密码长度须在6-32位字符';
        }
        return true;
    }

    public function checkAdmin($value)
    {
        $admin = Admin::findOrEmpty($value);
        if ($admin->isEmpty()) {
            return '管理员不存在';
        }
        return true;
    }

    public function checkAbleDisable($value, $rule, $data)
    {
        $admin = Admin::findOrEmpty($data['id']);
        if ($admin->isEmpty()) {
            return '管理员不存在';
        }

        if ($value && $admin['root']) {
            return '超级管理员不允许被禁用';
        }
        return true;
    }

    public function checkRole($value, $rule, $data)
    {
        $admin = Admin::findOrEmpty($data['id']);
        if ($admin->isEmpty()) {
            return '管理员不存在';
        }

        if ($admin['root']) {
            return true;
        }

        if (empty($data['role_id'])) {
            return '请选择角色';
        }

        return true;
    }

}

上述代码有几个优点与不足:

  1. 每个场景的验证规则通过appendonly等方法来添加或删除规则,好处是分开了每个场景的验证规则,不会混杂在一起,但是这样写起来比较繁琐,不够直观。
  2. 验证规则中的checkAdmincheckAbleDisablecheckRole等方法是自定义的验证规则写在同一个文件里,方便管理。

改写成Laravel

接下来我们改写成Laravel11的多场景验证器:

<?php

namespace App\Adminapi\Validate\Auth;

use App\Common\Model\Auth\Admin;
use App\Common\Validate\BaseValidate;
use Closure;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\Rule;

class AdminValidate extends BaseValidate
{
    public function rules($scene = '')
    {
        $rules = [
            'add' => [
                'account' => 'required|string|between:1,32|unique:admin,account',
                'name' => 'required|string|between:1,16|unique:admin,name',
                'password' => 'required|string|between:6,32',
                'password_confirm' => 'required_with:password|same:password',
                'role_id' => 'required',
                'multipoint_login' => 'required|in:0,1',
                'disable' => 'required|in:0,1',
            ],
            'detail' => [
                'id' => 'required|exists:admin,id',
            ],
            'edit' => [
                'id' => 'required|exists:admin,id',
                'account' => [
                    'required', 'string', 'between:1,32',
                    Rule::unique('admin', 'account')->ignore(request()->id)
                ],
                'name' => [
                    'required', 'string', 'between:1,16',
                    Rule::unique('admin', 'name')->ignore(request()->id)
                ],
                'password' => 'nullable|string|between:6,32',
                'password_confirm' => 'nullable|same:password',
                'role_id' => [
                    function ($attribute, $value, Closure $fail) {
                        $admin = Admin::find(request()->id);
                        if (!$admin) {
                            return $fail('管理员不存在');
                        }

                        if ($admin->root) {
                            return;
                        }

                        if (empty($value)) {
                            return $fail('请选择角色');
                        }
                    },
                ],
                'disable' => [
                    'required',
                    'in:0,1',
                    function ($attribute, $value, Closure $fail) {
                        $admin = Admin::find(request()->id);
                        if (!$admin) {
                            return $fail('管理员不存在');
                        }

                        if ($value && $admin->root) {
                            return $fail('超级管理员不允许被禁用');
                        }
                    },
                ],
                'multipoint_login' => 'required|in:0,1',
            ],
            'delete' => [
                'id' => 'required|exists:admin,id',
            ],
            'editSelf' => [
                'name' => 'required|string|between:1,16',
                'avatar' => 'required',
                'admin_id' => 'required|exists:admin,id',
                'password' => [
                    'nullable',
                    'string',
                    'between:6,32',
                    function ($attribute, $value, Closure $fail) {
                        if (empty(request()->password_old)) {
                            return $fail('请填写当前密码');
                        }

                        $admin = Admin::find(request()->attributes->get('adminId'));
                        if (!$admin) {
                            return $fail('管理员不存在');
                        }

                        $passwordSalt = Config::get('project.unique_identification');
                        $oldPassword = create_password(request()->password_old, $passwordSalt);

                        if ($admin->password !== $oldPassword) {
                            return $fail('当前密码错误');
                        }
                    },
                ],
                'password_confirm' => 'required_with:password|same:password',
            ],
        ];

        return $rules[$scene] ?? [];
    }

    protected $messages = [
        'id.required' => '管理员id不能为空',
        'account.required' => '账号不能为空',
        'account.between' => '账号长度须在1-32位字符',
        'account.unique' => '账号已存在',
        'password.required' => '密码不能为空',
        'password.between' => '密码长度须在6-32位字符',
        'password_confirm.required_with' => '确认密码不能为空',
        'password_confirm.same' => '两次输入的密码不一致',
        'name.required' => '名称不能为空',
        'name.between' => '名称须在1-16位字符',
        'name.unique' => '名称已存在',
        'role_id.required' => '请选择角色',
        'disable.required' => '请选择状态',
        'disable.in' => '状态值错误',
        'multipoint_login.required' => '请选择是否支持多处登录',
        'multipoint_login.in' => '多处登录状态值错误',
    ];

    public function messages()
    {
        return $this->messages;
    }
}

上述代码使用Closure闭包函数来实现自定义验证规则,把自定义验证规则统一到同一个类文件中。另外,不同场景的验证规则通过rules方法来返回, 每个场景的规则全部写出,不通过appendonly等方法动态生成,这样写起来更加直观,易于维护。

当然还存在一些问题,比如同一个字段在不同场景的验证规则会有重复的代码,我们可以写一个自动匹配逻辑来简化代码,类似这样:

     $scenes = [
            'id' => ['id'],
            'rename' => ['id', 'name'],
            'addCate' => ['type', 'pid', 'name'],
            'editCate' => ['id', 'name'],
            'move' => ['ids', 'cid'],
            'delete' => ['ids'],
        ];

        if (isset($scenes[$scene])) {
            return array_intersect_key($rules, array_flip($scenes[$scene]));
        }

        return $rules; // 默认返回所有规则

未完待续

本文章出自开源项目likeadmin_laravel的实施经验,后续会继续更新,欢迎关注。

技术栈:

  • PHP 8.0 => PHP 8.2
  • ThinkPHP 8 => Laravel 11
  • 管理后台:Vue3 + TypeScript + ElementPlus UI + TailwindCSS
  • 小程序:Vue3 + TypeScript + Uniapp + TailwindCSS
  • PC端:Vue3 + Nuxt

项目进度:
██████░░░░ 60%

欢迎加入开源共建~

项目地址:likeadmin_laravel

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 6

可以参考下【让你的validate支持场景验证】希望可以让你的改造更加精彩,这个方案我已经在生产环境用了很多年了。

2个月前 评论

我是这样实现的, 自己封装一个自定义的FormRequest, Request 继承自己的 FormRequest 类就行了

优点: 1.是几乎是无感的, 因为就改变了继承类 2. 用方法更灵活

类1 为了优化, Validater两个循环验证很多时候都是没必要的

<?php

namespace Mitoop\LaravelTools\Http;

use Illuminate\Foundation\Http\FormRequest;

class EfficientFormRequest extends FormRequest
{
    protected $stopOnFirstFailure = true;

    protected static $globalBail = false;

    protected function validationRules(): array
    {
        $rules = parent::validationRules();

        return static::$globalBail ? $this->applyBailToRules($rules) : $rules;
    }

    public static function applyGlobalBail(bool $value): void
    {
        static::$globalBail = $value;
    }

    protected function applyBailToRules(array $rules): array
    {
        foreach ($rules as &$rule) {
            if (is_array($rule)) {
                if (! in_array('bail', $rule)) {
                    array_unshift($rule, 'bail');
                }
            } elseif (! str_contains($rule, 'bail')) {
                $rule = 'bail|'.$rule;
            }
        }

        return $rules;
    }
}

类2 实现多场景

<?php

namespace Mitoop\LaravelTools\Http;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;

class EfficientSceneFormRequest extends EfficientFormRequest
{
    protected function validationRules(): array
    {
        $method = Str::after(Route::currentRouteAction(), '@');
        $scene = $method.'Rules';
        $rules = method_exists($this, $scene) ? $this->container->call([$this, $scene]) : [];

        return static::$globalBail ? $this->applyBailToRules($rules) : $rules;
    }
}

一个Request类 例如叫 AuthRequest 只需要继承 EfficientSceneFormRequest 类 控制器端只需要注入 AuthRequest 对象

class AuthRequest extends EfficientSceneFormRequest
{
    // 验证登录方法
    public function loginRules(): array 
    {
        return [
            'email' => ['required', 'string', 'email:filter', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'confirmed', Password::min(6)],
        ];
    }

   // 验证注册方法
    public function registerRules(): array
    {
        return [
            'email' => ['required', 'string', 'email:filter', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'confirmed', Password::min(6)],
        ];
    }
}

authorize 方法暂时没处理 也是类似的

这个思路是来自于 MineAdmin

2个月前 评论

最近有项目,正好跟着项目在学习likeamdin,感谢作者

2个月前 评论

还好没用过,立省100%

2个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!