真 · Python 序列化/反序列化库:marshmallow

前言

本文通过实际项目的经历,经过搜索与比较,终于找到了好用的 Python 序列化库:marshmallow。
它拥有类似 django 的语法,支持详细的自定义设置,适合各种各样的序列化/反序列化情景。

问题

我们都知道,Python 自带了一些序列化的库,例如 json、pickle、marshal 等。现在的问题是,要向一个 HTTP 服务器提交一个 POST 请求,附带一个 JSON 作为 payload,假设这个 payload 是这样的:

{
  "name": "abc",
  "price": 1.23456,
  "date": "2021-06-26"
}

那么,它对应的 Python 定义是

from dataclasses import dataclass
import datetime

@dataclass
class Item:
    name: str
    price: float
    date: datetime.date

所以,这个 POST 请求会是这样发送的:

import json
import requests

item = Item('abc', Decimal('1.23456'), '2021-06-26')
requests.post(url, data=json.dumps(item))

这样一跑,马上给报错:TypeError: Object of type Item is not JSON serializable。嗯?????怎么报错了?才知道 Python 自带的 json 库不支持序列化一个自定义的 class,仅仅支持那几个 Python 内置的类,如 dict, str, float, list, bool。对此,网上是有一些解决方法,但其实都是妥协之举:如果服务器返回一个 {"name": "abc","price": 1.23456,"date":"2021-06-26"},能方便地反序列化成实例么?

遇到这种问题,首先就想起“不要自己造轮子”的原则。Python 作为一门非常成熟的语言,就没有什么 pythonic 的解决方案?然后我去百度、Google,似乎还真没有,marshmallow 已经是我能找到的最好的了。

手上还有 JVM 的项目,可以用 jackson/fastjson 等库,那才是方便啊,类型传进去后,正反序列化一气呵成。

如果自己给这个类写一个 to_json() 方法,手动构造一个 dict 呢?想想还是不行,如果未来要修改这个类的属性,那么还得对应改。什么?你说连这个类都不要了,post 的时候直接现场构造一个 dict?这绝对是在给自己挖坑啊。

所以,marshmallow 赶紧学起来。

Marshmallow

Schema

最重要的,是定义 schema。对于刚才的 Item 类,对应的 schema 会这样写:

@dataclass
class Item:
    name: str
    price: Decimal
    date: datetime.date

# 对于其他的所有 fields, 可以参考文档
# https://marshmallow.readthedocs.io/en/stable/marshmallow.fields.html#api-fields

from marshmallow import Schema, fields

class ItemSchema(Schema):
    name = fields.String()
    price = fields.Decimal()
    date = fields.Date()

如果以前接触过 django,应该对这种写法很熟悉,不过不熟悉也没关系。

序列化

然后,就可以用 dump() 序列化了。

item = Item('abc', Decimal('1.23456'), datetime.date(2021, 6, 26))

schema = ItemSchema()
result_obj = schema.dump(item)
print(result_obj)

# 会输出 {'price': Decimal('1.23456'), 'name': 'abc', 'date': '2021-06-26'}

可以看到,在定义好的 schema 的帮助下,item 实例变成了一个 dict。

如果想输出一个字符串呢?可以把 schema.dump 换成 schema.dumps(不过由于 price 是一个 Decimal 实例,而自带的 json 不支持 Decimal 会报错,可以考虑另外装一个 simplejson 库来解决。这个问题在后文会再讲到)

反序列化

使用 load() 进行反序列化:

input_data = { 'name': 'abc', 'price': Decimal('1.23456'), 'date': '2021-06-26'}

load_result = schema.load(input_data)
print(load_result)

# 输出 {'name': 'abc', 'date': datetime.date(2021, 6, 26), 'price': Decimal('1.23456')}

注意到 date 属性变成了一个 Python 的 datetime.date 实例,这是我们希望的。

这里只是反序列化成了一个 dict,那么怎样才能返回一个真正的 Item 实例?可以给 schema 添加一个返回 Item 的方法,并打上 post_load 的注解(装饰器)。

from marshmallow import Schema, fields, post_load

class ItemSchema(Schema):
    name = fields.String()
    price = fields.Decimal()
    date = fields.Date()

    @post_load
    def make_item(self, data, **kwargs):
        return Item(**data)

result_obj = schema.dump(item)
print(result_obj)
# 返回 Item(name='abc', price=Decimal('1.23456'), date=datetime.date(2021, 6, 26))

可以,有点样子了。

数据校验

既然有了 schema,当然可以顺便在 load 时做校验了。例如输入的字段有没有多,有没有缺,属性的类型对不对,值的范围有没有超,哪些属性可以填 None(null)等等。这里不再赘述,见文档

自定义字段

Marshmallow 库自带的一些类型可能不够满足需求,例如想加一个 省份、身份证 什么的,用字符串的话好像感觉欠缺了一点校验。而刚才提到的 Decimal 比较难处理,我觉得也可以用自定义字段来解决。

例如,可以用 Method 字段来自定义正反序列化时所使用的方法。

class ItemSchema(Schema):
    name = fields.String()
    price = fields.Method('price_decimal_2_float', deserialize='float_2_decimal')
    date = fields.Date()

    @post_load
    def make_item(self, data, **kwargs):
        return Item(**data)

    def price_decimal_2_float(self, item: Item):
        return float(item.price)

    def float_2_decimal(self, float):
        return decimal.Decimal(str(float))

这样就可以正常用 dumps(), loads() 了:

result_str = schema.dumps(item)
print(result_str)
# {"date": "2021-06-26", "name": "abc", "price": 1.23456}

input_str = '{"date": "2021-06-26", "name": "abc", "price": 1.23456}'
load_result = schema.loads(input_str)
print(load_result)
# Item(name='abc', price=Decimal('1.23456'), date=datetime.date(2021, 6, 26))

太棒了,输入的 JSON 的 date 和 price,在反序列化后,自动变成了 datetime.dateDecimal

小结

Marshmallow 的核心是 schema,数据类型、校验等都记录在 schema 中,从而支持复杂对象的序列化和反序列化。如果说它有什么缺点,那必须是它仍然需要专门定义一个 schema 才能使用。如果是 JVM 系列的 jackson 等库,可以直接使用 data class 作为 model/schema,额外的配置通过注解等方式引入,这样会少一个对应的 schema 类。marshmallow 的做法使类的数量双倍了。总体来说,它仍然是不造轮子的情况下的好选择。

至于更深入的用法,还是需要参考官方文档,见文末。

相关参考


已有2条评论 发表评论

  1. yjcoshc /

    不可以直接用反射获取python类中的成员变量吗?比如这种:https://stackoverflow.com/questions/1398022/looping-over-all-member-variables-of-a-class-in-python
    变量取名的时候取m_name、m_price和m_date,然后写个to_json(),按照上面的方法然后filter。

    说起来枫哥还是彻底转码了吗。。。

    1. 7forz / 本文作者

      序列化不是问题,但是反序列化会比较麻烦..?
      是啊😂互联网金融码农

发表评论