TATECHATLAS
◎ Русский
Программирование / Руководство

Сериализация сложных объектов Python в JSON с помощью пользовательских кодировщиков

Модуль json обрабатывает только фиксированный набор типов Python. Для сериализации комплексных чисел, дат, путей или классов пользователей необходимо предоставить функцию default или подклассить JSONEncoder, затем сопоставить вывод с object_hook на стороне декодирования.

В этом материале

Модуль Python json преобразует только dict, list, tuple, str, int, float, bool и None по умолчанию. Всё остальное вызывает TypeError, если не перехватить это. Перехват происходит через параметр default функции json.dumps или через метод default подкласса JSONEncoder. Оба подхода получают неподдерживаемый объект и должны вернуть значение совместимое с JSON или вызвать TypeError для сигнализации об ошибке. Для точности обратного пути вы также должны предоставить соответствующий object_hook на стороне декодирования, который восстанавливает исходный тип из маркерного словаря или строки.

Почему JSON не сериализует произвольные объекты Python

Модуль json определяет фиксированную таблицу преобразования. Словарь Python отображается в объект JSON, список и кортеж отображаются в массив, строка отображается в строку, int и float отображаются в число, True и False отображаются в их литералы JSON, а None отображается в null. Комплексные числа, экземпляры datetime, объекты pathlib.Path и экземпляры классов пользователей не имеют записи в этой таблице. Когда кодировщик встречает значение вне таблицы, он вызывает хук default. Если хук не предоставлен, стандартный хук вызывает TypeError. Документация явно заявляет об этом: кодировщик поддерживает только перечисленные типы, и для его расширения необходимо подклассифицировать JSONEncoder и реализовать метод default.

import json
# This raises TypeError because complex has no default mapping
try:
    json.dumps(1 + 2j)
except TypeError as e:
    print(e)

При сериализации пользовательского класса всегда встраивайте ключ дискриминатора типа, такой как __type__, чтобы декодер мог отличить ваш маркерный словарь от обычного словаря, который случайно имеет те же имена полей.

Сериализация через параметр default в json.dumps

Параметр default принимает вызываемый объект, который получает неподдерживаемый объект и возвращает значение, совместимое с JSON. Возвращаемое значение может быть словарем, списком, строкой, числом или булевым значением. Если вызываемый объект не может обработать объект, он должен вызвать TypeError, чтобы другие объекты в том же документе могли быть закодированы по своим правилам или через цепочку super call. Этот подход идеален, когда вам нужна разовая трансформация в одном месте вызова, и вы не хотите определять переиспользуемый класс.

import json

def custom_json(obj):
    if isinstance(obj, complex):
        return {"__complex__": True, "real": obj.real, "imag": obj.imag}
    raise TypeError(f"Cannot serialize object of {type(obj)}")

print(json.dumps(1 + 2j, default=custom_json))

Расширение поведения через подкласс JSONEncoder

Когда одна и та же логика сериализации должна использоваться повторно в нескольких местах вызова или когда вам нужно настроить параметры уровня кодировщика, такие как indent или ensure_ascii вместе с пользовательской обработкой типов, подклассификация JSONEncoder является более чистым вариантом. Вы переопределяете метод default и вызываете super().default(o) для неподдерживаемых типов, чтобы стандартный TypeError был вызван с собственным сообщением библиотеки. Подкласс передается в json.dumps или json.dump через параметр cls.

import json

class SimpleEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        return super().default(o)

print(json.dumps({"z": 3 + 4j}, cls=SimpleEncoder))

Выбор между функцией default и подклассом JSONEncoder

Используйте простую функцию default, когда трансформация специфична для одного вызова, логика короткая, и вам не нужно делиться конфигурацией кодировщика. Функция передается инлайн и исчезает после вызова. Используйте подкласс JSONEncoder, когда логика должна использоваться повторно, когда вы хотите объединить пользовательскую обработку типов с настройками кодировщика по умолчанию, такими как sort_keys или пользовательский кортеж separators, или когда цепочка трансформаций растет за пределы двух или трех проверок isinstance, и плоская функция становится трудно читаемой.

import json
from datetime import datetime, timezone

class ProjectEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, datetime):
            return o.isoformat()
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        return super().default(o)

payload = {"ts": datetime(2024, 1, 15, tzinfo=timezone.utc), "z": 1+1j}
print(json.dumps(payload, cls=ProjectEncoder, sort_keys=True))

Кодирование дат, путей и классов пользователей

Объекты datetime не имеют отображения JSON. Конвенциональное представление - строка ISO 8601, производимая методом isoformat. Сторона декодера использует datetime.fromisoformat для восстановления значения. Объекты pathlib.Path сериализуются в свою строковую форму через str(path); декодер оборачивает строку обратно с Path(). Для классов пользователей рекомендуемый паттерн - маркерный словарь, включающий ключ дискриминатора типа, например __type__, установленный в имя класса, плюс поля, необходимые для восстановления.

import json
from datetime import datetime, timezone
from pathlib import Path

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

def encode(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Path):
        return str(obj)
    if isinstance(obj, Point):
        return {"__type__": "Point", "x": obj.x, "y": obj.y}
    raise TypeError(f"Unsupported type {type(obj)}")

def decode(dct):
    if dct.get("__type__") == "Point":
        return Point(dct["x"], dct["y"])
    return dct

p = Point(3, 4)
s = json.dumps(p, default=encode)
print(s)
restored = json.loads(s, object_hook=decode)
print(type(restored).__name__, restored.x, restored.y)

Параметры кодировщика, влияющие на вывод

ensure_ascii контролирует, будут ли символы не-ASCII экранированы как последовательности \uXXXX или излучены как есть. Когда ваш пользовательский default возвращает строки, содержащие текст не-ASCII, эта настройка меняет представление байтов, но не логическое содержимое. indent добавляет пробелы для читаемости и изменяет разделители по умолчанию с (',', ':') на (', ', ': '). sort_keys сортирует ключи словаря алфавитно перед строковым преобразованием, что полезно для детерминированного вывода в тестах. separators позволяет производить компактный вывод путем передачи (',',':'). allow_nan управляет тем, кодируются ли NaN, Infinity и -Infinity как литералы JavaScript или вызывают ValueError. check_circular обнаруживает циклы ссылок в контейнерах и вызывает ValueError при включении; если отключено, цикл вызывает RecursionError. skipkeys молча удаляет ключи словаря, которые не являются str, int, float, bool или None вместо вызова TypeError.

import json
from datetime import datetime

class Enc(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, datetime):
            return o.isoformat()
        return super().default(o)

print(json.dumps({"b": 1, "a": datetime(2024,1,1)}, cls=Enc, sort_keys=True, indent=2))

Обработка ошибок и ограничения переносимости

Когда default или метод default кодировщика вызывает TypeError, весь вызов dumps завершается неудачей; нет частичного вывода. Это означает, что один неподдерживаемый объект где угодно в дереве прерывает всю сериализацию. Спроектируйте свою функцию default так, чтобы она вызывала TypeError с четким сообщением, называющим проблемный тип, чтобы отладка была быстрой.

import json, io
buf = io.StringIO()
json.dump({"a": 1}, buf)
json.dump({"b": 2}, buf)  # second call makes the file invalid
print(buf.getvalue())  # {"a": 1}{"b": 2} - not valid JSON

Практический пример объединения комплексных чисел и пользовательского класса

Следующий полный пример демонстрирует оба подхода бок о бок. Самостоятельная функция default обрабатывает комплексные числа в одном месте вызова. Подкласс JSONEncoder обрабатывает комплексные числа и пользовательский класс Event в другом месте вызова, в сочетании с sort_keys для детерминированного вывода. Сторона декодера использует object_hook для восстановления обоих типов из их маркерных словарей.

import json

class Event:
    def __init__(self, name, ts, payload):
        self.name = name
        self.ts = ts
        self.payload = payload

# Approach 1: standalone default function
def complex_default(obj):
    if isinstance(obj, complex):
        return {"__complex__": True, "real": obj.real, "imag": obj.imag}
    raise TypeError(f"Cannot serialize {type(obj)}")

s1 = json.dumps(1 + 2j, default=complex_default)
print(s1)

# Approach 2: reusable JSONEncoder subclass
class AppEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, complex):
            return {"__complex__": True, "real": o.real, "imag": o.imag}
        if isinstance(o, Event):
            return {"__type__": "Event", "name": o.name,
                    "ts": o.ts, "payload": o.payload}
        return super().default(o)

s2 = json.dumps({"z": 3+4j, "ev": Event("click", 100, {"x": 1})},
                cls=AppEncoder, sort_keys=True)
print(s2)

# Round-trip decode
def app_hook(dct):
    if dct.get("__complex__"):
        return complex(dct["real"], dct["imag"])
    if dct.get("__type__") == "Event":
        return Event(dct["name"], dct["ts"], dct["payload"])
    return dct

restored = json.loads(s2, object_hook=app_hook)
print(type(restored["z"]).__name__, restored["ev"].name)

Что проверить

  • json.dumps без default или cls вызывает TypeError для complex, datetime, Path или произвольных экземпляров классов
  • Функция default должна вызывать TypeError для необработанных типов; возврат None тихо кодирует null
  • Подкласс JSONEncoder должен вызывать super().default(o) для необработанных типов, чтобы сохранить стандартное сообщение об ошибке
  • Точность обратного пути требует соответствующего object_hook на стороне декодирования, который проверяет ключ маркера
  • Повторные вызовы json.dump к одному объекту файла производят недействительный конкатенированный вывод
  • ensure_ascii, indent, sort_keys и separators влияют на представление, но не на логическое содержимое, производимое default
  • allow_nan=False превращает кодирование NaN и Infinity в ValueError
  • check_circular=True обнаруживает циклы в пользовательско-закодированных объектах и вызывает ValueError

Эта статья охватывает только стандартную библиотеку json модуля на CPython 3.6 и позже. Она не затрагивает сторонние сериализаторы, такие как orjson, ujson или rapidjson, которые имеют свои собственные механизмы расширения. Гарантии обратного пути, обсуждаемые здесь, полностью зависят от согласованности между вашими маркерами кодирования и хуками декодирования; библиотека сама по себе не предоставляет реестра типов или проверки схемы. Очень большие целые числа и значения decimal.Decimal могут потерять точность в потребителях JSON, которые парсят числа как двойной точности IEEE 754. Ограничения размера ввода для ненадежного JSON лежат на ответственности приложения, а не модуля.

Источники

  1. Python: json ↗
  2. Python: pathlib ↗
  3. Python: os and working directories ↗
Наверх ↑