Всем привет! Сегодня я расскажу вам историю развития типизации на примере одного из проектов в 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татья будет вам полезна!

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

Комментарии (4)


  1. NerVik
    15.03.2019 10:51

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

    вот этот момент очень интересует, в случае появления циклического импорта вы просто забиваете болт на типы, или как-то решаете проблему. Если второе, то расскажите как?


    1. arxell Автор
      15.03.2019 11:05

      пишем докстринг вида

      """
      circular import
      -> Optional[MetaOrder]:
      """
      

      typeguard в таком случае типы не проверяет

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


      1. mrShadow
        15.03.2019 11:36

        Позвольте уточнить. А предусмотренный модулем typing forward reference в виде

        something: Optional['MetaOrder']
        в вашем случае не работает?


        1. arxell Автор
          15.03.2019 14:24

          Это будет работать если внутри файла есть

          from ... import MetaOrder

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

          NameError: name 'MetaOrder' is not defined
          


          По крайне мере у меня так, я не смог эту проблему решить ((