Работа с данными JSON в Python

Оглавление

С момента своего появления JSON быстро стал стандартом де-факто для обмена информацией. Скорее всего, вы находитесь здесь, потому что вам нужно переместить некоторые данные из одного места в другое. Возможно, вы собираете информацию с помощью API или храните данные в базе данных документов. Так или иначе, вы по уши увязли в JSON, и вам придется искать выход из ситуации с помощью Python.

К счастью, это довольно распространенная задача, и, как и в случае с большинством других распространенных задач, Python делает ее почти отвратительно простой. Не бойтесь, товарищи питонисты и питонистки. Это будет проще простого!

Итак, мы используем JSON для хранения и обмена данными? Да, вы поняли! Это не что иное, как стандартизированный формат, который сообщество использует для передачи данных. Не забывайте, что JSON - не единственный формат, доступный для такой работы, но XML и YAML, пожалуй, единственные, которые стоит упомянуть на одном дыхании.

А (очень) краткая история JSON

Не так уж удивительно, что JavaScript Object Notation был вдохновлен подмножеством JavaScript языка программирования , имеющим дело с синтаксисом объектных литералов. У них есть отличный веб-сайт, на котором все это объясняется. Однако не волнуйтесь: JSON уже давно стал независимым от языка и существует как собственный стандарт, так что мы, к счастью, можем обойтись без JavaScript в рамках этого обсуждения.

В конечном счете, сообщество в целом приняло JSON, потому что его легко создавать и понимать как людям, так и машинам.

Смотрите, это JSON!

Приготовьтесь. Сейчас я покажу вам реальный JSON - такой, какой можно встретить в природе. Все в порядке: JSON должен быть доступен для чтения любому, кто использовал язык в стиле C, а Python - это язык в стиле C... так что это вы!

{
    "firstName": "Jane",
    "lastName": "Doe",
    "hobbies": ["running", "sky diving", "singing"],
    "age": 35,
    "children": [
        {
            "firstName": "Alice",
            "age": 6
        },
        {
            "firstName": "Bob",
            "age": 8
        }
    ]
}

Как видите, JSON поддерживает примитивные типы, такие как строки и числа, а также вложенные списки и объекты.

Погодите, это похоже на словарь Python! Я знаю, верно? На данный момент это практически универсальная объектная нотация, но я не думаю, что UON так же хорошо ложится на язык. Не стесняйтесь обсуждать альтернативы в комментариях.

Фух! Вы пережили свою первую встречу с диким JSON. Теперь вам осталось научиться его приручать.

Python поддерживает JSON нативно!

Python поставляется со встроенным пакетом json для кодирования и декодирования данных JSON.

Просто бросьте этого маленького парня в начало вашего файла:

import json

Маленький словарь

Процесс кодирования JSON обычно называют сериализацией. Этот термин относится к преобразованию данных в серию байтов (отсюда серийный) для хранения или передачи по сети. Вы также можете услышать термин маршалинг, но это совершенно другое обсуждение. Естественно, десериализация - это обратный процесс декодирования данных, которые были сохранены или переданы в стандарте JSON.

Ой! Это звучит довольно технично. Определенно. Но на самом деле все, о чем мы здесь говорим, - это чтение и написание. Подумайте об этом так: кодирование предназначено для записи данных на диск, а декодирование - для чтения данных в память.

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

Что происходит после обработки компьютером большого количества информации? Ему нужно сделать дамп данных. Соответственно, библиотека json предоставляет метод dump() для записи данных в файлы. Существует также метод dumps() (произносится как "dump-s") для записи в строку Python.

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

Python JSON
dict object
list, tuple array
str string
int, long, float number
True true
False false
None null

Простой пример сериализации

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

data = {
    "president": {
        "name": "Zaphod Beeblebrox",
        "species": "Betelgeusian"
    }
}

Очень важно сохранить эту информацию на диске, поэтому ваша задача - записать ее в файл.

Используя контекстный менеджер Python, вы можете создать файл с именем data_file.json и открыть его в режиме записи. (Файлы JSON удобно заканчиваются расширением .json.)

with open("data_file.json", "w") as write_file:
    json.dump(data, write_file)

Обратите внимание, что dump() принимает два позиционных аргумента: (1) объект данных, подлежащий сериализации, и (2) файлоподобный объект, в который будут записаны байты.

Или, если вы хотите продолжать использовать эти сериализованные JSON-данные в своей программе, вы можете записать их в собственный объект Python str.

json_string = json.dumps(data)

Обратите внимание, что файлоподобный объект отсутствует, так как на самом деле запись на диск не производится. В остальном dumps() точно так же, как dump().

Ура! Вы родили малыша JSON и готовы выпустить его на волю, чтобы он вырос большим и сильным.

Некоторые полезные аргументы ключевых слов

Помните, что JSON должен быть легко читаемым человеком, но читаемый синтаксис недостаточен, если все это скомкано вместе. К тому же у вас, вероятно, другой стиль программирования, чем у меня, и вам будет проще читать код, когда он отформатирован по вашему вкусу.

ПРИМЕЧАНИЕ: Оба метода dump() и dumps() используют одни и те же ключевые аргументы.

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

>>> json.dumps(data)
>>> json.dumps(data, indent=4)

Еще один вариант форматирования - аргумент с ключевым словом separators. По умолчанию это два кортежа строк-разделителей (", ", ": "), но для компактного JSON распространена альтернатива (",", ":"). Посмотрите еще раз на пример JSON, чтобы увидеть, где эти разделители вступают в игру.

Есть и другие, например sort_keys, но я понятия не имею, что они делают. Весь список можно найти в docs, если вам интересно.

Десериализация JSON

Здорово, похоже, вы поймали себе дикий JSON! Теперь пришло время привести его в порядок. В библиотеке json вы найдете load() и loads() для превращения закодированных в JSON данных в объекты Python.

Как и для сериализации, для десериализации существует простая таблица преобразования, хотя вы, вероятно, уже догадываетесь, как она выглядит.

JSON Python
object dict
array list
string str
number (int) int
number (real) float
true True
false False
null None

С технической точки зрения, это преобразование не является идеальной инверсией таблицы сериализации. Это означает, что если вы закодируете объект сейчас, а затем декодируете его позже, вы можете не получить точно такой же объект обратно. Я представляю себе это как телепортацию: разбейте мои молекулы на части здесь и соберите их обратно там. Я все еще тот же человек?

В реальности это больше похоже на то, как если бы один друг переводил что-то на японский, а другой - на английский. В любом случае, простейшим примером будет кодирование tuple и получение обратно list после декодирования, например, так:

>>> blackjack_hand = (8, "Q")
>>> encoded_hand = json.dumps(blackjack_hand)
>>> decoded_hand = json.loads(encoded_hand)

>>> blackjack_hand == decoded_hand
False
>>> type(blackjack_hand)
<class 'tuple'>
>>> type(decoded_hand)
<class 'list'>
>>> blackjack_hand == tuple(decoded_hand)
True

Простой пример десериализации

На этот раз представьте, что у вас есть данные, хранящиеся на диске, которыми вы хотели бы управлять в памяти. Вы по-прежнему будете использовать контекстный менеджер, но на этот раз откроете существующий data_file.json в режиме чтения.

with open("data_file.json", "r") as read_file:
    data = json.load(read_file)

Здесь все довольно просто, но имейте в виду, что результат этого метода может вернуть любой из допустимых типов данных из таблицы преобразований. Это важно только в том случае, если вы загружаете данные, с которыми раньше не сталкивались. В большинстве случаев корневым объектом будет dict или list.

Если вы получили JSON-данные из другой программы или иным способом получили строку данных в формате JSON в Python, вы можете легко десериализовать их с помощью loads(), которая, естественно, загружается из строки:

json_string = """
{
    "researcher": {
        "name": "Ford Prefect",
        "species": "Betelgeusian",
        "relatives": [
            {
                "name": "Zaphod Beeblebrox",
                "species": "Betelgeusian"
            }
        ]
    }
}
"""
data = json.loads(json_string)

Войла! Вы приручили дикий JSON, и теперь он находится под вашим контролем. Но что вы будете делать с этой властью, зависит только от вас. Вы можете кормить его, ухаживать за ним и даже учить его трюкам. Не то чтобы я вам не доверял... но держите его на поводке, хорошо?

Пример из реального мира (вроде того)

Для вводного примера вы будете использовать JSONPlaceholder, отличный источник поддельных JSON-данных для практических целей.

Сначала создайте файл сценария с именем scratch.py или любым другим. Я не могу вас остановить.

Вам нужно будет сделать API-запрос к сервису JSONPlaceholder, поэтому просто используйте пакет requests для выполнения тяжелой работы. Добавьте эти импорты в начало вашего файла:

import json
import requests

Теперь вы будете работать со списком TODO, потому что... ну, знаете, это ритуал посвящения или что-то в этом роде.

Выполните запрос к API JSONPlaceholder для конечной точки /todos. Если вы не знакомы с requests, есть удобный метод json(), который сделает всю работу за вас, но вы можете попрактиковаться, используя библиотеку json для десериализации атрибута text объекта ответа. Это должно выглядеть примерно так:

response = requests.get("https://jsonplaceholder.typicode.com/todos")
todos = json.loads(response.text)

Вы не верите, что это работает? Отлично, запустите файл в интерактивном режиме и проверьте сами. В процессе работы проверьте тип todos. Если вы чувствуете себя авантюристом, загляните в первые 10 или около того элементов списка.

>>> todos == response.json()
True
>>> type(todos)
<class 'list'>
>>> todos[:10]
...

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

Что такое интерактивный режим? А, я думал, ты никогда не спросишь! Знаете, как вы постоянно прыгаете туда-сюда между редактором и терминалом? Так вот, мы, хитрые питоны, используем флаг -i интерактивного режима при запуске скрипта. Это отличный трюк для тестирования кода, потому что он запускает скрипт, а затем открывает интерактивную командную строку с доступом ко всем данным из скрипта!

Ладно, пора действовать. Вы можете увидеть структуру данных, посетив конечную точку в браузере, но вот пример TODO:

{
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
}

Имеется несколько пользователей, каждый из которых имеет уникальный userId, а каждая задача имеет булево свойство completed. Можете ли вы определить, какие пользователи выполнили больше всего заданий?

# Map of userId to number of complete TODOs for that user
todos_by_user = {}

# Increment complete TODOs count for each user.
for todo in todos:
    if todo["completed"]:
        try:
            # Increment the existing user's count.
            todos_by_user[todo["userId"]] += 1
        except KeyError:
            # This user has not been seen. Set their count to 1.
            todos_by_user[todo["userId"]] = 1

# Create a sorted list of (userId, num_complete) pairs.
top_users = sorted(todos_by_user.items(), 
                   key=lambda x: x[1], reverse=True)

# Get the maximum number of complete TODOs.
max_complete = top_users[0][1]

# Create a list of all users who have completed
# the maximum number of TODOs.
users = []
for user, num_complete in top_users:
    if num_complete < max_complete:
        break
    users.append(str(user))

max_users = " and ".join(users)

Да, да, ваша реализация лучше, но суть в том, что теперь вы можете манипулировать данными JSON как обычным объектом Python!

Не знаю, как у вас, а у меня при повторном интерактивном запуске скрипта получаются следующие результаты:

>>> s = "s" if len(users) > 1 else ""
>>> print(f"user{s} {max_users} completed {max_complete} TODOs")
users 5 and 10 completed 12 TODOs

Это круто и все такое, но вы здесь, чтобы узнать о JSON. В качестве последнего задания вы создадите JSON-файл, содержащий завершенные TODO для каждого из пользователей, которые выполнили максимальное количество TODO.

Все, что вам нужно сделать, это отфильтровать todos и записать полученный список в файл. Для оригинальности можно назвать выходной файл filtered_data_file.json. Существует множество способов, которыми можно воспользоваться, но вот один из них:

# Define a function to filter out completed TODOs 
# of users with max completed TODOS.
def keep(todo):
    is_complete = todo["completed"]
    has_max_count = str(todo["userId"]) in users
    return is_complete and has_max_count

# Write filtered TODOs to file.
with open("filtered_data_file.json", "w") as data_file:
    filtered_todos = list(filter(keep, todos))
    json.dump(filtered_todos, data_file, indent=2)

Отлично, вы избавились от всех ненужных данных и сохранили все самое полезное в совершенно новом файле! Запустите скрипт снова и проверьте filtered_data_file.json, чтобы убедиться, что все получилось. Он будет находиться в том же каталоге, что и scratch.py, когда вы его запустите.

Теперь, когда вы проделали этот путь, вы наверняка чувствуете себя очень сексуально, верно? Не зазнавайся: смирение - это добродетель. Однако я склонен с вами согласиться. До сих пор все шло гладко, но вам, возможно, стоит задраить люки на последнем этапе путешествия.

Кодирование и декодирование пользовательских объектов Python

Что произойдет, если мы попытаемся сериализовать класс Elf из приложения Dungeons & Dragons, над которым вы работаете?

class Elf:
    def __init__(self, level, ability_scores=None):
        self.level = level
        self.ability_scores = {
            "str": 11, "dex": 12, "con": 10,
            "int": 16, "wis": 14, "cha": 13
        } if ability_scores is None else ability_scores
        self.hp = 10 + self.ability_scores["con"]

Не так удивительно, что Python жалуется, что Elf не сериализуемый (что вы знаете, если когда-нибудь пытались сказать эльфу обратное):

>>> elf = Elf(level=4)
>>> json.dumps(elf)
TypeError: Object of type 'Elf' is not JSON serializable

Хотя модуль json может работать с большинством встроенных типов Python, по умолчанию он не понимает, как кодировать пользовательские типы данных. Это все равно что пытаться вставить квадратный колышек в круглое отверстие - вам нужна пила и родительский контроль.

Упрощение структур данных

Теперь встает вопрос о том, как работать с более сложными структурами данных. Вы можете попытаться кодировать и декодировать JSON вручную, но есть несколько более умное решение, которое сэкономит вам немного работы. Вместо того чтобы сразу переходить от пользовательского типа данных к JSON, вы можете сделать промежуточный шаг.

Все, что вам нужно сделать, это представить ваши данные в терминах встроенных типов, которые json уже понимает. По сути, вы переводите более сложный объект в более простое представление, которое модуль json затем переводит в JSON. Это похоже на транзитивное свойство в математике: если A = B и B = C, то A = C.

Чтобы разобраться с этим, вам понадобится сложный объект, с которым можно поиграть. Вы можете использовать любой пользовательский класс, но в Python есть встроенный тип complex для представления комплексных чисел, и по умолчанию он не сериализуемый. Поэтому в этих примерах ваш сложный объект будет объектом complex. Уже запутались?

>>> z = 3 + 8j
>>> type(z)
<class 'complex'>
>>> json.dumps(z)
TypeError: Object of type 'complex' is not JSON serializable

Откуда берутся комплексные числа? Видите ли, когда действительное и мнимое числа очень любят друг друга, они складываются вместе и получается число, которое (вполне обоснованно) называется комплексным.

При работе с пользовательскими типами стоит задать себе вопрос: Какой минимальный объем информации необходим для воссоздания этого объекта? В случае с комплексными числами вам нужно знать только действительную и мнимую части, к которым вы можете получить доступ как к атрибутам объекта complex:

>>> z.real
3.0
>>> z.imag
8.0

Передача одинаковых чисел в конструктор complex достаточна для удовлетворения оператора сравнения __eq__:

>>> complex(3, 8) == z
True

Разбиение пользовательских типов данных на основные компоненты очень важно для процессов сериализации и десериализации.

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

Чтобы перевести пользовательский объект в JSON, достаточно указать функцию кодировки в параметре dump() метода default. Модуль json будет вызывать эту функцию для любых объектов, которые не являются нативными сериализуемыми. Вот простая функция декодирования, которую вы можете использовать для практики:

def encode_complex(z):
    if isinstance(z, complex):
        return (z.real, z.imag)
    else:
        type_name = z.__class__.__name__
        raise TypeError(f"Object of type '{type_name}' is not JSON serializable")

Обратите внимание, что вы должны поднять TypeError, если не получите тот объект, который ожидали. Таким образом, вы избежите случайной сериализации эльфов. Теперь вы можете сами попробовать кодировать сложные объекты!

>>> json.dumps(9 + 5j, default=encode_complex)
'[9.0, 5.0]'
>>> json.dumps(elf, default=encode_complex)
TypeError: Object of type 'Elf' is not JSON serializable

Почему мы закодировали комплексное число как tuple? Отличный вопрос! Это, конечно, был не единственный выбор, и не обязательно лучший. На самом деле, это не очень хорошее представление, если вы захотите расшифровать объект позже, как вы увидите в ближайшее время.

Другой распространенный подход заключается в подклассе стандартного JSONEncoder и переопределении его default() метода:

class ComplexEncoder(json.JSONEncoder):
    def default(self, z):
        if isinstance(z, complex):
            return (z.real, z.imag)
        else:
            return super().default(z)

Вместо того чтобы самому поднимать TypeError, вы можете просто позволить базовому классу справиться с этим. Вы можете использовать это либо непосредственно в методе dump() через параметр cls, либо создав экземпляр кодера и вызвав его метод encode():

>>> json.dumps(2 + 5j, cls=ComplexEncoder)
'[2.0, 5.0]'

>>> encoder = ComplexEncoder()
>>> encoder.encode(3 + 6j)
'[3.0, 6.0]'

Декодирование пользовательских типов

Хотя действительная и мнимая части комплексного числа абсолютно необходимы, на самом деле их не вполне достаточно для воссоздания объекта. Вот что происходит, когда вы пытаетесь закодировать комплексное число с помощью ComplexEncoder, а затем декодировать результат:

>>> complex_json = json.dumps(4 + 17j, cls=ComplexEncoder)
>>> json.loads(complex_json)
[4.0, 17.0]

Все, что вы получите в ответ, - это список, и вам придется передать значения в конструктор complex, если вы хотите получить этот сложный объект снова. Вспомните нашу дискуссию о телепортации. Не хватает метаданных, или информации о типе данных, которые вы кодируете.

Полагаю, что вопрос, который вы действительно должны задать себе: Каков минимальный объем информации, который одновременно необходим и достаточен для воссоздания этого объекта?

Модуль json ожидает, что все пользовательские типы будут выражены как objects в стандарте JSON. Для разнообразия вы можете создать файл JSON, на этот раз под названием complex_data.json, и добавить в него следующий object, представляющий комплексное число:

{
    "__complex__": true,
    "real": 42,
    "imag": 36
}

Видите, как умно? Этот ключ "__complex__" - метаданные, о которых мы только что говорили. Не имеет значения, какое значение он имеет. Чтобы заставить этот маленький хак работать, достаточно убедиться, что ключ существует:

def decode_complex(dct):
    if "__complex__" in dct:
        return complex(dct["real"], dct["imag"])
    return dct

Если "__complex__" не содержится в словаре, вы можете просто вернуть объект и позволить декодеру по умолчанию разобраться с ним.

Каждый раз, когда метод load() пытается разобрать object, вам предоставляется возможность вмешаться, прежде чем стандартный декодер начнет работать с данными. Вы можете сделать это, передав свою функцию декодирования в параметр object_hook.

Теперь сыграйте в ту же игру, что и раньше:

>>> with open("complex_data.json") as complex_data:
...     data = complex_data.read()
...     z = json.loads(data, object_hook=decode_complex)
... 
>>> type(z)
<class 'complex'>

Хотя object_hook может показаться, что является аналогом default параметра метода dump(), на самом деле аналогия начинается и заканчивается на этом.

Это работает не только с одним объектом. Попробуйте поместить этот список комплексных чисел в complex_data.json и запустить скрипт снова:

[
  {
    "__complex__":true,
    "real":42,
    "imag":36
  },
  {
    "__complex__":true,
    "real":64,
    "imag":11
  }
]

Если все прошло успешно, вы получите список complex объектов:

>>> with open("complex_data.json") as complex_data:
...     data = complex_data.read()
...     numbers = json.loads(data, object_hook=decode_complex)
... 
>>> numbers
[(42+36j), (64+11j)]

Вы также можете попробовать создать подкласс JSONDecoder и переопределить object_hook, но лучше придерживаться облегченного решения, когда это возможно.

Все готово!

Поздравляем, теперь вы можете использовать могучую силу JSON для всех своих недобрых нужд в Python.

Хотя примеры, с которыми вы здесь работали, конечно, надуманные и слишком упрощенные, они иллюстрируют рабочий процесс, который можно применять для решения более общих задач:

  1. Импортируйте пакет json.
  2. Прочитайте данные с помощью load() или loads().
  3. Обработайте данные.
  4. Запишите измененные данные с помощью dump() или dumps().

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

Сегодня вы отправились в путешествие: вы поймали и приручили дикий JSON и успели вернуться к ужину! В качестве дополнительного бонуса, изучение пакета json сделает изучение pickle и marshal легким делом.

Удачи во всех твоих будущих питонических начинаниях!

Вернуться на верх