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的新版本,各項目引用。

我有時候愛說一句很裝的話:一個問題,如果你找到了一個解決方案,那麼說明你還沒有理解這個問題。