这个字段我明明传了呀,为什么收不到 - Spring 中首字母小写,第二个字母大写造成的参数问题
问题现象
PS:本文首发于微信公众号:技术角落。感兴趣的同学可以查看并关注:https://mp.weixin.qq.com/s/yL1HpgTQdIPVjuFsq2YGFg
vSwitchId
、uShape
、iPhone
... 这类字段名,有什么特点?很容易看出来吧,首字母小写,第二个字母大写。它们看起来确实是符合 Java 中对字段所推崇的“小驼峰命名法”,即第一个单词小写,后面的单词首字母大写。但是,如果你在项目中给 POJO 类的字段以这种形式进行命名的话,那么可能会碰到 序列化/反序列化 的问题。。。下面就是一个我在项目中亲自踩过的坑
Spring Web 开发中,我们往往使用 POJO 对象来充当请求传递时的 body。例如现有一个用于传输的 POJO 对象,我将其进行简化后如下
@Data
public class InstanceRequest {
private String vSwitchId;
}
然后在 Controller 中使用这个对象作为 @RequestBody 来获得请求体,并在处理逻辑中输出 vSwitchId
字段
@RestController
public class InstanceController {
@RequestMapping("/createInstance")
public String createInstance(@RequestBody InstanceRequest request) {
// do something
System.out.println(request.getVSwitchId());
return "success";
}
运行上述应用后,我信心满满的发送一个 HTTP 请求进行测试,充满信心地认为控制台里会打印我传过去的信息
POST /createInstance HTTP/1.1
Content-Type: application/json
{
"vSwitchId": "xxxx"
}
结果却发现,控制台输出了一个大大的 null。。一脸懵逼,我逐字对比自己发送的 JSON 字段名和类里面的字段名。。v...S...w...i...t...c...h...I...d... 没问题呀,一个字母都不差呀,为什么收不到呢?
vSwitchId
字段为什么没有成功解析到?我们知道 Spring 是通过 jackson 框架来进行序列化和反序列化的,因此需要深入 jackson 的源码,看看为什么这个字段没有被成功反序列化。
深入 Jackson 源码探究原因
Jackon 中,主要通过AbstractJackson2HttpMessageConverter.readJavaType
方法将 HTTP 请求中的消息体转换为对象,因此直接对其打断点进行调试
根据断点逐步推进,进入 ObjectMapper._readMapAndClose
方法
看到这里有 _findRootDeserializer
方法,顾名思义,应该是根据当前想要转换的对象类型,来寻找对应的反序列化器了。那么继续进去看看...
往下层层递进后,找到创建反序列化器的地方,在 DeserializerCache._createDeserializer
里,也就是说是在 DeseializerCache 里面执行创建的步骤,这其实是很常见的 缓存+懒加载 模式:要使用的时候,首先去缓存里面拿,拿不到的时候再创建,创建完直接加入缓存。
在创建反序列化器的方法里,有个 BeanDescription
类值得注意,它指的是类的描述,因此猜测在这个类里面,我们的 POJO 类的字段应该已经被分析完毕了,那么上面的 vSwitchId 到底被分析成了啥,也可以在里面看到。
该类里面有 POJOPropertiesCollector ,那么我们 POJO 类的字段应该是被收集在这个类里面了。
值得注意的是,这是一个懒加载的类,内部的分析逻辑只有在第一次被用到时才会执行。分析逻辑在 POJOPropertiesCollector.collecAll()
这个方法里面。
下面重点就来了,看看这个方法
方法主要逻辑如下:
- 首先初始化了 props,存储所有反序列化过程中需要的属性
- 通过
_addFields(props)
方法从类的字段中抽取属性并加入 props 中 - 通过
_addMethods(props)
方法从类的 getter 和 setter 字段中抽取属性并加入 props 中 - 通过
_removeUnwantedProperties(props)
方法从 props 中剔除掉不想要的属性。哪些属性会被剔除?从代码可以看出,字段、getter、setter 都是私有属性、或者已经被标记为 ignore 的属性,是需要被剔除的。
通过调试发现,执行完 _addFields
后,vSwitchId
字段成功加入
再执行完 _addMethods(props)
后,神奇的事情发生了,加入了另外一个 props vswitchId
接下来,执行 _removeUnwantedProperties(props)
之后
发现 vSwitchId
这个正确的属性已经被剔除了,反而留下了 vswitchId
这个有问题的属性。这是为什么呢?上面提到,_removeUnwantedProperties
会剔除私有的属性,vSwitchId
这个 props 是来自字段的,而字段本身是私有的,因此它被剔除了。
接下来我们需要搞清楚为什么从 getter、setter 中拿到的属性是 vswitchId
而不是 vSwitchId
。
首先,getter 和 setter 是哪里来的?项目中我使用的 Lombok,也就是说 getter 和 setter 是由 Lombok 生成的。在大多数 IDE 中,如果使用 Lombok 生成 setter 方法,它将会被自动隐藏并不会显示在源代码中。如果想要查看生成的方法名称,通常 IDE 会提供一个叫做“Structure”(结构)或“Outline”(大纲)的功能,它可以列出类的所有成员变量和方法,其中也包括由 Lombok 生成的 setter 方法。
可以看到 get 和 set 方法的名称分别是 getVSwitchId
和 setVSwitchId
。接下来看看 Jackson 具体是如何解析方法,从而得到 props 的。相关代码在 DefaultAccessorNamingStrategy.legacyManglePropertyName
中
以上处理流程用大白话解释一下:首先会根据 offset
字段去除前面的三个字母,一般为 get 或 set。去除前面三个字母 'set' 后,发现第一个字母是大写的,因此将第一个字母小写,然后接着往后找,如果后面的还是大写,接着变小写...直到找到了一个本来就是小写的字母后,才将后面所有的字母一股脑添加进来。由于 setVSwitchId
在去除前面的 set 后,前面两个字母都是大写,因此在这种处理逻辑下,最后得到的属性名为 vswitchId
。换句话说,如果 set 方法的名称是 setvSwitchId
,那么处理后得到的就是正确的 vSwitchId
。
说到这里,问题其实就明了了,这个其实是由于 Lombok 生成 getter、setter 方法的语义规范与 Jackson 处理 get set 方法之间的不一致性,导致的属性名无法匹配上的问题。
Lombok
其实在 Lombok 社区里,也有人提出过这个问题,详见 https://github.com/projectlombok/lombok/issues/2693。
可以看出,这个其实是规范的问题,目前没有一个定论。。Lombok 认为自己生成 set、get 方法的规范没有问题,Jackson 那边也认为自己根据 set、get 方法来解析字段名的规范也没有问题,公说公有理,婆说婆有理。。不过,不管是谁有理,最后受到伤害的是我们开发者呀,只要你的项目中同时用到了 Lombok 和 Jackson,就会遇到这个问题。对于没有接触过这个问题的开发者来说,这个问题其实是会平白无故浪费很多时间的。
不过,Lombok 社区还是提出了一个 PR 来解决这个问题,详见 https://github.com/projectlombok/lombok/pull/2996 。
在以上 PR 中,Lombok 社区提供了一个配置项,
lombok.accessors.capitalization = [basic | beanspec] (default: basic)
默认为 basic,也就是 Lombok 默认的行为,会生成 setVSwitchId
这种方法名。
如果将其修改为 beanspec,那么会保持与 Spring、Jackson 相同的规范, 此时会生成 setvSwitchId
这种方法名。
详情也可以看 Lombok 的官方文档 https://projectlombok.org/features/GetterSetter
其中最后一句话很有意思,“Both strategies are commonly used in the java ecosystem, though beanspec is more common“。这意思是,“我承认 Jackson 那边使用的规范更常用一些,但是我默认还是要坚持我的规范...”。
解决方案
讲到这里,解决方案其实就出来了。这里介绍三种解决方案吧
方案一
使用 Lombok 的配置来解决。在项目根目录下创建 lombok.config
文件,并添加以下配置项即可
lombok.accessors.capitalization = beanspec
方案二
利用 IDE、或者手动生成 getter、setter 方法
public String getvSwitchId() {
return vSwitchId;
}
public void setvSwitchId(String vSwitchId) {
this.vSwitchId = vSwitchId;
}
方案三
利用 Jackson 的 JsonProperty 注解强行指定属性名
@Data
public class InstanceRequest {
@JsonProperty(value = "vSwitchId")
private String vSwitchId;
}
总结
我自己从这个事件中总结出来了一点经验。在 Java 里面,给类属性取名的时候,以前我想着是只要满足小驼峰命名法就万事大吉,不会有什么问题了。。。现在我知道了,并不是说满足小驼峰就万事大吉了,如果碰到 首字母小写、第二个字母大写 的这种情况,还是要特别注意,尤其是当这个类还被用于序列化/反序列化时,一定要注意其处理的规范性,要写(生成)生成符合 Java Bean 规范的 set、get 方法,否则这个小小的字段在反序列化时会一直困扰着你。。让你一直抓狂 “这个字段我明明传了呀,为什么 Spring 就是收不到”。