記錄一次自定義引數繫結錯誤問題的解決過程
問題背景
先說一下問題背景:整個專案是一個大的分散式系統,由十幾個子系統組成,本人負責其中兩個系統。分散式服務框架採用了公司封裝好的jar包,當然還有一些其他的底層框架。由於某些原因,公司更換了底層分散式服務框架和一些其他的框架,其中分散式服務框架主要是更改了一些包名和類名,其他基本沒變。在更換底層框架之前,系統已經經過了兩個版本的迭代,並且已在生產環境上線。
在更換底層框架之後,系統跑起來沒問題,經過簡單自測,也沒有發現問題。因為原系統本身是上過線的,所以就覺得既然改造之後能夠正常編譯和執行,因此肯定是沒問題的,就提交給測試了。結果在整合測試中,發現其中一個系統的一個重要介面的引數校驗不通過,然後整個整合測試流程就卡住了。
解決過程
於是趕緊排除問題。
系統中有多個介面,雖然每個介面的功能不一樣,但是接收引數的方式是一樣的,其他介面都正常,而這個介面的引數就校驗不過,首先想到的是引數不符合要求,但是仔細看了一下發現請求引數並沒有任何問題。
通過比較正常介面的引數模型和繫結不上的引數模型,發現後者中有Date型別的欄位,且該欄位上有json序列化和反序列化註解。根據經驗判斷(日期的格式轉換容易出問題),應該就是這個欄位的原因。經過測試,發現的確如此,去掉該欄位後引數就能正常繫結,顯然問題原因是日期欄位格式轉換有問題。進一步發現,日期欄位上面的json序列化和反序列化所使用的類在底層分散式服務框架和系統應用工具包中都有,而這裡採用的是系統應用工具包裡面的工具類。兩者序列化/反序列化的方法一樣,但是採用的依賴包不一樣,分散式服務框架中採用的是jackson 1.x版本的codehaus包,而工具類用的是2.0版本的fastxml包。
考慮到是因為兩者不相容導致日期解析不對,但又不能直接使用底層框架中的序列化/反序列化類,因為專案中還使用到了很多工具類中有而底層框架中沒有的其他方法。為了保持版本一致,將所有的依賴fastxml包的工具類的依賴包都替換成1.x版本的codehaus包,除了JsonUtil工具類之外。為什麼這個類不改呢?因為依賴包的主版本號都不一致,很多api也不一樣了,如果要改動這個類的話,就要修改很多方法,同時系統中使用到JsonUtil工具類的地方很多,都要做相應的修改,這個工作量還是不小的,再考慮到這個類不涉及日期解析(實際上toString方法暗含,所以留了坑),應該問題不大。
把相應的依賴包都替換之後,經過測試發現引數可以正常校驗,正常綁定了,大鬆一口氣,趕緊提交給測試。但是好景不長,第二天發現又出現問題了。原因就在沒有修改的JsonUtil工具類上,該類的toString方法將物件轉換成json字串,然而凡是物件中含有日期的,轉換成字串時只有年月日,沒有時分秒,即yyyy-MM-dd的格式,後面的時分秒丟失了。
沒辦法,只好把JsonUtil工具類一併修改了,這個工作量有點大,在花了一個下午和晚上加班之後,終於把要改的地方都改完了。再次提交測試。測試了一天之後又出現了新的問題。跟其他系統互動時,有個時間欄位應該是yyyy-MM-dd hh:mm:ss型別,而實際中卻是時間的long型,導致其他系統接收到該引數後解析異常。
一氣之下,將程式碼全部回滾。這次不管測試怎麼催,都沒有急著動手,而是靜下心來考慮。突然靈感來了,既然介面引數只是一個數據轉換物件,為什麼非得把dateTime設定為Date型別呢?上游系統傳過來的是一個json字串,我用String型別接收,然後在需要的時候通過日期格式化工具格式化一下不就行了嘛。然後簡單的將日期欄位的型別改為String型別,並在需要的地方轉換成日期格式,問題完美解決。
問題根源探究
但是問題解決了還不夠,知其然還要知其所以然。既然原來引數能夠正常繫結為什麼改造後不行呢?在所有的過濾器和攔截器中打上斷點進行debug,發現進來的請求引數都是正常的,一直執行到springmvc的invokeHandleMethod方法的RequestContextUtils.getInputFlashMap(request)這裡的時候,得到的請求引數變成了null。但是springmvc是不會有問題的,為什麼到這裡變成了空值呢?問題似乎走進了死衚衕。然後我發現一開始從過濾器進來的request中就有一個屬性是objectMapper,該屬性一看就是為了把json字串轉換成對應引數物件中的屬性欄位的。而這時候我才想到,系統的引數繫結註解並不是用的springmvc中的requestBody,而是自定義的引數繫結註解。再debug請求引數解析器,發現日期經過objectMapper轉換的時候產生了異常:not a valid representation (error: Can not parse date “xxxxxxxx xx:xx:xx”: not compatible with any of standard forms (“yyyy-MM-dd’T’HH:mm:ss.SSSZ”, “yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”, “EEE, dd MMM yyyy HH:mm:ss zzz”, “yyyy-MM-dd”))。
終於真相大白,原來是自定義引數解析器中的objectMapper用錯了。之前引數解析器用的是fastxml的objectMapper的,而更換之後則用的是codehaus的objectMapper。這樣一個看似不起眼的問題,導致出現問題的這兩天被各種催促,心情煩躁無比。最後問題解決時大鬆一口氣,搞清楚問題的原因後則更有成就感。下面針對出現的問題進行分析。
首先是第一個問題。日期欄位用fastxml包中的方法進行反序列化,以codehaus為基礎的解析器的objectMapper無法解析。原因是此時物件對映器做反序列化時會呼叫預設的deserialize方法(沒有指定日期格式,則會反序列化異常),而根本不會呼叫日期欄位上註解的反序列化方法。因此導致請求引數反序列化出現異常,無法正確繫結到介面方法的引數接收物件上。也就是說,反序列化註解和使用的方法所依賴的包必須和物件對映器所依賴的包保持一致,反序列化重寫的方法才能被使用。下面看一個例子:
String json = "{\"name\":\"haha\",\"id\":1,\"time\":\"2017-08-25 17:08:05\"}";
ObjectMapper objMapper = new ObjectMapper();
try {
Student acc = objMapper.readValue(json, Student.class);
System.out.println(acc);
} catch (JsonParseException e) {
e.printStackTrace();
} catch (JsonMappingException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
這裡物件對映器使用的是codehaus包。Student類中在time欄位上使用註解:
@JsonDeserialize(using = JacksonDateTimeParse.class)
這裡的註解使用的是fastxml包,反序列化類如下:
public class JacksonDateTimeParse extends JsonDeserializer<Date> {
private static final Logger LOG = LoggerFactory.getLogger(JacksonDateTimeParse.class);
@Override
public Date deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
String date = jsonParser.getText();
if (date == null || date.trim().length() == 0) {
return null;
}
try {
return format.parse(date);
} catch (Exception e) {
LOG.error("日期解析出錯",e);
}
return null;
}
}
類依賴的包也是fastxml的包。物件對映器和反序列化的包不一致,導致反序列化重寫的方法根本就不會被呼叫,將json字串轉換為物件時會報錯(因為日期格式不對)。如果將兩者依賴的包進行統一,不管是codehaus還是fastxml,只要是一致的就沒有問題。
然後再看第二個問題。在替換反序列化方法所在類的依賴包之後,反序列化註解和物件對映器依賴的包保持一致(都為codehaus),雖然可以正確繫結,但是在日期屬性輸出為字串的時候,丟掉了年月日後面的時分秒。看一下JsonUtil工具類(該類依賴fastxml)中的toString方法。
public static String toString(Object obj) {
return toString(obj, true);
}
public static String toString(Object obj, boolean includeNull) {
if (null == obj) {
return null;
}
if (obj instanceof String) {
return (String) obj;
}
try {
return getMapper(includeNull).writeValueAsString(obj);
} catch (JsonGenerationException e) {
LOG.error("JsonGenerationException error", e);
} catch (JsonMappingException e) {
LOG.error("JsonMappingException error", e);
} catch (IOException e) {
LOG.error("IOException error", e);
}
return null;
}
使用的getMapper(includeNull).writeValueAsString(obj)將obj輸出為字串。而getMapper是這樣的:
private static ObjectMapper getMapper(boolean serializeNull) {
ThreadLocal<ObjectMapper> tl = serializeNull ? INCLUDE_NULL_MAPPER : NOT_INCLUDE_NULL_MAPPER;
if (null == tl.get()) {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd"));
if (!serializeNull) {
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.disable(SerializationFeature.WRITE_NULL_MAP_VALUES);
}
tl.set(mapper);
}
return tl.get();
}
該方法從ThreadLocal容器中獲取物件對映器mapper,然後設定mapper的日期格式為yyyy-MM-dd。這樣一來,在序列化的時候,就會呼叫相應依賴包中的serilize方法,將日期欄位序列化為yyyy-MM-dd格式的字串,而丟掉了後面的時分秒。
再看第三個問題。將JsonUtil依賴的包也改為codehaus,再呼叫修改後的toString方法將物件輸出為json字串,修改後toString方法中的獲取到的物件對映器objectMapper沒有設定日期格式,日期欄位正常輸出。原因是因為Date欄位上這個註解:
@JsonSerialize(using = JacksonDateTimeFormat.class)
在JacksonDateTimeFormat類中重寫了序列化方法
public class JacksonDateTimeFormat extends JsonSerializer<Date> {
@Override
public void serialize(Date value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonProcessingException {
String formattedDate = "";
if(value != null) {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
formattedDate = formatter.format(value);
}
jgen.writeString(formattedDate);
}
}
由於此時註解和序列化方法的依賴包與物件對映器objectMapper的依賴包一致,因此通過objectMapper進行序列化時該序列化方法會被呼叫,日期格式被設定為了yyyy-MM-dd HH:mm:ss,正常輸出。那為什麼有的日期欄位又會以long型的時間戳進行輸出呢?這是因為日期欄位上沒有加上序列化註解,預設以long型時間戳格式輸出。
總結
至此本次出現的問題都可以解釋清楚了。當出現問題時,由於時間緊迫,很多東西都沒有仔細考慮清楚就急急忙忙地去改,由於不清楚具體的原因導致改了這個問題出現那個問題,頭疼醫頭,腳疼醫腳的做法不可取。最好的處理方式還是要找到確定的原因,才能避免連鎖反應。回過頭來看問題,最好的解決方案仍然是將接收引數物件的日期欄位改為String型別。這樣就不用管日期的序列化反序列化問題了。然後第二種方法就是仍然採用原來的引數繫結解析器,用fastxml中的objectMapper,日期欄位的序列化和反序列化也照原來的方式不變。最差的方法就是將所有fastxml包替換成codehaus包,和底層框架的依賴包統一。但這是不好的,一是因為修改程式碼工作量大,而是codehaus版本是老的版本,不推薦使用。