Python 優雅地 dumps 非標準型別

z正小歪發表於2017-11-11

在 Python 很經常做的一件事就是 Python 資料型別和 JSON 資料型別的轉換。

但是存在一個明顯的問題,JSON 作為一種資料交換格式有固定的資料型別,但是 Python 作為程式語言除了內建的資料型別以為還能編寫自定義的資料型別。

牆裂推薦:去看看 JSON 官網對 JSON 的介紹:www.json.org/json-zh.htm…

比如你肯定遇到過類似的問題:

>>> import json
>>> import decimal
>>> 
>>> data = {'key1': 'string', 'key2': 10, 'key3': decimal.Decimal('1.45')}
>>> json.dumps(data)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    json.dumps(data)
  File "/usr/lib/python3.6/json/__init__.py", line 231, in dumps
    return _default_encoder.encode(obj)
  File "/usr/lib/python3.6/json/encoder.py", line 199, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/usr/lib/python3.6/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
  File "/usr/lib/python3.6/json/encoder.py", line 180, in default
    o.__class__.__name__)
TypeError: Object of type 'Decimal' is not JSON serializable複製程式碼

那麼問題就來了,如何把各種各樣的 Python 資料型別轉化成 JSON 資料型別。
一種很不 pythonic 的做法就是,先轉換成某種能和 JSON 資料型別直接轉換的值,然後在 dump,這麼做很直接很暴力,但是在各種花式資料型別面前就很無力。

Google 是解決問題的重要方式之一,當你一頓搜尋過後,你就會發現其實可以在 dumps 時 encode 這個階段對資料進行轉化。

所以你肯定是那麼做的,完美地解決了問題。

>>> class DecimalEncoder(json.JSONEncoder):
...     def default(self, obj):
...         if isinstance(obj, decimal.Decimal):
...             return float(obj)
...         return super(DecimalEncoder, self).default(obj)
...     
... 
>>> 
>>> json.dumps(data, cls=DecimalEncoder)
'{"key1": "string", "key2": 10, "key3": 1.45}'複製程式碼

JSON 的 Encode 過程

文中程式碼摘自 github.com/python/cpyt…

刪除了幾乎所有的 docstring,由於程式碼太長,直接擷取了重要片段。可以在片段最上方的連結檢視完整的程式碼。

熟悉 json 這個庫的都知道基本只有4個常用的 API,分別是 dump、dumps 和 load、loads。

原始碼位於 cpython/Lib/json 中

# https://github.com/python/cpython/blob/master/Lib/json/__init__.py#L183-L238

def dumps(obj, *, skipkeys=False, ensure_ascii=True, check_circular=True,
        allow_nan=True, cls=None, indent=None, separators=None,
        default=None, sort_keys=False, **kw):

     # cached encoder
    if (not skipkeys and ensure_ascii and
        check_circular and allow_nan and
        cls is None and indent is None and separators is None and
        default is None and not sort_keys and not kw):
        return _default_encoder.encode(obj)

    if cls is None:
        cls = JSONEncoder

    # 重點
    return cls(
        skipkeys=skipkeys, ensure_ascii=ensure_ascii,
        check_circular=check_circular, allow_nan=allow_nan, indent=indent,
        separators=separators, default=default, sort_keys=sort_keys,
        **kw).encode(obj)複製程式碼

直接看到最後的 return。可以發現如果不提供 cls 預設就使用 JSONEncoder,然後呼叫該類的例項方法 encode。

encode 方法也十分簡單:

# https://github.com/python/cpython/blob/191e993365ac3206f46132dcf46236471ec54bfa/Lib/json/encoder.py#L182-L202
def encode(self, o):
    # str 型別直接 encode 後返回
    if isinstance(o, str):
        if self.ensure_ascii:
            return encode_basestring_ascii(o)
        else:
            return encode_basestring(o)

    # chunks 是資料中的各個部分
    chunks = self.iterencode(o, _one_shot=True)
    if not isinstance(chunks, (list, tuple)):
        chunks = list(chunks)
    return ''.join(chunks)複製程式碼

可以看出最後的我們得到 JSON 都是 chunks 拼接得到的,chunks 是呼叫 self.iterencode 方法得到的。

# https://github.com/python/cpython/blob/191e993365ac3206f46132dcf46236471ec54bfa/Lib/json/encoder.py#L204-257
    if (_one_shot and c_make_encoder is not None
            and self.indent is None):
        _iterencode = c_make_encoder(
            markers, self.default, _encoder, self.indent,
            self.key_separator, self.item_separator, self.sort_keys,
            self.skipkeys, self.allow_nan)
    else:
        _iterencode = _make_iterencode(
            markers, self.default, _encoder, self.indent, floatstr,
            self.key_separator, self.item_separator, self.sort_keys,
            self.skipkeys, _one_shot)
return _iterencode(o, 0)複製程式碼

iterencode 方法比較長,我們只關心最後幾行。

返回值 _iterencode,是函式中 c_make_encoder 或者 _make_iterencode 這兩個高階函式的返回值。

c_make_encoder 是來自 _json 這個 module ,這個 module 是一個 c 模組,我們不去關心這個模組怎麼實現的。
轉去研究同等作用的 _make_iterencode 方法。

# https://github.com/python/cpython/blob/191e993365ac3206f46132dcf46236471ec54bfa/Lib/json/encoder.py#L259-441
def _iterencode(o, _current_indent_level):
    if isinstance(o, str):
        yield _encoder(o)
    elif o is None:
        yield 'null'
    elif o is True:
        yield 'true'
    elif o is False:
        yield 'false'
    elif isinstance(o, int):
        # see comment for int/float in _make_iterencode
        yield _intstr(o)
    elif isinstance(o, float):
        # see comment for int/float in _make_iterencode
        yield _floatstr(o)
    elif isinstance(o, (list, tuple)):
        yield from _iterencode_list(o, _current_indent_level)
    elif isinstance(o, dict):
        yield from _iterencode_dict(o, _current_indent_level)
    else:
        if markers is not None:
            markerid = id(o)
            if markerid in markers:
                raise ValueError("Circular reference detected")
            markers[markerid] = o
        o = _default(o)
        yield from _iterencode(o, _current_indent_level)
        if markers is not None:
            del markers[markerid]
return _iterencode複製程式碼

同樣需要關心的只有返回的這個函式,程式碼裡各種 if-elif-else 逐一把內建型別轉換成 JSON 型別。
在對面無法識別的型別時候就使用了 _default() 這個方法,然後遞迴呼叫解析各個值。

_default 就是最前面那個被覆蓋的 default

到這裡就可以完全瞭解 Python 是如何 encode 成 JSON 資料。

總結一下流程,json.dumps() 呼叫 JSONEncoder 的例項方法 encode(),隨後使用 iterencode() 遞迴轉化各種型別,最後把 chunks 拼接成字串後返回。

優雅的解決方案

通過前面的流程分析之後,知道為什麼繼承 JSONEncoder 然後覆蓋 default 方法就可以完成自定義型別解析了。

也許你以後需要解析 datetime 型別資料,你可定會那麼做:

class ExtendJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, decimal.Decimal):
            return int(obj)

        if isinstance(obj, datetime.datetime):
            return obj.strftime(DATETIME_FORMAT) 

        return super(ExtendJSONEncoder, self).default(obj)複製程式碼

最後呼叫父類是 default() 方法純粹是為了觸發異常。

Python 可以使用 singledispatch 來解決這種單泛型問題。

import json

from datetime import datetime
from decimal import Decimal
from functools import singledispatch

class MyClass:
    def __init__(self, value):
        self._value = value

    def get_value(self):
        return self._value

# 建立三個非內建型別的例項
mc = MyClass('i am class MyClass ')
dm = Decimal('11.11')
dt = datetime.now()

@singledispatch
def convert(o):
    raise TypeError('can not convert type')

@convert.register(datetime)
def _(o):
    return o.strftime('%b %d %Y %H:%M:%S') 

@convert.register(Decimal)
def _(o):
    return float(o)

@convert.register(MyClass)
def _(o):
    return o.get_value()

class ExtendJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        try:
            return convert(obj)
        except TypeError:
            return super(ExtendJSONEncoder, self).default(obj)

data = {
    'mc': mc,
    'dm': dm,
    'dt': dt
}

json.dumps(data, cls=ExtendJSONEncoder)

# {"mc": "i am class MyClass ", "dm": 11.11, "dt": "Nov 10 2017 17:31:25"}複製程式碼

這種寫法比較符合設計模式的規範。假如以後有了新的型別,不用再修改 ExtendJSONEncoder 類,只需要新增適當的 singledispatch 方法就可以了, 比較 pythonic 。

如果你執意的想在類中新增 singledispatch 可以參考: stackoverflow.com/a/24602374/… ,當然我仍然覺得還是不要寫在類中比較好。

相關文章