上篇主要复习了HTTP以及POST相关的几种传参形式,这一篇来讲讲和实际开发更为紧密的内容:JSON。

初学者在需求分析阶段通常缺乏以下能力:

  • 无法通过看页面原型图分析出大致的请求格式和响应格式
  • 即使能分析出请求格式,却不知道用什么样的Java对象接收
  • 即使能分析出响应格式,却不知道用什么样的Java对象返回

chatgpt前后端传输方式 前后端数据传输格式_字段

这三个能力,其实都依赖于你对JSON的理解(本文主要讨论JSON,不涉及表单请求和文件上传类型)。

说得更具体些就是,初学者往往搞不清楚JSON与Java对象的转换关系(图片来自尚硅谷):

  • 请求:JSON转Java
  • 响应:Java转JSON

chatgpt前后端传输方式 前后端数据传输格式_Java_02

JSON

JSON的作用

在我刚入行时写过相关的博客,比较简单,感兴趣的同学可以看看:AJAX与JSON

JSON简单来说就是特定格式的字符串,注意,它的载体是字符串。很多人分不清JSON和JS对象,其实两者没有可比性。实在要说的话,JS对象和Java对象都是正儿八经的对象,存活于内存中(浏览器/服务器),而JSON只是字符串,往往承担的是网络传输的角色:

chatgpt前后端传输方式 前后端数据传输格式_字段_03

我们都知道,网页上的动态数据都是服务器那边组装后通过HTTP返回的,但很少有人会思考这样的一个问题:

服务器组装数据时用的是Java对象,比如user.setName("bravo"),但前端得到的却是JS对象,怎么做到的?

一般来说,浏览器被安装在我们的个人电脑中,而提供服务的Java应用则可能运行在阿里云服务器上,两者并不在同一片物理内存。由于对象存活必须依赖于内存(如果不做持久化,只要一断电,内存中的数据就没了),所以就上图而言,Java对象显然没法自己“跳出”服务器进入浏览器中,更别提Java对象如何变成JS对象。

要想完成跨内存的数据传输、对象转换,必须通过网络传输,而且需要一个传递信息的载体,过程中还涉及到序列化和反序列化。

实际上,整个请求响应的过程就是序列化和反序列化的过程:

  • 服务器端把Java对象序列化为JSON(中介,特定格式的字符串)
  • 网络不能直接传递对象,但可以传输字符串
  • 浏览器得到JSON后,反序列化为JS对象,然后设置到页面上

chatgpt前后端传输方式 前后端数据传输格式_chatgpt前后端传输方式_04

同理,不仅是浏览器和服务器,服务器和服务器之间也需要JSON作为数据交互的中介:

chatgpt前后端传输方式 前后端数据传输格式_字段_05

JSON的格式

使用Postman时,我们经常会发这样的请求:

chatgpt前后端传输方式 前后端数据传输格式_chatgpt前后端传输方式_06

Body就是请求体内容,一般都是字符串格式,所以这是一个JSON,而不是JS对象,尽管它们看起来很像。你可以理解为JSON是多种语言共同协定的一种数据交互格式:

  • "field":"xxx" 表示对象的字段,value如果是字符类型需要加"",数值可以不加
  • {} 表示对象或map或其他符合key-value格式的结构
  • [] 表示一组对象、一组字符串或一组数值

很简单吧?

然后各个语言都会遵守这个协定,转化为自己的对象结构,比如:

  • {}可以代表Java对象/Map,[]由于表示一组数据,刚好可以对应Java的数组、List或Set等单列集合
  • {}也可以代表Python对象/字典,[]对应Python的元组或list等
  • {}还可以代表PHP的对象,[]对应PHP中的Array
  • ...

当然,{}也可以代表JS对象,[]则可以转化为JS的数组。

为什么很多初学者会搞混JSON和JS对象呢?本质上还是因为:

  • 初学者都“见过”JS对象,而且它往往都是{}的形式出现
  • JS对象的格式和JSON确实比较像

但JSON本质是“干瘪瘪”的字符串,当各大语言需要进行反序列化时,就会按照上面的格式转为内存中“圆鼓鼓”的对象。应该把JS对象和Java对象看作一个梯队,而JSON则在另一个梯队,是一个特定格式的字符串。

常见JSON格式与Java对象的转换

这里只演示Java对象与JSON的转换:

chatgpt前后端传输方式 前后端数据传输格式_字段_07

如果不信的话,可以自己动手写一下接口,然后用Postman按照图中的格式发送JSON,看看接口能不能顺利接收参数。

上面说过,JSON的{}可以对应Java的对象或者Map,{}两个括号表示对象的边界,其实刚好对应类的{},里面的就是对象的字段。

我们来分析一下第一张图的结构。

接口返回的是一个HashMap,所以很容易想到最终JSON格式是:

{

...

}

接着往Map里put了一些value,我们先不管value是什么类型:

{

"1号男嘉宾" : xxx,

"2号男嘉宾" : xxx,

"3号男嘉宾" : xxx,

}

OK,JSON的大致结构出来了,再深入一层,看看xxx是什么。很明显,是一个Java的User对象,还是对应{}:

{

"1号男嘉宾" : {},

"2号男嘉宾" : {},

"3号男嘉宾" : {},

}

那么,这个User对象有哪些字段呢?填上即可:

{
    "1号男嘉宾": {
        "name": "雅木茶",
        "age": 23
    },
    "2号男嘉宾": {
        "name": "卡卡罗特",
        "age": 23
    },
    "3号男嘉宾": {
        "name": "贝吉塔",
        "age": 22
    }
}

其他两个分析过程同理,就不演示了(思考一下,如果User里面有Department会是什么样)。

刚才是顺着来,现在我们玩一下“逆推”。假设现在前端跑过来告诉你

大佬,这个接口我到时候这样传参给你行吗?

[
    {
        "name": "张飞",
        "age": 18,
        "tags": [
            "大眼睛",
            "大胡子"
        ]
    },
    {
        "name": "关羽",
        "age": 19,
        "tags": [
            "万人敌",
            "长胡子"
        ]
    },
    {
        "name": "刘备",
        "age": 20,
        "tags": [
            "刘皇叔",
            "摔阿斗"
        ]
    }
]

此时你应该如何设计入参才能接受前端这种格式的JSON呢?

jackson的一些操作

之前介绍过,服务器本身没有能力处理JSON和文件上传,都要靠第三方组件。SpringBoot则引入了jackson作为默认的JSON组件,其他常见的还有阿里的fastJson和谷歌的gson。

这里介绍一下jackson常见的几个注解。

@Slf4j
@RestController
public class UserController {

    @PostMapping("/addUser")
    public UserPOJO addUser(@RequestBody UserPOJO user) {
        user.setAge(null);
        return user;
    }

}

@Data
public class UserPOJO {
    /**
     * 姓名
     */
    private String name;
    /**
     * 年龄
     */
    private Integer age;
    /**
     * 用户类型
     */
    private Integer userType;
    /**
     * 个人标签
     */
    private List<String> tags;

}

chatgpt前后端传输方式 前后端数据传输格式_JSON_08

@JsonInclude

有时我们希望如果字段为null就不要返回给前端,可以使用@JsonInclude,它可以指定很多属性。

chatgpt前后端传输方式 前后端数据传输格式_Java_09

chatgpt前后端传输方式 前后端数据传输格式_JSON_10

chatgpt前后端传输方式 前后端数据传输格式_字段_11

@JsonInclude还可以加在类上,那么该对象所有为null的字段都不会参与JSON序列化。

@JsonProperty

对于一些老项目或者其他什么原因,原本传参使用的是下划线,比如user_type,而后端用Java改写时又要符合驼峰命名,此时可以用@JsonProperty做一层“隔离”。

chatgpt前后端传输方式 前后端数据传输格式_JSON_12

此时出入参都必须叫user_type:

chatgpt前后端传输方式 前后端数据传输格式_Java_13

@JsonFormat

有些同学容易把@JsonFormat和@DateTimeFormat搞混,我们单独开一个小节聊一聊。

时间格式

首先和大家说一下,数据库字段无论是datetime还是timestamp,其实都是可以自动对应Java的Date对象,一般讨论的所谓时间格式,都是指前端的显示格式:

chatgpt前后端传输方式 前后端数据传输格式_chatgpt前后端传输方式_14

刚才我们讨论为什么需要JSON时,提到一个观点:对象是在内存中存活的,无法直接进行网络传输。但是大家有没有想过:

class User {
    private String name;
    private Date birthday;
}

其实里面的字段也是对象,也要进行序列化。SpringBoot引入了jackson作为JSON序列化的组件,其中必然包括对Date进行序列化/反序列化的方案。

然而,SpringBoot1.x和2.x其实有较大的改动,其中就包括对Date格式化的改动。大家可以沿用刚才的项目,给UserPOJO加上birthday字段,然后在SpringBoot1.5.9和SpringBoot2.3.4环境下实验。

@Slf4j
@RestController
public class UserController {

    @PostMapping("/addUser")
    public UserPOJO addUser(@RequestBody UserPOJO user) {
        return user;
    }

}

SpringBoot1.x

chatgpt前后端传输方式 前后端数据传输格式_javaweb_15

chatgpt前后端传输方式 前后端数据传输格式_javaweb_16

SpringBoot2.x

chatgpt前后端传输方式 前后端数据传输格式_javaweb_17

chatgpt前后端传输方式 前后端数据传输格式_字段_18

有两个细节:

  • SpringBoot1.x的返回值是毫秒数,SpringBoot2.x是另一种格式
  • 当入参和出参时间格式不同时,会发生转换,此时会出现时间差
  • SpringBoot1.x传递2020-12-07T22:58:11.000+00:00,返回1607381891000(+8)
  • SpringBoot2.x传递1607353091000,返回2020-12-07T14:58:11.000+00:00(-8)

可以通过时间戳转换验证一下(注意单位)。

时差的问题可以通过配置解决,比如:

spring.jackson.time-zone=GMT+8

总的来说就是:

chatgpt前后端传输方式 前后端数据传输格式_JSON_19

如果希望更改出入参的时间格式,可以有局部和全局两种方式:

  • 局部:@JsonFormat / @DateTimeFormat
  • 全局
  • YAML
  • Config

@DateTimeFormat只适用于非JSON的POST请求,也就是说,如果项目本身都是JSON请求,你用@DateTimeFormat是无效的。你可以简单理解为:

  • @DateTimeFormat,走表单请求时间转换器(适用于GET、POST表单请求)
  • @JsonFormat,走JSON请求时间转换器(适用于POST JSON请求、JSON响应)

所以,你在这煞费苦心地调整表单请求的转换格式有啥用?

这里演示一下@JsonFormat的用法:

/**
 * 生日(时间格式很容易写错,可以抽取为常量或者使用第三方提供的,比如hutool就有)
 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date birthday;

chatgpt前后端传输方式 前后端数据传输格式_字段_20

此时出入参都变为指定格式:

chatgpt前后端传输方式 前后端数据传输格式_JSON_21

也可以在YAML中进行全局配置:

chatgpt前后端传输方式 前后端数据传输格式_chatgpt前后端传输方式_22

上面那个mvc:date-format是对表单请求的配置。

或者使用Config:

@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper getObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        // 全局配置
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        return objectMapper;
    }

}

如果说@JsonInclude/@JsonFormat加到字段上、类上分别是字段级别、类级别,那么上面的配置就是整个项目级别,因为Spring的bean默认单例,而这个唯一的ObjectMapper已经被做了手脚,最终所有接口的序列化/反序列化行为都被改写。

这种全局和局部的思想,后面会很常见,这里先点一下。