Spring RE 17 Spring MVC - 数据校验

Spring RE 17 Spring MVC - 数据校验

应用程序在执行业务逻辑之前, 需要验证从请求接收到的数据是否正确, 数据验证其实大原则上来说也算是业务的一部分. Spring MVC只要能从请求中将数据类型转换到目标对象, 就算完成了. 然而, 很多可以转换成目标对象的数据, 未必就是在业务层面来说的数据. 如果不同的人员都来编写各自业务部分的验

应用程序在执行业务逻辑之前, 需要验证从请求接收到的数据是否正确, 数据验证其实大原则上来说也算是业务的一部分. Spring MVC只要能从请求中将数据类型转换到目标对象, 就算完成了. 然而, 很多可以转换成目标对象的数据, 未必就是在业务层面来说的数据. 如果不同的人员都来编写各自业务部分的验证代码, 就会导致同样的数据可能在不同的地方被拦截. 还一个问题是, 数据输入错误, 必须要让用户知道哪里出错, 需要将出错信息返回到视图, 因此也需要在模型上设置错误数据. 视图上还必须要留出渲染错误信息的地方. 为了解决这个问题, Java EE有一个JSR-303标准, 专门验证一个Bean的数据合法性. 实现了JSR-303的就有Hibernate validator, 所以学会Hibernate真的是方便.
  1. JSR-303标准和Hibernate的扩展
  2. Spring配置验证框架
  3. 如何进行验证
  4. 展示错误信息
  5. 自定义错误信息

JSR-303标准和Hibernate的扩展

JSR-303是通过将验证逻辑直接绑定到数据对象的域上进行验证的方法, 具体来说, 就是将注解加到要验证的数据对象的各个域上. JSR-303标准提供了如下注解:
JSR-303
注解 说明
@Null 被注解的域必须是null
@NotNull 被注解的域不能为null
@AssertTrue 被注解的域必须是true
@AssertFalse 必须是false
@Min(value) 被注解的必须是一个number(基本类型), 其值必须大于等于注解的参数value
@Max(value) 被注解的必须是一个number(基本类型), 其值必须小于等于注解的参数value
@DecimalMin(value) BigDecimal类型的最小值
@DecimalMax(value) BigDecimal类型的最大值
@Size(max, min) 被注解的域大小(不是数值, 而是那个数据类型的size/长度/length)必须在max和min之间
@Digits(integer, fraction) 被注释的必须是数字, 而且值必须在可接受的范围内
@Past 被注解的是时间对象, 必须是一个过去的日期
@Future 被注解的是时间对象, 必须是一个将来的日期
@Pattern 使用正则表达式进行验证
Hibernate还有四个扩展的验证:
  1. @Email, 被注解的格式符合电子邮件地址的格式
  2. @Length, 被注解的字符串长度
  3. @NotEmpty, 字符串不能是空串, 这是为了有效区分null与空串
  4. @Range, 表示范围.
这里经过我实际检验, @Email@NotEmpty已经也是最新JSR-303的标准, 而Hibernate特有的只剩下@Length和@Range, 所以基本上不需要使用Hibernate特有的注解也可以完成验证.

Spring配置验证框架

想也不用想, Spring配置验证框架, 肯定也是需要创建一个Bean, 创建的过程中使用外部的实现, 就和JPA很类似. Spring的包是org.springframework.validation,其中重要的接口是Validator, 有两个方法:
  1. boolean supports(Class<?> clazz), 表示可以验证什么类.
  2. void validate(Object target, Errors errors), 对目标进行校验, 并将校验错误记录在errors内, 如果没有错误, errors内没有数据.
这个Bean的具体实现类是LocalValidatorFactoryBean, XML方式就是一个Bean标签, 直接指定类是org.springframework.validation.beanvalidation.LocalValidatorFactoryBean即可. 不过更方便的是, 如果配置了<mvc:annotation-driven/>或者以Java配置类方式写上了@ComponentScan("xxx"), Spring会在当前的classpath下寻找实现了JSR-303的供应商程序, 然后用其装配LocalValidatorFactoryBean 在Maven中加入Hibernate Validator的配置:
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.7.Final</version>
</dependency>
然后直接启动项目, 可以观察到一行日志:
17-Feb-2020 08:54:27.630 INFO [RMI TCP Connection(3)-127.0.0.1] org.hibernate.validator.internal.util.Version.<clinit> HV000001: Hibernate Validator 6.0.7.Final
通过IDEA的Spring支持, 可以查看当前Spring中的Bean, 可以看到有一个MVCValidator名称的Bean, 其实现类是org.springframework.validation.Validator, 说明确实自动创建成功了.

如何进行验证

前边已经说过, 验证是采用注解与要验证的类进行绑定的方式, 因此验证的第一步, 就是给被验证的类加上合适的注解.

添加验证规则

来新创建一个类Velkhana, 用于表示怪物猎人中的怪物冰呪龙, 为其加上注解:
public class Velkhana {

    @Pattern(regexp = "\\w{7,20}")
    private String monsterName;

    @DecimalMax("3245.06")
    @DecimalMin("2284.52")
    private BigDecimal size;

    @Past
    private Date releaseDate;

    ......
}
这里我查了一下, 怪物猎人里怪物的名称不会超过20个英文字母. 然后查了一下冰呪龙的大小金冠体积, 给Size标记了大小. 最后由于冰呪龙是冰原的首发怪物, 因此加入到本作的时间一定是过去时间, 也可以将这个日期看做击杀时间, 总之一定是过去的日期. 然后来编写一个控制器, 用来输入一个Velkhana的对象:
@Controller
@RequestMapping("/mhwi")
public class MHWI {

    @PostMapping("/add")
    public String getVelkhana(@ModelAttribute("vel") Velkhana velkhana) {
        System.out.println("传入的冰呪龙对象是: " + velkhana);
        return "index";
    }
}
在index.jsp中加上:
<p>当前的冰呪龙是: ${vel}</p>
之后可以用Postman来发一个Post请求到/mhwi/add, 要注意时间的字符串格式需要使用像06 Sep 2019这样的字符串, 才能被转换到Date对象. 随便发送一个超过限制的数据, index.jsp会显示如下:
当前的冰呪龙是: Monster{monsterName='aaaa', size=4000, releaseDate=Sat Feb 29 00:00:00 CST 2020}
很显然, 我们发送了超过不符合要求的数据, 不过此时依然正常显示, 是因为我们只是配置好了验证规则, 但没有实际进行验证, 所以接下来就是第二步, 开启验证.

开启验证

要开启验证, 需要给控制器方法做一些修改, 在传入的Velkhana对象之前加上@Valid注解. 注意, 从请求中获取数据和模型的注解只能同时在方法入参上加一个, 而@Valid不受此限制, 可以额外再加在参数上. 这里还需要注意的是, 只要加上了@Valid注解, 之后的参数马上就要传入一个BindingResult类型的参数, 用于存放验证结果. 不能随心所欲的设置参数. 有几个被验证的参数, 就要在每个参数之后都紧接着加上BindingResult参数. 修改控制器如下:
@Controller
@RequestMapping("/mhwi")
public class MHWI {

    @PostMapping("/add")
    public String getVelkhana(@Valid @ModelAttribute("vel") Velkhana velkhana, BindingResult bindingResult, Map<String, Object> map) {
        if (bindingResult.hasErrors()) {

            for (FieldError e : bindingResult.getFieldErrors()) {
                System.out.println(e.getField() + e.getDefaultMessage() + " 错误的值是: " + e.getRejectedValue());
            }
            map.put("vel", "出错了, 请重新输入");
            return "index";
        }
        System.out.println("传入的冰呪龙对象是: " + velkhana);
        return "index";
    }
}
再一次Post刚才的数据, 就会发现控制台打印出了错误信息:
releaseDate需要是一个过去的时间 错误的值是: Sat Feb 29 00:00:00 CST 2020
monsterName需要匹配正则表达式"w{7,20}" 错误的值是: a
size必须小于或等于3245.06 错误的值是: 4000
通过BindingResult对象, 可以通过FieldErrors得到所有验证错误的错误对象, 从每一个错误对象中, 可以取得属性名称, 验证错误的信息, 错误的值等信息. 这样在后台, 我们就可以知道哪些属性发生了错误, 从而可以进行相应的业务处理. 不过还有一个问题, 就是如何将错误信息展示给用户.

展示错误信息

其实从上边可以看出来, 既然我们可以获取错误对象的所有内容, 比如属性名称和原来错误的值, 就可以将其都放入到模型中, 用户提交表单之后如果发生错误, 可以依然返回表单页, 将错误信息都展示出来. 没错, Spring也是这么做的, 在方法参数前只要注解了@Valid, 哪怕没有那个@ModelAttribute注解, Spring也会将验证错误的信息全部都放入到隐含的模型中. 可以用Spring的form标签在JSP中获取: 我们来添加一个form.jsp, 用来输入怪物信息和显示验证结果:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<html>
<head>
    <title>输入怪物信息</title>
    <style>
        .errorClass {
            color: red;
        }

    </style>
</head>
<body>
<h1>输入怪物名称</h1>
<form:form modelAttribute="vel" action="${pageContext.request.contextPath}/mhwi/add">
    <div>
        请输入怪物名称
        <form:errors path="monsterName" cssClass="errorClass"/>
        <form:input path="monsterName" type="text"/>
    </div>
    <div>
        请输入怪物大小
        <form:errors path="size" cssClass="errorClass"/>
        <form:input path="size" type="number"/>
    </div>
    <div>
        请输入怪物参战时间
        <form:errors path="releaseDate" cssClass="errorClass"/>
        <form:input path="releaseDate" type="text"/>
    </div>
    <button type="submit">提交</button>
</form:form>
</body>
</html>
然后修改一下控制器, 因为表单绑定了vel对象, 需要每次创建一个空的, 然后表单可以自动的拿到用户上次提交的数据用于展示:
@Controller
@RequestMapping("/mhwi")
public class MHWI {

    @GetMapping("/form")
    public String form() {
        return "form";
    }

    //#1
    @ModelAttribute("vel")
    public Velkhana getVel() {
        return new Velkhana();
    }

    //#2
    @PostMapping("/add")
    public String getVelkhana(@Valid @ModelAttribute("vel") Velkhana velkhana, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {

            for (FieldError e : bindingResult.getFieldErrors()) {
                System.out.println(e.getField() + e.getDefaultMessage() + " 错误的值是: " + e.getRejectedValue());
            }
            return "form";
        }
        System.out.println("传入的冰呪龙对象是: " + velkhana);
        return "index";
    }
}
1号方法是新增的, 用于每次第一次访问表单的时候创建一个空白的对象用于表单绑定. 在2号方法里取消了设置vel到map上, 交给框架自行控制. 现在访问/mhwi/form, 填入一些不符合要求的结果, 就可以发现显示出了错误信息:
输入怪物名称
请输入怪物名称 需要匹配正则表达式"\w{7,20}" jar
请输入怪物大小 必须小于或等于3245.06 4000
请输入怪物参战时间 需要是一个过去的时间 Sat Feb 29 14:00:00 CST 2020
提交
而且页面依然会展示表单和用户已经提交的数据, 方便用户修改, 直到全部都正确为止, 就会转到index的JSP页面, 展示出正确的结果. 当然这个例子比较简陋, 页面顶部的地址栏都一直是/add结尾的控制器页面, 其实应该进行更详细的规划, 不过用来说明错误是足够了. 如果想自己操作页面,而不使用Spring提供的方式, 那么就可以自己将错误信息和字段放入到模型中来展示, 比如自己编写一个新表单form2.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<html>
<head>
    <title>输入怪物信息2</title>
    <style>
        .errorClass {
            color: red;
        }

    </style>
</head>
<body>
<h1>输入怪物名称</h1>
<form action="/mhwi/add2" method="post">
    <div>
    <c:if test="${monsterNameError!=null}">
        <p class="errorClass">${monsterNameError}</p>
    </c:if>
    <label for="">输入怪物名称 <input name="monsterName" type="text" value="${vel.monsterName}"></label>
    </div>
    <div>
    <c:if test="${sizeError!=null}">
        <p class="errorClass">${sizeError}</p>
    </c:if>
    <label for="">输入怪物大小 <input name="size" type="number" value="${vel.size}"></label>
    </div>
    <div>
        <c:if test="${releaseDateError!=null}">
        <p class="errorClass">${releaseDateError}</p>
    </c:if>
    <label for="">输入参战日期 <input name="releaseDate" type="text" value="${vel.releaseDate}"></label>
    </div>
    <button type="submit">提交</button>
</form>
</body>
</html>
这其中绑定了input的value到对象中, 然后通过判断是否存在错误信息来显示错误信息. 在控制器中, 用户输入的数据以绑定到模型中, 剩下的就是如果出现验证错误, 就在模型中放入对应的错误和信息即可:
@Controller
@RequestMapping("/mhwi")
public class MHWI {

    @GetMapping("/form2")
    public String form2() {
        return "form2";
    }

    @ModelAttribute("vel")
    public Velkhana getVel() {
        return new Velkhana();
    }

    @PostMapping("/add2")
    public String getVelkhana2(@Valid @ModelAttribute("vel") Velkhana velkhana, BindingResult bindingResult, Map<String, Object> map) {
        System.out.println("传入的冰呪龙对象是: " + velkhana);

        if (bindingResult.hasErrors()) {
            for (FieldError e : bindingResult.getFieldErrors()) {
                System.out.println(e.getField() + e.getDefaultMessage() + " 错误的值是: " + e.getRejectedValue());
                //向模型中放入验证错误的数据
                map.put(e.getField() + "Error", e.getDefaultMessage());
            }
            return "form2";
        }
        return "index";
    }
}
这样就完成了自定义的表单, 其功能和前边Spring的表单完全一致, 反复会让用户输入到正确结果为止.

自定义错误信息

错误信息也是可以自定义的, 预先定义的信息比较生硬, 可以通过注解的message属性来设置自定义的信息.
@Pattern(regexp = "\\w{7,20}", message = "怪物名称在7-20个字符之间")
private String monsterName;

@DecimalMax(value = "3245.06", message = "不能超过大金冠长度3245.06")
@DecimalMin(value = "2284.52", message = "不能小于小金冠长度2284.52")
private BigDecimal size;

@Past(message = "必须是过去的日期")
private Date releaseDate;
这就可以覆盖系统默认的错误消息. 还可以通过国际化来配置错误消息, 还可以自定义验证器, 这些高级内容就在需要的时候来看了. 这次重新看Spring, 发现写这种绑定数据的代码, 和渲染视图的代码都更加老练了, 还有@ModelAttribute的使用, 理解更加深刻了. 继续加油.
LICENSED UNDER CC BY-NC-SA 4.0
Comment