使用验证注解来实现表单验证

虽说前端的h5和js都可以完成表单的字段验证,但是这只能是防止一些小白、误操作而已。如果是一些别有用心的人,是很容易越过这些前端验证的,有句话就是说永远不要相信客户端传递过来的数据。所以前端验证之后,后端也需要再次进行表单字段的验证,以确保数据到后端后是正确的、符合规范的。本节就简单介绍一下,在SpringBoot的时候如何进行表单验证。

首先创建一个SpringBoot工程,其中pom.xml配置文件主要配置内容如下:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

创建一个pojo类,在该类中需要验证的字段上加上验证注解。代码如下:

package org.zero01.domain;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

public class Student {

    @NotNull(message = "学生名字不能为空")
    private String sname;

    @Min(value = 18,message = "未成年禁止注册")
    private int age;

    @NotNull(message = "性别不能为空")
    private String sex;

    @NotNull(message = "联系地址不能为空")
    private String address;

    public String toString() {
        return "Student{" +
                "sname='" + sname + '\'' +
                ", age=" + age +
                ", sex='" + sex + '\'' +
                ", address='" + address + '\'' +
                '}';
    }

    ... getter setter 略 ...
}

创建一个Controller类:

package org.zero01.controller;

import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.zero01.domain.Student;

import javax.validation.Valid;

@RestController
public class StudentController {

    @PostMapping("register.do")
    public Student register(@Valid Student student, BindingResult bindingResult){
        if (bindingResult.hasErrors()) {
            // 打印错误信息
            System.out.println(bindingResult.getFieldError().getDefaultMessage());
            return null;
        }
        return student;
    }
}

启动运行类,代码如下:

package org.zero01;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SbWebApplication {

    public static void main(String[] args) {
        SpringApplication.run(SbWebApplication.class, args);
    }
}

使用postman进行测试,年龄不满18岁的情况:
初识SpringBoot Web开发

控制台打印结果:

未成年禁止注册

非空字段为空的情况:
初识SpringBoot Web开发

控制台打印结果:

学生名字不能为空

使用AOP记录请求日志

我们都知道在Spring里的两大核心模块就是AOP和IOC,其中AOP为面向切面编程,这是一种编程思想或者说范式,它并不是某一种语言所特有的语法。

我们在开发业务代码的时候,经常有很多代码是通用且重复的,这些代码我们就可以作为一个切面提取出来,放在一个切面类中,进行一个统一的处理,这些处理就是指定在哪些切点织入哪些切面。

例如,像日志记录,检查用户是否登录,检查用户是否拥有管理员权限等十分通用且重复的功能代码,就可以被作为一个切面提取出来。而框架中的AOP模块,可以帮助我们很方便的去实现AOP的编程方式,让我们实现AOP更加简单。

本节将承接上一节,演示一下如何利用AOP实现简单的http请求日志的记录。首先创建一个切面类如下:

package org.zero01.aspect;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

@Aspect
@Component
public class HttpAspect {

    private static final Logger logger = LoggerFactory.getLogger(HttpAspect.class);

    @Pointcut("execution(public * org.zero01.controller.StudentController.*(..))")
    public void log() {
    }

    @Before("log()")
    public void beforeLog(JoinPoint joinPoint) {
        // 日志格式:url method clientIp classMethod param
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        logger.info("url = {}", request.getRequestURL());
        logger.info("method = {}", request.getMethod());
        logger.info("clientIp = {}", request.getRemoteHost());
        logger.info("class_method = {}", joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName());
        logger.info("param = {}", joinPoint.getArgs());
    }

    @AfterReturning(returning = "object", pointcut = "log()")
    public void afterReturningLog(Object object) {
        // 打印方法返回值内容
        logger.info("response = {}", object);
    }
}

使用PostMan访问方式如下:
初识SpringBoot Web开发

访问成功后,控制台输出日志如下:
初识SpringBoot Web开发

如此,我们就完成了http请求日志的记录。


封装统一的返回数据对象

我们在控制器类的方法中,总是需要返回各种不同类型的数据给客户端。例如,有时候需要返回集合对象、有时候返回字符串、有时候返回自定义对象等等。而且在一个方法里可能会因为处理的结果不同,而返回不同的对象。那么当一个方法中需要根据不同的处理结果返回不同的对象时,我们应该怎么办呢?可能有人会想到把方法的返回类型设定为Object不就可以了,的确是可以,但是这样返回的数据格式就不统一。前端接收到数据时,很不方便去展示,后端写接口文档的时候也不好写。所以我们应该统一返回数据的格式,而使用Object就无法做到这一点了。

所以我们需要将返回的数据统一封装在一个对象里,然后统一在控制器类的方法中,把这个对象设定为返回值类型即可,这样我们返回的数据格式就有了一个标准。那么我们就来开发一个这样的对象吧,首先新建一个枚举类,因为我们需要把一些通用的常量数据都封装在枚举类里,以后这些数据发生变动时,只需要修改枚举类即可。如果将这些常量数据硬编码写在代码里就得逐个去修改了,十分的难以维护。代码如下:

package org.zero01.enums;

public enum ResultEnum {

    UNKONW_ERROR(-1, "未知错误"),
    SUCCESS(0, "SUCCESS"),
    ERROR(1, "ERROR"),
    PRIMARY_SCHOOL(100, "小学生"),
    MIDDLE_SCHOOL(101, "初中生");

    private Integer code;
    private String msg;

    ResultEnum(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public Integer getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

然后就是创建我们的返回数据封装对象了,在此之前,我们需要先定义好这个数据的一个标准格式。我这里定义的格式如下:

{
    "code": 0,
    "msg": "注册成功",
    "data": {
        "sname": "Max",
        "age": 18,
        "sex": "woman",
        "address": "湖南"
    }
}

明确了数据的格式后,就可以开发我们的返回数据封装对象了。新建一个类,代码如下:

package org.zero01.domain;

import org.zero01.enums.ResultEnum;

/**
 * @program: sb-web
 * @description: 服务器统一的返回数据封装对象
 * @author: 01
 * @create: 2018-05-05 18:03
 **/
public class Result<T> {

    // 错误/正确码
    private Integer code;
    // 提示信息
    private String msg;
    // 返回的数据
    private T data;

    private Result(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    private Result(Integer code) {
        this.code = code;
    }

    private Result(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    private Result() {
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public static <T> Result<T> createBySucce***esultMessage(String msg) {
        return new Result<T>(ResultEnum.SUCCESS.getCode(), msg);
    }

    public static <T> Result<T> createBySuccessCodeResult(Integer code, String msg) {
        return new Result<T>(code, msg);
    }

    public static <T> Result<T> createBySucce***esult(String msg, T data) {
        return new Result<T>(ResultEnum.SUCCESS.getCode(), msg, data);
    }

    public static <T> Result<T> createBySucce***esult() {
        return new Result<T>(ResultEnum.SUCCESS.getCode());
    }

    public static <T> Result<T> createByErrorResult() {
        return new Result<T>(ResultEnum.ERROR.getCode());
    }

    public static <T> Result<T> createByErrorResult(String msg, T data) {
        return new Result<T>(ResultEnum.ERROR.getCode(), msg, data);
    }

    public static <T> Result<T> createByErrorCodeResult(Integer errorCode, String msg) {
        return new Result<T>(errorCode, msg);
    }

    public static <T> Result<T> createByErrorResultMessage(String msg) {
        return new Result<T>(ResultEnum.ERROR.getCode(), msg);
    }
}

接着修改我们之前的注册接口代码如下:

@PostMapping("register.do")
public Result<Student> register(@Valid Student student, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return Result.createByErrorResultMessage(bindingResult.getFieldError().getDefaultMessage());
    }
    return Result.createBySucce***esult("注册成功", student);
}

使用PostMan进行测试,数据正常的情况:
初识SpringBoot Web开发

学生姓名为空的情况:
初识SpringBoot Web开发

如上,可以看到,返回的数据格式都是一样的,code字段的值用于判断是一个success的结果还是一个error的结果,msg字段的值是提示信息,data字段则是存储具体的数据。有这样一个统一的格式后,前端也好解析这个json数据,我们后端在写接口文档的时候也好写了。


统一异常处理

一个系统或应用程序在运行的过程中,由于种种因素,肯定是会有抛异常的情况的。在系统出现异常时,由于服务的中断,数据可能会得不到返回,亦或者返回的是一个与我们定义的数据格式不相符的一个数据,这是我们不希望出现的问题。所以我们得进行一个全局统一的异常处理,拦截系统中会出现的异常,并进行处理。下面我们用一个小例子来做为演示。

例如,现在有一个业务需求如下:

  • 获取某学生的年龄进行判断,小于10,抛出异常并返回“小学生”提示信息,大于10且小于16,抛出异常并返回“初中生”提示信息。

首先我们需要自定义一个异常,因为默认的异常构造器只接受一个字符串类型的数据,而我们返回的数据中有一个code,所以我们得自己定义个异常类。代码如下:

package org.zero01.exception;

/**
 * @program: sb-web
 * @description: 自定义异常
 * @author: 01
 * @create: 2018-05-05 19:01
 **/
public class StudentException extends RuntimeException {

    private Integer code;

    public StudentException(Integer code, String msg) {
        super(msg);
        this.code = code;
    }

    public Integer getCode() {
        return code;
    }
}

新建一个 ErrorHandler 类,用于全局异常的拦截及处理。代码如下:

package org.zero01.handle;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.zero01.domain.Result;
import org.zero01.enums.ResultEnum;
import org.zero01.exception.StudentException;

/**
 * @program: sb-web
 * @description: 全局异常处理类
 * @author: 01
 * @create: 2018-05-05 18:48
 **/
// 定义全局异常处理类
@ControllerAdvice
// Lombok的一个注解,用于日志打印
@Slf4j
public class ErrorHandler {

    // 声明异常处理方法,传递哪一个异常对象的class,就代表该方法会拦截哪一个异常对象包括其子类
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public Result exceptionHandle(Exception e) {
        if (e instanceof StudentException) {
            StudentException studentException = (StudentException) e;
            // 返回统一的数据格式
            return Result.createByErrorCodeResult(studentException.getCode(), studentException.getMessage());
        }
        // 打印异常日志
        log.error("[系统异常]{}", e);
        // 返回统一的数据格式
        return Result.createByErrorCodeResult(ResultEnum.UNKONW_ERROR.getCode(), "服务器内部出现未知错误");
    }
}

注:我这里使用到了Lombok,如果对Lombok不熟悉的话,可以参考我之前写的一篇Lombok快速入门

在之前的控制类中,增加如下代码:

@Autowired
private IStudentService iStudentService;

@GetMapping("check_age.do")
public void checkAge(Integer age) throws Exception {
    iStudentService.checkAge(age);
    age.toString();
}

我们都知道具体的逻辑都是写在service层的,所以新建一个service包,在该包中新建一个接口。代码如下:

package org.zero01.service;

public interface IStudentService {
    void checkAge(Integer age) throws Exception;
}

然后新建一个类,实现该接口。代码如下:

package org.zero01.service;

import org.springframework.stereotype.Service;
import org.zero01.enums.ResultEnum;
import org.zero01.exception.StudentException;

@Service("iStudentService")
public class StudentService implements IStudentService {

    public void checkAge(Integer age) throws StudentException {
        if (age < 10) {
            throw new StudentException(ResultEnum.PRIMARY_SCHOOL.getCode(), ResultEnum.PRIMARY_SCHOOL.getMsg());
        } else if (age > 10 && age < 16) {
            throw new StudentException(ResultEnum.MIDDLE_SCHOOL.getCode(), ResultEnum.MIDDLE_SCHOOL.getMsg());
        }
    }
}

完成以上的代码编写后,就可以开始进行测试了。age &lt; 10 的情况:
初识SpringBoot Web开发

age &gt; 10 && age &lt; 16 的情况:
初识SpringBoot Web开发

age字段为空,出现系统异常的情况:
初识SpringBoot Web开发

因为我们打印了日志,所以出现系统异常的时候也会输出日志信息,不至于我们无法定位到异常:
初识SpringBoot Web开发

从以上的测试结果中可以看到,即便抛出了异常,我们返回的数据格式依旧是固定的,这样就不会由于系统出现异常而返回不一样的数据格式。


单元测试

我们一般会在开发完项目中的某一个功能的时候,就会进行一个单元测试。以确保交付项目时,我们的代码都是通过测试并且功能正常的,这是一个开发人员基本的素养。所以本节将简单介绍service层的测试与controller层的测试方式。

首先是service层的测试方式,service层的单元测试和我们平时写的测试没太大区别。在工程的test目录下,新建一个测试类,代码如下:

package org.zero01;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.zero01.domain.Result;
import org.zero01.domain.Student;
import org.zero01.service.IStudentService;

/**
 * @program: sb-web
 * @description: Student测试类
 * @author: 01
 * @create: 2018-05-05 21:46
 **/
@RunWith(SpringRunner.class)
@SpringBootTest
public class StudentServiceTest {

    @Autowired
    private IStudentService iStudentService;

    @Test
    public void findOneTest() {
        Result<Student> result = iStudentService.findOne(1);
        Student student = result.getData();
        Assert.assertEquals(18, student.getAge());
    }
}

执行该测试用例,运行结果如下:
初识SpringBoot Web开发

我们修改一下年龄为15,以此模拟一下测试不通过的情况:
初识SpringBoot Web开发

service层的测试比较简单,就介绍到这。接下来我们看一下controller层的测试方式。IDEA中有一个比较方便的功能可以帮我们生成测试方法,到需要被测试的controller类中,按 Ctrl + Shift + t 就可以快速创建测试方法。如下,点击Create New Test:
初识SpringBoot Web开发

然后选择需要测试的方法:
初识SpringBoot Web开发

生成的测试用例代码如下:

package org.zero01.controller;

import org.junit.Test;

import static org.junit.Assert.*;

public class StudentControllerTest {

    @Test
    public void checkAge() {
    }
}

接着我们来完成这个测试代码,controller层的测试和service层不太一样,因为需要访问url,而不是直接调用方法进行测试。测试代码如下:

package org.zero01.controller;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class StudentControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void checkAge() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/check_age.do")  // 使用get请求
                .param("age","18"))  // url参数
                .andExpect(MockMvcResultMatchers.status().isOk());  // 判断返回的状态是否正常
    }
}

运行该测试用例,因为我们之前实现了一个记录http访问日志的功能,所以可以直接通过控制台的输出日志来判断接口是否有被请求到:
初识SpringBoot Web开发

单元测试就介绍到这,毕竟一般我们不会在代码上测试controller层,而是使用postman或者restlet client等工具进行测试。