在 Python 中使用 JSON 時須要注意的編碼問題

寫這篇文章的原因是我使用 reqeusts 庫請求接口的時候, 直接使用請求參數裏的 json 字段發送數據, 可是服務器沒法識別我發送的數據, 排查了很久才知道 requests 內部是使用 json.dumps 將字符串轉成 json 的, 而 json.dumps 默認狀況下會將 非ASCII 字符轉義, 也就是我發送數據中的中文被轉義了, 因此服務器沒法識別. 這篇文章雖然是 json.dumps 問題的總結, 但也會涉及到 字符編碼 問題, 因此就簡單先說一下 字符編碼.html

Python 中的字符編碼

Python3 中, 字符 在內存中是使用 Unicode 存儲的, 常規的字符使用 兩個字節 表示, 一些很生僻的字符就須要 四個字節. 默認使用 Unicode 存儲是什麼意思呢, 那就是例子來解釋一下, 在 Python Shell 中輸入如下字符串 '\u4e2d\u6587', 觀察其輸出:python

In [51]: '\u4e2d\u6587'
Out[51]: '中文'
複製代碼

輸出的爲 中文 兩個字. 其實 \u4e2d\u6587 分別表示 Unicode 編碼(術語稱爲 碼點)的 十六進制 表示, 在 Python3 中以 \u 開頭的字符串被解析爲 Unicode 字符, 而後經過其十六進制 碼點 解析出具體的字符, 因此 中文 的內存表示即爲 \u4e2d\u6587.json

獲取字符 Unicode 碼點

標準庫提供了 ord 函數獲取一個字符的 Unicode 碼點, 使用 chr 函數將 碼點 轉換成 字符, 下面是示例:bash

In [54]: ord('中')
Out[54]: 20013

In [56]: chr(20013)
Out[56]: '中'
複製代碼

輸出的 碼點 是使用 十進制 表示的, 可使用如下代碼將十進制數字格式化成十六進制字符串:服務器

'{0:04x}'.format(20013)
複製代碼

使用 json.dumps

有了前面的鋪墊, 就能夠來講說 json.dumps 了. 下面以一個例子展開:網絡

In [121]: json.dumps('中文', ensure_ascii=True)
Out[121]: '"\\u4e2d\\u6587"'

In [122]: json.dumps('中文', ensure_ascii=False)
Out[122]: '"中文"'
複製代碼

能夠看到, 在 ensure_asciiTrue 的狀況下, 中文 被編碼成了 Unicode 碼, 爲 False 才能正常顯示, 但參數名爲何叫 ensure_ascii 呢? 來看一下 官方文檔 對這個參數的解釋:函數

若是 ensure_ascii 是 true (即默認值),輸出保證將全部輸入的非 ASCII 字符轉義。若是 ensure_ascii 是 false,這些字符會原樣輸出。
複製代碼

如今稍微明白了, 在 ensure_asciiTrue 的狀況下, 若是字符串中存在 非ASCII 字符就將其轉義, 根據結果能夠知道這個字符被轉義爲 Unicode 編碼並格式化成了一個字符串, 注意 "\\u4e2d\\u6587""\u4e2d\u6587" 是不一樣的, 前者是長度爲 12 的字符串, 後者則會被 Python 直接解析爲 中文, 長度爲 2. 這也就是我一開始出現的問題, 直接將轉義的字符串在網絡上傳輸可能會沒法被識別. 好比 中文 被轉義成 \\u4e2d\\u6587, 而服務器若是不知道它是被轉義過的字符串, 那它就是一個長度爲 12 的普通字符串, 確定會識別出錯. 而將 ensure_ascii 設爲 False 就不會進行轉義, 使用原始字符.編碼

識別轉義字符

若是服務器收到數據後發現是被轉化過的, 那怎麼識別呢? 其實被轉義字符串與使用 unicode_escape 對字符串進行編碼再使用 utf-8 進行解碼的結果一致, 代碼以下:spa

In [129]: msg
Out[129]: '中文'

In [130]: msg.encode('unicode_escape').decode('utf-8')
Out[130]: '\\u4e2d\\u6587'
複製代碼

因此識別只要反過來使用 utf-8 編碼再使用 unicode_escape 解碼就能夠了.調試

轉義是如何進行的

如今來看一下 json.dumps 究竟是怎麼對字符進行轉義的. 在 json.dumps 源碼中仔細調試的話會發現, 它調用的是 JSONEncoder.encode 方法, 而 encode 中的代碼片斷以下:

if self.ensure_ascii:
    return encode_basestring_ascii(o)
else:
    return encode_basestring(o)
複製代碼

它會根據 ensure_ascii 的值選擇調用函數. 而 encode_basestring_ascii 的值是 (c_encode_basestring_ascii or py_encode_basestring_ascii), 也就是默認是用 C 實現的版本, 其次使用 Python 實現的版本, 既然有 Python 版本, 固然要看一下是怎麼實現的, py_encode_basestring_ascii 能夠直接使用 from json.encoder import py_encode_basestring_ascii 導入, 直接在其內部就能夠調試. 下面是其源碼:

def py_encode_basestring_ascii(s):
    """Return an ASCII-only JSON representation of a Python string """
    def replace(match):
        s = match.group(0)
        try:
            return ESCAPE_DCT[s]
        except KeyError:
            n = ord(s)
            if n < 0x10000:
                return '\\u{0:04x}'.format(n)
                #return '\\u%04x' % (n,)
            else:
                # surrogate pair
                n -= 0x10000
                s1 = 0xd800 | ((n >> 10) & 0x3ff)
                s2 = 0xdc00 | (n & 0x3ff)
                return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)
    return '"' + ESCAPE_ASCII.sub(replace, s) + '"'
複製代碼

從最後的 return 能夠看到它其實是 正則匹配替換 而後在先後添加 雙引號. ESCAPE_ASCII 的定義以下:

ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])')
複製代碼

其中 ([\\"] 用於匹配 \\", 而 [^\ -~] 表示 \ -~ 取反(這裏的反斜槓貌似是對空格進行轉義, 我不是很理解, 不進行轉義依舊能夠匹配到), 在 ASCII 表裏, 空格字符 對應十進制是 40, ~176, 這是全部的 可打印字符, 取反就是全部編碼不在 40 ~ 176 的字符, 因此中文就會被匹配到, 下面爲 ASCII表:

ASCII表

對於匹配到的字符, 會傳入回調函數 replace 作轉義. replace 函數中的 ESCAPE_DCT 爲:

ESCAPE_DCT = {
    '\\': '\\\\',
    '"': '\\"',
    '\b': '\\b',
    '\f': '\\f',
    '\n': '\\n',
    '\r': '\\r',
    '\t': '\\t',
}
複製代碼

先從 ESCAPE_DCT 中獲取 製表符換行符 等經常使用字符的轉義, 若是失敗就獲取它的 Unicode 碼點, 而後判斷是否爲小於 0x10000 便是否爲 兩字節 字符(兩字節最大爲 0xFFFF ) , 若是是就格式化爲 Unicode 碼, 若是不是就使用 四字節 表示.

總結

因此在使用 requests 時, 若是數據要使用 json 傳輸而且有 中文, 那麼須要手動將 字典 進行 dump.

相關文章
相關標籤/搜索