10.1 RESTful概述

10.1.1 RESTful的概念

RESTful 是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便,得到越来越多的应用。尤其在移动互联网兴起后,RESTful 得到了非常广泛的应用,非常适合统一的后端服务对应多个前端的环境。

RESTRepresentational State Transfer 的缩写,字面上的翻译是”表现层状态转化”。如果一个架构符合 REST 原则,就称它为 RESTful 架构

要理解 RESTful 架构,最好的方法就是去理解 Representational State Transfer 的具体含义,它的每一个词代表了什么涵义。如果你把这个名称搞懂了,也就不难体会 REST 是一种什么样的设计。

REST 省略了主语,即Resources(资源)。所谓”资源”是网络上的一个实体,是网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲、一种服务,总之是一个具体的存在。你可以用一个 URI (统一资源定位符)指向它,每种资源对应一个特定的 URI。在 RESTful 风格的应用中,每一个 URI 都代表了一个资源。有些场景下说的RESTful API,其本质也是对应着资源。

“资源”是一种信息实体,它可以有多种外在表现形式。我们把”资源”具体呈现出来的形式,叫做Representation。比如,文本可以用 txt 格式表现,也可以用 HTML 格式、XML 格式、JSON 格式表现,甚至可以采用二进制格式;图片可以用 JPG 格式表现,也可以用 PNG 格式表现。

URI 只代表资源的实体,不代表它的形式。网络资源的具体表现形式应该在网络请求(通常是HTTP 请求)的头信息中用 诸如Accept、Content-Type等字段指定。类似这两个字段的信息才是Representation。

客户端访问服务器是一个互动过程。在这个过程中,势必涉及到数据和状态的变化。所有的状态都保存在服务器端,如果客户端想要操作服务器,必须通过某种手段,让服务器端发生”状态转化”(State Transfer)。而这种转化是建立在Representation基础上的。

10.1.2 Spring Boot的支持

SpringMVC 对 RESTful 提供了非常全面的支持,但繁琐的配置有时却让人望而却步。即使是开发一个Hello World的Web应用,都需要在pom文件中导入各种依赖、编写多个配置文件等,而处理各种JAR包依赖关系时的复杂工作几乎会成为一种“噩梦”。为了解决这些问题,Spring Boot应运而生。正如他的名称一样,一键启动、自动配置、开箱即用,使我们将重心只需要放在业务逻辑的开发上。

在Spring Boot中开发RESTful应用程序前需要做的主要工作就是加入spring-boot-starter-web依赖,下面是该starter的定义:

  1. plugins {
  2. id "org.springframework.boot.starter"
  3. }
  4. description = "Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container"
  5. dependencies {
  6. api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
  7. api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-json"))
  8. api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-tomcat"))
  9. api("org.springframework:spring-web")
  10. api("org.springframework:spring-webmvc")
  11. }

从上面的description使部分我们可以得知它的作用是

Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container

即“是用Spring MVC构建web (包括RESTful) 应用程序的启动器。使用Tomcat作为默认嵌入式容器。

在Spring Boot中构建RESTful Web服务时,HTTP请求被映射(Request Mapping)后交控制器(Controller)处理,主要涉及下面两类注解:

  • 请求URI映射注解 @RequestMapping
  • 控制器声明注解 @RestController

    10.2 请求控制器注解

    10.2.1 @RestController注解

    @RestController注解的用途是标记说明一个类的实例是Spring MVC Controller对象(即是一个HTTP请求控制器)。它是 @Controller@ResponseBody 的组合,在它出现之前,@Controller 注解要按照如下的规则来使用:

1、如果返回的是字符串或者是字符串匹配的模板名称即直接渲染视图,在这种情况对前后端的配合要求比较高,Java后端的代码要结合某种生成HTML的模板进行渲染,使用model(或者modelandview)对象的数据将填充为视图中的相关属性,然后展示到浏览器(这个过程也可以称为渲染)。

2、如果需要返回到指定页面,则需要配合视图解析器InternalResourceViewResolver

3、如果需要返回JSON、ML或自定义mediaType内容到页面,则需要在对应的方法上使用@ResponseBody 注解。

因为RESTfull模式返回的都是JSON数据,所以我们要么组合使用 @Controller 和 @ResponseBody 注解,要么使用 @RestController 注解。

使用 @Controller@RestController 注解的类不需要继承特定的父类或者实现特定的接口,Spring会使用扫描机制査找应用程序中所有基于这两个注解的控制器类分发处理器则会扫描使用了该注解的类的方法并检测该方法是否使用了 @RequestMapping 注解(只有使用 @RequestMapping 注解的方法才是真正处理请求的处理器)。由于它支持一个类同时处理多个请求动作,因此更加灵活。

10.2.2 返回对象的JSON序列化

如果使用 @RestConrroller 注解的类方法的返回值不是字符串而是对象实例,则Spring Boot会自动将该实例转换成 JSON 对象 并存入 HTTP Response 的 Body。Spring默认采用 Jackson 来处理 JSON,这个足够好用,并且在处理枚举类的序列化和反序列化的时候用两个注解就可以,因此不要为了并不需要的性能差别或编程便利而选择其他的JSON处理工具(比如 Google 的 GSON)。尤其不要使用Fast JSON!尽管这个 Fast JSON 的性能确实很好,但这是它牺牲了通用性并且做了太多的硬代码假设才换来的,它一直有较多的Bug,而且有的版本存在高危漏洞

在其他场景下使用JSON工具(类库)进行开发的时候,如果不涉及枚举类的序列化和反序列化,可以考虑选择Google 的 GSON,因为它使用的方法非常简单。

10.3 规范API接口返回格式

10.3.1 与前端相同的返回格式

在我们前端的《Antd Pro实战应用手把手教程》中,我们规定了如下的前后端数据交换格式(详见定义数据类型

  1. type QueryResult = {
  2. success: boolean;
  3. errorCode?: number;
  4. errorMessage?: string;
  5. data?: any;
  6. }

为了与之对应,我们在后端也做同样的定义

  1. package com.longser.restful.result;
  2. import com.fasterxml.jackson.databind.ObjectMapper;
  3. import lombok.Data;
  4. import lombok.SneakyThrows;
  5. @Data
  6. public class RestfulResult<T>{
  7. Boolean success;
  8. Integer errorCode;
  9. String errorMessage;
  10. T data;
  11. public RestfulResult() {
  12. }
  13. public RestfulResult(T data) {
  14. this.success = true;
  15. this.errorCode = 0;
  16. this.errorMessage = "";
  17. this.data = data;
  18. }
  19. public RestfulResult(int code, T data) {
  20. this.success = true;
  21. this.errorCode = code;
  22. this.data = data;
  23. }
  24. public RestfulResult(int code, String message) {
  25. this.success = false;
  26. this.errorCode = code;
  27. this.errorMessage = message;
  28. }
  29. @SneakyThrows
  30. @Override
  31. public String toString() {
  32. ObjectMapper mapper = new ObjectMapper();
  33. return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(this);
  34. }
  35. public static <T> RestfulResult<T> success(T data) {
  36. return new RestfulResult<T>(data);
  37. }
  38. public static <T> RestfulResult<T> success(int code, T data) {
  39. return new RestfulResult<T>(code, data);
  40. }
  41. public static <T> RestfulResult<T> fail(int code, String message) {
  42. return new RestfulResult<>(code, message);
  43. }
  44. }

代码中无参数的构造函数(方法)非常重要,没有它无法成功完成反序列化

这个类使用 Java 泛型技术使得 data 可以是任何类型。

10.3.2 API应用接口返回格式

把我们已经完成的 UserController 类的 getFirst 方法修改成如下的形式

  1. public RestfulResult getFirst() {
  2. return RestfulResult.success(userMapper.getOne(1L));
  3. }

重启后用 Postman 访问 http://localhost:8088/test/first ,可以得到如下的结果

  1. {
  2. "success": true,
  3. "errorCode": 0,
  4. "errorMessage": "",
  5. "data": {
  6. "id": 1,
  7. "userName": "David",
  8. "nickName": "Grace Runner",
  9. "mobile": "18801681588",
  10. "password": "",
  11. "gender": 1,
  12. "degree": 1
  13. }
  14. }

查询出的结果被封装到了 data 属性中。

10.4 优雅地统一接口返回格式

10.4.1 问题的提出

现在我们已经实现了想要的结果,约定在Controller 层通过 RestfulResult.success() 对返回结果进行包装后把RestfulResult 返回给前端。

但是这样还不够。

当前的作法需要所有做后端开发的工程师都知道这个约定,并且在开发每个 API 的时候做这个重复的劳动,浪费体力、要小心避免出错。而且,还很容易被其认证的老手嘲笑。所以我们需要对代码进行优化,用一个更优雅的方式处理返回格式,目标让 RestfulResult.success() 的封装行为自动自动化。

10.4.2 自动化封装接口返回内容

要实现自动化统一封装接口返回内容的目标,我们需要借助 Spring Boot 提供的 ResponseBodyAdvice 接口和 @RestControllerAdvice 注解。其中 ResponseBodyAdvice 接口的的作用是拦截 Controller 方法的返回值,统一处理返回值/响应体,一般用来统一返回格式,加解密,签名等等。

下面是完整的代码

  1. package com.longser.restful.result;
  2. import lombok.SneakyThrows;
  3. import org.springframework.core.MethodParameter;
  4. import org.springframework.http.MediaType;
  5. import org.springframework.http.converter.HttpMessageConverter;
  6. import org.springframework.http.server.ServerHttpRequest;
  7. import org.springframework.http.server.ServerHttpResponse;
  8. import org.springframework.lang.Nullable;
  9. import org.springframework.web.bind.annotation.RestControllerAdvice;
  10. import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
  11. @RestControllerAdvice
  12. public class RestfulResponseAdvice implements ResponseBodyAdvice<Object> {
  13. @Override
  14. public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
  15. return true;
  16. }
  17. @SneakyThrows
  18. @Override
  19. public Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType,
  20. Class<? extends HttpMessageConverter<?>> selectedConverterType,
  21. ServerHttpRequest request, ServerHttpResponse response) {
  22. if(Objects.requireNonNull(returnType.getMethod()).getReturnType().equals(Void.TYPE)){
  23. return body;
  24. }
  25. if(body instanceof RestfulResult){
  26. return body;
  27. }
  28. return RestfulResult.success(body);
  29. }
  30. }

在上面的代码中我们约定如果 API 接口返回类型为 void 的时候直接不做封装处理而直接返回原来的内容(应该是 null)。

因为这个类和应用突出的启动类 CloudApplication 在不同的包路径上,为了让应用程序在启动的时候能够扫描到标注在它上面的 @RestControllerAdvice 注解,需要指定扫描路径

  1. -@SpringBootApplication
  2. +@SpringBootApplication(scanBasePackages = {"com.longser"})
  3. @MapperScan({"com.longser.union.cloud.data.mapper"})
  4. public class CloudApplication {
  5. public static void main(String[] args) {
  6. SpringApplication.run(CloudApplication.class, args);
  7. }
  8. }

把 API 接口的方法改回原来的定义

  1. @GetMapping("/test/first")
  2. public UserEntry getFirst() {
  3. return userMapper.getOne(1L);
  4. }

重启后再次用 Postman 访问接口,也应该得到把查询结果封装在标准返回类 data 属性中的结果。

10.4.3 处理返回独的立字符串

现在增加一个简单的接口

  1. @GetMapping("/hello")
  2. public String helloWorld() {
  3. return "Hello World.";
  4. }

我们期望访问这个结构的结果时字符串封装到返回数据的 data 属性中,但实际却会发生类型转换的异常

  1. java.lang.ClassCastException: class com.longser.restful.result.RestfulResult cannot be cast to class java.lang.String (com.longser.restful.result.RestfulResult is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')

这是因为当 Spring 接口返回数据时,会根据 Content-Type 来选择一个 HttpMessageConverter 来处理,而字符串在不声明 Content-Type 的情况下优先使用 StringHttpMessageConverter,就导致了转换异常,需要设定成MappingJackson2HttpMessageConverter 用 Jackson 来处理。为此我们对字符串做单独的处理

  1. public Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType,
  2. Class<? extends HttpMessageConverter<?>> selectedConverterType,
  3. ServerHttpRequest request, ServerHttpResponse response) {
  4. if(Objects.requireNonNull(returnType.getMethod()).getReturnType().equals(Void.TYPE)){
  5. return body;
  6. }
  7. +
  8. + if(body instanceof String){
  9. + ObjectMapper objectMapper = new ObjectMapper();
  10. + response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
  11. +
  12. + return objectMapper.writeValueAsString(RestfulResult.success(body));
  13. + }
  14. return RestfulResult.success(body);
  15. }

说明:

  • 上面的代码在处理封装字符串的时候显式指定 contentType 为 application/json,这样可以防止出现因设置为 text/plain 而导致前端无法自动解析 JSON 的问题。
  • 尽管网络上有些人提出重载 WebMvcConfigurer 的 configureMessageConverters 方法,特别指定MappingJackson2HttpMessageConverter 来解决这个问题。但实际上这么做过于简单粗暴,在处理复杂数据类型转换的时候会选择错误的方法。因此不能这么做。

10.4.4 自定义注解实现例外定义

凡事有利就会有弊。自动化封装接口返回格式的好处时在编写接口代码的时候不需要做额外处理,但完全“一刀切”地自动化强行转换也不是完美的作法,毕竟在实际项目中存在返回其他格式数据的可能。为此我们自定义一个专门处理这种例外的注解:

  1. package com.longser.restful.annotation;
  2. import java.lang.annotation.*;
  3. @Documented
  4. @Inherited
  5. @Target({ElementType.METHOD})
  6. @Retention(RetentionPolicy.RUNTIME)
  7. public @interface IgnoreRestful {
  8. }

然后修改 RestfulResponseAdvice 的 supports 方法

public class RestfulResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
-       return true;
+       return !returnType.hasMethodAnnotation(IgnoreRestful.class);
    }

现在把 @IgnoreRestful 注解加到你想例外处理的接口或者控制器类上,就可以愉快的返回你想要的类型了

+   @IgnoreRestful
    @GetMapping("/hello")
    public String helloWorld() {
        return  "Hello World.";
    }

10.5 映射 URI 请求的各种方法

目前我们设计的唯一一个 API 接口使用了 @GetMapping 注解来把请求映射到具体的方法。本节我们详细讨论各种映射请求的方法。

10.5.1 可使用的注解

可以使用的注解有下面这些定义 URI 请求和控制器方法之间的映射:

  • @RequestMapping
  • @PostMapping
  • @GetMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

其中 @RequestMapping 设置了 method 参数后等效与其他一个或多个指定了具体动作的映射注解。

10.5.2 注解的作用点

在映射请求方法及相关参数时,可以在控制器类级别和/或控制器的方法级别上使用注解来建立请求 URI 和 方法之间的映射

在类的级别上使用注解会将一个特定请求的URI或者请求模式映射到一个控制器之上。 之后你还需要另外添加方法级别的注解作补充,进一步指定到不同处理方法的映射关系。

只有 @RequestMapping 可以标注在控制器类上,其他确定了了方法的映射注解只能作用在具体的方法上。

(1)在类方法上映射请求 URI
以类方法为映射作用点很简单,只需要在相应的方法前添加该注解即可。下面是上文示例的片段:

    @GetMapping("/test/first")
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }

getFirst方法会响应对 http://localhost:8088/test/first 的请求。@GetMapping("/test/first") 表示当请求地址为 /test/first 的时候,这个方法会被触发。其中,地址可以是多个,就是可以多个地址映射到同一个方法:

    @GetMapping({"/test/first","/test/second"})
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }

这个配置,表示/test/first/test/second 都可以访问到该方法。

(2)在类级别上定义请求URI

@RestController
@RequestMapping("/test/first")
public class TestController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }
}

我们期望这次的结果和之前的一样,但很意外,“404”了

{
    "success": true,
    "errorCode": 0,
    "errorMessage": "",
    "data": {
        "timestamp": "2021-11-02T13:54:10.017+00:00",
        "status": 404,
        "error": "Not Found",
        "path": "/test/first"
    }
}

把代码改一下

@RestController
@RequestMapping("/test/first")
public class TestController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

+   @GetMapping()
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }
}

重启应用后再运行测试方法就正常了。

这里的原因是如果把@RequestMapping注解作用在控制器类上并且想要访问该注解声明的 URI 时,应用程序并不知道它对应类的哪个方法,因此需要参没有数或参数为 “” 的注解来完成最终的映射 。

(3)双重定义而窄化请求
多数时候,同一个项目中会存在多个路径相关的资源(或接口),例如订单相关的接口都是 /order/xxx 格式的,用户相关的接口都是 /user/xxx 格式的。为了方便处理,这里的前缀(就是 /order、/user)可以统一定义在类上,然后在不同的方法上定义具体的目标。如下例,双重定义的结果时两个URI 分别为/api/sayHello 和/api/sayHi

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/sayHello")
    public String sayHello(@RequestParam String name) {
        return "Hello " + name;
    }

    @GetMapping("/sayHi")
    public String sayHi(@RequestParam String name) {
        return "hi " + name;
    }
}

(4)使用通配符
我可以在映射的时候使用通配符。下面是一个比较复杂和全面的例子

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping(value={
            "",
            "/page",
            "page*",
            "view/*",
            "**/msg"
    })
    public String hello(){
        return "Hello from index multiple mapping. ";
    }
}

如你在代码中所看到的,如下的这些 URL 都会由 indexMultipleMapping() 来处理:

  • localhost:8088/test
  • localhost:8088/test/
  • localhost:8088/test/page
  • localhost:8088/test/pageabc
  • localhost:8088/test/view/
  • localhost:8088/test/view/abc
  • localhost:8088/test/abc/msg

总结上述各种不同方法的特点,我们可以得出两个要点:

  • 在控制器类级别上的映射注解会应用到控制器的所有处理器方法上。
  • 在控制器方法上的映射注解是对类级别映射注解的补充。

    10.4.2 限定请求方法

    在讨论如何限定请求方法之前,我们先看两个默认规则:

  • 所有的HTTP请求,如果没有声明具体的请求方法,则默认被认为是 HTTP GET 类型的。

  • 使用 @RequestMapping 注解,如果没有限定具体的操作方法,则它既可以被 GET 请求访问到,也可以被 POST 请求访问到(但是 DELETE 请求以及 PUT 请求不可以访问到)。

为了能一将个请求映射到一个特定的 HTTP 方法,需要在 @RequestMapping 中使用 method 来声明 HTTP 请求所使用具体访问方法:

@RestController
@RequestMapping("/test/first")
public class TestController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @RequestMapping(method= RequestMethod.POST)
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }
}

@RestController
public class TestController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @RequestMapping(value="/test/first", method= RequestMethod.POST)
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }
}

注意:@RequestMapping(value=”/test/first”, method= RequestMethod.POST) 等效于 @PostMapping(“/test/first”)

通过 @RequestMapping 注解,指定了该接口映射 POST 请求,不可以被 GET 请求访问到,用浏览器直接访问(相当于 GET 请求)会得到错误页面,用 Postman得到如下的结果

{
    "success": true,
    "errorCode": 0,
    "errorMessage": "",
    "data": {
        "timestamp": "2021-11-02T14:09:16.640+00:00",
        "status": 405,
        "error": "Method Not Allowed",
        "path": "/test/first"
    }
}

当然,限定的方法也可以有多个:

@RestController
public class TestController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @RequestMapping(value="/test/first", method= {
        RequestMethod.POST,
        RequestMethod.GET,
        RequestMethod.DELETE,
    })
    public UserEntry getFirst() {
        return userMapper.getOne(1L);
    }
}

现在用浏览器直接访问(相当于GET 请求)或者用调试工具以DELETE方式,都能成功获得结果。

鉴于单纯使用 @RequestMapping 代表映射该 URI 上的全部动作,因此这么做不符合严格的 RESTful 规范,应该尽可能使用指定了具体动作的注解,或者在使用这个注解的时候通过 method 参数指明具体映射的动作。

10.6 识别请求参数的各种方法

10.6.1 定义参数的基本方法

在我们之前的讨论中用 @RequestParam 注解配合 @GetMapping 一起使用定义了一个参数,将请求的参数同处理方法的参数绑定在一起:

public String sayHello(@RequestParam String name)

在代码中 name 这个请求参数被映射到了处理方法的参数 name 上,如果请求参数和处理方法参数的名称不一样,可以用 @RequestParam 注解的参数来指定:

public String sayHello(@RequestParam("username") String name)

@RequestParam 注解的 required 属性可以用来声明该参数值是否是必须要的:

public String sayHello(@RequestParam(value = "name", required = false) String name) {

在这段代码中,因为 required 被指定为 false,所以 sayHello 处理方法对于如下两个 URL 都会进行处理:

  • /api/user 此时getByName得到的参数为null
  • /api/user?name=abc 此时getByName得到的参数为abc

@RequestParam 注解的 defaultValue 属性用可以来给取值为空的请求参数提供一个默认值:

public String  sayHello(@RequestParam(
            value = "name",
            defaultValue = "Noname",
            required = false) String name
)

在这段代码中,如果 person 这个请求参数为空,那么 getName 处理方法就会接收 Noname 这个默认值作为其参数。

@RequestParam 注解的 params 属性可以把对一个URI 的请求根据参数的不同而映射到不同的处理方法:

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping(value = "/user", params={"group=10"})
    String getGroup10(@RequestParam String id) {
       return  "Fetched parameter using params attribute group = 10. The id is " + id;
    }

    @GetMapping(value = "/user", params={"group=20"})
    String getGroup20(@RequestParam String id) {
        return  "Fetched parameter using params attribute group = 20. The id is " + id;
    }
}

在这段代码中,getGroup10getGroup20 两个方法处理同一个 URI (/test/user) ,但会根据 params 的具体属性决定具体执行哪一个方法。当 URI 是 /test/usr?group=10&id=1 时, 会执行getGroup10,因为 group 的值是10。对于/test/usr?group=20&id=1 这个URI,会执行getGroup20,因为 group 值是 20。

10.6.2 自动封装对象做参数

参数除了是简单数据类型之外,也可以是实体类。现在我们用一个 User 对象来接收前端传来的数据:

package com.longser.union.cloud.controller;

import com.longser.union.cloud.data.model.UserEntry;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/test")
public class UserController {

    private final UserMapper userMapper;

    @Autowired
    public TestController(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @GetMapping("/first")
    public UserEntry getFirst() {
        return  userMapper.getOne(1L);
    }

    @PostMapping(value = "/user/new")
    UserEntry addNew(UserEntry user) {
        System.out.println("The new user is " + user);

        return  user;
    }
}

前端传值的时候和之前的一样,只需要写属性名,不需要写对象名。下面是用Postman软件测试的情况
image.png
下面是在控制台窗口看到的输出

The new user is UserEntry(id=null, userName=用户名, nickName=昵称, mobile=13808881234, password=null, gender=MALE, degree=MASTER)

从结果可以看到,前端传过来的参数被自动组装到了类对象中。并且枚举类型完成了转换(这里的转换功能就是我们在3.6节中定义的全局转换器发挥的作用)。

当然,对象中可能还有对象。例如如下2个对象:

package com.longser.union.cloud.data.model;

import lombok.Data;

@Data
public class Author {
    private String name;
    private Integer age;
}
package com.longser.union.cloud.data.model;

import lombok.Data;

@Data
@NoArgsConstructor
public class Book {
    private String name;
    private Double price;
    private Boolean isPublic;
    private Author author;
}

然后我们用Book对象接收前端的所有参数

    @PostMapping("/book/new")
    Book addNew(Book book) {
        System.out.println("The new book is " + book);

        return book;
    }

Book 对象中,有一个 author 属性,如何给 author 属性传值呢?前端写法为author.nameauthor.age,下面是Postman的测试情况:
image.png
下面是在控制台窗口看到的输出

The new book is Book(name=Spring Boot实战教程, price=88.88, isPublic=true, author=Author(name=David, age=19))

可见后端直接用 Book 对象接收到了所有数据。

前端传递参数的时候,isPublic既可以是truefalse这样的字符串,也可以01这样的值,在后端都会被自动转换成 boolean 型。

10.6.3 自定义型换转类

前面的各种转换都是系统自动完成的,这种转换仅限于基本数据类型。系统无法自动转换类似日期这样的特殊数据类型。如果前端传一个日期到后端,后端不是用字符串接收,而是使用一个 Date 对象接收,这个时候就会因为字符串不会自动转换未日期类型而导致请求失败。

现在给 Boook 类增加一个日期属性

@Data
@NoArgsConstructor
public class Book {
    private String name;
    private Double price;
    private Boolean isPublic;
    private Author author;
+   private LocalDate publishDate;
}

执行请求会发生 400 Bad Request 错误
image.png
在控制台控制台窗口则可以看到增加准确的出错信息

org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors<LF>Field error in object 'book' on field 'publishDate': rejected value [2021-11-3]; codes [typeMismatch.book.publishDate,typeMismatch.publishDate,typeMismatch.java.time.LocalDate,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [book.publishDate,publishDate]; arguments []; default message [publishDate]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'java.time.LocalDate' for property 'publishDate'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.LocalDate] for value '2021-11-3'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2021-11-3]]]

其中最关键的是这句话

Failed to convert from type [java.lang.String] to type [java.time.LocalDate] for value '2021-11-3';

也就是它不知道怎么把 String 类型的 ‘2021-11-3’ 转换成 LocalDate。

为解决这个问题,需要我们手动定义下面这个参数类型转换器,将日期字符串手动转为一个 LocalDate 对象。

package com.longser.utils.date;

import org.springframework.core.convert.converter.Converter;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;

public class LocalDateConverter implements Converter<String, LocalDate> {
    @Override
    public LocalDate convert(String source) {
        try {
            DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-M-d");
            LocalDate theDate = LocalDate.parse(source, dateFormat);

            return theDate;
        } catch (DateTimeParseException ex) {
            ex.printStackTrace();
        }

        return null;
    }
}

在自定义的参数类型转换器中,将一个 String 转为 LocalDate 对象。

提示:

  • 上面的代码在做日期类型转换时,使用DateTimeFormatter而不是SimpleDateFormat,因为后者不是线程安全的。关于实际的更多讨论,请阅读教程第18章“日期时间的表示和计算”。
  • 在指定日期格式的时候,我们选择使用yyyy-M-d 而不是 yyyy-MM-dd,前者具有更好的兼容性。

接下来,在 SpringMVC 的配置文件中,配置该 Bean,使之生效。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(new IntegerCodeToEnumConverterFactory());
        registry.addConverterFactory(new StringCodeToEnumConverterFactory());

+       registry.addConverter(new LocalDateConverter());
    }
}

配置完成后,在服务端就可以接收前端传来的日期参数了。
image.png

The new book is Book(name=Spring Boot实战教程, price=88.88, isPublic=true, author=Author(name=David, age=19), publishDate=2021-11-03)

10.6.4 自动填充集合类对象

1. 自动填充String 数组

String 数组可以直接用数组去接收,前端传递的时候其实就多个相同的 key,这种一般用在 checkbox 中较多(有时也用在自定义标签的场景)。

例如前端增加兴趣爱好一项:

<input type="checkbox" name="favorites" value="足球">足球
<input type="checkbox" name="favorites" value="篮球">篮球
<input type="checkbox" name="favorites" value="乒乓球">乒乓球

在服务端用一个数组去接收 favorites 对象:

    @PostMapping("/favorites")
    String[] updateFavorites(String[] favorites) {
        System.out.println(Arrays.toString(favorites));

        return favorites;
    }

    @PostMapping("/book-favorites")
    public void doBookFavorites(Book book,String[] favorites) {
        System.out.println(Arrays.toString(favorites));
        System.out.println(book);
    }

注意,前端传来的数组对象,服务端不可以使用 List 集合去接收。

image.png

[足球, 篮球, 乒乓球]

image.png

[足球, 篮球, 乒乓球]
Book(name=Spring Boot实战教程, price=88.88, isPublic=true, author=null, publishDate=2021-11-03)

2. 自动填充 List 集合

如果需要使用 List 集合接收前端传来的数据,List 集合本身需要放在一个封装对象中,这个时候,List 中,可以是基本数据类型,也可以是对象。例如我们修改前文图书作者的定义,让一本书可以有多个作者:

package com.longser.union.cloud.data.model;

import lombok.Data;
import java.util.List;

@Data
public class Book {
    private String name;
    private List<Author> authors;
}

添加书籍的时候,前端可以传递多个作者,后端不需要修改。

前端页面写法如下:

<form action="/test/book/new" method="post">
<input type="text" name="name">
<input type="text" name="authors[0].name">
<input type="text" name="authors[0].age">
<input type="text" name="authors[1].name">
<input type="text" name="authors[1].age">
<input type="submit" value="提交">
</form>

用 Postman 测试
image.png
下面是控制台窗口看到的输出

The new book is Book(name=Spring Boot实战教程, price=88.88, isPublic=null, authors=[Author(name=David, age=20), Author(name=Lucy, age=18)], publishDate=null)

3. 自动填充 Map

相对于实体类而言,Map 是一种比较灵活的方案,但是,Map 可维护性比较差,因此一般不推荐使用。

10.6.5 自动映射请求响应对象

后端系统在接收和响应 HTTP 请求的过程中会根据上下文自动构建一些重要的对象,比如 Requst、Response、Session 等。这些对象屏蔽了直接操作 HTTP 底层的复杂性。其中**Request**封装了 HTTP 请求的各种信息,包括Header、Cookie、URI、Params等,在 Java 中用类 **HttpServletRequest** 表示。**Response** 封装 了HTTP请求的返回结果,在 Java 中用类 **HttpServletResponse** 表示。自通信开始,服务器会为每个用户创建一个独立的 **Session** 对象,记录特定的客户端、特定的服务器端以及不中断的操作时间等通信状态信息,在 Java 中用类 **HttpSession** 表示。

所有使用了映射注解的类方法,如果定义了HttpServletRequestHttpServletResponseHttpSession类型的参数(一种或多种),Spring会自动完成当次请求对应对象到参数的映射。如下例所示:

    @GetMapping("/http/object")
    String getHttpObject(HttpServletRequest request,
                                   HttpServletResponse response,
                                   HttpSession session,
                                   @RequestParam int id) {
        return "Request.Method is " +
                request.getMethod() +
                ". Response.Status is " +
                response.getStatus() +
                ". Session.id is " +
                session.getId() +
                ". The ID get from query string of URL with value element is " +
                id;
    }

下面是访问的结果
image.png

关于HttpServletRequestHttpServletResponseHttpSession 的使用方法不属于于本节的讨论范围,具体的可以看本章末尾的参考链接。

10.5.6 处理动态 URI

迄今为止我们讨论的请求参数都是以请求的参数(Params)或者Body中的form-data形式存在的。除此以外, RESTful 还可以把请求的参数置入到请求路径中,以动态URI的形式来传递参数。在后端解析的时候,可以使用 @PathVaraible 注解配合@RequestMapping 注解来处理动态 URI,把URI 中特定的值作为控制器中处理方法的参数。当然,你也可以使用正则表达式来只处理可以匹配到正则表达式的动态 URI。

下面时两个用动态 URI 传递参数的示例

    @GetMapping("/user/{id}")
    String getDynamicUriValue(@PathVariable Integer id) {
        return "Dynamic URI parameter fetched, ID is " + id;
    }

    @GetMapping("/user/{group}/{name}")
    String getDynamicUriValueRegex(
        @PathVariable String group,
        @PathVariable String name
        ) {
        return "Dynamic URI parameter fetched using regex: Group is " +
            group + ", Name is  " + name;
    }

从代码中我们可以很容易地看出@PathVariable@RequestParam运行方式不同:@PathVariable 是为了从 URI 里取到查询参数值。

上面的代码中,getDynamicUriValue 方法会在接到请求 /test/user/123 时执行(123可以是任意数字)。这里 getDynamicUriValue 方法 id 参数也会被动态地填充为 123 这个值。
image.png
方法 getDynamicUriValueRegex 会在接到 /api/user/groupname/username 的请求时执行(groupname和username分别为任意字符串)。image.png

10.5.7 指定请求的消息头

客户端发起请求时可以在消息头(即 Request.Header )中设置标准或自定义的信息,@RequestMapping 注解提供了一个 header 属性来根据消息头中的内容缩小请求映射的范围。

下面代码演示了如何匹配消息头中 content-type 的特定值:

    @GetMapping(value = "/header", headers = {
            "content-type=text/plain"
    })
    @IgnoreRestful
    String textPlain() {
        return "Mapping applied along with headers ";
    }

如代码所示,注解的 headers 属性只映射 content-type=text/plain 的情况。
image.png
大小写无关
image.png

image.png

你也可以像下面这样指定多个消息头:

    @GetMapping(value = "/header", headers = {
            "content-type=text/plain",
            "content-type=text/html",
            "UserName=David"
    })
    @IgnoreRestful
    String textPlain() {
        return "Mapping applied along with headers ";
    }

上面一组 headers 的条件时的关系。也就是他们必须同时存在。尽管看起来很别扭,但这里就是这样定义的。

10.6 后端响应跳转

实现后端响应跳转最简单的办法就是使用 HttpServletResponse.sendRedirect 重定。但是,做为前后端分离的软件,后端不应有任何修改路由的动作,因此不要在后端做跳转

10.7 参考资料

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。

[

](https://gitee.com/dromara/forest#http://forest.dtflyx.com/)

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。