在 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.

相关文章
相关标签/搜索