Python. DTO + аннотации типов
Python — отличный язык с помощью которого достаточно легко и быстро можно разрабатывать.Однако ингода динамическая типизация Python позволяет писать код, который просто писать, но довольно сложно читать и, как следствие, поддерживать.
В этой статье я хочу рассмотреть паттерн Data Transfer Object (DTO) который зачастую помогает сделать код более читабельным.
В начале был Dict
Dict — это одна из самых часто-используемых структур в Python. Она хорошо отпимизированна и отлично подходит для передачи неструктурированных данных.
В примере выше dict это часть API функции render. Действительно функция render это общая функция фреймворка и она ожидает сущность с переменным набором ключей/аттрибутов и возможностью итерироваться по ним.
Проблемы начинаются в тот момент, когда мы начинаем оперировать dict’ами (т.е. данными без явного указания схемы) там, где есть схема.
Это пример плохого кода (Не потому, что в нем опущены обработки ошибок. Это сделано просто, чтобы не загромождать код) т.к. в нем по всему пайплайну обработки сущности Order схема данных нигде явно не указывается. Для того, чтобы понять какие данные вообще есть в этом dict’е надо сделать запрос в API и посмотреть, что же там находится.
Время для комментариев
Первое, что приходит в голову, чтобы улучшить читаемость — это добавить комментарий в функцию get_order для того, чтобы описать схему.
У этого подхода 2 проблемы:
- Когда код будет изменяться от разработчика потребуются дополнительные усилия, чтобы описать схему. Комментарий ведь можно и не исправлять(особенно если production падает и ты срочно его чинишь), а код будет работать.
- Если схема данных изменится, то код сломается не в том месте, где мы получили данные от сервиса, а в том месте, где мы к ним обращаемся. Т.е. если из ответа пропадет поле created_at, то код сломается в методе save_order.
Давайте укажем схему явно
Это довольно простая идея: давайте в функции get_order прежде чем возвращать полученные данные собирать из них новый dict. Таким образом мы просто явно укажем набор полей, которые будут возвращены и решим обе проблемы предыдущего подхода:
- Невозможно добавить новое поле в ответ функции не указав его явно (или не сломав наш паттерн сделав update у dict)
- Если схема ответа API изменится, то код сломается прямо в этом месте и мы сразу поймем что именно пошло не так.
Таким образом мы уже можем выяснить схему данных не выполняя запрос.
Но может быть можно еще улучшить читаемость?
Аннотации типов и Data Transfer Object
Лично я считаю, что поддержка аннотации типов (type annotations и Mypy) это лучший аргумент для перехода на Python3. Да, они требуют дополнительной поддержки, но они сильно улучшают читаемость и простоту поддержки кода (ведь часть проверок за вас сделает статический анализатор кода). Если вы не знакомы с аннотацией типов в Python рекомендую к прочтению статью Real Python на эту тему.
Окей, с аннотациями понятно, но что такое Data transfer object?
Data transfer object (DTO) — это объект предназначенный только для транспортировки данных т.е. в нем нет никакой логики (никаких методов кроме “магических”). У этого паттерна есть много областей применения (они хорошо описаны у Мартина Фаулера), но т.к. в Python явная типизация опциональна, то DTO в Python приобретают новое назначение: DTO помогает явно указать контракт между компонетами.
Наш обновленный пример:
Теперь мы обращаясь к любой функции в пайплайне обработки заказа видим с каким типом мы работаем, можем перейти к нему и понять какой формат данных внутри и какого они типа. Если вы еще добавите к этому Mypy вы снимете с себя обязанность следить за тем, чтобы работа с данными велась по правилам — в случае чего Mypy укажет на проблему (только валидируйте входящие через API данные, я в примере эту секцию опустил просто, чтобы не загромождать).
Какие плюсы мы получили по сравнению с предыдущим решением, когда взяли DTO:
- Теперь нам не надо переходить к методу (или запускать pdb/repl), который породил dict, что понять что внутри. С какого бы мы места не смотрели на код, если он работает с DTO мы всегда можем понять его структуру и типы.
- Просто добавив аннотацию типов и DTO мы ввели явный контракт между двумя частями нашей программы и можем изменять их независимо друг от друга.
Т.е. теперь мне всегда использовать DTO?
Нет. В начале статьи я приводил пример с функцией render из Django, где использование DTO скорее доставит неудобства: метод render (как метод фреймворка) должен оперировать с коллекцией в которой есть набор ключей и значений для них и dict там отлично подходит.
И в целом dict’ом достаточно удобно пользоваться. Так, что DTO точно не серебрянная пуля.
Однако аннотация типов + DTO хорошо помогают явно указать схему данных которые передвигаются внутри вашего приложения.
Так-же хороший паттерн использования DTO это его использование как границы двух компонентов, которые будут изменяться по разным причинам: например между бизнес-логикой и отображением или между бизнес-логикой и внешним API. Таким образом вы делаете проще изменение внутренностей этих модулей.
Лайфхак, как сделать код еще более читабельным с помощью DTO (но не всем это подходит)
Просто сделайте его иммутабельным (например сделав его на основе NamedTuple из модуля typing): таким образом, вы будете уверены, что если кто-то захочет изменить DTO — он создаст новый. Этот подход часто заставляет писать более явный код.
Пример с иммутабельным DTO:
Теперь внутри функции order_processing_task явно видно, что функция process_order изменяет order_info.
Конечно, такой код будет производить больше мусора за счет создания объектов на каждое изменение, однако если для вас это не критично, то это отличный способ улучшить читаемость.
А в чем разница между Entity из Clean Architecture и DTO?
Кто-то может заметить, что DTO сам по себе похож на сущности в слое Entity из Clean Architecture. Да, они похожи, но между ними есть разница.
Entity из Clean Architecture это сущность бизес-логики в которой может быть поведение (а лучше, чтобы и было). Правда поведение в entity должно оперировать только сущностями с того-же слоя. Как пример: Entity Cart должно уметь пересчитывать свою сумму в зависимотси от Entity Item которые лежат внутри нее.
Слой Entity из clean architecture — это тема для отдельной статьи и наверное я ее когда-нибудь напишу (но это не точно).
Производительность
Конечно, любой из описанных подходов надо выбирать исходя из требований вашей системы к чиаемости/потреблению памяти/скорости работы. Но мое мнение такое: только высоконагруженное приложение почувствует разницу между использованием dict и DTO.
При иммутабельных DTO все конечно уже не так: на каждое изменение данных придется выделять память на новый объект.
Резюме
DTO, как я уже писал выше, не серебрянная пуля, однако может значительно повысить читаемость и упростить контроль за изменениями Python кода.