这个字段我明明传了呀,为什么收不到 - Spring 中首字母小写,第二个字母大写造成的参数问题

问题现象

PS:本文首发于微信公众号:技术角落。感兴趣的同学可以查看并关注:https://mp.weixin.qq.com/s/yL1HpgTQdIPVjuFsq2YGFg

vSwitchIduShapeiPhone... 这类字段名,有什么特点?很容易看出来吧,首字母小写,第二个字母大写。它们看起来确实是符合 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 方法的名称分别是 getVSwitchIdsetVSwitchId。接下来看看 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 就是收不到”。

posted @ 2023-05-11 16:19  技术角落  阅读(1061)  评论(2编辑  收藏  举报