關於前後端 JSON 解析差異問題與思考

本文主要總結了作者在一次涉及流程表單的需求發佈中遇到的問題及思考總結。

一、問題回顧

在一次涉及流程表單的需求發佈時,由於表單設計的改動,需要在歷史工單中的一個 json 字段增加一個屬性,效果示意如下:

[{"key1":"value1"}] ->  [{"key1":"value1", "key2":"value2"}]

由於歷史數據較多,採用了通過 odc 從數據庫查詢數據,線下開發數據處理腳本,更新數據後生成 sql 去線上執行,腳本示例如下。

String target = JSON.toJSONString(
      JSON.parseObject(oraData).put("key2","value2")
  )

在數據變更時未發現問題,存量工單抽查顯示正常。但在第二天有業務反饋,有部分工單出現前端異常導致無法展示的問題。

根據經驗分析,這類問題一般是前端在處理數據時出現異常導致,考慮到昨天對工單中的一個 json 字段進行了變更,初步推斷應該是該字段變更後影響了前段解析。

爲了驗證猜測,將前端查詢的接口返回數據,拷貝到在線解析 json 的網站中進行解析測試。結果發現頁面解析失敗,觀察發現 json 中包含回車,回車刪除後解析正常。在 dev 驗證刪除後數據解析正常後,應急對線上異常工單進行了處理。

二、問題思考

2.1 JavaScript 如何解析 json 字符串

首先在 Chrome 控制檯做一個簡單的 json 解析實驗,過程如下:

var str = "{\"key\":\"v1\\nv2\"}"
console.log(str)
// 打印結果 {"key":"v1\nv2"}
console.log(JSON.parse(str))
// 打印結果 {key: 'v1\nv2'}
console.log(JSON.parse(str).key)
// 打印結果 v1
// v2

經過測試,可以得到以下結論:

1)JavaScript 在加載字符串時,會自動識別 斜槓 並進行反轉義;

2)在進行 json 解析,會對值對應的字符串進行二次反轉義,將 \ n 解析爲回車字符。

在查閱 JSON 官方對 parse 工具的流程說明後,印證了上述測試。

但在處理過程中,我們看到解釋器的允許通過範圍,有說明不包含 control character。常見的回車、換行等字符都屬於 control character。

所以可以推測,在 JavaScript 解析 json 時,如果遇到 control character,可能會引起異常情況。

爲了驗證猜想做了以下對比實驗。

經過測試可以發現,在 JavaScript 使用的 JSON 解析器 sdk 中,會對於字符串中的 control character 進行校驗,如遇到則會直接拋出異常。由於 JavaScript 在加載字符串時會先做一次字符轉義,會將”\n“轉換爲回車字符,即 (byte) 13,使得解析 JSON 時提示異常。

這裏會出現一個新的疑問,字符轉義這個問題看起來是會經常出現的,爲什麼到現在才碰到一次呢。這裏繼續對 JavaScript 的 JSON 編碼器進行測試。

經過測試可以發現,JavaScript 在對字符串進行 JSON 編碼時,會自動對字符串中的斜槓進行編碼,正好對應了在 JSON 解碼時對字符串自動解碼。到這裏可以明白了,同時僅使用 JavaScript 的 JSON 編碼器和解碼器,會自動對 control character 進行轉義和反轉義處理,不會出現異常。那在 Java 中呢?

2.2 Java 如何解析 json 字符串

在 java 中,常用的 JSON 處理 SDK 是 fastjson,此處以 fastjson 進行測試。

爲了搞清楚在 java 中 fastjson 會如何解析 json 字符串中的 control character,進行了以下實驗:

        Map<String, String> testMap = new HashMap<>();
        testMap.put("key", "v1\nv2");
        System.out.println("1 >> " + JSON.toJSONString(testMap));
        String testStr1 = "{\"key\":\"v1\nv2\"}";
        System.out.println("2 >> " + testStr1);
        System.out.println("3 >> " + JSON.parseObject(testStr1).getString("key"));
        String testStr2 = "{\"key\":\"v1\\nv2\"}";
        System.out.println("4 >> " + testStr2);
        System.out.println("5 >> " + JSON.parseObject(testStr2).getString("key"));

打印結果如下:

1 >> {"key":"v1\nv2"}
2 >> {"key":"v1
v2"}
3 >> v1
v2
4 >> {"key":"v1\nv2"}
5 >> v1
v2

經過測試,我們可以得出以下結論:

1)java 的 fastjson 在進行 json 解析前,加載字符串時也會進行反轉義處理,對於 \ n 等 control character 也會轉義爲對應的 byte 值;

2)java 的 fastjson 在對 json 中的字符串進行解碼時,會將字符串中的轉義最進行反轉義處理;

3)java 的 fastjson 在對 json 中的字符串進行解碼時,不會受到字符串中 control character 的影響,可以正常提取值。

爲了驗證上面的推論,這裏截取了 fastjson 關於解析 String 字符串的部分源碼。

public final void scanString() {
        np = bp;
        hasSpecial = false;
        char ch;
        for (;;) {
            ch = next();
            if (ch == '\"') {
                break;
            }
            if (ch == EOI) {
                if (!isEOF()) {
                    putChar((char) EOI);
                    continue;
                }
                throw new JSONException("unclosed string : " + ch);
            }
            if (ch == '\\') {
                     if (!hasSpecial) {
                         hasSpecial = true;
                                            if (sp >= sbuf.length) {
                        int newCapcity = sbuf.length * 2;
                        if (sp > newCapcity) {
                            newCapcity = sp;
                        }
                        char[] newsbuf = new char[newCapcity];
                        System.arraycopy(sbuf, 0, newsbuf, 0, sbuf.length);
                        sbuf = newsbuf;
                    }
                    copyTo(np + 1, sp, sbuf);
                    // text.getChars(np + 1, np + 1 + sp, sbuf, 0);
                    // System.arraycopy(buf, np + 1, sbuf, 0, sp);
                }
                ch = next();
                switch (ch) {
                    case '0':
                        putChar('\0');
                        break;
                    case '1':
                        putChar('\1');
                        break;
                    case '2':
                        putChar('\2');
                        break;
                    case '3':
                        putChar('\3');
                        break;
                    case '4':
                        putChar('\4');
                        break;
                    case '5':
                        putChar('\5');
                        break;
                    case '6':
                        putChar('\6');
                        break;
                    case '7':
                        putChar('\7');
                        break;
                    case 'b': // 8
                        putChar('\b');
                        break;
                    case 't': // 9
                        putChar('\t');
                        break;
                    case 'n': // 10
                        putChar('\n');
                        break;
                    case 'v': // 11
                        putChar('\u000B');
                        break;
                    case 'f': // 12
                    case 'F':
                        putChar('\f');
                        break;
                    case 'r': // 13
                        putChar('\r');
                        break;
                    case '"': // 34
                        putChar('"');
                        break;
                    case '\'': // 39
                        putChar('\'');
                        break;
                    case '/': // 47
                        putChar('/');
                        break;
                    case '\\': // 92
                        putChar('\\');
                        break;
                    case 'x':
                        char x1 = next();
                        char x2 = next();
                        boolean hex1 = (x1 >= '0' && x1 <= '9')
                                || (x1 >= 'a' && x1 <= 'f')
                                || (x1 >= 'A' && x1 <= 'F');
                        boolean hex2 = (x2 >= '0' && x2 <= '9')
                                || (x2 >= 'a' && x2 <= 'f')
                                || (x2 >= 'A' && x2 <= 'F');
                        if (!hex1 || !hex2) {
                            throw new JSONException("invalid escape character \\x" + x1 + x2);
                        }
                        char x_char = (char) (digits[x1] * 16 + digits[x2]);
                        putChar(x_char);
                        break;
                    case 'u':
                        char u1 = next();
                        char u2 = next();
                        char u3 = next();
                        char u4 = next();                        int val = Integer.parseInt(new String(new char[] { u1, u2, u3, u4 }), 16);
                        putChar((char) val);
                        break;
                    default:
                        this.ch = ch;
                        throw new JSONException("unclosed string : " + ch);
                }
                continue;
            }
            if (!hasSpecial) {
                sp++;
                continue;
            }
            if (sp == sbuf.length) {
                putChar(ch);
            } else {
                sbuf[sp++] = ch;
            }
        }
        token = JSONToken.LITERAL_STRING;
        this.ch = next();
    }

閱讀源碼後,我們可以驗證我們的猜想,得出以下結論:

1)fastjson 在解析字符串時,會自動將遇到的轉義字符進行反轉義;

2)對於不需要特殊處理的字符,直接存入數據,繼續處理下一字符。

2.3 Java 和 JavaScript 交互如何出現 JSON 解析問題

經過上面對 fastjson 和 JavaScript 在 JSON 處理的區別,可以清晰的看出來本次問題是如何出現的。

起初在 JavaScript 進行 JSON 編碼和解碼時,會在編碼時額外對 \ n 等 control character 進行一次反轉義。結果是在 JavaScript 對 json 字符串解碼時,可以經過兩次反轉義再得到 control character,不會觸發校驗引起意外情況。

原始數據:[{"key1","v1\\nv2"}]

但是在需要追加數據時,由於數據加工處理是使用 java 腳本進行處理的,因此出現了編碼差異。fastjson 在解析 JSON 數據時,自動將 \ n 等轉義字符進行反轉義,解析爲 control character。但是在加工處理後,將 JSON 數據編碼爲 json 字符串時,僅僅對 control character 進行了一次處理,還原爲 \n 形式。

更新後數據:[{"key1","v1\nv2"},{"key2","value2"}]

更新到數據庫後,在前端查詢解析時,由於在字符串加載時自動反轉義出了 control character,就會出現由於字符無法識別解碼錯誤的問題。

針對該問題,後續如果還有類似需求進行處理,需要編碼得到數據後,對 json 字符串中的所有 \ 再次進行一次反轉義,使得數據可以通過兩次反轉義再得到實際期望字符,即可解決該問題。

期望正確數據:[{"key1","v1\\nv2"},{"key2","value2"}]

三、經驗總結

1)在需要進行數據處理的時候,儘量保持只有一方對數據進行加工和解析。如果具備條件則數據的編碼、解碼全部由後端處理;或者由於需求影響,全部由前端進行編碼、解碼,對於多個合作方使用了未知 SKD 編解碼數據的場景也可以複用相同的原則,可以很大程度上避免該類問題發生。

2)在開發過程中,對於數據處理類 sdk 的升級、替換等操作需要慎之又慎。不同 sdk 的數據處理方式可能存在很多細小的差別,簡單替換容易爲日後出現事故埋下隱患。在進行升級、替換前一定要要針對與各類可能出現的數據情況進行充分驗證。‍

阿里雲開發者社區,千萬開發者的選擇

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XOxbSN86iew5wAJkK1K1Xw