問題現象
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 就是收不到”。