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

ec373e9683c63cb2e8001eebc5f23f84.jpg

Привет, Хабр!

Сегодня мы рассмотрим одну из самых известных, но одновременно спорных технологий Python — библиотеки pickle. Если вы когда‑нибудь задумывались, как сохранять объекты в виде байтового потока и затем восстанавливать их, то эта статья для вас.

Pickle — это механизм сериализации объектов в Python. Он позволяет сохранить их в бинарном формате и затем загружать обратно, сохраняя связи между объектами. Он полезен для кэширования, обмена данными между процессами и сохранения состояния приложений. Однако pickle не безопасен, так как позволяет выполнять произвольный код при загрузке.

Рассмотрим подробнее.

Внутреннее устройство pickle

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

На данный момент в Python реализовано шесть версий протоколов, каждая из которых привносит улучшения в производительность и функциональность:

  • Протокол 0 (ASCII) — это самый старый протокол, работающий в текстовом формате. Он человекочитаемый, но крайне неэффективный с точки зрения размера данных и скорости обработки.

  • Протокол 1 (бинарный) — первая попытка создания бинарного формата для pickle, но к сегодняшнему дню устарел и практически не используется.

  • Протокол 2 — введён в Python 2.3. Он добавил поддержку новых стилей классов (new‑style classes), что позволило корректно сериализовать объекты, использующие slots.

  • Протокол 3 — появился в Python 3.0, добавил поддержку сериализации объектов типа bytes, но оказался несовместимым с Python 2.

  • Протокол 4 — введён в Python 3.4, значительно улучшил работу с большими объектами, оптимизировал сериализацию коллекций и добавил поддержку out‑of‑band данных.

  • Протокол 5 — самый современный, появился в Python 3.8. Он поддерживает буферизацию больших данных, позволяя передавать объекты через shared memory без копирования.

Использование подходящего протокола зависит от конкретной задачи. В большинстве случаев лучше использовать pickle.HIGHEST_PROTOCOL, чтобы автоматом выбрать наиболее эффективный вариант.

Для работы с pickle доступны следующие функции:

  • pickle.dump (obj, file, protocol=None,…) — сериализует объект obj и записывает его в открытый файловый объект file.

  • pickle.dumps (obj, protocol=None,…) — сериализует объект в виде байтового потока и возвращает его.

  • pickle.load (file,…) — читает сериализованный объект из file и возвращает его в исходном виде.

  • pickle.loads (data,…) — десериализует объект из байтов data.

Если необходимо гибко управлять процессом сериализации, есть классы:

  • pickle.Pickler (file, protocol=None,…) — создаёт объект сериализатора с возможностью настройки поведения.

  • pickle.Unpickler (file,…) — создаёт объект для загрузки сериализованных данных с возможностью ограничивать десериализуемые объекты.

Pickle использует таблицу диспетчеризации (dispatch table), которая сопоставляет классы с функциями сериализации. По дефолту pickle автоматически выбирает правильный способ сериализации для большинства встроенных типов Python. Однако, если есть пользовательские классы, для них можно явно задать кастомные методы сериализации.

Для этого существует модуль copyreg, который позволяет регистрировать собственные обработчики сериализации:

import copyreg
import pickle

class CustomClass:
 def init(self, value):
 self.value = value

def pickle_custom(obj):
 return CustomClass, (obj.value,)

copyreg.pickle(CustomClass, pickle_custom)

obj = CustomClass(42)
serialized = pickle.dumps(obj)
deserialized = pickle.loads(serialized)
print(deserialized.value) # 42

Благодаря copyreg.pickle(), определяем, как именно должен сериализоваться объект нашего пользовательского класса. copyreg.pickle() нужен для сложных случаев, а не для простых классов.

Pickle на примере котиков

Представим, что мы создаём сервис для обмена фотографиями и данными о котиках. Пусть есть класс Cat, который хранит имя, породу, возраст и, конечно, фотографию (в виде байтов). И вот тут нам поможет наш pickle, для сохранения и восстановления наших объектов.

Код:

import pickle
from datetime import datetime
import os
from typing import List

class Cat:
 «""Класс, представляющий кота с базовыми характеристиками.»»»
 def init(self, name: str, breed: str, age: int, photo: bytes):
 self.name = name
 self.breed = breed
 self.age = age
 self.photo = photo # представим, что это бинарные данные изображения
 self.created_at = datetime.now() # метка создания объекта

 def repr(self):
 return (f»Cat(name={self.name!r}, breed={self.breed!r}, age={self.age}, »
 f»created_at={self.created_at.isoformat()}»)
 
 def meow(self):
 return f»{self.name} говорит: Мяу!»

class CatDatabase:
 «""Класс для управления базой данных котов с использованием pickle.»»»
 def init(self, filename: str = 'cats.pickle'):
 self.filename = filename
 self.cats: List[Cat] = self.load_cats()
 
 def add_cat(self, cat: Cat):
 «""Добавляет нового кота в базу и сохраняет изменения.»»»
 self.cats.append(cat)
 self.save_cats()
 
 def save_cats(self):
 «""Сериализует список котиков и сохраняет в файл.»»»
 with open(self.filename, 'wb') as f:
 pickle.dump(self.cats, f, protocol=pickle.HIGHEST_PROTOCOL)
 print(f»Данные успешно сохранены в {self.filename}»)
 
 def load_cats(self) → List[Cat]:
 «""Загружает список котиков из файла, если он существует.»»»
 if not os.path.exists(self.filename):
 print(«Файл с котиками не найден! Создаю новую базу.»)
 return []
 with open(self.filename, 'rb') as f:
 return pickle.load(f)
 
 def list_cats(self):
 «""Выводит всех котиков из базы.»»»
 if not self.cats:
 print(«В базе данных пока нет котиков.»)
 for cat in self.cats:
 print(cat)
 
 def find_cat_by_name(self, name: str) → Cat | None:
 «""Находит кота по имени. Если не найден — возвращает None.»»»
 return next((cat for cat in self.cats if cat.name.lower() == name.lower()), None)

# Создаём базу данных котиков
cat_db = CatDatabase()

# Добавляем новых котиков
cat1 = Cat(«Мурзик», «Абиссинская», 3, b»\xff\xd8\xff\xe0...»)
cat2 = Cat(«Барсик», «Персидская», 5, b»\x89PNG\r\n\x1a\n...»)

cat_db.add_cat(cat1)
cat_db.add_cat(cat2)

# Выводим список котиков
cat_db.list_cats()

# Ищем котика по имени
found_cat = cat_db.find_cat_by_name(«Барсик»)
if found_cat:
 print(«Найден кот:», found_cat.meow())
else:
 print(«Кот не найден.»)

Создали класс Cat с атрибутами name, breed, age, photo, created_at. Реализовали CatDatabase — класс, который: загружает данные при инициализации; умеет добавлять новых котиков; сохраняет их в файл с помощью pickle; позволяет находить котика по имени; умеет выводить весь список котов. Добавили обработку ошибок при поиске котика по имени.

Вывод кода:

Файл с котиками не найден! Создаю новую базу.
Данные успешно сохранены в cats.pickle
Данные успешно сохранены в cats.pickle
Cat(name='Мурзик', breed='Абиссинская', age=3, created_at=...)
Cat(name='Барсик', breed='Персидская', age=5, created_at=...)
Найден кот: Барсик говорит: Мяу!

Нюансы работы pickle

Существует несколько моментов, которые важно понимать, чтобы использовать pickle.

Для сложных объектов, которые не могут быть сериализованы напрямую (например, если они содержат открытые файловые дескрипторы или сетевые соединения), используются методы getstate() и setstate(). Рассмотрим пример, где класс Cat хранит открытый файловый дескриптор для логирования активности котика:

class CatLogger:
 def init(self, cat: Cat, log_file: str):
 self.cat = cat
 self.log_file = log_file
 self.log_handle = open(log_file, 'a', encoding='utf-8')
 
 def log(self, message: str):
 entry = f»[{datetime.now().isoformat()}] {self.cat.name}: {message}\n»
 self.log_handle.write(entry)
 self.log_handle.flush()
 
 def getstate(self):
 # Мы не можем сериализовать файловый дескриптор, поэтому убираем его из состояния
 state = self.__dict__.copy()
 del state['log_handle']
 return state
 
 def setstate(self, state):
 self.__dict__.update(state)
 # Восстанавливаем файловый дескриптор при десериализации
 self.log_handle = open(self.log_file, 'a', encoding='utf-8')

# Пример использования:
logger = CatLogger(cat1, 'cat_logs.txt')
logger.log(«Котик проснулся и захотел кушать!»)

# Сохраняем объект логгера
with open('cat_logger.pickle', 'wb') as f:
 pickle.dump(logger, f, protocol=pickle.HIGHEST_PROTOCOL)

# Позже, в другом процессе, восстанавливаем объект
with open('cat_logger.pickle', 'rb') as f:
 restored_logger = pickle.load(f)
restored_logger.log(«Котик снова проявил активность!»)

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

Иногда требуется более тонкая настройка, когда стандартных методов getstate и setstate недостаточно. Например, пусть нам нужно сериализовать специальный объект, который содержит большое количество данных (например, изображение котика), и нужно использовать оптимизированный путь для его передачи.

Создадим класс, который будет поддерживать out‑of‑band буферы (поддержка с протоколом 5):

from pickle import PickleBuffer

class CatImage:
 def init(self, image_data: bytes):
 self.image_data = image_data
 
 def __reduce_ex__(self, protocol):
 if protocol >= 5:
 # Используем PickleBuffer для оптимизации передачи данных
 return (self.__class__._reconstruct, (PickleBuffer(self.image_data),))
 else:
 # Для старых протоколов возвращаем обычный bytes объект
 return (self.__class__._reconstruct, (self.image_data,))
 
 @classmethod
 def reconstruct(cls, data):
 # data может быть PickleBuffer или bytes, в зависимости от протокола
 if isinstance(data, PickleBuffer):
 # Вызываем метод raw() для получения оригинального байтового представления
 data = data.raw().tobytes()
 return cls(data)
 
 def _repr__(self):
 return f»CatImage({len(self.image_data)} байт)»

Так можно использовать метод __reduce_ex__ для реализации оптимизированной сериализации. Если объект очень большой, минимизация копий памяти может стать важной.

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

24 марта пройдет открытый урок на тему «Это база: типы данных в Python». Подробнее

Опасность pickle и почему его нельзя использовать с непроверенными данными

Допустим, нам прислали файл с сериализованным объектом. Вы, ничего не подозревая, загрузили его с помощью pickle.load(). Что может произойти? Посмотрим:

import pickle
import os

# Вредоносный payload
class Malicious:
 def reduce(self):
 return os.system, («rm ‑rf /»,) # Никогда не запускайте этот код!

# Сериализуем объект
with open(«danger.pkl», «wb») as f:
 pickle.dump(Malicious(), f)

# Где‑то в другом месте кода
with open(«danger.pkl», «rb») as f:
 pickle.load(f) # Атака! Код выполнится при загрузке

В классе Malicious определён метод reduce, который указывает pickle, какой объект инициализировать при десериализации. Вредоносный объект при десериализации вызывает os.system() и выполняет любой произвольный код. В результате pickle.load(f) запустит команду echo, но вместо этого здесь может быть удаление файлов, скачивание трояна, создание бэкдора и т. д.

Как защититься?

  1. Никогда не загружайте pickle‑файлы из неизвестных источников.

  2. Используйте альтернативные форматы (JSON, MsgPack, YAML), если данные приходят от пользователя.

  3. Ограничьте классы, которые могут загружаться, переопределив find_class() у Unpickler:

import io
import pickle
import builtins

SAFE_BUILTINS = {»range», «set», «frozenset», «dict», «list», «int», «float», «bool»}

class RestrictedUnpickler(pickle.Unpickler):
 def find_class(self, module, name):
 if module == «builtins» and name in SAFE_BUILTINS:
 return getattr(builtins, name)
 raise pickle.UnpicklingError(f»Загрузка {module}.{name} запрещена!»)

def safe_loads(data: bytes):
 «""Безопасная десериализация данных»»»
 return RestrictedUnpickler(io.BytesIO(data)).load()

# Безопасная десериализация
safe_data = pickle.dumps([1, 2, 3])
print(safe_loads(safe_data)) # Работает

# Попытка загрузить вредоносный объект
try:
 print(safe_loads(pickle.dumps(Malicious())))
except pickle.UnpicklingError as e:
 print(f»Атака предотвращена: {e}»)

Как сериализовать объекты с несериализуемыми атрибутами

Иногда в классе могут быть несериализуемые объекты — например, файловые дескрипторы, сокеты или потоки. pickle не умеет сериализовать их напрямую, но есть способы это обойти.

Попробуем сериализовать объект с несериализуемым атрибутом (open()):

import pickle

class FileLogger:
 def init(self, filename):
 self.filename = filename
 self.file = open(filename, «a», encoding=»utf-8»)

 def log(self, message):
 self.file.write(f»[LOG] {message}\n»)
 self.file.flush()

 def getstate(self):
 «""Удаляем файловый дескриптор перед сериализацией»»»
 state = self.__dict__.copy()
 state[«file»] = None # Чтобы избежать KeyError
 return state

 def setstate(self, state):
 «""Восстанавливаем файл после десериализации»»»
 self.__dict__.update(state)
 self.file = open(self.filename, «a», encoding=»utf-8»)

# Теперь всё работает!
logger = FileLogger(«log.txt»)

with open(«logger.pkl», «wb») as f:
 pickle.dump(logger, f) # Работает

with open(«logger.pkl», «rb») as f:
 restored_logger = pickle.load(f) # Восстанавливается
restored_logger.log(«Система запущена!») # Лог снова работает
TypeError: cannot pickle '_io.TextIOWrapper' object

Файл нельзя сериализовать, потому что он ссылается на открытый файловый дескриптор.

Можно удалять несериализуемые атрибуты перед сохранением и восстанавливать их после загрузки:

import pickle

class FileLogger:
 def init(self, filename):
 self.filename = filename
 self.file = open(filename, «a», encoding=»utf-8»)

 def log(self, message):
 self.file.write(f»[LOG] {message}\n»)
 self.file.flush()

 def getstate(self):
 «""Удаляем файловый дескриптор перед сериализацией»»»
 state = self.__dict__.copy()
 state[«file»] = None # Чтобы избежать KeyError
 return state

 def setstate(self, state):
 «""Восстанавливаем файл после десериализации»»»
 self.__dict__.update(state)
 self.file = open(self.filename, «a», encoding=»utf-8»)

# Теперь всё работает!
logger = FileLogger(«log.txt»)

with open(«logger.pkl», «wb») as f:
 pickle.dump(logger, f) # Работает

with open(«logger.pkl», «rb») as f:
 restored_logger = pickle.load(f) # Восстанавливается

restored_logger.log(«Система запущена!») # Лог снова работает

getstate() удаляет file, потому что он несериализуемый. setstate() загружает объект и открывает файл заново, сохраняя его работоспособность после десериализации.

Еще пару моментов

Если структура объекта может измениться, добавьте версию в сериализуемые данные. Например, можно добавить атрибут version и реализовать миграции в методе setstate.

class Cat:
 version = 1 
 def init(self, name, breed, age, photo):
 self.name = name
 self.breed = breed
 self.age = age
 self.photo = photo
 self.created_at = datetime.now()
 
 def getstate(self):
 state = self.__dict__.copy()
 state['__version__'] = self.__version__
 return state
 
 def setstate(self, state):
 version = state.pop('__version__', 0)
 if version < 1:
 # Миграция для объектов старой версии
 state.setdefault('created_at', datetime.now())
 self.__dict__.update(state)

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

import zlib

# Сериализация и сжатие
raw_data = pickle.dumps(cats, protocol=pickle.HIGHEST_PROTOCOL)
compressed_data = zlib.compress(raw_data)

# Десериализация
decompressed_data = zlib.decompress(compressed_data)
cats_restored = pickle.loads(decompressed_data)

Также есть модуль pickletools для анализа сериализованных потоков. Это поможет понять, как именно устроен ваш pickle и найти возможные проблемы с производительностью и безопасностью.

import pickletools

with open('cats.pickle', 'rb') as f:
 data = f.read()
pickletools.dis(data)

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

try:
 with open('cats.pickle', 'rb') as f:
 cats = pickle.load(f)
except (pickle.UnpicklingError, EOFError) as e:
 # Логируем ошибку и предпринимаем действия по восстановлению данных
 print(«Ошибка загрузки данных:», e)
 cats = [] # или применяем другой алгоритм восстановления

Если данные критичны, подумайте о подписывании данных с помощью HMAC. Таким образом, можно убедиться, что данные не были изменены кем‑либо.

import hmac, hashlib

secret_key = b'my_super_secret_key'

def sign_data(data: bytes) → bytes:
 return hmac.new(secret_key, data, hashlib.sha256).digest()

def verify_data(data: bytes, signature: bytes) → bool:
 expected = hmac.new(secret_key, data, hashlib.sha256).digest()
 return hmac.compare_digest(expected, signature)

# Пример использования:
raw_data = pickle.dumps(cats, protocol=pickle.HIGHEST_PROTOCOL)
signature = sign_data(raw_data)
# Сохраняем raw_data и signature

Надеюсь, эта статья помогла вам глубже понять, как устроена библиотека pickle и как её можно использовать. А как pickle используется у вас? Делитесь своими кейсами в комментариях!

А подробнее с инструментом можно ознакомиться здесь.

Всё, что нужно знать о программировании на Python с нуля до middle+ можно изучить на специализации «Python‑разработчик» под руководством экспертов.

Habrahabr.ru прочитано 18464 раза