可视化表单校验规则

解读

国内一线/二线公司面试时,这道题通常不是“写个正则”那么简单,而是考察候选人能否把“后端校验逻辑”抽象成一套可复用、可配置、可前端实时渲染的规则体系。
核心诉求有三点:

  1. 后端定义规则 → 前端拿到规则即可渲染出带校验提示的表单,无需二次手写。
  2. 规则变更 → 前后端零代码同步,产品/运营可在后台“拖拖拽拽”完成。
  3. 高并发场景 → 规则引擎必须支持 OPcache 级别缓存、支持序列化到 Redis,避免每次请求重新解析。
    面试官会顺着“存储结构 → 渲染协议 → 性能优化 → 安全”四层深挖,答不出“规则缓存”或“XSS 过滤”基本会被判不及格。

知识点

  1. JSON Schema(国内大厂事实标准,比 XML 轻量,前端好解析)。
  2. PHP 8 的 readonly/枚举/属性(Attributes)可用来声明式定义规则。
  3. 规则仓库模式:Repository + 内存缓存(OPcache)+ Redis 二级缓存。
  4. 前端渲染协议:统一返回 {field, type, rules[], errorMsg, componentHint},前端用 async-validator 或自研引擎直接消费。
  5. 安全:规则里禁止直接输出 HTML,所有文案走 Symfony Translation 组件转义,防止存储型 XSS。
  6. 性能:规则缓存键加入版本号(git commit short)+ 租户 ID,支持灰度发布。
  7. 扩展:支持闭包规则(ClosureRule),通过 serialize_closures 包把闭包转成可缓存 AST,兼顾灵活与性能。

答案

下面给出可直接落地的最小可用方案(Laravel 风格,原生 PHP 同理)。

  1. 规则定义(PHP 8 枚举 + 属性)
enum RuleType: string {
    case REQUIRED = 'required';
    case REGEX    = 'regex';
    case REMOTE   = 'remote'; // 异步校验
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Rule {
    public function __construct(
        public RuleType $type,
        public mixed    $constraint,
        public string   $errorMsg = '',
        public int      $order    = 0,
    ) {}
}

final class UserForm {
    #[Rule(RuleType::REQUIRED, constraint: true, errorMsg: '手机号不能为空', order: 1)]
    #[Rule(RuleType::REGEX,   constraint: '/^1[3-9]\d{9}$/', errorMsg: '手机号格式错误', order: 2)]
    public string $mobile;

    #[Rule(RuleType::REQUIRED, constraint: true, errorMsg: '短信验证码不能为空')]
    #[Rule(RuleType::REMOTE,   constraint: '/api/verify/sms', errorMsg: '验证码无效')]
    public string $smsCode;
}
  1. 规则解析器(只解析一次,结果缓存到 OPcache + Redis)
final class RuleParser {
    private const CACHE_KEY = 'form_rules:%s'; // %s=版本号+租户ID
    public static function parse(string $formClass): array {
        $key = sprintf(self::CACHE_KEY, self::version());
        return Cache::remember($key, 3600, function () use ($formClass) {
            $rules = [];
            $refl  = new ReflectionClass($formClass);
            foreach ($refl->getProperties() as $prop) {
                $attrs = $prop->getAttributes(Rule::class);
                foreach ($attrs as $attr) {
                    /** @var Rule $rule */
                    $rule = $attr->newInstance();
                    $rules[$prop->getName()][] = [
                        'type'      => $rule->type->value,
                        'constraint'=> $rule->constraint,
                        'message'   => trans($rule->errorMsg), // 多语言
                        'order'     => $rule->order,
                    ];
                }
                // 按 order 排序,保证前端校验顺序
                usort($rules[$prop->getName()], fn($a,$b)=>$a['order']<=>$b['order']);
            }
            return $rules;
        });
    }
    private static function version(): string {
        return config('app.form_rule_version', substr(exec('git rev-parse --short HEAD'), 0, 7));
    }
}
  1. 控制器暴露可视化协议
class FormMetaController extends Controller {
    public function rules(string $form): JsonResponse {
        // 白名单校验,防止反射任意类
        $allow = config('form.whitelist', []);
        abort_unsafe(in_array($form, $allow), 403);
        return response()->json([
            'fields' => RuleParser::parse($form),
        ]);
    }
}

前端拿到 JSON 后直接渲染:

  • required → 在 <input> 上加 required 属性;
  • regex → 生成 pattern 并绑定 onblur 校验;
  • remote → 自动发 POST 到 constraint 地址,回包 {valid:true|false,msg:''}
  1. 后端真正入库时再次校验(不可信任前端)
Validator::make($input, RuleConverter::toLaravel($rules))->validate();

RuleConverter::toLaravel 把 JSON Schema 转成 Laravel 原生规则数组,100% 复用逻辑,保证前后端一致。

拓展思考

  1. 多租户 SaaS 场景:每个租户可在后台“拖拖拽拽”生成新表单,规则存 MySQL,版本号改为 tenantId+formId+md5(rules),实现秒级灰度。
  2. 复杂关联校验:如“省市区三级联动必填”,可把闭包序列化后存库,解析时用 opis/closure 还原,兼顾动态与性能。
  3. 微服务拆分:把 RuleParser 独立成“表单元服务”,用 gRPC 暴露 GetFormSchema(),PHP-FPM / Go / Node 统一消费,实现跨语言复用。
  4. 安全加固:
    • 所有正则先做 preg_quote 白名单过滤,防止 ReDoS;
    • remote 接口必须走公司统一网关,带 JWT 鉴权,防止被刷短信。
  5. 性能极限优化:
    • 规则缓存预热:在 Deploy 阶段用 Laravel Command 提前 RuleParser::parse() 写入 Redis,避免首次请求穿透;
    • 对超大表单(>100 字段)采用分片缓存 + 前端按需懒加载,降低首次传输体积。