Pull to refresh
4
21.1
Yan Khachko @sijokun

Python Backend Enginner & Founder

Send message

Первая версия кода была сделана в феврале, после этого этот же код был партирован в другой наш проект. Пока полет нормальный и все это время жили на том, что было написано тогда в режиме ASAP. Я не писал статью сразу – велосипед как вино, ему нужно настоятся и оправдать себя.

Многих фичей в изначальной версии не было, например Nullable, они нам были не нужны. Для публичной версии я добавил побольше типов, скоро планирую еще Variant добавить, чтобы уж совсем универсально стало.

Спасибо!

Библиотека пока в ранней версии, поэтому 0.1.3, а не 1.0.0. Изначально она была сделана ASAP за пару ночей для срочного решения задачи, я попробовал привести до публикации ее в более приличный вид, но еще не все исправил. Комментарии читаю, записываю на листочек и буду делать – так же приветствую пулл реквесты.

Проблема в том, что приходится поддерживать схему в двух местах – Pydyntic с которым работаем внутри питона и одельно .proto схему + кодген по нему. Плюс PyByntic в том, что все поля задаются один раз в одном месте.

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

Average size PyByntic: 2157.96 bytes
Average size PyByntic+gzip: 1178.82 bytes
Average size JSON+gzip: 2364.51 bytes

Сжатый JSON все еще хуже PyByntic, а если сжать PyByntic, то разница в два раза. Ну и CPU вы на gzip туда сюда будете заметно больше тратить, чем на запись и чтение байт.

Раскрою секрет: Все типы моей библиотеке взяты и полностью совместимы с нативным форматом ClickHouse.

Ради интереса можно даже бинарные даты от SELECT ... FORMAT Native ей распарсить (нужно оставить только сами данные без хедера).

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

размер неправильный (UInt16), в user.proto uint32 item_id = 1;.

В Protobuf нет ничего ниже uint32: документация.

не очень понял вот эту строчку.

Protobuf юзера
Protobuf юзера

В протобаф весь нестед объект лежит подряд, вот каждый итем, сначала его ид, потом тип, потом остальные поля и так по кругу. Я же раскрываю в "колонки", будут сначала все айди нестед итемов подряд, потом все типы и т.д. Если, например, одно текстовое поле с одинаковым текстом "text" повторяется в каждом итеме, то в моем формате будет texttexttexttexttext, для алгоритмов компрессии это выгоднее. В бенчмарке мы справниваем без компрессии, поэтому к нему это не относилось, а просто комментарий в общем о формате.

DateTime32 потерял зону - было 2106-02-07 06:28:15, стало 2106-02-07 03:28:15+00:00

Зоны мы не сохраняем, но планируется добавить опцию timezone-aware. Это можно через кастомный тип сделать. Для нас этой необходимости небыло, мы на бэкенде храним все даты в UTC.

try:
auth_data = validateand_extract_auth(Authorization, BOT_TOKEN)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid auth data: {e}")

У FastApi есть механизм Depends, который решает эту задачу заметно удобнее – один раз прописываешь для роутера и дальше в каждой ручке внутри роутера будет проверка токена и уже готовый объект инит даты

Скрытый текст

def parse_init_data(init_data: str) -> TelegramInitData:
    parsed_query = urllib.parse.parse_qs(init_data)

    data = {}
    for key in parsed_query:
        data[key] = parsed_query[key][0]
    data["user"] = json.loads(data["user"])

    return TelegramInitData.validate(data)


def validate_telegram_data(query_string: str) -> bool:
    data = dict(urllib.parse.parse_qsl(query_string))

    received_hash = data.pop("hash", None)
    if received_hash is None:
        return False

    data_check_string = "\n".join(
        f"{key}={urllib.parse.unquote(value)}" for key, value in sorted(data.items())
    )

    secret_key = hmac.new(
        key="WebAppData".encode("utf-8"),
        msg=BOT_TOKEN.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).digest()

    check_hash = hmac.new(
        key=secret_key, msg=data_check_string.encode("utf-8"), digestmod=hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(check_hash, received_hash)
async def decode_auth_header(request: Request):
    expected_header = "authorization"

    if expected_header not in request.headers:
        raise HTTPException(status_code=401, detail="Authorization header is required")

    token_encoded = request.headers[expected_header].removeprefix("TMA ")
    token = base64.b64decode(token_encoded).decode()

    if not validate_telegram_data(token):
        raise HTTPException(status_code=401, detail="Invalid initData")

    init_data = parse_init_data(token)

    request.state.data = init_data

    return init_data

Мой пример инит дату в Base64 в Authorization: TMA .... передает, но думаю концепт понятен.


И пример испоьзования:

app.include_router(router, dependencies=[Depends(decode_auth_header)])

@router.post("/")
async def something(request: Request):
  user_id = request.state.data.user.id 

В библиотеке есть DateTime64 – это основная суть библиотеки, возможность самому выбрать максимально точно сколько байт тебе нужно для твоих данных.

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


Типы DateTime и Date полностью позаимстованы из ClickHouse, со всеми их минусами и плюсами.

https://github.com/sijokun/PyByntic/tree/test_protobuf_vs_pybyntic/protobuf_vs_pybyntic

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

PyByntic – 2300 байт на юзера
Protobuf – 3500 байт на юзера
Json – порядка 10+ тысяч байт на юзера

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


За счет чего выигрыши:
1. Вообще не сохраняется структура данных, использован абсолютный возможный минимумом байтов. Это совершенно не подходит для долгого хранения данных, например на диске, так как в байтах не сохраняется даже версия модели (это в планах добавить), но для хранения кэша в Редисе это не является большой проблемок.
2. В теории PyByntic лучше подходит для сжатия, потому что одинаковые поля хранятся рядом.

Например:

class Tag:
    description = "some long text"
    id = 1

class Post:
    text = "long text"
    tags: list[Tag]

tags = []
for i in range(1000):
    tags.append(Tag(description="very long description", id=i))

post = Post(text="test", tags=tags)

После сериализации структура становится колоннообразной:

post.text = "long text"
post.tags.description = ["some long text"] * 1000
post.tags.id = [1, 2, .... 1000]

Затем сохраняются только сами компактные бинарные данные. Поскольку все повторяющиеся длинные описания расположены в памяти подряд, а не разбросаны среди объектов, алгоритмы сжатия могут работать гораздо эффективнее.

В микрафонах часто бывает именно крона

Батут работает! (нет)

Information

Rating
372-nd
Date of birth
Registered
Activity

Specialization

Backend Developer
Python
SQL
Linux
Golang
Kubernetes
English