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

    Всем привет! Сегодня я расскажу вам историю развития типизации на примере одного из проектов в 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
    Ostrovok.ru
    106,00
    Компания
    Поделиться публикацией

    Комментарии 13

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

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

        0
        пишем докстринг вида
        """
        circular import
        -> Optional[MetaOrder]:
        """
        

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

        В нашем проекте все проблемы циклических импортов возникают, когда одна django модель импортирует другую.
          0
          Позвольте уточнить. А предусмотренный модулем typing forward reference в виде
          something: Optional['MetaOrder']
          в вашем случае не работает?
            +2
            Это будет работать если внутри файла есть
            from ... import MetaOrder

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

            NameError: name 'MetaOrder' is not defined
            


            По крайне мере у меня так, я не смог эту проблему решить ((
        0
        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,
            )
        )
        

        Не очень понятно зачем у вас модели принимают dict, а не конкретные атрибуты. Это же, по-сути, делает невозможным проверку такого кода статическим type-checker'ом, если только в конструкторе модели не указан тип TypedDict
          0
          можно и так
          return OrderInfoResult(
                  order_id=data_in.order_id,
                  checkin_at=dt.datetime.today(),
                  checkout_at=dt.datetime.today() + dt.timedelta(days=1),
                  cancelled_at=None,
          )
          


          это уже зависит от того какая библиотека для валидации используется
          0
          декоратор @contract поддерживает Generic'и?
            0
            Не могу сказать насчет pycontracts
            typeguard прямо сейчас кажется нет, тк вот issue github.com/agronholm/typeguard/issues/21
              0
              Получается вы вообще не используете в проекте generic'и? Или у вас @typechecked используется только для входных/выходных параметров api?
            0
            Получается вы вообще не используете в проекте generic'и?

            Не используем.

            или у вас @typechecked используется только для входных/выходных параметров api?

            Нуу, это где-то 50% проекта + бизнес логика еще где-то 30%
              0
              Добрый день, спасибо что упомянули о enforce. Как автор этой библиотечки — приятно слышать, когда о ней говорят. Если честно, то много негатива услышал, когда ее чисто на голом энтузиазме писал.

              Хочу еще сказать, что поддержка 3.7+ просто нереальная задача для меня одного на данный момент. Это придется почти все переписать. И надо будет дропнуть поддержку всех более старых версий. Это слишком сильно давит на мотивацию. Если кто-то хочет помочь, то буду рад помочь.
                0
                я верно понимаю что основная проблема в зависимости и в этом issue
                github.com/golemfactory/golem/issues/3954?
                  0
                  github.com/RussBaz/enforce/issues/71

                  С моей стороны самая большая пробоема в том, что в 3.7 сильно поменялась внутренняя структура модуля typing. И в месте с этим встала большая проблема совместимости с прошлыми версиями. Да и вообще, теперь надо много чего переписывать.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое