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

问题现象

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 就是收不到”

Spring Boot 应用,可以使用 Jackson 库来进行 JSON 序列化和反序列化。默认情况下,Jackson 库会根据 Java 对象属性的命名规范来生成 JSON 字段名,而在 JavaScript ,对象属性一般使用小写字母命名。 为了解决这个问题,可以使用 Jackson 库的命名策略来改变生成的 JSON 字段名。常用的命名策略有 SnakeCase 命名策略和 KebabCase 命名策略,它们分别将属性名大写字母转换为下划线或短横线,并将所有单词转换为小写。这样,在序列化时生成的 JSON 字段名就会符合约定的命名规范。 具体实现方式如下: 1. 在 Spring Boot 应用的配置文件添加以下配置,将 Jackson 库的命名策略设置为 `LOWER_CASE_WITH_UNDERSCORES`: ```yaml spring: jackson: property-naming-strategy: LOWER_CASE_WITH_UNDERSCORES ``` 2. 在需要进行序列化和反序列化的类上添加 `@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)` 注解,该注解将使用 SnakeCase 命名策略(即将属性名大写字母转换为小写,并添加下划线)。 ```java import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) public class MyClass { private String myField; // getter and setter } ``` 这样,在进行序列化时生成的 JSON 字段名就会符合 JavaScript 的小写命名规范。在前端接收后端返回的 JSON 数据时,也不需要对字段名进行任何转换,可以直接使用小写字母的属性名。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值