JSON金額解析BUG的解決過程
- 2019 年 10 月 8 日
- 筆記
【原創申明:文章為原創,歡迎非盈利性轉載,但轉載必須註明來源】
這是在我們開發的一個支付系統中暴露的一個BUG,問題本身比較簡單,有意思的是解決問題的過程。將過程分享出來,希望能夠對大家有所幫助。
一、錯誤現象
在我們的支付系統中,有一個賬戶模塊負責記錄交易的流水,以供後續的查詢以及對賬清賬等功能使用。就在春節放假前最後一天,當客戶完成交易後,運營同事發現一個天大的問題,流水表中的部分金額,跟提交支付的金額有出入,差了幾分錢。
這位客官說了,幾分錢的問題,還是問題?哈哈,我也這麼想,奈何運營、產品、測試同事們都不答應。好吧,其實我們程序猿是有潔癖的,怎麼容忍有這樣的問題出現?把火車票、機票都先放在看不見的地方,解決問題先。
先從不同的數據庫中找出付款前後的金額進行比較,發現還真不是個案。這是當時比較的結果,黑體部分有差異。

這些數據中,業務系統的金額跟客戶提交金額相等,賬戶記錄的金額有異。
二、分析並定位問題
1.數據流轉過程
下圖是一個簡略的支付、記錄流水的過程。

通過檢查各個環節的報文及數據庫中保存的數據,發現問題出在第4步,金額在支付系統中無誤,發送到賬戶系統並保存到數據庫後就出現了誤差。這兒發生了什麼?
2.賬戶記賬的處理過程
這是一個簡略的處理過程,支付系統生成json並傳輸到賬戶系統,解析後保存到數據庫。

經過查看各個環節的日誌,發現問題出在解析環節。
3.錯誤重現
經過定位、調試,發現問題出在解析json數據的代碼上。賬戶系統接收到傳輸來的json數據後,首先保存在一個字符串content中,然後利用代碼將字符串轉換為json對象。
JSONObject json = JSONObject.fromObject(content);
在Eclipse中設置斷點跟蹤,發現這行代碼執行前後的變量值差異:

在轉換前後,金額從 527726.03 變成了527726,這個差異符合前面觀察到的錯誤現象。仔細查看json字符串,發現金額沒有使用雙引號括起來,說明生成json的時候,直接賦值的是金額,而不是轉成字符串後再賦值。
那麼如果將金額用雙引號括起來,會有這個問題嗎?再測試一下

神奇的是,轉換為字符串後,轉成json就沒有問題了。
我們解析json,使用的是sf的json-lib庫,其他json庫是不是也有問題呢?使用另兩個json庫做了一些測試後發現,只有json-lib有這個問題。
有問題
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.4</version>
<classifier>jdk15</classifier>
</dependency>
沒問題
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20160212</version>
</dependency>
和
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.6.2</version>
</dependency>
三、初步解決方案
根據前面的分析,立刻就有了兩個很自然的解決方案:修改json中金額的格式、換JSON庫。
1.修改json格式
用這個方案,只需要在支付系統中生成json對象的時候,將金額轉成字符串之後在賦值到json即可。
但這種方案有缺點,需要將所有生成json的地方都檢查一遍,確保所有金額都用字符串傳遞。因為這個地方代碼有問題,其他地方代碼也會有問題,只是還沒暴露出來而已。
2.替換json庫
這種方案,可以將json-lib替換為org.json。暫時不考慮gson,是因為這個gson庫需要為json編寫對應的Java類,修改工作量比較大。
那麼,json-lib和org.json在代碼生有什麼差異呢?網上找了找,粗略的比較如下:
|
json-lib |
org.json |
---|---|---|
構造 json 對象 |
JSONObject.fromObject(content) |
new JSONObject(content) |
是否存在key |
containsKey() |
has() |
array方法 |
size() add() |
length() put() |
讀取json的限制 |
|
限制數據格式 |
spring封裝 |
MappingJackson2HttpMessageConverter 支持 |
貌似缺省不支持 |
這種方案的代碼量也是很大,所有涉及到json轉換的地方都需要修改代碼。如果採用替換json庫的方法,有沒有更簡便一點的做法呢?
把《設計模式》裏面的各種名稱想了想,「適配器模式」,能不能用上?
3.替換json庫+適配器
針對這個方案,做了一些技術預演,大概思路如下圖

理想的目標是所有源碼只需要使用一次查找-替換操作即可。

這個方案應該是可行的,只是這兩個適配器類的寫法需要比較嚴謹一點,寫完代碼後需要經過充分的測試無誤,才能真正執行。
四、問題解決了嗎?
前面提到了三種解決方案,從修改工作量上來看,第一種方案應該是最合適的,只需要修改支付系統的代碼即可,代碼也容易定位,修改也不容易出錯。採用適配器的這個方案,看起來很高大上的樣子,但風險較大,暫時先放棄。
還有沒有更簡單的方法?
1.json-lib為什麼會出錯?
負責開發賬戶的同事,下載了json-lib的源碼,進行了進一步的跟蹤調試,更準確的定位到了出錯的位置:是在調用commons-lang.jar中的NumberUtils類中代碼時出錯。下圖是一個簡單的調用過程。

最終出錯的地方是在解析 Float !!重新寫一個最簡單的測試用例,
float floatValue = Float.valueOf("542772.03");
結果,floatValue = 542772.0。這是JDK的Float 數據類型固有的問題,我們同時在JDK1.7和JDK1.8下進行測試,都有這個問題。
同時,順手寫了一個測試用例,找出最小的十個會出錯的金額,如下:
error1131072.01131072.02
error2131072.04131072.05
error3131072.07131072.06
error4131072.09131072.1
error5131072.13131072.12
error6131072.15131072.16
error7131072.18131072.19
error8131072.21131072.2
error9131072.24131072.23
error10131072.26131072.27
基本上每過幾分錢就會出錯。
2.有什麼新的解決方案?
能想到兩個新的方案
1、修改 java.lang.Float
2、修改 org.apache.commons.lang.math.NumberUtils
這兩種方案,技術上可行嗎?要從這個思路上去解決問題,需要解決兩個問題:
1、能不能修改源碼,解決BUG?
2、怎麼讓修改後的類,生效?
考慮到後續需要討論的解決方案,先介紹一個大家可能司空見慣但沒注意過的概念::ClassLoader
3.JVM ClassLoader
參考書目:《深入理解Java虛擬機》,有興趣的自行閱讀。(其實是我也講不清楚)
① Tomcat中的class 加載順序
對於普通java類,按照如下優先級進行加載。
l tomcat/webapps/<war>/WEB-INF/classes
l tomcat/webapps/<war>/WEB-INF/lib/*.jar
l tomcat/lib/*.jar
l jre/lib/*.jar
是不是所有的java類都是這個加載順序?如果可以,我們是不是可以隨便重載jdk自己提供的類?
② JRE ClassLoader
Java在設計的時候已經考慮到這個風險,不能允許隨便替換JRE自己的類。所以,針對JRE自身的代碼,使用的是另一套ClassLoader。對所有java.*和javax.*,使用的加載順序

詳細解析,自行查資料吧,我也不懂。
關鍵是結論:除非我們重寫 JRE的jar,才能通過修改 java.lang.Float來解決問題。何況Float的問題,應該不好修改,否則Java早解決了。
3.怎麼修改NumberUtils
在NumberUtils,方法 createNumber(String)首先調用createFloat(String)解析,如果拋Exception,再調用createDouble(String)。
有兩個自然地修改方案:
1、修改 createNumber(),不再調用 createFloat(),直接調用createDouble()。
2、修改 createFloat(),如果數據解析出錯,拋異常。
下面列了一個粗略的修改createFloat(String)的實現,基本思路是解析後再同原字符串做一個比較,如果值不同則拋異常。
public static Float createFloat(String str) {
if (str == null) {
return null;
}
str = removeZeroTail(str);
Float floatValue = Float.valueOf(str);
if (!removeZeroTail(String.valueOf(floatValue)).equals(str)) {
throw new NumberFormatException(str + " parse float error.");
}
return floatValue;
}
4.修改後的NumberUtils放哪兒?
根據前面對class loader的分析,修改後的NumberUtils類,有兩個保存位置。
① 在賬戶系統中重寫NumberUtils類
將NumberUtils類重寫在src/main/java中,部署後在war/WEB-INF/classes下。
如果採用這個方案,需要在所有的項目中重寫這個類。
③ 重做一個commons-langs.jar
我們使用的版本是2.6,如果能夠重做一個新的版本,並讓各個項目能方便的引用,這個方案應是最簡單的。恰好,我們有內部的Maven庫,分享jar不是問題。
五、最終方案:重做commons-lang.jar
1.代碼修改
這個就不多說了,Eclipse建一個項目,進行必要的修改,然後打包放到內部maven庫中。順便推薦一個搭建maven內部庫的利器:nexus,價格便宜(免費)量又足。當然前提是你需要有一個能夠供大家訪問的服務器。
2.項目修改方案
各項目修改方案,僅需要修改 pom.xml
① 所有引用了commons-lang的depencency
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.4</version>
<classifier>jdk15</classifier>
<exclusions>
<exclusion>
<artifactId>commons-lang</artifactId>
<groupId>commons-lang</groupId>
</exclusion>
</exclusions>
</dependency>
注意exclusion所有的commons-lang老版本引用。
② 引用commons-lang的新版本
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.7.0-SNAPSHOT</version>
</dependency>
六、解決方案的變遷過程
簡單列一下方案變遷過程,
1、支付系統修改json格式的封裝代碼,金額都使用字符串。
2、賬戶系統替換 json 解析包。
3、寫一個 json proxy,從org.json繼承,實現json-lib的接口。
4、在項目中重寫 NumberUtils工具類。
5、重做一個commons-lang的新版本,各項目引用。
我有時候愛說一句很裝的話:一個問題,如果你找到了一個解決方案,那麼說明你還沒有理解這個問題。