Solon

E、请求参数校验、及定制与扩展

</> markdown

在业务的实现过程中,尤其是对外接口开发,我们需要对请求进行大量的验证并返回错误状态码和描述。在前面的内容中也已经使用过验证机制。

该文将介绍 solon.validation 插件的使用和扩展。能力实现与说明:

加注位置能力实现基础说明
函数基于 @Before Handler 实现对请求上下文里的值做校验(header, param..)
参数基于 @Around Interceptor 实现对函数的参数值本身做校验

使用效果如下:

//这个注解一定要加在控制器类上(或者控制器基类上)
@Valid
@Controller
public class UserController {
    //
    //这里只是演示,用时别乱加
    //
    @NoRepeatSubmit  //重复提交验证
    @Whitelist     //白名单验证
    @Mapping("/user/add")
    public void addUser(
            @NotNull String name, 
            @Pattern("^http") String icon, //注解在参数或字段上时,不需要加 value 属性
            @Validated User user) //实体校验,需要加 @Validated
    { 
        //...
    }
    
    //分组校验
    @Mapping("/user/update")
    public void updateUser(@Validated(UpdateLabel.class) User user){
        //...
    }
}

@Data
public class User {
    @NotNull(groups = UpdateLabel.class) //用于分组校验
    private Long id;
    
    @NotNull
    private String nickname;
    
    @Email  //注解在参数或字段上时,不需要加 value 属性
    private String email;
    
    @Validated //验证列表里的实体
    @NotNull
    @Size(min=1) //最少要有1个
    private List<Order> orderList;
}

默认策略,有校验不通过的会马上返回。如果校验所有,需加配置申明(返回的信息结构会略不同):

solon.validation.validateAll: true

Solon 的校验框架,可支持Context的参数较验(即请求传入的参数),也可支持实体字段较验。

注解作用范围说明
Valid控制器类启用校验能力(加在控制器类上,或者控制器基类上)
Validated参数 或 字段校验(参数或字段的类型)实体(或实体集合)上的字段
Date参数 或 字段校验注解的值为日期格式
DecimalMax(value)参数 或 字段校验注解的值小于等于@ DecimalMax指定的value值
DecimalMin(value)参数 或 字段校验注解的值大于等于@ DecimalMin指定的value值
Email参数 或 字段校验注解的值为电子邮箱格式
Length(min, max)参数 或 字段校验注解的值长度在min和max区间内(对字符串有效)
Logined控制器 或 动作校验本次请求主体已登录
Max(value)参数 或 字段校验注解的值小于等于@Max指定的value值
Min(value)参数 或 字段校验注解的值大于等于@Min指定的value值
NoRepeatSubmit控制器 或 动作校验本次请求没有重复提交
NotBlacklist控制器 或 动作校验本次请求主体不在黑名单
NotBlank动作 或 参数 或 字段校验注解的值不是空白
NotEmpty动作 或 参数 或 字段校验注解的值不是空
NotNull动作 或 参数 或 字段校验注解的值不是null
NotZero动作 或 参数 或 字段校验注解的值不是0
Null动作 或 参数 或 字段校验注解的值是null
Numeric动作 或 参数 或 字段校验注解的值为数字格式
Pattern(value)参数 或 字段校验注解的值与指定的正则表达式匹配
Size参数 或 字段校验注解的集合大小在min和max区间内(对集合有效)
Whitelist控制器 或 动作校验本次请求主体在白名单范围内

注1:可作用在 [动作 或 参数] 上的注解,加在动作上时可支持多个参数的校验。

注2:如果 json body 提交的数据,想在 [动作] 上验证,可通过 过滤器 把 json 数据转换部分到 ctx.paramMap()。

1、开始定制使用

solon.validation 通过 ValidatorManager,提供了一组定制和扩展接口。

@NoRepeatSubmit 改为分布式锁验证

NoRepeatSubmit 默认使用了本地延时锁。如果是分布式环境,需要定制为分布式锁:

@Component
public class NoRepeatSubmitCheckerNew implements NoRepeatSubmitChecker {
    @Override
    public boolean check(NoRepeatSubmit anno, Context ctx, String submitHash, int limitSeconds) {
        return LockUtils.tryLock(Solon.cfg().appName(), submitHash, limitSeconds);
    }
}

//或者去掉 @Component 手动注册到 ValidatorManager
//ValidatorManager.setNoRepeatSubmitChecker(new NoRepeatSubmitCheckerNew());

或者 完全重写 NoRepeatSubmitValidator,并进行重新注册

@Whitelist 实现验证

框架层面没办法为 Whitelist 提供一个名单库,所以需要通过一个接口实现完成对接。

@Component
public class WhitelistCheckerNew implements WhitelistChecker {
    @Override
    public boolean check(Whitelist anno, Context ctx) {
        String ip = ctx.realIp();

        return CloudClient.list().inListOfServerIp(ip);
    }
}

//或者去掉 @Component 手动注册到 ValidatorManager
//ValidatorManager.setWhitelistChecker(new WhitelistCheckerNew());

或者 完全重写 WhitelistValidator,并进行重新注册

2、校验异常处理

通过过滤器(或,路由拦截器)捕捉异常

//可以和其它异常处理合并一个过滤器
@Component
public class ValidatorFailureFilter implements Filter {
    @Override
    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
        try {
            chain.doFilter(ctx);
        } catch (ValidatorException e) {
            //v1.10.4 后,添加 getCode() 接口
            ctx.render(Result.failure(e.getCode(), e.getMessage()));
        }
    }
}

定制 ValidatorFailureHandler 接口的组件,构建提示信息(可选,一般默认的就够了)

//通过定义 ValidatorFailureHandler 实现类的组件,实现自动注册。
@Component
public class ValidatorFailureHandlerImpl implements ValidatorFailureHandler {
    @Override
    public boolean onFailure(Context ctx, Annotation anno, Result rst, String message) throws Throwable {
        if (Utils.isEmpty(message)) {
            if (Utils.isEmpty(rst.getDescription())) {
                message = new StringBuilder(100)
                        .append("@")
                        .append(anno.annotationType().getSimpleName())
                        .append(" verification failed")
                        .toString();
            } else {
                message = new StringBuilder(100)
                        .append("@")
                        .append(anno.annotationType().getSimpleName())
                        .append(" verification failed: ")
                        .append(rst.getDescription())
                        .toString();
            }
        }
        //这里也可以直接做输出,不过用异常更好
        throw new ValidatorException(rst.getCode(), message, anno, rst);
    }
}

//也可以手动配置(找个地方写一下)
//ValidatorManager.setFailureHandler((ctx, ano, rst, message) -> {
//    //..
//});

3、尝试添一个扩展校验注解

先定义个校验注解 @Date

偷懒一下,直接把自带的扔出来了。只要看这过程后,能自己搞就行了:-P

@Target({ElementType.PARAMETER})   //只让它作用到参数,不管作用在哪,最终都是对Context的校验
@Retention(RetentionPolicy.RUNTIME)
public @interface Date {
    @Note("日期表达式, 默认为:ISO格式")  //用Note注解,是为了用时还能看到这个注释
    String value() default  "";

    String message() default "";
}

添加 @Date 的校验器实现类

public class DateValidator implements Validator<Date> {
    public static final DateValidator instance = new DateValidator();

    @Override
    public String message(Date anno) {
        return anno.message();
    }

    /**
     * 校验强类型值(注解在参数上时用到)
     * */
    @Override
    public Result validateOfValue(Date anno, Object val0, StringBuilder tmp) {
        if (val0 != null && val0 instanceof String == false) {
            return Result.failure();
        }

        String val = (String) val0;

        if (verify(anno, val) == false) {
            return Result.failure();
        } else {
            return Result.succeed();
        }
    }

    /**
     * 校验上下文的参数(注解在函数上时用到)
     * */
    @Override
    public Result validateOfContext(Context ctx, Date anno, String name, StringBuilder tmp) {
        String val = ctx.param(name);

        if (verify(anno, val) == false) {
            return Result.failure(name);
        } else {
            return Result.succeed();
        }
    }

    private boolean verify(Date anno, String val) {
        //如果为空,算通过(交由@NotEmpty之类,进一步控制)
        if (Utils.isEmpty(val)) {
            return true;
        }

        try {
            if (Utils.isEmpty(anno.value())) {
                DateTimeFormatter.ISO_LOCAL_DATE_TIME.parse(val);
            } else {
                DateTimeFormatter.ofPattern(anno.value()).parse(val);
            }

            return true;
        } catch (Exception ex) {
            return false;
        }
    }
}

注册到校验管理器

@Configuration
public class Config {
    @Bean
    public void adapter() {
        //
        // 此处为注册验证器。如果有些验证器重写了,也是在此处注册
        //
        ValidatorManager.register(Date.class, new DateValidator());
    }
}

可以使用它了

@Valid
@Controller
public class UserController extends VerifyController{
    @Mapping("/user/add")
    public void addUser(String name, @Date("yyyy-MM-dd") String birthday){
        //...
    }
}