Пориньте у Python 3/Серіалізація об'єктів

Щосуботи, відколи ми живемо в цій квартирі, я прокидався в 6:15, насипав собі миску вівсянки, додавав чверть чашки двохпроцентного молока, сідав на цей кінець цього дивану, вмикав BBC Америка, і дивився доктора Хто.
Шелдон, Теорія Великого Вибуху

Згоди ідея серіалізації виглядає простою. В нас є структура даних в пам'яті яку ми хочемо зберегти, перевикористати чи відправити комусь іншому. Як це можна зробити? Ну, все залежить від того як ви хочете її зберігати, як ви хочете її повторно використовувати, і кому ви хочете її відправити. Багато ігор дозволяють вам збегігати свій прогрес при завершенні гри, і продовжувати звідки почали, коли ви запускаєте гру наступного разу. (Ну і звісно так вміють багато програм окрім ігор.) В даному випадку, структура даних що зберігає ваш поточний прогрес повинна записатись на диск коли ви виходите з програми, а потім завантажитись в пам'ять коли ви запускаєте програму знову. Такі дані призначені для використання тою ж програмою що їх створила, не пересилаються через мережу і ніколи не читаються іншими програмами. Тому проблеми сумісності обмежуються гарантуванням того що наступні версії програми будуть здатні читати дані записані попередніми версіями.

Для подібних випадків ідеальним є модуль pickle. Він є частиною стандартної бібліотеки мови Python, тому завжди доступний. Він швидкий, основна його частина написана на C, як і сам інтерпретатор мови. Він може зберігати досить складні структури даних мови Python.

Що може зберігати модуль pickle?

  • Всі стандартні типи даних що підтримує Python: булевий, цілий, числа з плаваючою крапкою, комплексні числа, рядки, байтові об'єкти, та None.
  • Списки, кортежі, словники і множини що містять будь-які комбінації стандартних типів даних.
  • Списки, кортежі, словники і множини що містять будь-яку послідовність списків, кортежів, словників і множин що містять будь-яку послідовність стандартних типів даних (і так далі, до максимального рівня вкладеності що підтримує Python[1]).
  • Функції, класи та екземпляри класів (з кількома застереженнями).

Якщо цього для вас недостатньо, модуль pickle ще й розширюваний. Якщо ви зацікавлені в розширенні, перегляньте посилання в параграфі Для подальшого читання в кінці розділу.

Коротке зауваження про приклади цього розділу

ред.

Цей розділ розповідає історію з двома оболонками інтерпретатора. Всі приклади в цьому розділі є частиною єдиної сюжетної арки. Вас проситимуть перемикатись між двома оболонками поки я демонструватиму вам модулі pickle та json.

Щоб не заплутатись, запустіть одну сесію інтерпретатора Python і створіть наступну змінну:

>>> shell = 1

Тримайте це вікно відкритим. Тепер відкрийте ще одне, і опишіть таку змінну:

>>> shell = 2

Протягом цього розділу я використовуватиму змінну shell щоб позначити яка з оболонок використовується в кожному прикладі.


* * *


Збереження даних в файл Piсkle

ред.

Модуль pickle працює з структурами даних. Давайте створимо одну.

>>> shell
1
>>> entry = {}
>>> entry['title'] = 'Dive into history, 2009 edition'
>>> entry['article_link'] = 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
>>> entry['comments_link'] = None
>>> entry['internal_id'] = b'\xDE\xD5\xB4\xF8'
>>> entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> entry['published'] = True

Ідея тут полягає в тому щоб створити словник що може містити щось корисне, наприклад елемент фіду Atom. Але я також хочу щоб він міститв кілька різних типів даних, щоб показати що може модуль pickle. Не треба вишукувати в цих значеннях забагато змісту.

>>> import time
>>> entry['published_date'] = time.strptime('Fri Mar 27 22:20:42 2009')
>>> entry['published_date']
time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1)

Модуль time містить структуру даних (struct_time) що являє собою момент в часі (з точністю до одної мілісекунди) і функції для маніпуляції з часовими структурами даних. Функція strptime() приймає форматований рядок і перетворює його в struct_time. Цей рядок записаний в стандартному форматі, але ви можете керувати форматом текстового запису часу. Зверніться до модуля time за додатковими деталями.

Ну, от в нас вийшов симпатичний словничок. Давайте збережемо його в файл.

>>> shell
1
>>> import pickle
>>> with open('entry.pickle', 'wb') as f:
...     pickle.dump(entry, f)
...

Тут ми використовуємо функцію open() щоб відкрити файл. Ми встановлюємо режим файла в 'wb' що означає відкрити файл для запису в двійковому режимі. Загорніть запис в конструкцію with щоб переконатись що файл автоматично закриється коли ми закінчимо роботу з ним.

Функція dump() модуля pickle приймає серіалізовну структуру даних мови Python, серіалізує її в двійковий формат використовуючи останню версію протоколу pickle мови Python, і зберігає її в відкритий файл.

Останнє речення було досить важливим.

  • Модуль pickle приймає структуру даних мови Python і зберігає її в файл.
  • Щоб зробити це він серіалізує структуру даних використовуючи формат який називається "протокол pickle".
  • Протокол pickle специфічний для мови Python, немає гарантії міжмовної сумісності. Скоріш за все ви не зможете взяти щойностворений файл entry.pickle і зробити з ним щось корисне за допомогою Perl, PHP, Java чи іншої мови.
  • Не кожна структура мови Python може бути серіалізованою модулем pickle. Протокол pickle змінювався кілька разів коли в мові Python з'являлись нові типи даних, але досі існують певні обмеження.
  • Через ці зміни немає гарантії сумісності цих файлів між різними версіями мови Python. Новіші версії мови підтримують старі формати серіалізації, але старіші версії не підтримують нові формати (бо в них немає відповідних нових типів даних).
  • Якщо ви не вкажете інше, всі функції модуля pickle використовуватимуть останню версію протоколу. Це гарантує вам максимальну гнучкість при виборі даних які можна серіалізувати, але також означає що файл не читатиметься в старих версіях мови Python, які не підтримують останню версію протоколу.
  • Останньою версією протоколу pickle є двійковий формат. Переконайтесь що ви відкриваєте файли в двійковому режимі, інакше дані будуть пошкоджені при записі.


* * *


Завантаження даних з файлу pickle

ред.

Тепере переключіться в другий інтерпретатор Python - в той в якому ми не створювали словник entry.

>>> shell
2
>>> entry
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined

Як бачимо в інтерпретаторі №2 немає змінної entry. Ми описали змінну entry в першій оболонці, але це зовсім інше середовище з власним станом.

>>> import pickle
>>> with open('entry.pickle', 'rb') as f:
...     entry = pickle.load(f)
...

Відкрийте файл entry.pickle який ми створили в інтерпретаторі №1. Модуль pickle використовує двійковий формат даних, тому ми повинні завжди відкривати файли pickle в двійковому режимі.

Фукнція pickle.load() приймає потоковий об'єкт, читає серіалізовані дані з потоку, створює новий об'єкт мови Python, відтворює серіалізовані дані в цьому об'єкті, і повертає його як результат.

>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link':
 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Тепер змінна entry - це словник з вже знайомими нам ключами та значеннями.

Послідовність pickle.dump() / pickle.load() в результаті дає нам нову структуру даних яка дорівнює оригінальній структурі.

Тепер давайте знову повернемось в оболонку №1.

>>> shell
1
>>> with open('entry.pickle', 'rb') as f:
...     entry2 = pickle.load(f)
...

Давайте відкриємо файл entry.pickle і завантажимо серіалізовані дані в нову змінну, entry2.

>>> entry2 == entry
True

Python підтверджує що два словники, entry та entry2 рівні. В цьому інтерпретаторі ми створили entry з нуля, почавши з порожнього словника, і вручну присвоюючи значення відповідним ключам. Ми серіалізували цей словник і зберегли його в файлі entry.pickle. Зараз ми прочитали серіалізовані дані з того файлу і створили ідеальну копію оригінальної структури даних.

>>> entry2 is entry
False

Рівність це не те саме що ідентичність. Я сказав що ми створили ідеальну копію оригінальної структури даних, і це правда. Але це все одно копія.

>>> entry2['tags']
('diveintopython', 'docbook', 'html')
>>> entry2['internal_id']
b'\xDE\xD5\xB4\xF8'

З причин які стануть зрозумілими далі в цьому розділі, я хочу вказати на те що занчення ключа 'tags' - кортеж, а значення ключа 'internal_id' - байтовий об'єкт.


* * *


Серіалізація без файлу

ред.

Приклади в попередній секції показали нам як серіалізувати об'єкт прямо в файл на диску. Але що якщо ми не хочемо, чи не потребуємо файла? Також можна серіалізувати в байтовий об'єкт в пам'яті.

>>> shell
1
>>> b = pickle.dumps(entry)

Функція pickle.dumps() (зауважте 's' наприкінці імені) виконує таку ж серіалізацію як і pickle.dump(). Але замість того щоб прийняти потоковий об'єкт і записати серіалізовані дані на диск, вона просто повертає серіалізовані дані.

>>> type(b)
<class 'bytes'>

Так як протокол pickle використовує двійковий формат даних, функція pickle.dumps повертає об'єкт типу bytes.

>>> entry3 = pickle.loads(b)

Функція pickle.loads() (знову, зауважте 's' наприкінці імені) виконує таку ж десеріалізацію як і фукнція pickle.load(). Але замість того щоб прайняти потоковий об'єкт і прочитати серіалізовані дані з диску, вона приймає байтовий об'єкт що містить серіалізовані дані, такий як той що повернула функція pickle.dumps().

>>> entry3 == entry
True

Кінцевий результат такий самий - ідеальна копія оригінального словника.


* * *


Байти та рядки показують свою потворну голову знову

ред.

Протокол pickle існував протягом багатьох років, і розвивався разом з мовою Python. Зараз існує чотири різні версії протоколу pickle.

  • Python 1.x мав два протоколи, текстовий формат ("версія 0") та двійковий формат ("версія 1").
  • В Python 2.3 з'явився новий протокол pickle ("версія 2") призначений для роботи з новою функціональністю класів в Python. Це двійковий формат.
  • В Python 3.0 з'явився інший протокол pickle ("версія 3") з підтримкою байтових об'єктів та байтових масивів. Він теж двійкового формату.

Ой, гляньте, відмінність між байтами та рядками знову показує свою потворну голову. (Якщо вас це здивувало, значить ви недостатньо уважно читаєте). На практиці це означає що хоча Python 3 може читати дані записані протоколом версії 2, Python 2 не може читати дані записані протоколом версії 3.


* * *



Дослідження файлів Pickle

ред.

Як виглядає протокол pickle? Давайте на хвилину вийдемо з інтерпретатора і подивимось на створений нами файл entry.pickle. Для неозброєного ока, це переважно абракадабра.

you@localhost:~/diveintopython3/examples$ ls -l entry.pickle
-rw-r--r-- 1 you you 358 Aug 3 13:34 entry.pickle
you@localhost:~/diveintopython3/examples$ cat entry.pickle
comments_linkqNXtagsqXdiveintopythonqXdocbookqXhtmlq?qX publishedq?
XlinkXJhttp://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition
q Xpublished_dateq
ctime
struct_time
?qRqXtitleqXDive into history, 2009 editionqu.

Це не надто допомагає. Можна бачити рядки, але інші типи даних записані невидимими або нечитабельними символами. Поля не розділяються очевидним чином пропусками чи табуляціями. Це не той формат який хотілось би досліджувати вручну.

>>> shell
1
>>> import pickletools
>>> with open('entry.pickle', 'rb') as f:
...     pickletools.dis(f)

Тому використовуємо для цього вбудовані інструменти які дадуть нам таку інформацію:

   0: \x80 PROTO      3
   2: }    EMPTY_DICT
   3: q    BINPUT     0
   5: (    MARK
   6: X        BINUNICODE 'published_date'
  25: q        BINPUT     1
  27: c        GLOBAL     'time struct_time'
  45: q        BINPUT     2
  47: (        MARK
  48: M            BININT2    2009
  51: K            BININT1    3
  53: K            BININT1    27
  55: K            BININT1    22
  57: K            BININT1    20
  59: K            BININT1    42
  61: K            BININT1    4
  63: K            BININT1    86
  65: J            BININT     -1
  70: t            TUPLE      (MARK at 47)
  71: q        BINPUT     3
  73: }        EMPTY_DICT
  74: q        BINPUT     4
  76: \x86     TUPLE2
  77: q        BINPUT     5
  79: R        REDUCE
  80: q        BINPUT     6
  82: X        BINUNICODE 'comments_link'
 100: q        BINPUT     7
 102: N        NONE
 103: X        BINUNICODE 'internal_id'
 119: q        BINPUT     8
 121: C        SHORT_BINBYTES 'ÞÕ´ø'
 127: q        BINPUT     9
 129: X        BINUNICODE 'tags'
 138: q        BINPUT     10
 140: X        BINUNICODE 'diveintopython'
 159: q        BINPUT     11
 161: X        BINUNICODE 'docbook'
 173: q        BINPUT     12
 175: X        BINUNICODE 'html'
 184: q        BINPUT     13
 186: \x87     TUPLE3
 187: q        BINPUT     14
 189: X        BINUNICODE 'title'
 199: q        BINPUT     15
 201: X        BINUNICODE 'Dive into history, 2009 edition'
 237: q        BINPUT     16
 239: X        BINUNICODE 'article_link'
 256: q        BINPUT     17
 258: X        BINUNICODE 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
 337: q        BINPUT     18
 339: X        BINUNICODE 'published'
 353: q        BINPUT     19
 355: \x88     NEWTRUE
 356: u        SETITEMS   (MARK at 5)
 357: .    STOP
highest protocol among opcodes = 3

Найцікавішою інформацією що видав цей дизасемблер є останній рядок, тому що він вказує на версію протоколу в якій зберігався файл. В протоколі pickle немає явного запису версії. Щоб визначити яку версію протоколу використовували для збереження файлу, потрібно подивитись на маркери ("опкоди") в запіклених даних і використати знання про те які опкоди в якій версії протоколу були введені. Функція pickletools.dist() робить саме це, і виводить результат в останньому рядку свого виводу. Ось функція що повертає лише номер версії, не друкуючи нічого:

import pickletools

def protocol_version(file_object):
    maxproto = -1
    for opcode, arg, pos in pickletools.genops(file_object):
        maxproto = max(maxproto, opcode.proto)
    return maxproto

А ось вона в дії:

>>> import pickleversion
>>> with open('entry.pickle', 'rb') as f:
...     v = pickleversion.protocol_version(f)
>>> v 3


* * *


Серіалізація об'єктів Python для читання в інших мовах

ред.

Формат даних що використовується модулем pickle специфічний для Python. Не робиться ніяких спроб зробити його сумісним з іншими мовами. Якщо міжмовна сумісність є однією з ваших вимог, вам потрібно розглянути інші формати серіалізації. Одним з таких форматів є JSON. "JSON" означає "JavaScript Object Notation", тобто "запис об'єктів в мові JavaScript", але не дайте цій назві себе обдурити - JSON спеціально створений для того щоб могти використовуватись в різних мовах програмування.

Python3 містить в своїй стандартній бібліотеці модуль json. Як і модуль pickle, модуль json містить функції для серіалізації структур даних, збереження серіалізованих даних на диску, завантаження серіалізованих даних з диску і десеріалізації даних назад в новий об'єкт мови Python. Але також і є деякі важливі відмінності. По-перше, формат даних JSON текстовий а не двійковий. RFC 4627 описує формат JSON, і те як різні типи даних повинні бути закодовані в тексті. Наприклад, булеве значення зберігається або як п'ятисимвольний рядок 'false' або як чотирисимвольний рядок true. Всі значення в JSON чутливі до регістру.

По-друге, як і з будь-яким іншим текстовим форматом, є проблема з вільним місцем. JSON дозволяє вставляти між значеннями довільну кількість прогалин (пропусків, табуляцій, переходів на новий рядок). Це дозволяє вам "красиво роздрукувати" свої дані в JSON, гарно вкладаючи значення всередину значень з різними рівнями відступів, так що це буде зручно читати в браузері чи текстовому редакторі. Модуль json в Python має можливості такого форматування при закодовуванні.

По-третє, існує багаторічна проблема кодування символів. JSON кодує всі значення як простий текст, але як ви знаєте, немає такої штуки як простий текст. JSON повинен зберігатись в кодуванні Unicode (UTF-32, UTF-16, чи за замовчуванням, UTF-8), і третя секція RFC 4627 описує те як визначити яке кодування використовується.


* * *


Збереження даних в файл JSON

ред.

JSON виглядає неймовірно подібним до структур даних які ви могли б описати в мові JavaScript. Це не випадковість, і ви навіть можете використовувати функцію eval() в JavaScript щоб "розкодувати" дані серіалізовані в JSON. (Звичайні застереження щодо даних яким не варто довіряти тут теж потрібно брати до уваги, але суть в тому що JSON це правильний код JavaScript). Як такий, JSON повинен здаватись для вас знайомим.

>>> shell
1
>>> basic_entry = {}
>>> basic_entry['id'] = 256
>>> basic_entry['title'] = 'Dive into history, 2009 edition'
>>> basic_entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> basic_entry['published'] = True
>>> basic_entry['comments_link'] = None

Замість того щоб використовувати стару структуру entry ми створимо нову. Далі в цьому розділі ми побачимо що стається коли ми намагаємось закодувати більш складні структури даних в JSON.

>>> import json
>>> with open('basic.json', mode='w', encoding='utf-8') as f:
...     json.dump(basic_entry, f)

JSON - це текстовий формат, і це означає що ми повинні відкрити цей файл в текстовому режимі і задати кодування символів. Ви ніколи не прогадаєте з UTF-8.

Як і модуль pickle модуль json описує функцію dump() яка приймає структуру даних мови Python та потоковий об'єкт в режимі запису. Функція dump серіалізує структуру даних і записує її в потоковий об'єкт. Якщо це робити всередині блока with, можна бути певним що після завершення файл буде правильно закритим.

То як виглядає результат серіалізації в JSON?

you@localhost:~/diveintopython3/examples$ cat basic.json
{"published": true, "tags": ["diveintopython", "docbook", "html"], "comments_link": null,
"id": 256, "title": "Dive into history, 2009 edition"}

Ну, це однозначно читабельніше ніж файл pickle. Але в JSON можна вставляти білі місця між значеннями, і модуль json надає простий спосіб для того щоб цим скористатись, і створити ще читабельніший файл JSON.

>>> shell
1
>>> with open('basic-pretty.json', mode='w', encoding='utf-8') as f:
...     json.dump(basic_entry, f, indent=2)

Якщо передати параметр indent в функцію json.dumps(), вона зробить результуючий JSON файл більш читабельним, за рахунок збільшення його розмірів. Параметр indent це ціле число, де 0 означає "розмістити кожне значення на новому рядку", а будь-яке додатнє ціле число означає "розмістити кожне значення на власному рядку, і використати таку кількість пропусків щоб робити відступи у вкладених структурах даних.

А ось отриманий результат:

you@localhost:~/diveintopython3/examples$ cat basic-pretty.json
{
  "published": true,
  "tags": [
    "diveintopython",
    "docbook",
    "html"
  ],
  "comments_link": null,
  "id": 256,
  "title": "Dive into history, 2009 edition"
}


* * *


Співставлення типів даних в Python і JSON

ред.

Так як JSON не є специфічним для Python, існує кілька неспівпадінь з типами мови Python. Деякі з цих неспівпадінь це просто відмінності в іменуванні, але є два важливі типи даних в Python які зовсім відсутні в JSON. Давайте подивимось чи ви їх знайдете:

Примітка JSON Python 3
об'єкт словник
масив список
рядок рядок
ціле ціле
дійсне число з плаваючою комою
* true True
* false False
* null None
* Всі значення JSON чутливі до регістру

Ви зауважили чого бракує? Кортежів і байт! JSON має тип "масив", який модуль json перетворює в список в Python, але не має окремого типу для "заморожених масивів" (кортежів). І хоча JSON досить добре підтримує рядки, він не має підтримки байтових об'єктів чи байтових масивів.


* * *


Серіалізація типів даних непідтримуваних в JSON

ред.

Хоча JSON й не має вбудованої підтримки байтових об'єктів, це не означає що ми не можемо серіалізувати об'єкти типу bytes. Модуль json надає засоби розширення кодування й декодування для невідомих типів. (Тут під "невідомими" я маю на увазі не описані в JSON). Очевидно що модуль json знає про масиви байт, але він зв'язаний обмеженнями специфікації JSON). Якщо ви хочете закодувати байти, чи інші типи які не підтримуються чистим JSON, ви повинні надати власні кодери й декодери для тих типів.

>>> shell
1
>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Ок, зараз час повернутись до старої структури entry. Вона містить все: булеве значення, значення None, рядок, кортеж рядків, об'єкт bytes, і структуру time.

>>> import json
>>> with open('entry.json', 'w', encoding='utf-8') as f:

Я знаю, що казав це раніше, але це варто повторити: JSON це текстовий формат. Завжди відкривайте файли JSON в текстовому режимі з кодуванням символів UTF-8.


...     json.dump(entry, f)
... 
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "C:\Python31\lib\json\encoder.py", line 170, in default
    raise TypeError(repr(o) + " is not JSON serializable")
TypeError: b'\xDE\xD5\xB4\xF8' is not JSON serializable

Ой це не добре. Що трапилось?

Ось що трапилось: функція json.dump() спробувала серіалізувати об'єкт b'\xDE\xD5\xB4\xF8' типу bytes, але в неї не вийшло, тому що в JSON немає підтримки об'єкту bytes. Тим не менш, якщо зберігання байтів для вас важливе, ви можете описати власний "міні формат серіалізації".


def to_json(python_object):

Щоб описати власний "міні-формат серіалізації" для типу даних який не передбачений в JSON, просто опишіть функцію що приймає об'єкт Python як параметр. Цей об'єкт й буде тим об'єктом який функція json.dumps() не може серіалізувати самостійно. В нашому випадку байтовий об'єкт b'\xDE\xD5\xB4\xF8'.

    if isinstance(python_object, bytes):

Варто перевіряти тип даних який функція json.dumps() передає їй. Це не є обов'язковим якщо ваша функція серіалізує лише один тип даних, але це дуже чітко визначає коли можна застосовувати вашу функцію, і спрошує її розширення якщо пізніше потрібно буде додати можливість серіалізації інших типів.

        return {'__class__': 'bytes',
                '__value__': list(python_object)}

В даному випадку, я вирішив перетворити об'єкт bytes в словник. Ключ __class__ зберігатиме оригінальний тип даних (в вигляді рядка 'bytes'), а ключ __value__ буде містити саме значення. Звісно ци значенням не може бути сам об'єкт bytes, вся суть тут в тому щоб перетворити його на щось що може бути серіалізованим в JSON! Об'єкт bytes - це просто послідовність цілих чисел, кожне з яких приймає значення в діапазоні 0-255. Ми можемо використати функцію list() для того щоб перетворити об'єкт bytes в список цілих. Так b'\xDE\xD5\xB4\xF8' стане [222, 213, 180, 248]. (Порахуйте самі! Воно працює! Байт \xDE це шістнадцятковий запис десяткового числа 222, \xD5 - числа 213 і так далі.)

    raise TypeError(repr(python_object) + ' is not JSON serializable')

Це важливий рядок. Структура даних яку ви серіалізуєте може містити типи які ні вбудований серіалізатор JSON, ні ваш серіалізатор не можуть обробити. В такому випадку ваш серіалізатор повинен згенерувати виняток TypeError, щоб функція json.dumps() могла дізнатись про те що наш серіалізатор не розпізнав тип.


І це все, не потрібно більше нічого робити. Ця наша функція серіалізації повертає словник мови Python а не рядок. Ми не робимо всю серіалізацію самостійно, ми просто перетворюємо тип в підтримуваний JSON. Функція json.dumps() зробить решту.

>>> shell
1
>>> import customserializer

Припустимо що customserializer - це модуль в якому ми нещодавно описали функцію to_json.

>>> with open('entry.json', 'w', encoding='utf-8') as f:

Текстовий режим, кодування UTF-8, бла-бла-бла. (Ви забудете! Я іноді забуваю! І все працюватиме до певного часу, але потім поламається, і поламається в найбільш незручний момент).

...     json.dump(entry, f, default=customserializer.to_json)
...

Це важлива частина, щоб використати нашу фукнцію приведення типу при серіалізації, ми повинні передати її всередину json.dumps() як параметр default. (Ура, все в Python - об'єкт!)

Traceback (most recent call last):
  File "<stdin>", line 9, in <module>
    json.dump(entry, f, default=customserializer.to_json)
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "/Users/pilgrim/diveintopython3/examples/customserializer.py", line 12, in to_json
    raise TypeError(repr(python_object) + ' is not JSON serializable')
TypeError: time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1) is not JSON serializable

Ну, так, насправді це не працює. Але гляньте на виняток. Функція json.dumps() більше не жаліється на те що не може серіалізувати об'єкт bytes. Тепер вона жаліється на зовсім інший об'єкт: time.struct_time.

І хоча отримання іншого винятку не виглядає прогресом, але насправді це прогрес! Просто потрібно ще дещо підправити щоб це запрацювало.

import time

def to_json(python_object):
    if isinstance(python_object, time.struct_time):
        return {'__class__': 'time.asctime',
                '__value__': time.asctime(python_object)}
    if isinstance(python_object, bytes):
        return {'__class__': 'bytes',
                '__value__': list(python_object)}
    raise TypeError(repr(python_object) + ' is not JSON serializable')

Ми розширимо функцію customserializer.to_json() перевіркою того чи є об'єкт що їй передається (той з яким має проблеми json.dump() об'єктом класу time.struct_time. Якщо так, потрібно зробити щось схоже на те перетворення яке ми робили з об'єктом bytes: перетворити time.struct_time в словник який містить тільки значення що можуть серіалізуватись в JSON. В цьому випадку найпростішим способом це зробити є перетворити його в рядок за допомогою функції time.asctime(). Ця функція перетворить цей страшнуватий time.struct_time в рядок виду Fri Mar 27 22:20:42 2009'.

З цими двома приведеннями типів вся структура даних entry повинна серіалізуватись в JSON без подальших проблем.

>>> shell 1
>>> with open('entry.json', 'w', encoding='utf-8') as f:
...     json.dump(entry, f, default=customserializer.to_json)
...
you@localhost:~/diveintopython3/examples$ cat example.json
{"published_date": {"__class__": "time.asctime", "__value__": "Fri Mar 27 22:20:42 2009"},
"comments_link": null, "internal_id": {"__class__": "bytes", "__value__": [222, 213, 180, 248]},
"tags": ["diveintopython", "docbook", "html"], "title": "Dive into history, 2009 edition",
"article_link": "http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition",
"published": true}


* * *


Завантаження даних з файла JSON

ред.

Як і модуль pickle модуль json має функцію load() яка приймає потоковий об'єкт, читає з нього дані закодовані в JSON, і створює новий об'єкт Python що відображає структуру даних JSON.

>>> shell
2
>>> del entry
>>> entry
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined

З метою демонстрації перемкніться на оболонку №2 та видаліть структуру даних entry що ви створили раніше в цьому розділі за допомогою модуля pickle.

>>> import json
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f)
...

В найпростішому випадку функція json.load() працює так само як і pickle.load(). Ви передаєте потоковий об'єкт і вона повертає новий об'єкт Python.

>>> entry
{'comments_link': None,
 'internal_id': {'__class__': 'bytes', '__value__': [222, 213, 180, 248]},
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': {'__class__': 'time.asctime', '__value__': 'Fri Mar 27 22:20:42 2009'},
 'published': True}

В мене гарна та погана новини. Спершу гарна новина: функція json.load() успішно прочитала файл entry.json який ми створили з оболонки №1 і створила новий об'єкт Python що містить наші дані. Тепер погана новина: вона не відтворила оригінальну структуру entry. Два значення 'internal_id' та 'published_date' були відтворені як словники. А якщо точніше, то словники зі значеннями сумісними з JSON які ми згенерували в функції to_json().

json.load() не знає нічого про жодну функцію перетворення що ми могли передати в json.dumps(). Нам потрібна функція протилежна до to_json() - функція що прийме перетворений JSON-об'єкт і відтворить за ним оригінальний тип даних Python.


# додаємо це до customserializer.py
def from_json(json_object):

Ця функція перетворення також приймає один параметр і повертає одне значення. Але той параметр що вона приймає це не рядок, а об'єкт Python - результат десеріалізації рядка що містить JSON.

    if '__class__' in json_object:
        if json_object['__class__'] == 'time.asctime':
            return time.strptime(json_object['__value__'])
        if json_object['__class__'] == 'bytes':
            return bytes(json_object['__value__'])
    return json_object

Все що потрібно - це перевірити чи цей об'єкт має ключ '__class__' який створила функція to_json(). Якщо має, то його значення підкаже нам як перетворити значення назад в типи Python.

Для того щоб перетворити рядочок з часом повернений функцією time.asctime(), використайте функцію time.strptime(). Ця функція приймає відформатований рядок з датою та часом (формат може налаштовуватись, але значення за замовчуванням таке саме як і значення за замовчуванням функції time.asctime()) та повертає time.struct_time.

А щоб перетворити список цілих в об'єкт bytes можна просто використати функцію bytes().


І це все. В функції to_json() ми обробляли лише два типи і тепер ці типи обробляються в функції from_json(). Ось результат:

>>> shell
2
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f, object_hook=customserializer.from_json)
...

Щоб приєднати функцію from_json() до процесу десеріалізації передайте її як параметр object_hook в функцію json.load(). Функції що приймають функції це так зручно!

>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Структура даних entry тепер містить ключ 'internal_id' значенням якого є об'єкт bytes. А за ключем 'published_date' зберігається об'єкт time.struct_time.

Правда є ще один глюк.

>>> shell
1
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry2 = json.load(f, object_hook=customserializer.from_json)
... 
>>> entry2 == entry
False

Навіть після того як ми застосувати при серіалізації функцію to_json() та при десеріалізації функцію from_json(), ми все ще не повністю відтворили ідеальну копію оригінальної структури даних. Чому ні?

>>> entry['tags']
('diveintopython', 'docbook', 'html')

В оригінальній структурі даних entry значення ключа 'tags' було кортежем з трьох елементів.

>>> entry2['tags']
['diveintopython', 'docbook', 'html']

Але в отриманій структурі даних entry2 значення ключа 'tags' це список з трьох елементів. JSON не відрізняє списки та кортежі, він має лише один спископодібний тип дани - масив, і модуль json при серіалізації тихо перетворює як кортежі так і списки в масиви JSON. В більшості випадків можна ігнорувати відмінності між кортежами та списками, але іноді при роботі з модулем json про це варто пам'ятати.

Для подальшого читання

ред.

Багато статтей про модуль pickle згадують модуль cPickle. В Python 2, були дві реалізації модуля pickle одна з яких написана на чистому Python, інша на C (але її можна було використовувати з Python). В Python 3 ці два модулі було об'єднано, тому вам варто завжди імпортувати pickle. Можливо ви знайдете корисні статті, але ви повинні ігнорувати застарілу інформацію про cPickle.

Про серіалізацію та модуль pickle:

Про JSON та модуль json:

Про розширюваність pickle:

Примітки

ред.
  1. Насправді рівень вкладеності не обмежується глибиною стеку. Обмежується лише глибина рекурсії функцій що працюють з такими структурами даних але їх не обов'язково робити рекурсивними.[1]

XML · Веб-сервіси HTTP