Кодируем данные экземпляра класса при хранении в файле или базе данных

Сохранение конфиденциальности при обработке пользовательских данных считается признаком хорошего тона. Во время передачи между клиентским приложением и сервером данные, как правило, защищены SSL шифрованием. Если данные представляют собой не строку текста, а имеют более сложную модель из строк, чисел, булевых переменных и пользовательских типов, то для их экземпляров нужно применять сериализацию. Сериализация возможна в файл или строку. В самом простом случае в файле данные сохранены в текстовом виде и их можно легко прочитать. Если у нас есть наборы однотипных данных, то их принято выводить в таблицах, а хранить в базах данных. Одна строка данных — один экземпляр класса модели. Пользователи могут требовать конфиденциальности и мы кодируем данные экземпляра класса, чтобы их не смог прочитать даже администратор базы данных. Чтобы данные из базы не только выводить в таблице, но и использовать для, например, вычислений, их нужно десериализовать в экземпляры класса.

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

Таблица данных о продуктах
Рис. 1 Отображение данных о продуктах

Процесс создания сайта я описываю в других своих статьях. Данные о пользователях и их продуктах я сохраняю в двух связанных таблицах базы данных. Обработку данных о продуктах разделим на три этапа.

Сериализация и десериализация данных класса

Я понимаю сериализацию как преобразование данных объекта в общепринятый тип данных в установленном формате. Десериализация воссоздаёт экземпляр объекта из данных в этом формате. Если мы хотим обмениваться данными между приложениями, которые пишем сами, то можно придумать формат и самим. Но зачем изобретать велосипед? Когда данные мы получаем от сервера или другого приложения, то они придут к нам в каком-то общепринятом формате. Особенно это актуально, если стыкуем данные классов, написанных на разных языках программирования. Чаще всего используется формат JSON. Он очень похож на словарь Python. Те же фигурные скобки, в которых через запятую пары ключ-значение. В качестве ключей названия полей класса. В качестве значений — значения полей сериализуемого экземпляра класса. Поддерживаются не все типы данных. Гарантирован стабильный результат для полей типов строка, число, булевых полей и полей с незаданным значением. Для полей пользовательских типов надо что-то реализовывать самостоятельно. Но вернёмся к нашим продуктам.

class MyProduct(object):
    def __init__(self, nm, dt, store, w, pr):
        self.name = nm
        self.date = dt
        self.storage = store
        self.wt = w
        self.price = pr

    def __str__(self):
        return f'{self.name} {self.date} {self.storage} {self.wt} {self.price}'

Я буду использовать класс MyProduct, определённый выше. 2 текстовых поля для имени и даты. 3 числовых поля для срока годности (int), веса и стоимости (float). Давайте создадим экземпляр нашего класса и сериализуем его в формате JSON. Код и результат ниже:

product = MyProduct('Молоко', '07.05.2023', 14, 0.9, 87)
str_json = json.dumps(product)
#str_json = json.dumps(product.__dict__)
print(str_json)

Этот код выдаёт ошибку: builtins.TypeError: Object of type MyProduct is not JSON serializable. Чтобы получить требуемый результат, нужно либо создать для класса свой class CustomEncoder(json.JSONEncoder), либо вызвать преобразование в виде json.dumps(product.__dict__). В результате получим строку вида:

{«name»: «\u041c\u043e\u043b\u043e\u043a\u043e», «date»: «07.05.2023», «storage»: 14, «wt»: 0.9, «price»: 87}

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

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

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

def to_object(d):
    if d['class_name'] == 'MyProduct':
        return MyProduct(d['name'], d['date'], d['storage'], d['wt'], d['price']) 

Проверка значения по ключу ‘class_name’ является своеобразной гарантией того, что в переданном словаре именно объект класса MyProduct. Само собой в словаре такая пара ключ : значение должна быть. Метод to_object по сути передаёт значения по ключам словаря в конструктор класса и возвращает созданный экземпляр. Используем его так:

pr1 = json.loads(str_json, object_hook=MyProduct.to_object)

Если вывести в консоль получившейся объект pr1, то получим строку, определенную функцией класса __str__ : Молоко 07.05.2023 14 0.9 87

— Формат pickle

Pickle является собственным форматом сериализации объектов в Python. Интерфейс pickle обеспечивает четыре метода: dump, dumps, load, и loads. Метод dump() сериализует в открытый файл (файл-подобный объект). Метод dumps() сериализует в строку. Пара методов load() и loads() десериализуют из открытого файлового объекта и из строки соответственно.

Pickle поддерживает по умолчанию текстовый протокол, но имеет также двоичный протокол, который более эффективен, но не читается человеком. Применим pickle для сериализации экземпляра класса MyProduct (тот же product Молоко) :

import pickle
print(pickle.DEFAULT_PROTOCOL)
4
str_pkl = pickle.dumps(product, protocol=0)
print(str_pkl)
b'ccopy_reg\n_reconstructor\np0\n(c__main__\nMyProduct\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\n(dp5\nVname\np6\nV\\u041c\\u043e\\u043b\\u043e\\u043a\\u043e\np7\nsVdate\np8\nV07.05.2023\np9\nsVstorage\np10\nI14\nsVwt\np11\nF0.9\nsVprice\np12\nI87\nsVclass_name\np13\nVMyProduct\np14\nsb.'
bin_pkl = pickle.dumps(product, protocol=pickle.HIGHEST_PROTOCOL)
print(bin_pkl)
b'\x80\x05\x95\x7f\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\tMyProduct\x94\x93\x94)\x81\x94}\x94(\x8c\x04name\x94\x8c\x0c\xd0\x9c\xd0\xbe\xd0\xbb\xd0\xbe\xd0\xba\xd0\xbe\x94\x8c\x04date\x94\x8c\n07.05.2023\x94\x8c\x07storage\x94K\x0e\x8c\x02wt\x94G?\xec\xcc\xcc\xcc\xcc\xcc\xcd\x8c\x05price\x94KW\x8c\nclass_name\x94h\x01ub.'

Результат преобразования — массив байт. В зависимости от применённой версии протокола (0 и HIGHEST_PROTOCOL) результат значительно отличается. Что внутри особенно во 2 случае понять уже трудно. Если выполнить десериализацию, то модуль версию протокола определяет автоматически и воссоздаёт два одинаковых экземпляра класса. В консоль объекты выводятся так, как определено в методе __str__. Смотри вызов и результат ниже:

ds = pickle.loads(str_pkl)
print(ds)
Молоко 07.05.2023 14 0.9 87
dp = pickle.loads(bin_pkl)
print(dp)
Молоко 07.05.2023 14 0.9 87

Модуль pickle неплохо справляется и с пользовательскими типами данных. Есть некоторые ограничения, например, тип данных — лямбда функция или соединение с базой данных. Более подробно можно прочитать в документации. Подкупает то, что никакие дополнительные костыли как в JSON, не нужны. Но обмен возможен только для приложений на Python. Также в глаза бросается разница объемов сохраненных данных и выводимых данных в методе __str__.

— Мой вариант в формате CSV

Так как мой проект учебный и обрабатываться данные будут внутри одного приложения, то можно применить свой формат. За основу используем формат CSV, в котором данные таблиц выводятся построчно. Данные из ячеек (столбцов) идут друг за другом через разделитель (по умолчанию ‘;‘). Используя опыт работы с JSON, реализуем внутри нашего класса два метода для получения данных экземпляра класса в формате CSV и восстановления из такой строки, код ниже :

def to_csv_str(self, sep=';'):
    return sep.join([self.name, self.date, str(self.storage), str(self.wt), str(self.price), 'prd'])
    
def csv_to_object(csv, sep=';'):
    ls = csv.split(sep)
    if ls[-1] == 'prd':
        return MyProduct(ls[0], ls[1], int(ls[2]), float(ls[3]), float(ls[4]))

В конец CSV строки добавим текстовую константу ‘prd’ чтобы при десериализации точно понимать, что это данные именно экземпляра класса MyProduct. Порядок применения и вывод результатов в блоке ниже:

str_csv = product.to_csv_str()
print(str_csv)
Молоко;07.05.2023;14;0.9;87;prd

pr4 = MyProduct.csv_to_object(str_csv)
print(pr4)
Молоко 07.05.2023 14 0.9 87

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

Кодируем данные экземпляра класса (псевдо шифрование)

На прошлом этапе мы получили данные экземпляра класса в удобном, но легко читаемом формате. Собственно шифрование это отдельная достаточно объёмная тема. Есть данные, ключ, алгоритм и результат. Как правило по 3 частям можно восстановить 4ю. Стойкость шифра от вскрытия путем перебора определяется длиной ключа. Сам ключ мы хранить нигде не будем. Доверим это пользователю. По нашему запросу он будет вводить свой ключ при входе на сайт. В качестве ключа пользователь должен запомнить комбинацию из не менее 8 знаков: цифры и/или латинские буквы. Псевдо шифрование будет заключаться в сложении по модулю 2 байтовых массивов данных и ключа. Алгоритм реализуем в функции ниже :

def mycripto(bt, skey):
    l = len(skey)
    res = list(bt)
    for i in range(len(bt) // l):
        for j in range(l):
            res[i * l + j] = bt[i * l + j] ^ skey[j]
    else:
        i += 1
        for j in range(len(bt) % l):
            res[i * l + j] = bt[i * l + j] ^ skey[j]
    return bytes(res)

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

b_cripto = mycripto(str.encode(str_csv, encoding='utf-8'), skey)
print(b_cripto)
b'.W\x7fS\tE\x1b\x11=c.u\x94\xdd\xee\xd0\xfb\x9a\xc3\xeb\xce\xf9\x9c\xd6\xe8\xca\xf0\x9f\xc3\xe0\xc5\xf3\x98\xd6\xa9\x8c\xaf'

b_csv = mycripto(b_cripto, skey)
print(b_csv.decode('utf-8'))
Молоко;07.05.2023;14;0.9;87;prd

Функции str.encode и bytes.decode преобразуют строку в байтовый массив в кодировке ‘utf-8’ и обратно байтовый массив в строку.

Зашифрованные байты в строку base64 и обратно

Чтобы передавать байтовые массивы по каналам связи, как правило, используют их перекодирование в формат base64. Кодировка Base64 — это тип преобразования байтов в символы ASCII. 8-битный байтовый массив делится на 6-битные группы, которые заменяются символами кода ASCII по специальной таблице. Некратный 6 битам массив дополняется нулями. В результате получаются строки из больших и маленьких латинских букв, цифр, символов ‘+’ и ‘/’. Некоторые протоколы могут воспринимать значения отдельных байт или их комбинаций как управляющие или служебные символы. В тоже время обычный текст не оказывает влияния на их работоспособность. В нашем случае могут встретиться в байтовом массиве все значения от 0 до 255. Для передачи информации в базу данных нужно использовать базовые типы, т.е. тип string нас вполне устроит.

Модуль base64 предоставляет несколько функций для кодирования и декодирования в разных форматах. Для их использования модуль нужно импортировать. Ниже приведу пример кодирования и декодирования строки с выводом в консоль результатов.

import base64

message = "Python is fun"
message_bytes = message.encode('ascii')    # ascii => bytes
base64_bytes = base64.b64encode(message_bytes)   # bytes => base64 bytes
base64_message = base64_bytes.decode('ascii')    # bytes => ascii
print(base64_message)
UHl0aG9uIGlzIGZ1bg==

#base64_message = 'UHl0aG9uIGlzIGZ1bg=='
base64_bytes = base64_message.encode('ascii')    # ascii => bytes
message_bytes = base64.b64decode(base64_bytes)   # base64 bytes => bytes
message = message_bytes.decode('ascii')    # bytes => ascii
print(message)
Python is fun

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

Подводим итоги

Рассмотрев по отдельности этапы обработки данных, давайте соберём всё в двух методах класса. В методе to_csv_cripto_base64 мы кодируем данные экземпляра класса сразу в строку формата base64. В методе base64_cripto_csv_to_object из строки формата base64 восстанавливаем экземпляр класса. Код функций приведён ниже :

def to_csv_cripto_base64(self, skey=b'\xff\xff\xff\xff\xff', sep=';'):     
    s_csv = self.to_csv_str(sep)
    b_cripto = mycripto(str.encode(s_csv, encoding='utf-8'), skey)
    return base64.b64encode(b_cripto).decode('utf-8')

def base64_cripto_csv_to_object(bs64: str, skey=b'\xff\xff\xff\xff\xff', sep=';'):
    b_cripto = base64.decodebytes(bs64.encode('utf-8'))
    s_csv: str = mycripto(b_cripto, skey).decode('utf-8')
    return MyProduct.csv_to_object(s_csv, sep)

В блоке ниже приведу пример использования этих функций и выведенные в консоль результаты :

sp_b64 = product.to_csv_cripto_base64(mysecr)
print(sp_b64)
Lld/UwlFGxE9Yy51lN3u0Puaw+vO+ZzW6Mrwn8PgxfOY1qmMrw==

pr5 = MyProduct.base64_cripto_csv_to_object(sp_b64, mysecr)
print(pr5)
Молоко 07.05.2023 14 0.9 87

Как видно из результатов в консоли, поставленные цели достигнуты. Строка в формате base64 выглядит какой-то абракадаброй, но после обратного преобразования снова превращается в исходный экземпляр класса. Если раскодирование выполняется с неправильным ключом, то программа может аварийно завершиться выбросив исключение, например, builtins.UnicodeDecodeError: ‘utf-8’ codec can’t decode byte 0x80 in position 2: invalid start byte. Это будет необходимо предусмотреть, так как пользователь может ввести неправильный ключ. Об этом его нужно проинформировать, а программа должна продолжить работать!

Приведённая в статье достаточно простая методика показывает как скрыть данные пользователя.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *