今天在公司编辑并发布完了这篇博客, 想着早点回家, 结果开的太快, 回家下中环的时候追尾了, 自然是我全责. 所以这篇文字是我晚上回到家之后又修改的. 打了一通电话, 还好有一个在徐汇的4S店营业, 明天一大早赶快把车子开过去. 可能要到20号左右才能修好, 下个星期要过无车生活了.
车子前边撞的还挺厉害, 中网已经破碎了, 里边的横梁也有点弯, 左前大灯也撞碎了. 还好仔细检查了一遍, 没有影响到散热和风扇, 至少能开, 如果不能开,那就太麻烦了.
今天还是情人节, 昨天下班路上给老婆买了玫瑰, 不管怎么说, 也要好好过一个疫情下的情人节了.
在之前, 基本上已经了解完了从请求中获取数据的方式. 不过可以注意到的是, 之前我们的请求体, 都是x-www-form-urlencoded这种媒体格式.

这种媒体格式, 可以通过@RequestParam来处理, 可以直接绑定参数中的数据对象. 但是请求的媒体类型有很多中, 如果是其他类型, 这个时候就需要一种办法, 能够将请求体转换成所需要的数据类型.

在Spring中, 将请求体映射为一个数据对象的注解就是@RequestBody, 不过可不是直接加上就可以使用, 而是先来看看其背后的原理.

这里也会顺便看一下@ResponseBody, 因为原理是一样的, 只是结果不同.

  1. 原理
  2. @RequestBody和@ResponseBody的使用
  3. 注解的替代品: HttpEntity<T>和ResponseEntity<T>
  4. @RestController

原理

如果我们将上一节的例子改成:

@RequestMapping("/usercreate")
public ModelAndView createUser(@RequestBody User user) {
    System.out.println(user);
    ModelAndView modelAndView = new ModelAndView();
    modelAndView.setViewName("index");
    modelAndView.addObject("user", user);
    return modelAndView;
}

再向这个地址发送POST请求, 可以得到415错误, 这是为什么呢, 是因为@RequestBody后台不是通过从JSP来的对象绑定技术, 而是使用HttpMessageConverter<T>接口的具体实现类来进行转换.

这个转换还可以是双向的, 即将指定的媒体类型的数据转换成某个class对象, 也可以反过来. 所以也可以用于将控制器返回的信息进行转换后写入到响应中.

这个接口的源码如下:

public interface HttpMessageConverter<T> {


    //这个方法表示是否可以读取指定的类和媒体类型.如果可以就返回true, 不行就返回false
    //不给出具体的媒体类型, 就可以使用所有的媒体类型
    //所谓可以读取的类型, 就是指要将请求体转换成的类型
	boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

    //这个方法表示是否可以写指定的类和媒体类型
    //所谓可以写, 就是能将指定的类, 转换成请求体和对应的媒体类型
	boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

    //返回所有受支持的媒体类型, 这个方法返回的结果就是该转换器支持的媒体类型
	List<MediaType> getSupportedMediaTypes();


    //读取请求信息流, 将其转换成对象T, 这个方法很显然是在判断canRead之后, 就来调用这个方法, 将信息流组装成T对象
	T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
			throws IOException, HttpMessageNotReadableException;

	//将一个T类型, 转换成信息流写入到响应中, 同时指定响应的媒体类型
	void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
			throws IOException, HttpMessageNotWritableException;
}

这个时候应该就会理解为什么会报415错误了, 在方法里我们使用@RequestBody User user, 然后POST过来数据, 对于@RequestBody来说, 就是要找到一个如下的转换器:

public class MyConverter implements HttpMessageConverter<User> {

    @Override
    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        if (clazz == User.class && mediaType == MediaType.APPLICATION_FORM_URLENCODED) {
            return true;
        } else {
            return false;
        }
    }

    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        return false;
    }

    @Override
    public List<MediaType> getSupportedMediaTypes() {
        List<MediaType> mediaTypes = new ArrayList<>();
        mediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
        return mediaTypes;
    }

    @Override
    public User read(Class<? extends User> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        ......
        return aUser;
    }

    @Override
    public void write(User user, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {

    }
}

当然, 一般就不会自行编写这个转换器了. Spring提供了很多具体的实现, 不一一列出了, 默认情况下, 会装配并启动其中的四个:

  1. StringHttpMessageConverter
  2. ByteArrayHttpMessageConverter
  3. SourceHttpMessageConverter
  4. AllEncompassingFormHttpMessageConverter

这几个转换类, 其实更多的是用于将类写入流, 而不是从中读取. 需要的话, 可以通过xml进行配置. 需要先覆盖默认的RequestMappingHandlerAdapter, 然后配置其中的messageConverters属性.

看到这里, 就会知道, @ResponseBody的原理是一样的, 只不过工作在响应阶段, 将数据对象转换成可以写入响应的格式和对应的媒体类型.

这些类型转换, 最重要的就是从特殊的请求中获取数据并组装, 很多时候都用在非人机界面的交互中, 最经典的例子就是Rest风格的控制器. 这个后边再说了.

接下来先看看如何使用这两个东西.

@RequestBody和@ResponseBody的使用

这两个注解的最大区别是: @RequestBody需要绑定控制器方法的参数, 而@ResponseBody要注解到控制器的方法上, 以控制最后返回的类型.

@RequestBody有一个设置是required, 默认是true, 表示必须有请求体. 一般自动化Web应用肯定要有请求体. 我们可以来写点代码测试一下:

来看两个例子是怎么转换的:

//入参转换成字符串
@RequestMapping("/inner")
public String inBound(@RequestBody(required = false) String content) {
    System.out.println("这个请求的内容是: " + content);
    return "index";
}

来对这个路径进行一系列测试:

  1. 纯GET请求, 控制台打印出null, 说明没有请求体
  2. GET请求, 在URL中加上参数, 控制台打印出null, 说明没有请求体
  3. 纯POST请求, 控制台打印出null, 说明没有请求体
  4. 纯POST请求在URL中加上参数, 控制台打印出参数和值, 说明会把POST请求的URL参数也当成body, 同时还说明其实这个转换器在内部过程中还获取了请求头的信息.
  5. 纯POST请求, 只在请求体内加上内容, 媒体格式是x-www-form-urlencoded, 控制台打印出参数和值. 这里很有意思, 如果只有一个属性名, 没有值, 转换的结果会自动在后边加上一个=
  6. 纯POST请求, 只在请求体内加上内容, 媒体格式是text/plain, 控制台原样打印出请求体的结果.

这里中文还是乱码, 因为还没有加上过滤器. 此外StringHttpMessageConverter的源码中可以看到字符集是ISO-8859.通过测试可以看到, StringHttpMessageConverter至少对于
两种媒体类型做出了不同的反应. x-www-form-urlencoded会去按照属性=值&属性=值的方式去转换,如果遇不到等于号, 还会自动加上一个. 而对于text/plain, 会原样转换, 并不会寻找键值对.

既然刚才成功的测试了StringHttpMessageConverter在入参的使用, 观察这个控制器, 我们返回的是字符串”index”, 已经知道这会被当成一个视图名称进行解析. 这是因为框架默认会去解释这个方法的返回值.

如果我只想返回”index”作为响应体呢? 这个时候就需要使用@ResponseBody,将其加在这个方法上:

@ResponseBody
@RequestMapping("/inner")
public String inBound(@RequestBody(required = false) String content) {
    System.out.println("这个请求的内容是: " + content);
    return "index";
}

会是什么样的结果呢? 用浏览器访问http://localhost:8080/body/inner, 果然, 就得到了一个"index"字符串, 响应的详细信息是:

HTTP/1.1 200 OK
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 5
Date: Fri, 14 Feb 2020 05:19:40 GMT
index

可以发现, @ResponseBody对于控制器方法来讲, 就是那个开关. 如果注解上, 控制器方法就不会再返回(被框架解释成)ModelAndView对象, 而是会通过返回的类型, 去查找能否将返回的类型转换成相应的类型.

对于字符串类型来说, 正好可以将其转换成字符串, 所以就有内置的StringHttpMessageConverter来将字符串类型写入响应中.

由于这是字符串转字符串, 还看不出来在哪里转换, 来对比返回一幅图片的控制器, 一个使用OutputStream直接写入响应, 一个使用类型转换:

//使用OutputStream直接将图片数据写入响应
//在之前的学习中, 知道如果直接操作响应, 控制器方法的返回类型需要设置成void
@RequestMapping("/image")
public void image(OutputStream outputStream) throws IOException {
    Resource file = new FileSystemResource("C:\\Users\\Minko\\Pictures\\mhwi.jpg");
    FileCopyUtils.copy(file.getInputStream(), outputStream);
}

//图片就是二进制数据, 将图片转换成byte[]类型
//加上注解, 然后直接返回字节数组对象
@ResponseBody
@RequestMapping("/byteimage")
public byte[] image() throws IOException {
    Resource file = new FileSystemResource("C:\\Users\\Minko\\Pictures\\mhwi2.jpg");
    return FileCopyUtils.copyToByteArray(file.getInputStream());
}

你可能认为第二个控制器也成功的返回了图片. 其实不是, 第一个控制器返回了浏览器显示的图片, 第二个控制器返回了一堆乱码.

但是第二个控制器说明ByteArrayHttpMessageConverter生效了, 将字节数组写入到了响应中, 如果转换失败, 会报错.

这两个返回的差异在于, ByteArrayHttpMessageConverter还附带了对于媒体类型的写入, 查看详细响应可以知道, 第一个响应没有附带任何媒体类型信息, 因为只是写入了一堆数据, 只不过浏览器会去尝试将其解释为可以解释的类型.
而第二个响应的媒体类型是:application/octet-stream, 表示一个二进制文件, 浏览器显示的自然怪怪的了. 但是这恰好说明ByteArrayHttpMessageConverter确实控制了返回的媒体类型.

注解的替代品: HttpEntity<T>和ResponseEntity<T>

如果不想使用注解, 可以使用注解的两个替代类, 即HttpEntity<T>ResponseEntity<T>.

前者当成方法参数传递给控制器方法, T为请求体转换成的类型. 后者当成方法的返回值, T是要转换成请求体的类型. 所以可以看到二者本质上注解背后的转换器规则是一样的.

改写一下上边两个方法, 一看便知:

@RequestMapping("/inner")
public ResponseEntity<String> inBound(HttpEntity<String> httpEntity) {

    System.out.println(httpEntity.getHeaders());
    System.out.println(httpEntity.getBody());

    return new ResponseEntity<>("index", HttpStatus.OK);
}

这里可以打印出头部信息和请求体来看. ResponseEntity<>的构造方法也很简单, 就是一个要转换的类, 和Http响应码.

再写一下发送图片二进制数据的类:

@RequestMapping("/byteimage")
public ResponseEntity<byte[]> image() throws IOException {
    Resource file = new FileSystemResource("C:\\Users\\Minko\\Pictures\\mhwi2.jpg");
    return new ResponseEntity<>(FileCopyUtils.copyToByteArray(file.getInputStream()), HttpStatus.OK);
}

Spring会自动检测控制器方法入参的类型以及返回的类, 如果是HttpEntity<T>ResponseEntity<T>, 在内部依然是和注解一样都去调用转换器, 从而输出结果.

唯一要注意的是, 内建的很多转换器都会写一个特定的媒体类型, 这是不受控制的.

JSON转换器与@RestController

前边的类型转换对于日常开发来说最主要的用途是什么, 就是创建前后端分离的Web服务. 那么就要求将一个类能够转换成JSON, 使用JSON转换类.

Java中提供JSON服务的就是大名鼎鼎的Jackson包, 可以使用这个包, 搭配Spring提供的JSON转换, 来创建Rest风格的控制器.

先添加Maven 依赖:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.10.2</version>
</dependency>

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.10.2</version>
</dependency>

然后需要使用Spring提供的转换类, 在WebConfig类中, 使用WebMvcConfigurationSupport提供的configureMessageConverters方法, 来添加转换类:

@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    converters.add(new MappingJackson2HttpMessageConverter());
}

这个方法参数中的converters就是所有可用的转换类, 可以向其中添加Spring提供的MappingJackson2HttpMessageConverter对象即可.

创建的时候, 要保证classpath下边有Jackson的包, 这样就可以成功启动, 在之后, 就可以来试着写一下了.

@ResponseBody
@RequestMapping("/user")
public User getUser() {
    User user = new User();
    Address address = new Address();
    address.setDetail("zhr");
    user.setAddress(address);
    user.setAge(6);
    user.setUserName("cony");
    return user;
}

@ResponseBody
@RequestMapping("/acceptuser")
public String getUser(@RequestBody User user) {
    System.out.println("接受到的JSON转换成的是: " + user);
    return "index";
}

然后用GET请求访问这个第一个控制器, 就可以得到一个JSON字符串如下:

{"userName":"cony","age":6,"address":{"detail":"zhr"}}

然后就用上边返回的字符串, 用Postman发一个POST请求, Content-type设置为application/json, 发送到/acceptuser路径, 之后就会看到, 这个JSON被自动转换成了一个User对象.

Jackson转换器在接收数据的时候, 是通过Content-type来进行判断的, 如果将application/json改成比如text/plain, 就会报415错误, 不接受指定的媒体类型.

这就是Rest风格的控制器. 从Spring 4.0开始, 如果一个控制器类中的方法接受和返回的响应都是JSON, 就提供了一个新的注解叫做@RestController可以加在类上,
其中的所有方法就无需再加@ResponseBody注解. 这个注解实际上就是@ResponseBody@Controller的综合.

上边的类可以改写如下:

@RestController
public class BodyController {


    @RequestMapping("/user")
    public User getUser() {
        User user = new User();
        Address address = new Address();
        address.setDetail("zhr");
        user.setAddress(address);
        user.setAge(6);
        user.setUserName("cony");
        return user;
    }

    @RequestMapping("/acceptuser")
    public String getUser(@RequestBody User user) {
        System.out.println("接受到的JSON转换成的是: " + user);
        return "index";
    }
}

Rest风格的控制器现在也搞清楚是怎么一回事了.