1. 程式人生 > >初識SpringBoot Web開發

初識SpringBoot Web開發

Java Web開發 SpringBoot 表單驗證 異常處理

使用驗證註解來實現表單驗證

雖說前端的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歲的情況:
技術分享圖片

控制臺打印結果:

未成年禁止註冊

非空字段為空的情況:
技術分享圖片

控制臺打印結果:

學生名字不能為空

使用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訪問方式如下:
技術分享圖片

訪問成功後,控制臺輸出日誌如下:
技術分享圖片

如此,我們就完成了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> createBySuccessResultMessage(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> createBySuccessResult(String msg, T data) {
        return new Result<T>(ResultEnum.SUCCESS.getCode(), msg, data);
    }

    public static <T> Result<T> createBySuccessResult() {
        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.createBySuccessResult("註冊成功", student);
}

使用PostMan進行測試,數據正常的情況:
技術分享圖片

學生姓名為空的情況:
技術分享圖片

如上,可以看到,返回的數據格式都是一樣的,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 的情況:
技術分享圖片

age &gt; 10 && age &lt; 16 的情況:
技術分享圖片

age字段為空,出現系統異常的情況:
技術分享圖片

因為我們打印了日誌,所以出現系統異常的時候也會輸出日誌信息,不至於我們無法定位到異常:
技術分享圖片

從以上的測試結果中可以看到,即便拋出了異常,我們返回的數據格式依舊是固定的,這樣就不會由於系統出現異常而返回不一樣的數據格式。


單元測試

我們一般會在開發完項目中的某一個功能的時候,就會進行一個單元測試。以確保交付項目時,我們的代碼都是通過測試並且功能正常的,這是一個開發人員基本的素養。所以本節將簡單介紹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());
    }
}

執行該測試用例,運行結果如下:
技術分享圖片

我們修改一下年齡為15,以此模擬一下測試不通過的情況:
技術分享圖片

service層的測試比較簡單,就介紹到這。接下來我們看一下controller層的測試方式。IDEA中有一個比較方便的功能可以幫我們生成測試方法,到需要被測試的controller類中,按 Ctrl + Shift + t 就可以快速創建測試方法。如下,點擊Create New Test:
技術分享圖片

然後選擇需要測試的方法:
技術分享圖片

生成的測試用例代碼如下:

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訪問日誌的功能,所以可以直接通過控制臺的輸出日誌來判斷接口是否有被請求到:
技術分享圖片

單元測試就介紹到這,畢竟一般我們不會在代碼上測試controller層,而是使用postman或者restlet client等工具進行測試。

初識SpringBoot Web開發