作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
上一篇我们通过编写MyBatis的转换器最终完成枚举在DAO层和数据库之间的转换:
现在让我们把目光往前移,思考一下如何编写SpringMVC的转换器完成前端与Controller层的枚举转换。
目录结构
pom.xml(小册使用的版本都是2.3.4,但今天遇到坑了,后面会提到)
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.3.4.RELEASE
com.example
springboot_enum
0.0.1-SNAPSHOT
springboot_enum
Demo project for Spring Boot
1.8
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
org.springframework.boot
spring-boot-maven-plugin
POJO
@Data
public class UserDTO {
/**
* 姓名
*/
private String name;
/**
* 年龄
*/
private Integer age;
/**
* 用户类型,枚举
*/
private UserTypeEnum userType;
}
UserTypeEnum
@Getter
public enum UserTypeEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
private final Integer type;
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
}
Controller
@Slf4j
@RestController
@RequestMapping("/api/web/user")
public class UserController {
@GetMapping("/get")
public void get(UserDTO userDTO) {
log.info(userDTO.toString());
}
@PostMapping("/postForm")
public void postForm(UserDTO userDTO) {
log.info(userDTO.toString());
}
@PostMapping("/postJson")
public void postJson(@RequestBody UserDTO userDTO) {
log.info(userDTO.toString());
}
}
首先,要和大家交代一下,常见的请求方式分两大类(不算REST风格):
GET和POST有个很大区别是:GET请求的参数放在请求行,而POST请求的参数放在请求体(Body)。
另外,POST请求又细分很多种:
如果你足够细心,平时使用Postman时就会注意到以上三种POST请求(形式虽不同,但参数都在Body):
我们会在JavaWeb章节详细介绍它们的区别,这里按下不表。
需要注意的是,从后端接口参数的格式看,POST请求中的表单提交方式和GET请求是很相似的:
所以本文在测试时分为两个阵营:
测试的方向分为:
开始测试之前,再来回顾一下我们写的枚举:
枚举名称(name)分别叫"STUDENT","TEACHER",之前分析过,所有的枚举类默认继承Enum,而Enum重写了toString():
所以当我们打印STUDENT或TEACHER对象时,最终会打印: "STUDENT"、"TEACHER"。
OK,接下来让我们开始测试。
测试请求时,我们的关注点是:前端传入"userType:STUDENT",后端是如何变成UserTypeEnum对象的。
GET请求
POST表单
很明显,前端传"STUDENT"、"TEACHER"等枚举名称(name)时,SpringMVC能自动帮我们转为对应的枚举对象,而在实际打印时由于调用了toString(),所以显示userType=STUDENT。
那么,为什么枚举名称name为什么会自动转为枚举对象UserTypeEnum呢?我们先不管SpringMVC怎么做到的,通过断点,很容易发现SpringMVC在解析"STUDENT"这个字符串时最终调用了Enum#valueOf(),然后根据name获取枚举对象:
无论是GET还是POST表单,传入0或1都失败了(ordinal从0开始),也就是说SpringMVC默认不支持根据ordinal转换:
Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'userDTO' on field 'userType': rejected value [1]; codes [typeMismatch.userDTO.userType,typeMismatch.userType,typeMismatch.com.bravo.demo.enums.UserTypeEnum,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userDTO.userType,userType]; arguments []; default message [userType]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'com.bravo.demo.enums.UserTypeEnum' for property 'userType'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [com.bravo.demo.enums.UserTypeEnum] for value '1'; nested exception is java.lang.IllegalArgumentException: No enum constant com.bravo.demo.enums.UserTypeEnum.1]]
特别注意最后的异常信息,似乎在哪见到过:
也就是说,对于GET/POST表单请求,SpringMVC都是根据valueOf()来匹配枚举对象的。
也即是说,对于GET和POST表单请求而言,如果想正确的反序列化(String转为Enum对象),前端只能传Enum.name。
理由如上
理由如上
对于前端来说,他们可能更喜欢传递枚举内部的字段,比如UserTypeEnum.type,而不是Enum.name。有没有办法更改SpringMVC的默认行为,当前端传递userType=1时,把1转为UserTypeEnum的“学生”对象呢?
要解决这个问题,可以分两步:
由于我们已经知道整个请求链路的终点是调用Enum#valueOf()进行转换,于是给valueOf()打上断点:
省略中间的步骤,根据调用链进行反推,很快定位到AbstractPropertyAccessor#setPropertyValues():
这是个for循环,它拿到了UserDTO的所有属性并逐个进行赋值。比如截图的代码显示SpringMVC正在给UserDTO.userType字段赋值。
再往下走几步会看到GenericConversionService#convert():
找到converter后调用converter的convert()方法进行值转换:
最终把转换后的值设置给UserDTO.userType。
我们发现,SpringMVC默认的枚举转换器是StringToEnumConverterFactory:
它的convert()方法正好调用了Enum.valueOf(),所以GET/POST表单请求时只能传Enum.name,至此真相大白。
整个流程是:
有了上面的铺垫,关于GET/POST表单请求时如何自定义枚举入参转换器已经很明确了。
/**
* 自定义枚举转换器(直接抄StringToEnumConverterFactory)
*
* @author mx
*/
public final class MyEnumConverterFactory implements ConverterFactory {
@Override
public Converter getConverter(Class targetType) {
return new StringToEnum(targetType);
}
private static class StringToEnum implements Converter {
private final Class enumType;
public StringToEnum(Class enumType) {
this.enumType = enumType;
}
/**
* StringToEnumConverterFactory默认是调用Enum.valueOf(),也就是根据Enum.name匹配
* 我们改成根据Enum.ordinal匹配
*
* @param source
* @return
*/
@Override
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
for (T enumObject : enumType.getEnumConstants()) {
if (source.equals(String.valueOf(enumObject.ordinal()))) {
return enumObject;
}
}
return null;
}
}
}
把它加到请求链路中:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 把我们自定义的枚举转换器添加到Spring容器,Spring容器会把它加入到SpringMVC的拦截链路中
registry.addConverterFactory(new MyEnumConverterFactory());
}
}
测试:
特别特别注意,把MyEnumConverterFactory加入调用链后,jackson原本的StringToEnumConverterFactory就不起作用了,此时前端传入"STUDENT"、"TEACHER"将无法成功解析。
上面这样还是无法满足我们的需求:我们只是把原先默认支持Enum.name改为Enum.ordinal。
部分同学可能有疑问:你刚才为什么不直接在上面的ConverterFactory中调用getType()或者getDesc()呢?
不是我不想,而是不好这样做。两点理由:
解决办法有两个:
IEum接口
/**
* 统一的枚举接口
*
* @author mx
*/
public interface IEnum {
/**
* 强制指定按哪个字段进行反序列化
*
* @return
*/
T getValue();
}
让UserTypeEnum实现IEnum:
@Getter
public enum UserTypeEnum implements IEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
private final Integer type;
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
/**
* 强制指定按哪个字段进行反序列化
*
* @return
*/
@Override
public String getValue() {
return this.desc;
}
}
改写MyEnumConverterFactory:
/**
* 自定义枚举转换器,配合统一枚举接口IEnum
*
* @author mx
*/
public final class MyEnumConverterFactory implements ConverterFactory {
@Override
public Converter getConverter(Class targetType) {
return new StringToEnum(targetType);
}
private static class StringToEnum implements Converter {
private final Class enumType;
public StringToEnum(Class enumType) {
this.enumType = enumType;
}
@Override
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
for (T enumObject : enumType.getEnumConstants()) {
// 默认项目中所有Enum都实现了IEnum,那么必然有getValue()
if (source.equals(String.valueOf(enumObject.getValue()))) {
return enumObject;
}
}
return null;
}
}
}
测试:
/**
* 指定反序列化字段
*
* @author mx
*/
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyJsonCreator {
}
/**
* 自定义枚举转换器,还是用原生的Enum
* 使用分三步:
* 1.自定义一个注解,假设叫@MyJsonCreator
* 2.读取注解
* 3.解析注解字段的值,找到匹配的枚举对象
*
* MyEnumConverterFactory主要负责第2、3步
*
* @author mx
*/
public final class MyEnumConverterFactory implements ConverterFactory {
@Override
public Converter getConverter(Class targetType) {
return new StringToEnum(targetType);
}
private static class StringToEnum implements Converter {
private final Class enumType;
public StringToEnum(Class enumType) {
this.enumType = enumType;
}
@Override
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
try {
for (T enumObject : enumType.getEnumConstants()) {
Field[] declaredFields = enumObject.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
// 读取@MyJsonCreator标注的字段
if (declaredField.isAnnotationPresent(MyJsonCreator.class)) {
declaredField.setAccessible(true);
// 读取对应的字段value
Object fieldValue = declaredField.get(enumObject);
// 匹配并返回对于的Enum
if (source.equals(String.valueOf(fieldValue))) {
return enumObject;
}
}
}
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
}
}
在UserTypeEnum中使用:
@Getter
public enum UserTypeEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
@MyJsonCreator
private final Integer type;
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
}
为了不干扰后续的实验,请大家先把自定义的枚举转换器注释掉:
测试POST JSON:
我们惊奇的发现:SpringMVC默认就支持了Enum.name和Enum.ordinal的转换,但对于子类UserTypeEnum的特有字段(type、desc)是不识别的。
有部分同学可能有点晕了,来捋一捋:
很明显POST JSON和GET/POST表单使用的不是同一个转换器,并且从上面的异常信息可以捕捉到一丝丝信息:
Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `com.bravo.demo.enums.UserTypeEnum` from String "学生": not one of the values accepted for Enum class: [TEACHER, STUDENT]; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `com.bravo.demo.enums.UserTypeEnum` from String "学生": not one of the values accepted for Enum class: [TEACHER, STUDENT]
at [Source: (PushbackInputStream); line: 4, column: 17] (through reference chain: com.bravo.demo.pojo.UserDTO["userType"])]
市面上常见的3种JSON转换工具:
SpringBoot默认使用jackson作为JSON转换工具,比如我们经常会用的ObjectMapper其实就是jackson的。
而JSON转换工具的作用点有两个:
GET或POST表单请求由于参数并不是JSON形式,所以用不到jackson,只需要实现ConverterFactory:
而POST JSON请求则需要实现HttpMessageConverter(jackson已经提供):
请注意,ConverterFactory和HttpMessageConverter两个接口的包路径都不一样,并没有什么关联。
由于JSON请求本质是字符串,所以必须要有反序列化的过程。SpringMVC对外提供了HttpMessageConverter接口用于处理JSON,而SpringBoot内置的jackson提供了该接口的实现类MappingJackson2HttpMessageConverter:
当一个JSON请求达到SpringMVC,容器会根据为当前请求参数挑选合适的Converter:
此时就轮到jackson的MappingJackson2HttpMessageConverter出场了。如果你跟着debug,就会发现实际上大部分工作都是AbstractJackson2HttpMessageConverter干的,jackson的主要贡献是提供了ObjectMapper实例及各种Serializer、Deserializer用于序列化和反序列化:
AbstractJackson2HttpMessageConverter内部的ObjectMapper被赋值后(通过构造器),如果有请求到达SpringMVC,它会调用ObjectMapper(Serializer、Deserializer)对参数进行转换。
比如EnumDeserializer默认支持转换Enum.name、Enum.ordinal:
具体的源码就不在这里带大家跟读了,我们会在Spring章节分析@RequestBody时解释,目前大家可以像下面截图一样打上断点,然后用Postman分别传递数字(Enum.ordinal)或字符串(Enum.name)体会一下:
你会发现,jackson的EnumDeserializer默认的解析策略是:
如果你刚好是从上一篇文章过来的,就会发现jackson的策略和MyBatis很像,都支持了Enum.name和Enum.ordinal的转换。那么,如果前端传递的是UserTypeEnum.type或者UserTypeEnum.desc呢?
好在,jackson还提供了@JsonCreator注解让我们自己指定反序列化的字段:
@Slf4j
@Getter
public enum UserTypeEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
/**
* 用@JsonValue指定序列化字段,后面再介绍,不用管
*/
@JsonValue
private final Integer type;
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
/**
* 静态方法+@JsonCreator指定根据哪个字段反序列化
*
* @param desc
* @return
*/
@JsonCreator
public static UserTypeEnum getEnum(String desc) {
for (UserTypeEnum item : values()) {
if (item.getDesc().equals(desc)) {
log.info("进来了, desc:{}, item:{}", desc, item.toString());
return item;
}
}
return null;
}
public static void main(String[] args) throws IOException {
// 模拟Postman发送JSON请求
ObjectMapper objectMapper = new ObjectMapper();
String json = "{\n" +
" \"name\": \"bravoPostJson\",\n" +
" \"age\": 18,\n" +
" \"userType\": \"老师\"\n" +
"}";
System.out.println(json);
// 请求:反序列化
UserDTO userDTO = objectMapper.readValue(json, UserDTO.class);
System.out.println(userDTO);
// 响应:序列化
String returnJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(userDTO);
System.out.println(returnJson);
}
}
结果
请求:
{
"name": "bravoPostJson",
"age": 18,
"userType": "老师"
}
接收:
进来了, desc:老师, item:TEACHER
UserDTO(name=bravoPostJson, age=18, userType=TEACHER)
响应:
{
"name" : "bravoPostJson",
"age" : 18,
"userType" : 2
}
看起来很完美啊,但是你用Postman去请求就会报错。我调试了一晚上(坑爹),结果发现是当前SpringBoot版本问题(2.3.3),SpringBoot2.0.x是可以的(估计2.3.3修改了jackson的默认设置):
介绍完前端如何传入枚举参数(入),最后讲讲枚举如何响应给前端(出)。其实答案已经呼之欲出:@JsonValue,但方案不止一种。
在测试前,请大家修改Controller,让接口返回UserDto:
@Slf4j
@RestController
@RequestMapping("/api/web/user")
public class UserController {
@GetMapping("/get")
public UserDTO get(UserDTO userDTO) {
log.info(userDTO.toString());
return userDTO;
}
@PostMapping("/postForm")
public UserDTO postForm(UserDTO userDTO) {
log.info(userDTO.toString());
return userDTO;
}
@PostMapping("/postJson")
public UserDTO postJson(@RequestBody UserDTO userDTO) {
log.info(userDTO.toString());
return userDTO;
}
}
把之前请求相关的配置先注释掉,并把SpringBoot版本改为2.0.5:
OK,我们自定义MyEnumConverterFacotry注释后,对于GET/POST表单请求重新使用默认的StringToEnumConverterFactory,仅支持Enum.name反序列化。而POST JSON请求默认支持Enum.name和Enum.ordinal。
现在你可以认为代码都回到了最初创建SpringBoot项目的状态。由于这回是测试响应形式,我们不关心入参,所以统一传递大家都支持的Enum.name。
需要注意的是,无论GET/POST表单还是POST JSON请求,它们只是请求方式不同,而响应形式其实都是JSON,因为我们使用了@RestController = @Controller + @ResponseBody。
所以,对于响应只需测试其中任意一组即可。
至于使用了@ResponseBody后SpringMVC如何处理返回值,由于篇幅已经太长,留到Spring部分再聊。但有一点可以肯定,正如JSON请求那样,JSON响应也会经过jackson的处理,而且必然调用HttpMessageConverter的write()。
中间复杂的调用就跳过了,直接看AbstractJackson2HttpMessageConverter#writeInternal():
即最终会调用objectWriter.writeValue(generator, value)进行序列化写入response缓冲区。我们注意到,在调用writeValue()之前,userType字段还是个UserTypeEnum对象:
而writeValue()本身已经没有什么好分析了:
所以为什么UserTypeEnum最终会变成userType = "STUDENT"?这和SpringMVC本身没什么关系,取决于JSON转换工具怎么设计的,而jackson默认就是调用Enum.name()。
如何改变jackson对枚举类型的默认序列化规则呢?
在需要序列化的字段上加@JsonValue即可。特别注意,对于POST JSON请求,使用@JsonValue必须配合使用@JsonCreator,否则会报错(很难受):
做了上面的设置,相当于告诉jackson序列化响应时调用对象的toString()即可,相应地我们要重写toString():
/**
* 自定义JSON响应时枚举字段的序列化行为:调用toString()
*
* @return
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
}
@Getter
public enum UserTypeEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
private final Integer type;
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
@Override
public String toString() {
return "没想到吧,UserTypeEnum序列化后竟然是完全无关的文字~";
}
}
测试GET响应:
测试POST JSON响应:
SpringMVC对请求和响应的处理原本就复杂,再加上枚举,使得整篇文章难度加大不少。很多同学可能有点晕,这里总结一下,并尝试给出我推荐的方案。
个人推荐的方案
请求
响应
/**
* 指定GET/POST表单请求反序列化字段
* POST JSON请求反序列字段请用jackson原生注解@JsonCreator
*
* @author mx
*/
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyJsonCreator {
}
@Slf4j
@Getter
public enum UserTypeEnum {
STUDENT(1, "学生"),
TEACHER(2, "老师"),
;
private final Integer type;
/**
* MyEnumConvertFactory+@MyJsonCreator指定GET/POST表单请求根据哪个字段反序列化
*/
@MyJsonCreator
private final String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
/**
* 静态方法+@JsonCreator指定POST JSON请求根据哪个字段反序列化
*
* @param desc
* @return
*/
@JsonCreator
public static UserTypeEnum getEnum(String desc) {
for (UserTypeEnum item : values()) {
if (item.getDesc().equals(desc)) {
log.info("进来了, desc:{}, item:{}", desc, item.toString());
return item;
}
}
return null;
}
/**
* 统一序列化字段,调用toString()返回
*
* @return
*/
@Override
public String toString() {
return String.valueOf(this.type);
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* 自定义GET/POST表单提交方式的入参反序列化规则
*
* @param registry
*/
@Override
public void addFormatters(FormatterRegistry registry) {
// 把我们自定义的枚举转换器添加到Spring容器,Spring容器会把它加入到SpringMVC的拦截链路中
registry.addConverterFactory(new MyEnumConverterFactory());
}
/**
* 自定义JSON响应时枚举字段的序列化行为:调用toString()
*
* @return
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
}
}
/**
* 自定义枚举转换器,还是用原生的Enum
* 使用分三步:
* 1.自定义一个注解,假设叫@JsonCreator
* 2.读取注解
* 3.解析注解字段的值,找到匹配的枚举对象
*
* MyEnumConverterFactory主要负责第2、3步
*
* @author mx
*/
public final class MyEnumConverterFactory implements ConverterFactory {
@Override
public Converter getConverter(Class targetType) {
return new StringToEnum(targetType);
}
private static class StringToEnum implements Converter {
private final Class enumType;
public StringToEnum(Class enumType) {
this.enumType = enumType;
}
@Override
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
try {
for (T enumObject : enumType.getEnumConstants()) {
Field[] declaredFields = enumObject.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
// 读取@MyJsonCreator标注的字段
if (declaredField.isAnnotationPresent(MyJsonCreator.class)) {
declaredField.setAccessible(true);
// 读取对应的字段value
Object fieldValue = declaredField.get(enumObject);
// 匹配并返回对于的Enum
if (source.equals(String.valueOf(fieldValue))) {
return enumObject;
}
}
}
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
}
}
我个人实际开发时无论是Controller层还是DAO层,都习惯手动转换枚举。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬