Сериализация и десериализация данных — это преобразование между необработанной структурой данных и экземплярами классов для их хранения и передачи. Например, преобразование объектов Python в JSON-представление. Мы рассмотрим две популярные Python-библиотеки Marshmallow и Pydantic, которые помогут нам справиться как с преобразованием, так и с валидацией данных. Сначала я представлю вам каждую библиотеку, используя небольшие примеры, а потом мы сравним их и разберем различия. Я также расскажу, чего вам стоит избегать при работе с обеими библиотеками.

Введение

Прежде чем передавать данные в другие системы или сохранять их, вам потребуется сериализовать объекты Python в необработанную («сырую») структуру данных, которую можно передать по сети, сохранить или также десериализовать, если ваша система получает данные по сети или из хранилища. Встроенные в Python решения, такие как pickle или json, достаточно ограниченны. Pickle работает только с сериализацией и десериализацией подконтрольных вам классов, и вам нужны быть уверенными в том, что название модуля или пакета не было изменено, иначе десериализация сломается. Модуль json для Python умеет преобразовывать данные из строки в словарь и наоборот, и не способен десериализовать в экземпляр вашего класса. Поскольку работа с экземплярами ваших собственных классов намного тоньше, чем работа с dicts, преобразование вам просто необходимо. Это особенно очевидно, если вы работает с внешними данными, полученными из REST-сервиса, или разбираете JSON-, XML- или YAML-файлы, которые вы не контролируете. Библиотеки Marshmallow и Pydantic позволяют выполнять эти операции с минимальными усилиями. Есть еще несколько других библиотек со схожими возможностями, но они либо редко используются, либо не поддерживаются на момент написания статьи, либо являются частью больших фреймворков (например, Django Rest Framework), а это то же самое, что развозить пиццу укладчиком асфальта. 

Преобразование, валидация и схемы данных

Как показано на рисунке ниже, преобразование необработанных данных в объекты Python происходит в два этапа:

Чтобы валидация работала, вам необходимо определить схему. Рассмотрим пример:

from dataclasses import dataclass
from typing import Optional, List

@dataclass
class Book:
    title: str
    isbn: str

@dataclass
class User:
    email: str
    books: Optional[List[Book]] = None

Эти простые классы данных содержат правильные типы (например, str для title и isbn), но много информации отсутствует. Название книги может быть ограничено 120 символами в вашем приложении. Кроме того, не каждая возможная строка является действительным номером ISBN или действующим адресом электронной почты. Следовательно, вам нужно каким-то образом явно указать в полях ваши знания предметной области. Давайте посмотрим, как это можно сделать с помощью Marshmallow и Pydantic.

Десериализация с помощью Marshmallow

После установки marshmallow через pip определим нашу первую схему:

from marshmallow import Schema, fields, validate

def validate_isbn(isbn: str) -> None:
    """ Код проверки isbn (опущено), бросает исключение 
    marshmallow.ValidationError если валидация не прошла"""

class BookSchema(Schema):
    title = fields.String(required=True, validate=validate.Length(max=120))
    isbn = fields.String(required=True, validate=validate_isbn)

class UserSchema(Schema):
    email = fields.Email(required=True)
    books = fields.Nested(BookSchema, many=True, required=False)

Нам нужно создать два новых класса BookSchema и UserSchema, которые определяют поля, их типы и дополнительные валидаторы. Marshmallow поставляется с большим количеством готовых к использованию валидаторов (например, Email или Length), и в этом примере мы определили собственный ISBN-валидатор как простую функцию (реализация опущена для краткости).

Взглянем на процесс десериализации:

import json

raw_data_str = """{
    "email": "foo@bar.com",
    "books": [
        {"title": "Автостопом по Галактике", "isbn": "123456789"},
        {"title": "Идеальный программист", "isbn": "987654321"}
    ]
}"""
json_data = json.loads(raw_data_str)
schema = UserSchema()
user = schema.load(json_data) # бросает исключение если валидация не прощла

Объект user выглядит так же, как json_data, но это очищенная, проверенная версия, содержащая только те поля, о которых знает UserSchema. Чтобы заставить Marshmallow создавать объекты классов Book и User, мы должны добавить, в каждую нашу схему метод перехвата с декоратором post_load, который выглядит следующим образом (пример только для UserSchema):

from marshmallow import post_load
class UserSchema(Schema):
    .... for other stuff see above ....
    @post_load
    def make_obj(self, data, **kwargs): # название метода может быть любым
        return User(**data)

Благодаря этому, переменная user будет объектом User, в котором атрибут books представляет собой список объектов Book (при условии, что вы также реализовали post_load для класса BookSchema).

Хотя этот подход обеспечивает хорошее разделение между определениями классов и схемой, он требует большого количества кода. К счастью, есть пакет marshmallow-dataclass. С его помощью вы можете достичь того же результата, дополнив классы данных User и Book:

import json
from dataclasses import dataclass, field
from typing import Optional, List
import marshmallow_dataclass
from marshmallow import validate
from marshmallow.fields import Email
@dataclass
class Book:
    title: str = field(metadata=dict(required=True, validate=validate.Length(max=120)))
    isbn: str = field(metadata=dict(required=True, validate=validate_isbn))
@dataclass
class User:
    email: str = field(metadata={"marshmallow_field": Email()})
    books: Optional[List[Book]] = None
"""
UserSchema - это не экземпляр, а объект класса
"""
UserSchema = marshmallow_dataclass.class_schema(User) 
user_schema_instance = UserSchema()  # вот тут создаем экземпляр
json_data = json.loads(raw_data_str)  # raw_data_str определение опущено
user = user_schema_instance.load(json_data)

Получившаяся переменная user — это объект класса User, так как билиотека marshmallow-dataclass сама встраивает post_load в класс.

Десериализация с помощью Pydantic

Pydantic не является прямым конкурентом Marshmallow, потому что цель Pydantic состоит в том, чтобы добавлять валидацию к (схемным) объектам на протяжении всего их жизненного цикла. Эта библиотека обычно применяет динамическую проверку типов во время исполнения кода на уровне конфигурации, например, при создании экземпляра объекта, в отличие от Marshmallow, которая применяет проверку типа (включая проверку данных) только в определенных точках, всякий раз, когда вы вызываете schema.load(), schema.dump() или schema.validate(). Давайте посмотрим, как тот же пример выглядит с Pydantic (сначала обязательно выполните pip install pydantic):

from typing import List
from pydantic import BaseModel, constr, EmailStr, validator
class MyBaseModel(BaseModel):
    """ 
    Базовая модель, от которой мы будем наследовать наши классы
    Предоставляет базовые параметры конфигурации
    """
    class Config:
        validate_assignment = True
class BookModel(MyBaseModel):
    title: constr(max_length=120)  # constr = ограниченная строка, существуют и другие con ... типы
    isbn: str
    @validator('isbn')
    def validate_isbn(cls, isbn):
        """ 
        Код валидации номер isbn (реализация опущена)
        Возвращает строку isbn после проверки
        Если валидация не прошла, кидает исключение ValueError
        """
        return isbn
class UserModel(MyBaseModel):
    email: EmailStr
    books: List[BookModel] = None
raw_data_str = ...  # опустим определение
json_data = json.loads(raw_data_str)
user = UserModel(**json_data)
user.email = "not-an-email"  # бросает исключение pydantic.error_wrappers.ValidationError

Как и Marshmallow, Pydantic поддерживает множество типов данных помимо стандартных встроенных типов Python, таких как электронная почта, URL-адрес или номера платежных карт. Обязательно внимательно изучите руководство библиотеки, потому что в нем описаны некоторые интересные подводные камни. Например, Pydantic на самом деле не так педантична, когда дело доходит до сопоставления типов. Вызов BookModel(title=1337, isbn=1234) будет работать (ошибка проверки не возникает), хотя 1337 — это число, а не строка. Pydantic преобразует предоставленные данные в случае, если не произойдет потери данных. Чтобы избежать такого поведения, вы должны аннотировать атрибуты с использованием строгих типов, например, pydantic.StrictStr вместо str, как описано здесь.

Pydantic тоже позволяет определять классы, используя замещающую замену для dataclass. Рассмотрим на примере:

from typing import List
from pydantic import constr, EmailStr, validator
from pydantic.dataclasses import dataclass
class Config:
    validate_assignment = True
@dataclass(config=Config)
class Book:
    title: constr(max_length=120)
    isbn: str
    @validator('isbn')
    def validate_isbn(cls, isbn):
        """ 
        Код валидации номер isbn (реализация опущена)
        Возвращает строку isbn после проверки
        Если валидация не прошла, кидает исключение ValueError
        """
        return isbn
@dataclass(config=Config)
class User:
    email: EmailStr
    books: List[Book] = None

В процессе десериализации получается объект нужного класса.

Сериализация

Если вы хотите сериализовать данные, например, чтобы передать или сохранить их, вам нужно просто вызвать определенный метод, предоставляемый Marshmallow или Pydantic, чтобы получить вложенный dict, который затем можно преообразовать в любую желаемую форму, скажем, в строку JSON или в какую-либо двоичную форму, используя pickle, MessagePack и т. д. Если вы используете Marshmallow, то вам нужно вызвать метод dump(obj) применительно к объекту вашей схемы. Обратите внимание, что этот вызов снова провалидирует данные, что может вызвать неожиданные ошибки для объектов, которые вы создали вручную (с использованием их обычного конструктора, а не с помощью schema.load()) с фактически недопустимыми данными. Чтобы получить в Pydantic вложенный dict, вам нужно вызвать метод o.dict() у объекта модели o, который наследуется от pydantic.BaseModel. Обратите внимание, что этот dict может по-прежнему содержать непримитивные типы, такие как объекты datetime, с которыми многие конвертеры (включая модуль json в Python) не могут работать. В таких случаях подумайте об использовании o.json() вместо o.dict()

Что лучше, Marshmallow или Pydantic?

Выбор сводится к личным предпочтениям и потребностям. Рассмотрим несколько критериев:

  • Популярность или стабильность: не стоит выбирать библиотеку, которая не очень популярна и, следовательно, имеет высокий риск быть заброшенной. И Marshmallow, и Pydantic примерно одинаково популярны, каждая из них имеет ~ 5 тысяч звезд на GitHub. Тем не менее, разработка Marshmallow, кажется, более важна: ~ 90 против 235 открытых проблем. (Примечание переводчика: на текущий момент у Pydantic около 7 тысяч звезд против 5,5 тысяч у Marshmallow и 296 против 103 проблем соответственно)

  • Производительность: авторы Pydantic разработали тест, по результатам которого Pydantic превосходит по производительности Marshmallow. Это преимущество имеет смысл только в том случае, если вы выполняете много операций по (де)сериализации данных. В таком случае я рекомендую проверить влияние на производительность одной из ваших наиболее сложной схемы. Никогда не доверяйте слепо тестам, сделанным другими. Если вы работаете с вводом-выводом JSON, то также можете использовать ujson для ускорения преобразования между необработанными данными str и вложенными dict Python как для Marshmallow, так и для Pydantic.

  • Взаимодействие со стандартами: Pydantic имеет встроенную поддержку для создания определений схемы OpenAPI или JSON из кода вашей модели. Есть также официально одобренный инструмент-генератор, который преобразует существующие определения схемы OpenAPI/JSON в классы моделей Pydantic. В Marshmallow из коробки таких возможностей нет. Однако есть сторонние проекты, такие как marshmallow-jsonschema и apispec, которые конвертируют классы схемы Marshmallow в схему JSON или OpenAPI соответственно, но не наоборот!

Если всё, что вам требуется, это сериализация и десериализация в определенных частях вашего приложения, то рекомендую Marshmallow. Pydantic — хороший выбор, если вам важна безопасность типов на протяжении всего жизненного цикла ваших объектов, лучшая совместимость со стандартами или очень хорошая производительность.

Подводные камни сериализации

Напоследок хочу поговорить о подводных камнях, с которым я столкнулся при использовании этих библиотек.

Именование переменных

Когда вы получаете необработанные данные типа «ключ-значение», например, в форме JSON, ключи могут соответствовать всевозможным соглашениям об именах. Скажем, вы можете получить {"user-name": "Peter"} (kebab-case), {"userName": "Peter"} (camelCase) или {"user_name": "Peter"} (snake_case) . Однако в Python чаще используется snake_case, и применение любого другого именования было бы чрезвычайно странным.

К счастью и Marshmallow, и Pydantic предлагают поддержку динамического переименования полей. Здесь можно найти примеры для Marshmallow, а здесь для Pydantic (примеры для Pydantic применимы к десериализации, а для сериализации вам просто нужно вызвать o.dict(by_alias=True) вместо o.dict()).

Неоднозначность и полиморфизм данных

Обработка данных с помощью обеих библиотек работает должным образом, если набор классов детерминирован. В частности, нужны явные указания (в вашем коде) о том, какую схему использовать для десериализации объекта dict, который вы только что откуда-то получили. Давайте рассмотрим пример, который не является детерминированным, но неоднозначнен, поскольку он использует наследование:

@dataclass
class Animal:
    name: str
@dataclass
class Tiger(Animal):
    weight_lbs: int
@dataclass
class Elephant(Animal):
    trunk_length_cm: int
@dataclass
class Zoo:
    animals: List[Animal]
animals = [Elephant(name="Eldor", trunk_length_cm=176), Tiger(name="Roy", weight_lbs=405)]
zoo = Zoo(animals=animals)

Tiger и Elephant наследуются от Animal . Однако при сериализации Zoo.animals считаются просто объектами Animal. Следовательно, сериализация zoo приведет к чему-то вроде {'animals': [{'name': 'Eldor'}, {'name': 'Roy'}]}. Информация из классов наследников отсутствует.

Ни Pydantic, ни Marshmallow не поддерживают такие данные. Для Marshmallow есть несколько вспомогательных проектов, но я нашел их утомительным в использовании. Лучшее решение: добавить атрибут ко всем неоднозначным классам, например, datatype, содержащий строковый литерал для каждого класса. В этом случае вам также требуется уменьшить двусмысленность, явно указав поле typing.Union, в котором перечислены все возможные ожидаемые классы. Ниже пример для marshmallow-dataclass:

from marshmallow.fields import Constant
@dataclass
class Tiger(Animal):
    weight_lbs: int
    datatype: str = field(metadata=dict(marshmallow_field=Constant("tiger")), default="tiger")
@dataclass
class Elephant(Animal):
    trunk_length_cm: int
    datatype: str = field(metadata=dict(marshmallow_field=Constant("elephant")),  default="elephant")
@dataclass
class Zoo:
    animals: List[Union[Tiger, Elephant]]  # ранее List[Animal]

Но хотя этот подход и делает возможной (де)сериализацию неоднозначных данных, он портит схему специфичными артефактами, созданными только для обработки неоднозначных атрибутов

Заключение

Marshmallow и Pydantic — очень мощные фреймворки. Я предлагаю вам поэкспериментировать с ними, чтобы прочувствовать, насколько хорошо они могут быть встроены в вашу существующую кодовую базу, и проверить производительность вашей системы с ними. Вам нужно заранее решить, хотите ли вы поместить определение класса и схемы в один класс (с использованием dataclass) или разделить их. Последний вариант семантически чище, но требует больше кода.

От переводчика

Мое мнение может не совпадать с мнением автора статьи, но я счел её достаточно полезной, особенно для тех, кто хочет быстро понять основные различия между этими фреймворками. Я постарался максимально сохранить лексический стиль изложения материала, который вкладывал автор, лишь в нескольких местах прибегал к немного иным, но более понятным формулировкам.

В кодовой базе ДомКлик преобладает Marshmallow, так как она была более известна на момент формирования стека большинства команд, остальные же использовали те инструменты, с которыми привыкли работать, или же те, которые больше всего подходят под задачи конкретного проекта. Я желаю всем придерживаться тех же принципов.

Комментарии (3)


  1. Stormwalker
    03.08.2021 15:22
    +3

    У pydantic интерфейс попроще — инстанцирование через обычный конструктор, никакой путаницы с dump/load, и декораторами. Но лично для меня главная фича — автокомплит у моделей pydantic в IDE.


  1. Megadeth77
    03.08.2021 18:12

    Размеченные объединения через литералы это вообще киллинг фича, не знаю какие там специфичные артефакты, работает как часы, очень удобно.


    Pydantic вообще огонь, там есть еще фичи типа GenericModel, когда модель параметризуется TypeVar-ом, можно делать
    JSON(...) в аннотации полей, если в самом поле уже лежит json — pydantic его десериализует автоматом. Можно срастить pydantic с обычными дженериками и через анализ типов в рантайме автоматизировать, например, парсинг док из монги в модели, при этом останется статическая проверка типов для коллеции.


  1. vagon333
    04.08.2021 17:39
    +1

    Используем Pydantic в FastAPI.
    Значительно упрощает формирование сложной схемы request/response в OpenAPI.