1. 程式人生 > >Spring Boot 構建應用——開發 RESTful API

Spring Boot 構建應用——開發 RESTful API

Restful 本質上是一個優雅的 URI 表達方式,基於 Restful 設計的軟體可以更簡潔,更有層次,更易於實現快取等機制。資源的狀態和狀態轉移。下面來看一組 傳統API 和 RESTful API 寫法的對比:

- 傳統API請求方法 傳統API寫法 RESTful API請求方法 RESTful API寫法
查詢 GET /user/query?name=tom GET /user?name=tom
詳情 GET /user/getInfo?id=1 GET /user/{id}
建立 POST /user/create?name=tom POST /user
修改 POST /user/update?id=1&name=joy PUT /user/{id}
刪除 GET /user/delete?id=1 DELETE /user/{id}

Restful API 用 URL 描述資源,用 HTTP 方法描述行為,使用 HTTP 狀態碼來表示不同的結果,使用 json 互動資料(/ 模組 / 資源 / {標示} / 集合1 / …)。

開發 RESTful API 常用的 Spring MVC 註解:

Spring MVC常用註解 說明
@RestController Spring4之後新加的註解,等同於@ResponseBody+@Controller,標明此Controller提供RESTful API
@GetMapping 組合註解,等同於@RequestMapping(method = RequestMethod.GET),同理還有@PostMapping、@PutMapping、@DeleteMapping
@RequestParam 對映請求引數到java方法的引數,屬性required:是否必傳
@PageableDefault 指定分頁引數預設值
@RequestBody 對映請求體到java方法的引數
@Valid 和 @BindingResult 驗證請求引數的合法性並處理校驗結果

除此之外,Hibernate Validator 庫還提供了一些對引數校驗的註解,如下:

Hibernate Validator校驗註解 說明
@NotNull 值不能為空
@Null 值必須為空
@Pattern(regex=) 字串必須匹配正則表示式
@Size(min=, max=) 集合的元素數量必須在min和max之間
@CreditCardNumber(ignoreNonDigitCharacters=) 字串必須是信用卡號(按美國的標準驗的-_-!)
@Email 字串必須是Email地址
@Length(min=,max=) 檢查字元的長度
@NotBlank 字串必須有字元
@NotEmpty 字串不為null,集合有元素
@Range(min=,max=) 數字必須大於等於min,小於等於max
@SafeHtml 字串是安全的html
@URL 字串是合法的URL
@AssertFalse 值必須是false
@AssertTrue 值必須是true
@DecimalMax(value=,inclusive=) 值必須小於等於(inclusive=true)/小於(inclusive=false)value屬性指定的值。可註解在字串型別的屬性上
@DecimalMin() 值必須大於等於(inclusive=true)/大於(inclusive=false)value屬性指定的值。可註解在字串型別的屬性上
@Digits(integer=,fraction=) 數字格式檢查,integer指定整數部分的最大長度,fraction指定小數部分的最大長度
@Future 值必須是未來的日期
@Past 值必須是過去的日期
@Max(value=) 值必須小於等於value指定的值,不能註解在字串型別的屬性上
@Min(value=) 值必須大於等於value指定的值,不能註解在字串型別的屬性上

1.基於Restful設計的增刪改查

測試用例這裡使用 MockMvc 結合 PerfTest 進行編寫,PerfTest 需要新增 Maven 依賴:

<dependency>
    <groupId>org.databene</groupId>
    <artifactId>contiperf</artifactId>
    <version>2.3.4</version>
</dependency>

1.查詢請求API

public class User {
    //使用介面來宣告多個檢視
    public interface UserSimpleView {};
    public interface UserDetailView extends UserSimpleView {};
    private Long id;
    private String username;
    private String password;
    private Date gmtCreate;
    private Date gmtModified;
    //在值物件的get方法上指定檢視,然後在Controller方法上指定檢視,就可以達到使用UserSimpleView檢視隱藏password的效果
    @JsonView(UserSimpleView.class)
    public Long getId() { return id; }
    @JsonView(UserSimpleView.class)
    public String getUsername() { return username; }
    @JsonView(UserDetailView.class)
    public String getPassword() { return password; }
    @JsonView(UserSimpleView.class)
    public Date getGmtCreate() { return gmtCreate; }
    @JsonView(UserSimpleView.class)
    public Date getGmtModified() { return gmtModified; }
    //省略setter請求
}
@RestController
public class UserController {
    private Logger logger = LoggerFactory.getLogger(UserController.class);
    @Autowired
    private UserService userService;
    @GetMapping("/user")
    @JsonView(User.UserSimpleView.class) //指定檢視
    public List<User> queryUser(@RequestParam String username) {
        return userService.queryUser(username);
    }
    //新增正則表示式 \d+ 表示id只能為數字
    @GetMapping("/user/{id:\\d+}")
    @JsonView(User.UserSimpleView.class)
    public User getUserInfo(@PathVariable String id) {
        return userService.getUserInfo(id);
    }
}

編寫測試用例:

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {
    @Autowired
    private UserController userController;
    private MockMvc mvc;
    private Logger logger = LoggerFactory.getLogger(UserControllerTest.class);
    @Rule
    public ContiPerfRule i = new ContiPerfRule();
    @Before
    public void setup() {
        mvc = MockMvcBuilders.standaloneSetup(userController).build();
    }
    @Test
    @PerfTest(invocations = 100000, threads = 1000)
    public void whenQueryUserSuccess() throws Exception {
        MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/user")
                .param("username", "tom")
                .contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(3))
                .andReturn();
        logger.info(result.getResponse().getContentAsString());
    }
    @Test
    @PerfTest(invocations = 100000, threads = 1000)
    public void whenGetUserInfoSuccess() throws Exception {
        MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/user/1")
                .contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.username").value("tom"))
                .andReturn();
        logger.info(result.getResponse().getContentAsString());
    }
    @Test
    @PerfTest(invocations = 100000, threads = 1000)
    public void whenGetUserInfoFail() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/user/a")
                .contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(MockMvcResultMatchers.status().is4xxClientError());
    }
}

2.建立請求API

在 UserController 增加建立請求的介面:

/**
 * @param user @Valid:校驗資料,約束需要在pojo中定義,校驗結果會通過BindingResult返回,可以寫成:
 * public User createUserInfo(@Valid @RequestBody User user, BindingResult erros) {
 *     if (erros.hasErrors()) {
 *         erros.getAllErrors().stream().forEach(error -> logger.error(error.getDefaultMessage()));
 *     }
 *     Date date = new Date();
 *     user.setGmtCreate(date);
 *     user.setGmtModified(date);
 *     return userService.createUserInfo(user);
 * }
 * 如果不寫BindingResult,將會通過Spring Boot中預設的錯誤處理機制返回給客戶端
 */
@PostMapping("/user")
@JsonView(User.UserSimpleView.class)
public User createUserInfo(@Valid @RequestBody User user) {
    Date date = new Date();
    user.setGmtCreate(date);
    user.setGmtModified(date);
    return userService.createUserInfo(user);
}

在 User 中新增校驗約束:

@NotBlank(message = "使用者名稱不能為空") //校驗約束
private String username;
@NotBlank(message = "密碼不能為空")
private String password;

在 UserControllerTest 增加建立請求的測試用例:

@Test
@PerfTest(invocations = 100000, threads = 1000)
public void whenCreateUserSuccess() throws Exception {
    String content = "{\"username\":\"bob\",\"password\":\"123\"}";
    MvcResult result = mvc.perform(MockMvcRequestBuilders.post("/user")
            .content(content)
            .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.id").value("1"))
            .andReturn();
    logger.info(result.getResponse().getContentAsString());
}

3.修改請求API

在 UserController 增加修改請求的介面:

@PutMapping("/user/{id:\\d+}")
@JsonView(User.UserSimpleView.class)
public User updateUserInfo(@Valid @RequestBody User user) {
    Date date = new Date();
    user.setGmtCreate(date);
    user.setGmtModified(date);
    return userService.updateUserInfo(user);
}

在 UserControllerTest 增加修改請求的測試用例:

@Test
@PerfTest(invocations = 100000, threads = 1000)
public void whenUpdateUserSuccess() throws Exception {
    String content = "{\"id\":\"1\",\"username\":\"bob\",\"password\":\"123\"}";
    MvcResult result = mvc.perform(MockMvcRequestBuilders.put("/user/1")
            .content(content)
            .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.id").value("1"))
            .andReturn();
    logger.info(result.getResponse().getContentAsString());
}

4.刪除請求API

在 UserController 增加刪除請求的介面:

@DeleteMapping("/user/{id:\\d+}")
@JsonView(User.UserSimpleView.class)
public void deleteUserInfo(@PathVariable String id) {
    userService.deleteUserInfo(id);
}

在 UserControllerTest 增加刪除請求的測試用例:

@Test
@PerfTest(invocations = 100000, threads = 1000)
public void whenDeleteUserSuccess() throws Exception {
    MvcResult result = mvc.perform(MockMvcRequestBuilders.delete("/user/1")
            .contentType(MediaType.APPLICATION_JSON_UTF8))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andReturn();
    logger.info(result.getResponse().getContentAsString());
}

2.Restful API錯誤處理

1.Spring Boot 中預設的錯誤處理機制

Spring Boot 提供預設的錯誤處理機制,瀏覽器傳送請求遇到錯誤返回 html 錯誤網頁,APP 傳送請求遇到錯誤返回 json 錯誤程式碼。
這種錯誤機制的具體實現可以檢視 org.springframework.boot.autoconfigure.web.BasicErrorController 原始碼:

@Controller
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {
    @RequestMapping(produces = {"text/html"})
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
    }
    @RequestMapping
    @ResponseBody
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    }
}

可以看出,其實就是根據 produces 引數來實現的。

2.自定義異常處理

大部分業務我們使用 Spring Boot 預設提供的錯誤處理機制即可。如果我們需要單獨處理某些瀏覽器響應錯誤,例如 404,就需要自定義異常。新建 resources/resources/error 目錄,在其中新建 404.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"> <title>404</title>
</head>
<body> 您所訪問的頁面不存在</body>
</html>

執行專案,此時從瀏覽器訪問 API 介面,如果發生 404 錯誤則不再返回 Spring Boot 提供的 html,而是返回上面自定義的 html(這種自定義只對瀏覽器生效,不會影響 APP)。下面我們自定義 APP 異常:

public class UserNotExistException extends RuntimeException {
    private String id;
    public UserNotExistException(String id) {
        super("user not exist");
        this.id = id;
    }
    public String getId() {
        return id;
    }
}

新建控制器的錯誤處理器,丟擲的 UserNotExistException 都會到這裡處理:

@ControllerAdvice
public class ControllerExceptionHandler {
    @ExceptionHandler(UserNotExistException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Map<String, Object> handleUserNotExistException(UserNotExistException ex) {
        Map<String, Object> result = new HashMap<>();
        result.put("id", ex.getId());
        result.put("message", ex.getMessage());
        return result;
    }
}

此時,如果程式碼發生錯誤,我們只需要手動 丟擲 UserNotExistException(id) 即可。例如獲得錯誤響應為:

{
    "id": "1",
    "message": "user not exist"
}

3.Restful API的攔截機制

攔截機制 不同點
過濾器 依賴於servlet容器,能拿到原始的請求和相應資訊,但是拿不到真正處理請求方法的資訊
攔截器 不依賴與servlet容器,能拿到原始的請求和相應資訊,也能拿到真正處理請求方法的資訊,但是拿不到方法被呼叫時引數的值
切片 攔截Spring管理Bean的訪問,可以拿到方法被呼叫時引數的值

三者的順序是:過濾器 -> 攔截器 -> ControllerAdvice -> 切片 -> Controller

1.過濾器Filter

@Component //新增該註解即可生效
public class TimeFilter implements javax.servlet.Filter {
    //初始化
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }
    //處理過濾器邏輯
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        long start = System.currentTimeMillis();
        chain.doFilter(request, response);
        System.out.println("Time Filter 耗時:" + (System.currentTimeMillis()-start));
    }
    //銷燬
    @Override
    public void destroy() {
    }
}

2.新增第三方Filter到自己的專案

新建配置類註冊 bean 即可(這裡假設是第三方的TimeFilter):

@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean timeFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        TimeFilter timeFilter = new TimeFilter();
        registrationBean.setFilter(timeFilter);
        List<String> urls = new ArrayList<>();
        //所有路徑都起作用
        urls.add("/*");
        registrationBean.setUrlPatterns(urls);
        return registrationBean;
    }
}

3.攔截器Interceptor

攔截器是指通過統一攔截從瀏覽器發往伺服器的請求來完成功能的增強,處理所有請求的共性問題。如解決亂碼問題(web.xml 中配置 filter)、許可權驗證問題、驗證是否登入等。攔截器的工作原理和過濾器非常相似。

@Component
public class TimeInterceptor implements HandlerInterceptor {
    /**
     * 執行步驟1: 在控制器處理請求之前被呼叫
     * @param handler 被攔截請求物件例項
     * @return false:表示攔截當前請求, 請求被終止, true:表示不攔截當前請求, 請求被繼續
     */
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        request.setAttribute("startTime", System.currentTimeMillis());
        return true;
    }
    /**
     * 執行步驟2: 在控制器處理請求之後被呼叫, 生成檢視之前執行的動作
     * @param handler 被攔截請求物件例項
     * @param modelAndView 可通過modelAndView改變顯示的檢視或修改發往檢視的方法, 比如當前時間
     */
    @Override
    public void postHandle(HttpServletRequest request,
                           HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    }
    /**
     * 執行步驟3: 在DispatcherServlet完全處理完請求後被呼叫,可用於清理資源等
     * 注意: 當有攔截器丟擲異常時,會從當前攔截器往回執行所有的攔截器的afterCompletion方法
     * @param handler 被攔截請求物件例項
     */
    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response, Object handler, Exception e) throws Exception {
        Long start = (Long) request.getAttribute("startTime");
        System.out.println("Time Interceptor 耗時:" + (System.currentTimeMillis()-start));
    }
}

在配置類中新增攔截器:

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{
    @Autowired
    private TimeInterceptor timeInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timeInterceptor);
    }
}

另外多個攔截器協同時工作流程圖如下:

多個攔截器協同時工作流程圖

3.切片Aspect

@Aspect
@Component
public class TimeAspect {
    private final static Logger logger = LoggerFactory.getLogger(TimeAspect.class);
    @Around("execution(public * com.example.security.web.controller.UserController.*(..))")
    public Object handleControllerMethod(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object[] args = pjp.getArgs();
        for (Object arg : args) {
            System.out.println("Time Aspect 引數:" + arg);
        }
        Object object = pjp.proceed();
        System.out.println("Time Aspect 耗時:" + (System.currentTimeMillis()-start));
        return object;
    }
}

4.使用REST方式處理檔案上傳

新建檔案上傳的控制器 UploadController:

@RestController
public class UploadController {
    @PostMapping("/upload")
    public FileInfo upload(MultipartFile file) throws IOException {
        String folder = "/Users/guochao/Documents";
        String _fileName = file.getOriginalFilename();
        String suffix = _fileName.substring(_fileName.lastIndexOf("."));
        File localFile = new File(folder, UUID.randomUUID().toString() + suffix);
        file.transferTo(localFile);
        return new FileInfo(localFile.getAbsolutePath());
    }
}

新建 UploadControllerTest 新增檔案上傳的測試用例:

@RunWith(SpringRunner.class)
@SpringBootTest
public class UploadControllerTest {
    @Autowired
    private UploadController uploadController;
    private MockMvc mvc;
    private Logger logger = LoggerFactory.getLogger(UserControllerTest.class);
    @Rule
    public ContiPerfRule i = new ContiPerfRule();
    @Before
    public void setup() {
        mvc = MockMvcBuilders.standaloneSetup(uploadController).build();
    }
    @Test
    @PerfTest(invocations = 100000, threads = 1000)
    public void whenUploadSuccess() throws Exception {
        MvcResult result = mvc.perform(MockMvcRequestBuilders.fileUpload("/upload")
                .file(new MockMultipartFile("file", "test.txt", "multipart/form-data", "hello upload".getBytes("UTF-8"))))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andReturn();
        logger.info(result.getResponse().getContentAsString());
    }
}

5.非同步處理REST服務

@RestController
public class AsyncController {
    private Logger logger = LoggerFactory.getLogger(AsyncController.class);
    /**
     * 同步處理
     */
    @RequestMapping("/sync")
    public String sync() throws InterruptedException {
        long start = System.currentTimeMillis();
        logger.info("主執行緒開始");
        Thread.sleep(1000);
        logger.info("主執行緒結束,耗時" + (System.currentTimeMillis()-start));
        return "success";
    }
    /**
     * 非同步處理
     */
    @RequestMapping("/async")
    public Callable<String> async() {
        long start = System.currentTimeMillis();
        logger.info("主執行緒開始");
        Callable<String> result = () -> {
            long start2 = System.currentTimeMillis();
            logger.info("副執行緒開始");
            Thread.sleep(1000);
            logger.info("副執行緒返回,耗時" + (System.currentTimeMillis()-start2));
            return "success";
        };
        logger.info("主執行緒結束,耗時" + (System.currentTimeMillis()-start));
        return result;
    }
}