[Python] История типизации на примере одного большого проекта

Блог компании Ostrovok.ru, Python, Проектирование и рефакторинг, Разработка веб-сайтов

Всем привет! Сегодня я расскажу вам историю развития типизации на примере одного из проектов в Ostrovok.ru.

Эта история началась задолго до хайпа о typing в python3.5, более того, она началась внутри проекта, написанного еще на python2.7.

2013 год: совсем недавно был релиз python3.3, мигрировать на новую версию смысла не было, так как каких-то конкретных фичей она не добавляла, а боли и страдания при переходе принесла бы очень много.

Я занимался проектом Partners в Ostrovok.ru – этот сервис отвечал за все, что связано с партнерскими интеграциями, бронированиями, статистикой, личным кабинетом. У нас использовались как внутренние API для других микросервисов компании, так и внешнее API для наших партнеров.

В какой-то момент в команде сформировался следующий подход к написанию обработчиков HTTP ручек или какой-либо бизнес логики: 1. данные на входе и на выходе должны быть описаны структурой (классом), 2. содержимое экземпляров структур должно быть провалидировано в соответствии с описанием, 3. функция, которая принимает структуру на входе и отдает структуру на выходе, должна проверять типы данных на входе и на выходе соответственно.

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

Пример:

import datetime as dt

from contracts import new_contract, contract
from schematics.models import Model
from schematics.types import IntType, DateType


# in
class OrderInfoData(Model):
    order_id = IntType(required=True)


# out
class OrderInfoResult(Model):
    order_id = IntType(required=True)
    checkin_at = DateType(required=True)
    checkout_at = DateType(required=True)
    cancelled_at = DateType(required=False)


@new_contract
def pyOrderInfoData(x):
    return isinstance(x, OrderInfoData)


@new_contract
def pyOrderInfoResult(x):
    return isinstance(x, OrderInfoResult)


@contract
def get_order_info(data_in):
    """
    :type data_in: pyOrderInfoData
    :rtype: pyOrderInfoResult
    """
    return OrderInfoResult(
        dict(
            order_id=data_in.order_id,
            checkin_at=dt.datetime.today(),
            checkout_at=dt.datetime.today() + dt.timedelta(days=1),
            cancelled_at=None,
        )
    )


if __name__ == '__main__':
    data_in = OrderInfoData(dict(order_id=777))
    data_out = get_order_info(data_in)
    print(data_out.to_native())

В примере используются библиотеки: schematics и pycontracts.

  • schematics — способ описывать и валидировать данные.
  • pycontracts — способ проверять данные на входе/выходе функции в runtime.

Такой подход позволяет:

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

Важно понимать, что проверка типов (не валидация) работает только в runtime, и это удобно при локальной разработке, запуске тестов в CI и проверке работоспособности релиз кандидата в staging среде. В продакшн среде это необходимо отключать, иначе будет тормозить сервер.

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

В какой-то момент я стал замечать, что запуск проекта занимает уже заметные несколько секунд – это раздражало, поскольку каждый раз при редактировании кода и запуске тестов приходилось долгое время сидеть и ждать. Когда это ожидание стало занимать 8-10 секунд, мы решили наконец разобраться, что там творится под капотом.

На деле все оказалось довольно просто. Библиотека pycontracts при запуске проекта парсит все docstring, которые покрыты @contract, чтобы зарегистрировать в памяти все структуры и потом правильно их проверять. Когда количество структур в проекте исчисляется тысячами, вся эта штука начинает тормозить.

Что с этим делать? Правильный ответ – искать другие решения, к счастью на дворе уже 2018 год (python3.5-python3.6), да и свой проект мы уже мигрировали на python3.6.

Я стал изучать альтернативные решения и думать, как можно мигрировать проект с “pycontracts + описание типов в docstring” на “что-то + описание типов в typing annotation”. Оказалось, если обновить pycontracts до свежей версии, то можно описывать типы в typing annotation стиле, например, это может выглядеть так:

@contract
def get_order_info(data_in: OrderInfoData) -> OrderInfoResult:
    return OrderInfoResult(
        dict(
            order_id=data_in.order_id,
            checkin_at=dt.datetime.today(),
            checkout_at=dt.datetime.today() + dt.timedelta(days=1),
            cancelled_at=None,
        )
    )

Проблемы начинаются в том случае, если нужно использовать структуры из typing, например Optional или Union, так как pycontracts НЕ умеет с ними работать:

from typing import Optional

@contract
def get_order_info(data_in: OrderInfoData) -> Optional[OrderInfoResult]:
    return OrderInfoResult(
        dict(
            order_id=data_in.order_id,
            checkin_at=dt.datetime.today(),
            checkout_at=dt.datetime.today() + dt.timedelta(days=1),
            cancelled_at=None,
        )
    )

Я начал искать альтернативные библиотеки для проверки типов в runtime:

  • enforce
  • typeguard
  • pytypes

Enforce на тот момент не поддерживал python3.7, а мы уже обновились, pytypes не понравился синтаксисом, в итоге выбор пал на typeguard.

from typeguard import typechecked

@typechecked
def get_order_info(data_in: OrderInfoData) -> Optional[OrderInfoResult]:
    return OrderInfoResult(
        dict(
            order_id=data_in.order_id,
            checkin_at=dt.datetime.today(),
            checkout_at=dt.datetime.today() + dt.timedelta(days=1),
            cancelled_at=None,
        )
    )

Вот примеры из реального проекта:

@typechecked
def view(
    request: HttpRequest,
    data_in: AffDeeplinkSerpIn,
    profile: Profile,
    contract: Contract,
) -> AffDeeplinkSerpOut:
    ...

@typechecked
def create_contract(
    user: Union[User, AnonymousUser],
    user_uid: Optional[str],
    params: RegistrationCreateSchemaIn,
    account_manager: Manager,
    support_manager: Manager,
    sales_manager: Optional[Manager],
    legal_entity: LegalEntity,
    partner: Partner,
) -> tuple:
    ...

@typechecked
def get_metaorder_ids_from_ordergroup_orders(
    orders: Tuple[OrderGroupOrdersIn, ...], contract: Contract
) -> list:
    ...

В итоге после долгого процесса рефакторинга нам удалось полностью перевести проект на typeguard + typing annotations.

Каких результатов мы достигли:

  • проект запускается за 2-3 секунды, что как минимум не раздражает.
  • повысилась читаемость кода.
  • проект стал меньше как в количестве строк, так и в файлах, так как больше нет регистраций структур через @new_contract.
  • умные IDE типа PyCharm стали лучше индексировать проект и делать разные подсказки, поскольку теперь это не комментарии, а честные импорты.
  • можно использовать статические анализаторы вроде mypy и pyre-check, так как они поддерживают работу с typing annotations.
  • python сообщество в целом движется в сторону типизации в том или ином виде, то есть текущие действия – это инвестиции в будущее проекта.
  • иногда возникают проблемы с циклическими импортами, но их немного, и ими можно пренебречь.

Надеюсь, эта cтатья будет вам полезна!