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

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

ve_4hfcpaxntn9-25xdiq9bv0z0.png

Эта история началась задолго до хайпа о 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татья будет вам полезна!

Ссылки:
* enforce
* typeguard
* pytypes
* pycontracts
* schematics

© Habrahabr.ru