這個欄位我明明傳了呀,為什麼收不到 - Spring 中首字母小寫,第二個字母大寫造成的引數問題

技術角落發表於2023-05-11

問題現象

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

相關文章