8350
Советы для разработчиков ПО от @Tishka17 Поддержать материально https://www.tinkoff.ru/cf/2NkdXaljivI Programming, python, software architecture и все такое
Юзкейсы, сценарии и интеракторы
Когда мы пишем наш код (программу, сервис, библиотеку), мы так или иначе анализируем зачем она нужна. У пользователей и заказчика продукта есть цели. Бизнес хочет получить прибыль, покупатель - получить товар, пользователь техподдержки - не показаться начальнику глупым, чтобы его не уволили. Есть цели более глобальные, такие как достижение благополучия, есть более конкретные подцели, как доставка товара на конкретный адрес.
Зачастую с системой взаимодействуют не только люди, поэтому часто используют более общий термин — актор.
Анализируя цели и потребности всех участников взаимодействия, мы приходим к формулированию требований, одной из форм которых являются use cases, которые и описывают взаимодействие некоторого актора с системой ради удовлетворения его целей. Каждый юзкейс - это текстовый документ, но они могут дополняться диаграммами. Юзкейсы могут включаться друг в друга или расширять. Например, "ввод адреса" может быть конкретным юзкейсом, использующимся как в юзкейсе "покупка товара", так и в юзкейсе "заказ уборки".
Юзкейс представляет собой некий набор шагов, при этом их можно разделить на основной сценарий и расширения. Cценарий - это последовательность шагов взаимодействия, в то время как юзкейс - набор возможных вариантов развития событий. Шаг в данном случае - ещё не конкретный api вызов, а просто логически выделенная операция, значимое намерение актора или реакция системы. Иногда оно может ссылаться и на другой юзкейс.
Переходя от этапа анализа к разработке, разбираясь с возможными юзкейсами и детализируя взаимодействие, мы уже можем зафиксировать конкретные детали реализации интерфейсов. Таким образом, одно логическое действие превращается в api вызовы, клики мышью и т.п. И в коде мы уже работаем с этими вызовами, хотя мы все ещё пишем программу, реализующую свою часть юзкейсов.
Обработка таких вызовов в коде обычно начинается с контроллера (адаптером между основной частью программы и фреймворком), а затем в реализацию прикладной части бизнес логики (часто используются термины "прикладной сервис" и "интерактор юзкейса"), а далее, возможно, в некие сущности и более универсальный части кода. Количество таких слоев зависит от конкретной методологии и сложности решаемых задач.
Хочу обратить внимание, что несмотря на то, что мы используем термин "интерактор юзкейса" - это лишь часть системы, реагирующая на действия актора. Сами юзкейсы при этом - более теоретическая часть, описывающая требования к нашему продукту как к черному ящику, но мы используем её для проектирования и организации наших интеракторов.
Пример юзкейсов.
Покупка товара
1. Пользователь выбирает товар из каталога
2. Пользователь указывает адрес
3. Пользователь оплачивает товар
Указание адреса
Основной сценарий
1. Пользователь вводит адрес.
2. Система показывает найденные варианты доставки.
3. Пользователь подтверждает выбранный вариант.
Расширение
3.а Пользователя не устраивают предложенные варианты
3.a.1 Пользователь повторяет ввод пока не будет найден подходящий вариант
При этом, если мы разрабатываем веб приложение, то могут быть такие api-вызовы как "GET /address-suggestions/" или "PUT /order/delivery". Для другого типа приложения, детали реализации взаимодействия так же будут отличаться
Дополнительные материалы:
• https://martinfowler.com/bliki/UseCasesAndStories.html
• https://alistaircockburn.com/Articles/Use-Case-Foundation-Ivar-Alistair
• https://habr.com/ru/articles/248063/
• https://blog.cleancoder.com/uncle-bob/2011/11/22/Clean-Architecture.html
SQLAlchemy и ORM
SQLAlchemy в python предоставляет два набора API:
1. Возможность конструировать и выполнять запросы в БД
2. Получать и сохранять сущности
Первый набор api (Core функциональность) - это в первую очередь объекты запросов select, insert, update и других, а также метод execute, который есть у объекта Сonnection (можно использовать Session, но о нем ниже). Часто при этом используются объекты Table для описания структуры БД.
Кроме этого, sqlalchemy можно использовать как ORM. Это означает, что вы создаете классы, описывающие ваши сущности, а затем все операции с БД делаете только через них: вы загружаете сущности, сохраняете сущности, удаляете сущности (см. пост про стейт приложения и БД). При этом Connection вам уже недостаточно, вам требуется Session.
В чем ключевые отличия ORM подхода от Core?
• Для загрузки сущностей используется session.get для получения по первичному ключу или select(Model) запрос в других случаях. Обратите внимание, что речь идет именно о загрузке моделей целиком, а не отдельных полей таблиц. Кроме того, сессия реализует паттерн IdentityMap, то есть если объект уже загружен в память, то в некоторых случаях повторный запрос в БД не требуется (при get или загрузке связанных моделей), а если БД вернет одну сущность несколько раз, будет переиспользован один экземпляр.
• Для добавления новых сущностей мы используем метод session.add. Сессия реализует паттерн Unit of Work, поэтому все добавленные сущности будут хранится в промежуточном буфере и сохранятся при flush или commit. Если сущности содержат связи, они тоже будет автоматически добавлены. При чём алхимия следит за правильным порядком вставки и даже пытается оптимизировать запросы.
• Для применения изменений надо просто поменять сущности и закоммитить транзакцию. Опять же, благодаря Unit of work все изменения отслеживаются автоматически и не требуется вызывать никаких методов сохранения на каждом экземпляре вручную.
• Для удаления есть отдельный метод UoW - session.delete. Кроме непосредственно удаления сущности он так же следит за связями и обрабатывает их, зачастую более сложным способом чем умеет сама СУБД.
Таким образом, если вы используете модели алхимии, но вызываете insert или update, вы фактически не используете ORM. Так можно делать в целях оптимизации в отдельных нагруженных частях программы, но оно не должно восприниматься как основной способ работы с ORM.
Другой ошибкой является создание отдельных доменных сущностей одновременно с моделями ORM. Из-за этого приходится писать достаточно сложные мапперы, которые часто не учитывают наличие IdM и UoW, что может привести к некорректной логике сохранения данных. Вместо этого можно выделить три подхода:
• Не использовать ORM. Пишите сущности как вам удобно и сохраняйте в БД используя core-функциональность. Вы лишаетесь встроенного UoW, но и не конфликтуете с ним.
• Использовать модели ORM как бизнес сущности. Ваши сущности выглядят чуть более грязно, однако это только метаданные, которые в БЛ использоваться не будут.
• Использовать датаклассы и императивный маппинг, описывая отдельно Table объекты. Вы все ещё ограничены тем, что алхимия меняет модели, внедряя скрытые атрибуты, но ваша БЛ уже это не видит. Вы не можете использовать совсем кастомные мапперы для отдельных моделей, но зато имеете все возможности алхимии.
Даже используя ORM приходится писать сложные select запросы для реализации поиска или получения сводной информации, поэтому имеет смысл реализовать паттерн репозиторий, скрывая их построение внутри, но при этом мы все ещё полагаемся на возможности сессии как UoW.
Кроме того, в нашем приложении кроме логики работы с сущностями могут существовать запросы чтения, возвращающие клиенту определенный срез данных. Для этого мы можем реализовать простой DAO, использующий Core алхимии или описать альтернативные модели, которые будут императивно маппиться на те же таблицы, но иметь другую структуру.
Дополнительные материалы:
• Mapping SQLAlchemy to dataclass
• Mapping a Class against Multiple Tables
• Patterns Implemented by SQLAlchemy
Стейт при работе с базами данных
Подходы работы с БД можно разделить на две группы:
• Ориентированные на состояние в памяти (ActiveRecord, Data Mapper, UoW)
• Ориентированные на действия с состоянием в БД (DAO)
Если ты ориентирован на состояние в памяти, то фактически твоё приложение имеет вид
1. Загрузил состояние сущностей
2. Выполнил необходимые операции по его изменению
3. Сохранил состояние
Такой подход позволяет тестировать логику изменения без базы данных, а так же унифицировать взаимодействие с БД, тем самым снижая сложность кода. Однако появляются и ограничения:
• Если кроме указанных операций что-то изменить напрямую в БД, то загруженный стейт перестанет соответствовать состоянию в БД. Любые операции с ним могут привести к некорректному результату. Чтобы этого не произошло, не стоит выполнять insert/update/delete запросы кроме как при сохранении состояния после изменения.
• Если одна и та же сущность доступны несколькими путями (например при отношениях многие-ко-многим, но не только), то при загрузке мы можем получить несколько её экземпляров. Изменение одного из экземпляров не будет автоматически отражено во втором и мы получим состояние противоречащее само себе. Чтобы такого избежать, рекомендуется использовать паттерн Identity Map.
• В серверном приложении несколько конкурентных запросов могут пытаться изменить одни сущности, что может привести к порче данных при сохранении. Чтобы этого избежать, можно использовать разные виды блокировок, а также следить чтобы всегда грузился полный набор связанных объектов, нужных для контроля правил.
В подходе, ориентированном на прямое изменение состояния в БД мы храним в памяти минимальный набор данных, необходимый для выполнения запросов. При правильной организации кода мы иногда можем использовать БД более эффективно за счет уменьшения количества передаваемых данных. Однако,
• Часть бизнес логики переезжает в запросы или в хранимые процедуры.
• Блокировки все ещё могут быть нужны, но часто они могут быть выставлены СУБД автоматически.
• API хранилища усложняется, появляется много очень разнообразных методов, теряется унификация
Первый подход становится наиболее актуален при наличии сложной модели данных, когда есть большое количество разных сущностей или при работе с ними требуется сложная бизнес логика. Второй подход полезен когда важнее оптимизация (например при действительно высокой нагрузке) или когда используются специфические хранилища, не позволяющие реализовать полноценное сохранение сущностей.
При этом, даже ориентируясь на работу с состоянием, может быть актуально для эффективности отдельные операции вынести в БД (в основном массовые), однако при этом нужно быть очень аккуратным.
Так же стоит отметить что паттерн DAO может быть применен для загрузки данных для отображения, когда нам не требуется выполнение бизнес логики, но нужен специфический срез разных частей БД.
Дополнительные материалы:
• https://martinfowler.com/bliki/DDD_Aggregate.html
• https://martinfowler.com/eaaCatalog/identityMap.html
Anti corruption layer
Часто в наших приложениях мы обращаемся к каким-то внешним системам. Иногда мы просто обращаемся к ним, иногда ждем обратных вызовов. Это может быть платежная система, сервис уведомлений, инвентаризации или что-то ещё.
Ключевое в таких интеграциях то, что такие системы имеют свой жизненный цикл, свой контекст и часто их устройство слабо похоже на то, что нам надо от них, однако интеграция с ними может быть жизненно необходима для нашего приложения.
Для того, чтобы сделать наше приложение более устойчивым к изменениям внешнего API и одновременно сохранить нашу логику чистой, мы выделяем отдельный компонент - Anti Corruption Layer. Его задача - скрыть за собой детали взаимодействия с этой системой, предоставив нашей бизнес логике понятное ей API.
Структура с использованием ACL может состоять из таких частей:
• Какая-то часть нашей бизнес-логики использует интеграцию. Она знает о том, что принципиально это за сервис, однако не знает о деталях общения с ним.
• У бизнес-логики есть требования к взаимодействию. Они выражаются в виде интерфейсов и каких-то моделей данных. Хоть мы и абстрагируемся от деталей, мы всё-таки выражаем наше представление о конкретном сервисе, но лишь об интересных нам аспектах и о данных в терминах понятных нашему бизнесу.
• Сторонний сервис для нас представляет некоторое API, удобное или не очень. Иногда это вызовы библиотек, иногда - удаленные обращения по HTTP или другим протоколам. Мы не контролируем код, который это реализует. Это чужой код.
• Если сторонний сервис не предоставляет для нас достаточно понятного API, мы самостоятельно реализуем клиентский фасад. Он обязательно выражается в терминах чужой системы, что позволит нам при её изменениях проще его обновлять. Он может реализовывать только часть вызовов, или игнорировать часть данных, но он достаточно примитивный (хоть и может содержать сложные парсеры DTO) и призван просто улучшить читаемость чужого API. Это можно сделать вводя более строгие типы, конкретизируя сигнатуры методов, разделяя вызовы или группируя по смыслу. Важно не пытаться тут сразу транслировать удаленные вызовы в наши доменные сущности. Если у нас уже есть достаточно хорошая реализация чужого API, то дополнительный фасад не требуется.
• Чтобы совместить фасад с нашим интерфейсом, мы делаем адаптер. Он будет трансформировать вызовы выраженные в наших терминах (понятные нашей БЛ) в вызовы в терминах чужого сервиса (понятные фасаду или чужому коду). При этом на одно обращение к нему, он может делать несколько вызовов чужого API, если это требуется.
• Так как кроме самих вызовов необходимо так же менять форму данных, это часто удобно вынести в отдельные трансляторы. В качестве них могут быть использованы дополнительные объекты или просто методы адаптера.
Пример из практики в комментарии
Дополнительные материалы:
• https://martinfowler.com/articles/patterns-legacy-displacement/legacy-mimic.html
• https://adaptix.readthedocs.io/en/latest/conversion/tutorial.html
Dependency Inversion Principle
Принцип инверсии зависимостей (DIP) часто путают с техникой внедрения зависимостей (DI), но это разные вещи, служащие разным целям. Начнем с самой инверсии.
Представим ситуацию: у нас есть компонент А и ему для работы нужен компонент D. Например, для обработки данных нам надо их загрузить из БД. Это прямая зависимость: компонент А знает о компоненте D, а компонент D не знает о компоненте А. Под знанием я имею в виду использование в коде типов, импортов, да и в целом проектирование одного куска кода исходя из того, как устроен второй.
Инверсия этой зависимости получится когда компонент А перестанет знать о компоненте D, а вместо этого компонент D станет знать о компоненте А. То есть обработка данных не знает о том, как они загружаются, но код загрузки данных может знать, что их будут обрабатывать. Держим в голове, что D все ещё должен использоваться внутри А - мы не меняем логику кода, мы только работаем с тем, как устроена зависимость.
Чтобы добиться такой инверсии, мы выделяем требования компонента А к зависимости. Это его часть. Они часто могут быть выражены в виде интерфейса или абстрактного класса (B). В свою очередь, компонент D будет реализовывать эти требования. После этих манипуляций мы получаем, что компонент А ничего не знает о настоящем D. В свою очередь, D начинает знать о требованиях А. В рамках примера мы получаем интерфейс "Загрузчик данных" и реализацию "ЗагрузчикДанныхSQL".
• Было: А -знает-> D. D не знает об А. А использует D.
• Стало: А не знает D. D -знает-> о требованиях А. А все ещё использует D, но думает только о B.
Обратите внимание, что я говорю о компонентах - это могут быть модули, группы классов или даже функции. Так же нигде не было речи о том, как D будет подставлен вместо B, мы можем использовать любые подходы для организации этого, но конечно же DI зачастую удобнее.
Непосредственно сам принцип инверсии зависимостей говорит, что более абстрактные части код не должны знать о более конкретных, более универсальные о частных, более высокоуровневые о низкоуровневых. Иногда это выполняется само по себе, но иногда мы для этого специально инвертируем зависимости.
Например, коду расчета заработной платы может понадобиться выводить результат на экран. И, скорее всего, коду расчета совершенно не важно, какое у экрана разрешение и вообще, действительно ли это настоящий экран. На лицо зависимость, которую мы можем инвертировать. При этом, у нас есть код, который непосредственно занимается выводом на экран, у него есть зависимость от самого экрана, однако они на одном уровне и инверсия не требуется.
Цели этого - борьба со сложностью программы, облегчение тестирования, увеличение гибкости системы, изоляция будущих изменений, упрощение переиспользования кода. Имея понятные абстракции, мы можем быстрее понять, что делает код, не вдаваясь в детали реализации зависимостй. Мы можем подменять реализации зависимостей, если нам это понадобится, мы с большей вероятностью не сломаем другой код, если не нарушаем контракт, в отличие от ситуации, когда контракта нет.
У DIP есть цена. Если без его использования мы могли сразу перейти к реализации и увидеть, как всё устроено, с DIP эту реализацию ещё надо поискать. Если абстракция выделена плохо, недостаточно полно описывает требования или наоборот загрязнена ненужными деталями, мы платим цену DIP, но не получаем его преимуществ.
Дополнительные материалы:
• https://martinfowler.com/articles/dipInTheWild.html
• https://blog.cleancoder.com/uncle-bob/2016/01/04/ALittleArchitecture.html
• https://martinfowler.com/bliki/TestDouble.html
Паттерны работы с базами данных
В большинстве проектов мы храним какие-то данные. Для этого используются разные виды баз данных: реляционные, nosql или даже специализированные HTTP API. Такие хранилища имеют специфическое API, которое мы обычно хотим скрыть от основного кода за некоторой абстракцией. Вот стандартные варианты, описанные, в частности, Мартином Фаулером.
Первая группа паттернов работы с БД - отделяющие реализацию операций с хранилищем от данных. Благодаря такому разделению, мы можем построить несколько реализаций шлюза, возвращающих однотипные структуры (например, для заглушек на время тестирования или использования нескольких источников данных). Обратите внимание, что в паттернах этой группы мы можем полностью скрыть детали организации хранилища.
DAO - наиболее простой вариант, он представляет собой достаточно тупой класс, который просто выполняет операции с хранилищем и возвращает данные в том или ином виде. Он не должен содержать какого-то своего состояния (будь то кэши или IdentityMap). Он получает и возвращает только данные в виде неких абстрактных RecordSet или простых DTO, то есть структур, не содержащих логики. Плюсы такого паттерна: простота реализации, возможность точечного тюнинга запросов. Паттерн описан в "Core J2EE Patterns", а у Фаулера встречается очень близкое описание под именем Table Data Gateway.
Data Mapper - в отличие от DAO занимается не просто передачей данных, а двусторонней синхронизацией моделей бизнес логики с хранилищем. То есть он может загружать какие-то сущности и потом сохранять их обратно. Внутри он может содержать IdentityMap для исключения дублей модели с одним identity или создания лишних запросов на загрузку. Каждый маппер работает с моделью определенного типа, но в случае составных моделей он иногда может обращаться к другим мапперам (например, при использовании select-in load). При использовании Unit Of Work, тот обращается именно к мапперу для сохранения данных.
Repository - фактически вариант Data Mapper, предназначенный для работы с корневыми сущностями. Для прикладной бизнес логики репозиторий выглядит как коллекция, содержащая корни агрегатов. Он может использоваться для получения полиморфных моделей, а также может возвращать некоторую сводно-статистическую информацию (например, количество элементов или сумму полей) или даже выполнять какие-то расчеты, не выходящие за пределы общей компетенции хранилища данных. Это основной паттерн при использовании богатых доменных моделей. Паттерн описан у Эрика Эванса, а у Фаулера встречаются некоторые варианты его реализации.
Вторая группа - паттерны, смешивающие данные и работу с хранилищем. Их использование может усложнить тестирование или изменение кода, но, тем не менее, они используются.
Raw Data Gateway - предлагает каждой строке таблицы поставить в соответствие экземпляр класса. Мы получаем отдельный класс Finder для загрузки строк и собственно класс шлюза строки, который предоставляет доступ к загруженным данным и обладает методами сохранения себя в БД.
Active Record - вариант RDG, но содержащий бизнес логику. По факту, мы имеем богатые доменные модели не абстрагированные от хранилища. Часто методы загрузки данных реализованы просто как static-методы в этом же классе вместо выделения отдельного Finder.
Строит отметить, что многие ORM в Python реализуют Active Record и активно используют при этом неявный контроль соединений и транзакций. В отличие от них SQLAlchemy реализует паттерн Data Mapper и может дать больший уровень абстракции над хранилищем (обратите внимание на подход с map_imperatively).
Дополнительные материалы:
• http://www.corej2eepatterns.com/Patterns2ndEd/DataAccessObject.htm
• https://martinfowler.com/eaaCatalog/identityMap.html
• https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#applying-orm-mappings-to-an-existing-dataclass-legacy-dataclass-use
Полиморфизм при наследовании и LSP.
Когда мы строим иерархию объектов, мы часто делаем одноименные методы с разным поведением. Если в родительском классе такой метод отсутствует, то мы в целом вольны в наследниках делать что захотим.
Если же родительский класс содержит такой метод, то у нас есть следующие варианты:
1. Реализация в дочернем классе полностью сохраняет внешнее поведение метода (параметры, результат, побочные эффекты), но отличается реализацией и как следствие нефункциональными характеристиками (например, производительностью). В этом случае классы полностью взаимозаменяемы.
2. Реализация в дочернем классе полностью сохраняет поведение родительского класса, но делает дополнительную работу или меняет поля, отсутствующие в родительском классе. Мы все также можем использовать дочерний класс там, где ожидается родительский, но в других частях программы мы получаем дополнительные возможности.
3. Мы меняем поведение метода родительского класса, но не нарушаем его важные характеристики. В этом случае мы должны четко понимать, какие требования есть к базовому методу, чтобы не нарушить совместимость. Если мы не соблюдаем принцип инверсии зависимости и базовый класс не является абстрактным, может получиться, что требования к методу слишком конкретные и тогда этот вариант фактически сводится к предыдущему. При этом мы можем расширять область значений параметров метода (снимая некоторые ограничения или переходя к родительским типам), а иногда и сужать область значений результата.
4. Мы сохраняем формальные характеристики метода (сигнатуру, возвращаемое значение), но сильно меняем его поведение. Как правило, это происходит когда требования к методу не выделены или по ошибке. В этом случае инструменты, предоставляемые языком программирования, могут предполагать что методы все ещё совместимы, что не является правдой на самом деле.
5. Мы меняем даже сигнатуру метода несовместимым образом. Например, произвольно меняем тип результата или параметров, но не так как в п.3. Класс однозначно нельзя использовать там, где ожидается родительский и это могут обнаружить автоматические инструменты.
Если мы наследуемся от какого-то объекта, согласно принципу подстановки Барбары Лисков (LSP) мы не должны нарушать совместимость. То есть, если в каком-то коде ожидается экземпляр базового класса, а мы туда подставляем дочерний, код должен работать корректно и согласно нашим ожиданиям.
Этот принцип предполагает, что у нас есть определенные требования к поведению нашего кода, сформулированные для базового класса. Они могут быть выражены в документации, а также в виде тестов. Дочерний класс должен полностью соблюдать эти требования и соответственно проходить тесты, причем это относится к поведению объекта на протяжении всей его жизни. Требования не могут быть нарушены даже после вызовов методов дочернего класса, отсутствующих у родителя.
Может показаться, что в точности соблюдая требования, мы лишаемся полиморфизма, однако это не так. Обратите внимание на предложенные выше варианты. (Пример в комментариях)
Таким образом,
• LSP требует совместимости между родительским и дочерними классами на уровне выполнения требований.
• Дочерний объект должен сохранять ожидаемое поведение всегда, после вызова любых методов, включая отсутствующие у родителя.
• Дочерний объект должен проходить тесты, ожидающе экземпляр базового класса
• Использование абстракций позволяет нам добиться большей гибкости при реализации дочерних классов
• Даже при конкретных требованиях у нас есть альтернативные варианты реализации
Дополнительные материалы:
• https://lenta.ru/news/2009/03/11/turing/
• https://ru.wikipedia.org/wiki/Абстрактный_тип_данных
• /channel/advice17/58
• https://en.wikipedia.org/wiki/Dependency_inversion_principle
Аутентификация и авторизация
Наши приложения выполняют разные сценарии и для некоторых из них может быть важно, что за пользователь перед нами. То есть, для целей бизнес-логики может быть необходимо получить некоторые уникальные данные пользователя, которые позволят его отличить от других - это идентификация. Реализуется она различным способом: иногда мы можем явно спросить у пользователя, кто он, иногда мы получаем информацию из сетевых пакетов или системы. Идентификационные данные дальше могут использоваться по-разному: их можно записать в лог, использовать как ссылку на владельца при создании объектов в системе или в различных проверках внутри нашей логики.
Идентификация должна выполняться безопасно: иногда пользователь может попытаться выдать себя за другого. Процесс проверки, что пользователь не обманывает нас в том, кто он - аутентификация. Она не всегда актуальна: если мы получили сообщение от telegram, мы можем верить информации об отправителе, потому что доверяем серверам телеграма. Однако, если мы получили HTTP запрос, мы должны принять меры для обеспечения защиты от подделки личности пользователя (аутентифицировать его).
Когда пользователь первый раз обращается к нашему сайту, мы обычно отправляем его на сценарий входа (первичная аутентификация, login, sign in). Этот сценарий может быть достаточно сложным, состоять из нескольких шагов (например в случае двух- и многофакторной аутентификации), требовать использовании СУБД и внешних сервисов. Процедура входа скорее всего будет отделена от основной части приложения или даже реализовываться внешней системой (например, Keycloak). Иногда процедуру логина на сайт называют "авторизацией на сайте", но не следует это путать с авторизацией действий (см. ниже). В случае веб-приложений, после первичной аутентификации мы часто используем различные токены для того, чтобы в последующих действиях было проще его аутентифицировать. Проверка таких токенов связана с протоколом доставки, может задействовать базы данных и снова выполняется вне основной бизнес логики - адаптерами или отдельной подсистемой. В том числе, её иногда может выполнять реверс-прокси. Часто спустя какое-то время пользователя просят повторить процедуру входа.
Многие операции в нашем приложении мы не хотим разрешать выполнять кому попало. Например, мы можем разрешить редактировать какой-то объект только его владельцу, а блокировать пользователей - админам. Проверка, разрешено ли выполнять какой-то сценарий пользователю - это авторизация, часть бизнес логики. Есть разные модели авторизации, связанные с проверкой роли пользователя (RBAC), отношений пользователя и объекта (ReBAC) или даже с какими данными объекта он работает (ABAC). Выбор того или иного варианты авторизации определяется требованиями вашей системы.
С точки зрения архитектуры приложения
• Идентификация выполняется для целей бизнес-логики или логирования, адаптеры помогают её реализовать.
• Аутентификация не является частью основной бизнес-логики приложения, выполняется адаптерами или полностью отдельной частью логики.
• Авторизация выполняется только бизнес-логикой, она не может быть корректно вынесена в слой представления, но может быть отделена от основной логики интерактора.
Дополнительные материалы
• https://auth0.com/intro-to-iam/what-is-oauth-2
• https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/
• https://owasp.org/Top10/A01_2021-Broken_Access_Control/
Unit of work
Паттерн Unit of work (единица работы) предназначен для того, чтобы следить за изменениями объектов и потом координировано их сохранять в базу данных.
Это позволяет:
• Ограничить время жизни транзакции
• Не выполнять обращение к БД сразу при выполнении изменений, а значит попытаться сделать это более эффективно
• Более удобно следить за изменениями в случае сложной иерархии или большого количества типов моделей.
Принцип использования Unit of Work состоит из двух этапов:
1. Сначала мы регистрируем в нем, что с нашими моделями были изменения (register_new, register_dirty, register_deleted).
2. Затем в какой-то момент сохраняем все эти изменения в БД (commit)
Изменения могут регистрировать как сами модели, так и прикладной код, использующий их. Таким образом, каждый раз, когда мы что-то делаем с моделями (добавляем, удаляем, изменяем), мы не отправляем сразу запрос в БД, а вместо этого добавляем эти изменения в UoW для последующего сохранения.
Хотя Unit of Work имеет метод для коммита изменений, он является более сложной вещью чем просто управление транзакциями. Суть его в том, чтобы накапливать изменения перед отправкой в базу данных. При этом он может выполнять оптимизации запросов, например, объединяя вставку данных в одну таблицу в один запрос. Также, в нем может быть реализована логика контроля целостности данных, например, с помощью оптимистических блокировок.
Сам Unit of work обращается в БД не напрямую, а через отдельные объекты, реализующие паттерн Data Mapper. Условно, в данном случае, каждый такой объект умеет отправлять в БД изменения (insert, update, delete) модели определенного типа и UoW знает в какой из мапперов обращаться для каждой из сохраненных моделей. Обратите внимание, что Unit of Work не используется для доступа к мапперам / шлюзам к БД, его задача другая. Более того, использование его в таком смысле будет нарушением принципа разделения интерфейсов.
С концепциями Unit of Work и Data Mapper тесно связан паттерн Identity Map, когда мы храним реестр загруженных экземпляров моделей для их идентификаторов. И, хотя оба из них могут использоваться независимо друг от друга, хорошей идеей будет реализация Unit of Work, использующего IdM.
Некоторые ORM, такие как SQLAlchemy, самостоятельно реализуют паттерн Unit of work: каждый экземпляр модели SQLAlchemy связана с объектом Session и её изменения записываются в базу данных в момент вызова session.flush()/session.commit().
Пример одной из возможных реализаций: https://github.com/Tishka17/python-uow-demo
Дополнительные материалы:
• https://martinfowler.com/eaaCatalog/unitOfWork.html
• https://martinfowler.com/eaaCatalog/dataMapper.html
• https://techspot.zzzeek.org/2012/02/07/patterns-implemented-by-sqlalchemy/
Абстрактные классы и интерфейсы
Если рассуждать, не привязываясь к языку программирования, то:
Абстрактный класс - это заготовка для класса. В нем часто есть методы с реализацией и методы, помеченные как абстрактные. Экземпляры такого класса напрямую создавать нельзя. Нужно отнаследоваться от него и заполнить пропущенные методы.
Абстрактный класс может содержать данные, обычные методы. Его отличает именно наличие абстрактных методов. В некоторых языках - это методы без тела (C++, Java), в некоторых (Python) - методы со специальной пометкой. Чтобы наследник класса перестал быть абстрактным, надо реализовать в нем все такие методы.
Интерфейс же - это требования к тому, что должен уметь объект. Это набор сигнатур операций. Как правило, речь о наборе названий методов, их параметрах и типе результата, но иногда речь и про доступ к атрибутам. В общем случае, интерфейс может не существовать в коде как именованная сущность.
Интерфейс существует просто по факту того что вы написали. Если ваша функция принимает объект и вызывает у него методы foo() и bar(), требуемый ей интерфейс можно выразить как "объект с методами foo и bar, которые не требуют аргументы". Если у вас есть класс с методами foo и bar, то его экземпляры удовлетворяют интерфейсам "любой объект", "объект с методом foo", "объект с методами foo и bar" и др.
С практической стороны работа с интерфейсами отличается от языка к языку:
• Python проверяет соответствие объекта ожиданиям функции по факту вызова операций с ним во время выполнения кода. Сторонние линтеры могут проверять это другим способом, ориентируясь на аннотации типов или ещё как-то. Для того чтобы выразить требования к интерфейсу в тайпхинтах, мы можем оформить класс, наследующийся от Protocol. Для реализации такого интерфейса достаточно реализовать соответствующие методы, но можно и наследоваться от него для упрощения поиска ошибок.
• В Golang интерфейс описывается в коде с помощью ключевого слова interface. В дальнейшем он используется как тип переменных или параметров функции. Соответствие структуры интерфейсу проверяется по факту реализации в ней нужных методов. Отдельно декларировать, что структура удовлетворяет интерфейсу, нельзя. Стоит отметить, что в Go не поддерживается наследование и поэтому об абстрактных классах не может идти и речи.
• В Java интерфейс описывается с помощью ключевого слова interface и классы указывают, чему они соответствуют, с помощью implements. Даже если класс фактически содержит все необходимые методы, он не соответствует интерфейсу, если сам это не задекларировал явно.
• В C++ отсутствует понятие интерфейса на уровне языка и принято использовать чисто абстрактные классы как их замену. Чтобы показать, что наш класс реализует интерфейс, мы наследуемся от соответствующего абстрактного класса. При этом язык шаблонов имеет свою отличающуюся логику.
Дополнительные материалы:
• https://philippegroarke.com/blog/2017/05/09/static-duck-typing-in-c/
• https://docs.oracle.com/javase/tutorial/java/IandI/defaultmethods.html
• https://peps.python.org/pep-0544/
Dependency Injection
Принцип внедрения зависимостей, будучи достаточно простым, концептуально оказывается часто неочевидным.
Суть его в том, что когда у нас одному из объектов требуется другой, то он не создает или ищет его сам, а принимает извне. Например, если вашей функции нужно соединение с БД, то она не должна ни импортировать его, ни брать из глобальной переменной, ни создавать сама. Ей это соединение должны передать.
Само собой, какой-то код будет создавать эти зависимости, и тут мы стараемся отделять его от кода, использующего их. Благодаря этому:
• во-первых, делаем этим зависимости более явными;
• во-вторых, можем управлять тем, будет ли использован один экземпляр зависимости или разные;
• в-третьих, можем использовать один и тот же код с разными реализациями зависимостей.
Представьте, что вашему классу нужны некоторые параметры конфигурации, которые влияют на его поведение, и вы хотите протестировать разные варианты. Если бы класс сам грузил настройки, то вам пришлось бы в тестах учитывать, как именно он это делает, и возможно манипулировать теми объектами, которые обычно не меняются в процессе работы программы. Если же код класса получает эти настройки извне, то вы просто сделаете несколько вызовов с разными настройками. И даже если код класса изменится, тесты останутся корректными.
Можно выделить три способа внедрения зависимостей:
1. Внедрение через параметры функции/метода. Просто передаем зависимость как ещё один параметр:
def clear_users(cursor):
cursor.execute("TRUNCATE users;")
cursor = connection.cursor()
clear_users(cursor)
clear_users(cursor)
class UsersDAO:
def __init__(self, cursor):
self.cursor = cursor
def clear_users(self):
self.cursor.execute("TRUNCATE users;")
dao = UsersDAO(connection.cursor())
dao.clear_users()
dao.clear_users()
class UsersDAO:
def clear_users(self):
self.cursor.execute("TRUNCATE users;")
dao = UsersDAO()
dao.cursor = connection.cursor()
dao.clear_users()
dao.clear_users()
Двухфазная инициализация
Иногда, по каким-то причинам мы не можем выполнить всю инициализацию при создании класса (в конструкторе или в __init__). Например, это может быть выполнение асинхронного ввода/вывода, простановка циклических ссылок между двумя созданными объектами или особоый механизм обработки ошибок инициализации. В этом случае иногда создают вспомогательный метод, который нужно вызвать сразу после создания объекта. Стоит использовать такой подход с осторожностью.
Например, мы хотим создать гейтвей для работы с БД. Следующий код не будет работать:
class SomeGW:Мы не можем выполнять async код в ините класса, поэтому можно попытаться сделать двухфазную инициализацию:
def __init__(self, db_uri):
self.connection = await asyncpg.connect(db_uri)
gw = SomeGw("postgresql://postgres@localhost/test")
class SomeGW:В таком случае необходимо следить, что соединение не будет использовано до завершения второй фазы инициализации (вызова connect). Также, у созданного объекта формально
def __init__(self):
self.connection = None
async def connect(self, db_uri):
self.connection = await asyncpg.connect(db_uri)
gw = SomeGw()
await gw.connect("postgresql://postgres@localhost/test")
self.connection может быть None, что приведет к дополнительным проверкам в коде всех методов и предупреждениям линтера. Проще было ввести дополнительную функцию:
class SomeGW:
def __init__(self, connection):
self.connection = connection
async def new_some_gw(db_uri):
connection = await asyncpg.connect(db_uri)
return SomeGw(connection)
gw = await new_some_gw("postgresql://postgres@localhost/test")
Generic-репозиторий - просто ленивый антипаттерн
Оригинал статьи
Generic-репозиторий часто используется для ускорения разработки слоя доступа к данным (data layer). В большинстве случае обобщение заходит слишком далеко и становится ловушкой для ленивых разработчиков.
Обобщенный (generic) репозиторий часто выглядит как в примере ниже. Он определяет обобщенные методы для типичных операций с данными, таких как обновление, получение или удаление. Он привлекателен для разработчиков, потому что прост, гибок и позволяет вам реализовать большую модель доменной области без необходимости написать хоть строку кода.
T= TypeVar("T", bound=Base)
class Repository(Protocol[T]):
model: Type[T]
def get_all(self) -> List[T]: ...
def find_by(self, **kwargs) -> List[T]: ...
def get_by_id(self, id: int) -> T: ...
def add(self, item: T) -> None: ...
def update(self, item: T) -> None: ...
def delete(self, item: T) -> None: ...
Проблема в том, что это - не удобная и аккуратная абстракция, а скорее способ сэкономить время, срезая углы. И это может привести к нарушению согласованности решения в ряде аспектов.def find(self, query: Any) -> Iterable[T]: ...Вторая строка намного более конкретная. Она четко определяет отношение между доменными объектом и хранилищем. Но кроме определенности контракта, её реализация будет намного более читаемой.
def find_customer_by_name(self, name: str) -> Iterable[Customer]: ...
Запуск программ и команд
Когда наше приложение запускается, оно получает список аргументов командной строки (argv в параметрах функции main во многих языках или sys.argv в Python). Эти параметры задаются тем, кто запускает программу и зависят от ОС и способа запуска. Нулевой элемент списка - сама программа как её запускали (путь или имя).
C-API
* В Linux мы запускаем программу, указывая её имя или путь и параметры в виде массива. Если указано просто имя, происходит поиск такого файла среди папок, указанных в переменной окружения PATH* В Windows же мы передаем параметры в виде одной строки, которая разбивается на части уже самим приложением. В отличие от Linux, файл для запуска ищется не только в PATH, но и в текущем каталоге
Desktop GUI
* выбор непосредственно запускаемого файла
* выбор ярлыка, ссылающегося на запускаемый файл (например, .lnk или .desktop). Такой файл может содержать дополнительную информацию о способе и параметрах запуска приложения
* выбор файла, ассоциированного с программой для запуска. Например, кликая по .docx файлу, мы запускаем MS Word, а кто-то - LibreOffice. Ассоциации обычно устанавливаются по расширению или по типу файла, определенному исходя из содержимого. В этом случае сначала по файлу определяется, какая программа должна быть запущена, а затем она запускается и путь к файлу передается в argv* перетаскивая файл на иконку приложения. Путь к файлу будет передан в argvКонсоль
Командная оболочка имеет свой язык, с помощью которого мы с ней взаимодействуем. Часто это достаточно мощный язык с управляющими конструкциями, циклами, подпрограммами и т.п. Часто правила разбора команды отличаются между оболочками и даже в похожих языках могут быть разные правила для обработки, например, кавычек.
Когда мы вводим строку, происходит её разбор согласно правилам текущего шелла. Из строки выделяется команда, которую надо запустить, её параметры, могут добавляться переменные окружения или перенаправляться потоки ввода-вывода. Команда может быть как встроенной командой шелла, так и внешней программой, которую мы запускаем.
Например,
* cd не является отдельной программой, это именно команда шелла. Так как текущий каталог меняется только для конкретного процесса и не распространяется на родительские, мы бы и не смогли реализовать это в виде отдельного приложения.
* ls - отдельное приложение, которое входит в состав пакета coreutilsPython
Используя Popen мы можем указать, что мы запускаем в виде списка и тогда первый элемент - программа которую мы запускаем. Весь список целиком прилетит в argv. Так как в Windows необходимо передавать параметры в виде строки, Python их сериализует согласно стандартным правилам этой ОС, хотя нет гарантий, что запускаемое приложение следует им. Если мы передаем одну строку - непосредственно она будет передана ОС для запуска. То есть в этом случае поведение будет отличаться для разных ОС.
Так же есть параметр shell, который меняет запускаемую команду, добавляя к ней путь к cmd.exe или sh. Обращаю ваше внимание, что путь к cmd ищется только в системных каталогах, а путь к sh захардкоджен как /bin/sh (/system/bin/sh на android). То есть при этом не учитываются выбор текущей командной оболочки пользователя.
Дополнительные материалы:
* https://man7.org/linux/man-pages/man3/exec.3.html
* https://github.com/python/cpython/blob/main/Lib/subprocess.py
* https://learn.microsoft.com/ru-ru/windows/win32/api/processenv/nf-processenv-searchpatha
Компоненты web-приложения
Разрабатывая веб-сервис мы пишем код, работающий с данными запросов и реализующий бизнес логику. При этом служебные части, которые необходимы для работы приложения по протоколу HTTP(s), обычно используются готовые. С этой точки зрения можно выделить следующие компоненты:
1. Наш код. Реализует специфическую для приложения логику.
2. Web-фреймворк. Выбирает, какие из наших функций вызывать в зависимости от параметров запроса, а также реализует базовую логику работы со структурой HTTP пакетов. Некоторые web-фреймворки могут включать сюда работу с прикладными протоколами или определенными подходами к разработке API (такими как REST, json-rpc и graphql) или выполнять функции, не имеющие отношения именно к web, но часто встречающиеся в приложениях (IoC-контейнер, доступ к базе данных и т.п.). Часто веб-фреймворк не занимается обработкой подключений по HTTP, а лишь реализует часть прикладной логики. Важно отметить, что некоторые фреймворки (например, flask или django) хоть и умеют обрабатывать HTTP запросы, но эта функциональность реализована в них частично и имеет ограничения по безопасности и надежности, и может быть использована только в процессе разработки. Примеры: fastapi, django, spring, laravel.
3. Cервер приложений. Принимает внешние запросы по какому-либо сетевому протоколу и вызывает код приложения (или веб-фреймворка). Он может принимать соединения по HTTP, специализированному протоколу или коммуницировать с отдельно стоящим веб-сервером с помощью стандартных потоков ввода-вывода. В случае python есть стандарт WSGI, описывающий как сервер приложений должен вызывать код на питоне, а также ASGI, аналогично работающий с asyncio кодом. Есть множество совместимых веб-серверов, что дает некоторую свободу выбора. Также сервер приложений, зная о цикле обработки запросов, может следить, чтобы код не завис и при необходимости принимать действия по перезапуску. Сейчас многие веб-сервера позволяют делать множество вызовов запущенного кода, не требуя его перезапуска. Однако, другие, как apache2 с модулем mod_php, могут перезапускать код на каждый HTTP-запрос. При этом, такие веб-сервера всё ещё могут быть уязвимы к определенным видам атак или неэффективно работать с TLS, что требует запуска перед ними реверс-прокси. Примеры: uvicorn, gunicorn, tomcat, php-fpm.
4. Супервизор. Это системный процесс, который занимается запуском и управлением различными службами. В случае деплоя на выделенный сервер, это скорее всего будет systemd. Однако стоит также упомянуть оркестраторы (например, k8s), позволяющие запускать много копий кода на разных серверах.
5. Балансировщик и реверс-прокси. Специализированные веб-серверы, не выполняющие логики и прямого вызова прикладного кода, но позволяющие оптимизировать работу под нагрузкой. Они могут более эффективно выполнять задачи шифрования трафика, кэшировать контент и перенаправлять запросы на большее количество экземпляров приложения. Кроме балансировки на уровне обработки HTTP-запроса, она может так же выполняться на уровне 3 OSI (протокол ip) или dns-сервера (round-robin, geo-dns). Примеры: nginx, haproxy.
6. CDN или сервер для раздачи статического контента. Специализированные веб-сервера, рассчитанные на эффективную раздачу контента, который меняется очень редко. Эту функцию может выполнять реверс-прокси или специализированное решение, в том числе облачное.
В зависимости от используемых технологий и требований (стабильность, безопасность, производительность, функциональность) состав компонентов может отличаться. Например, статический контент может раздаваться самим приложением (хотя некоторые, такие как Django, не разрешают это делать) или может использоваться веб-сервер, входящий в состав фреймворка (например, в aiohttp). В других случаях мы можем захотеть использовать цепочку балансировщиков разного уровня.
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Round_robin_DNS
* https://peps.python.org/pep-0333/
* https://kubernetes.io/docs/concepts/services-networking/ingress/
* https://en.wikipedia.org/wiki/Cloudflare
Меня позвали поговорить о разном: о проектах, опыте, программировании в целом.
https://www.youtube.com/watch?v=B4XrghSsUhQ
Asyncio и колбэки
В прошлом посте мы рассмотрели как asyncio взаимодействует с генераторами, но на более низком уровне он взаимодействует с колбэками, а генераторы и корутины - вспомогательные возможности для более удобного написания кода.
Базовая функциональность лупа заключается в том, что он умеет вызывать в цикле синхронные функции. Пока один колбэк не отработает, луп не перейдет к другому. Такие функции можно добавить через вызов loop.call_soon (или loop.call_soon_threadsafe, если мы хотим это сделать из другого потока). Дополнительно, у него есть некоторые другие функции вроде задания задержки вызова функции.
Следующий важный объект - Future. Его задача достаточно простая - хранить некоторый результат и вызывать колбэк при его установке. И все ещё это обычная синхронная функция, но уже вызывающаяся не планировщиком лупа, а напрямую, когда кто-то делает set_result
Однако взаимодействие с лупом через колбэки многим кажется достаточно неудобным, в связи с чем и было предложено поддержать генераторы, которые в дальнейшем превратились в async функции.
В качестве клея между асинк функциями (читай генераторами) и логикой вызова функций лупом выступает объект Task. Таск добавляет свой метод как колбэк в лупе и когда луп вызовет его, он промотает внутренний генератор на шаг вперед. Если при этом встретится Future, таск зарегистрируется в нем на ожидание результата и, таким образом, шаг будет завершен и управление вернется в луп. Когда кто-то установит результат на футуре, он сразу обратится к нашему таску и сможет передать ему управление.
При этом возникает важный вопрос, а кто пометит футуру завершенной? У нас есть несколько вариантов:
• Другой таск или колбэк, просто вызвав set_result
• Другой поток (так как объекты asyncio не потокобезопасны, это будет сделано через call_soon_threadsafe). Например, так работает aiofiles или granian.
• Сам луп согласно своей внутренней логике
Asyncio loop имеет множество методов, которые возвращают Future объекты и при этом включают какую-то внутреннюю логику. Одна из важнейших функций - работа с сокетами. Операционная система имеет два режима работы с сокетами - блокирующий и неблокирующий. В блокирующем режиме наши вызовы send/recv блокируют тред пока сокет не будет готов, в неблокирующем же - мы сразу получим предложение повторить вызов позже. Благодаря этому можно во время ожидания переключиться на другую работу. Дополнительно, ОС имеют разные варианты API для опроса сокетов на предмет доступности (вы, конечно, можете пытаться делать send/recv на каждом в цикле, но это очень не эффективно): select, epoll, kqueue, IOCP.
Таким образом, loop имеет методы вида sock_recv, sock_sendall которые возвращают Future и говорят лупу заниматься обслуживанием таких сокетов (опрашивать готовность и собственно передать данные). Дальнейшее поведение лупа будет зависеть от реализации, но в случае Linux это будет вызов epoll между вызовами колбэков. Как только чтение/запись в сокет будет завершено, луп сам (или какая-то его подсистема, например Транспорт) установит результат футуре. Аналогично можно работать с pipe и сабпроцессами.
Кратко:
1. Loop умеет вызывать синхронные колбэки
2. Future хранит результат и вызывает колбэки
3. Task адаптирует генератор под колбэки лупа и связывает с футурами
4. Loop сам опрашивает сокеты в неблокирующем режиме и обновляет футуры
5. Футуры можно обновлять из других колбэков или тредов
Дополнительные материалы:
• https://peps.python.org/pep-3156
• https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports
• https://www.kegel.com/c10k.html
• https://man7.org/linux/man-pages/man7/io_uring.7.html
Asyncio и цикл событий
Чтобы понять, как рабоатет asyncio, давайте рассмотрим, как в Python работают функции.
Мы можем вызвать функцию, она выполнится. Просто интерпретатор берет её код и выполняет целиком. Если же мы берём функцию-генератор и просто вызываем, ничего не будет происходить. Тебе надо по ней пробежаться циклом или самому вызывая next. Когда мы вызываем next, код функции доходит до следующего yield и останавливается.
Представим, что у нас есть основной части программы цикл, который крутит этот генератор.
def foo():
yield 1
yield 2
for _ in foo():
pass
yield вызывается обычная функция, то она выполняется как обычно, и не важно, что это происходит внутри генератора. А что, если наш генератор внутри себя вызывает другой генератор через yield from?def foo():
yield 1
yield 2
def bar():
yield from foo()
for _ in bar():
pass
yield. То есть мы как бы крутим уже вложенный генератор, а не оригинальный. Нашему циклу не важно, откуда берутся шаги внутри функции: то ли она сама их выдумала, то ли получила из вложенных вызовов.await вместо yield.next, он может в это время дёрнуть другой генератор, если у него есть их список. То есть, такие независимые генераторы - это таски asyncio.yield и отдаешь управление циклу событий
Словари
Словари есть во многих языках программирования и предназначены для поиска и хранения значений, связанных с некоторыми ключами. В простом случае словарь можно представить как список пар ключ-значение, но ключи уникальны.
Основная задача словаря - обеспечивать доступ по ключу, поэтому мы хотим, чтобы он делал это эффективно. Если хранить пары как обычный неупорядоченный список, для поиска придется перебирать его каждый раз, но можно сделать быстрее.
Одна из возможных реализаций словаря - использование сбалансированных деревьев поиска (обычно красно-черных). Принцип работы заключается в том, что для каждого узла дерева все что в правой ветке - больше него, а всё что в левой - меньше. Если ветки не сильно разной длины, мы можем достаточно быстро так найти нужный элемент. Единственное, что требуется от ключей - возможность их сравнивать на <,> и =. Результат сравнения не должен меняться в процессе жизни дерева, чтобы порядок сохранялся. Примеры: std::map в C++, TreeMap в Java, SortedDictionary в C#
Более распространенный вариант - использование хэш таблиц. В этом случае мы храним элементы в списке, но имеем способы быстро найти место в списке по ключу. Для этого используется хэш функция. Примеры: dict в Python, std::unordered_map в C++, HashMap в Java
Хэш-функция - это такая функция, которая из кучи возможных данных умеет делать числа ограниченного размера. Для одинаковых данных числа должны получаться одинаковые, для разных - как получится. Так как вариантов исходных данных заведомо больше чем чисел в ограниченном диапазоне, то повторения (коллизии) неизбежны.
Таким образом, когда нам надо узнать, где находится элемент в хэш-таблице, мы ключ превращаем в число (сначала с помощью хэширования, затем обрезая до нужного размера). А так как для разных ключей числа могут повторяться, мы дальше дополнительно проверяем тот ключ нашли или нет. Чтобы обработать несколько ключей, мы либо храним их в дополнительном маленьком списке, либо специальным алгоритмом пересчитываем индекс и прыгаем дальше. Желательно, чтобы такие повторения происходили не очень часто, но сами по себе они неизбежны.
Таким образом, для работы хэш-таблицы нам нужно, чтобы:
• для каждого ключа было можно посчитать число-хэш
• хэш у одинаковых ключей был одинаков
• хэш ключа не менялся и не ломал этим логику расположения элементов
Можно попытаться упростить эти требования до того, чтобы ключи были неизменяемы, но в целом это не требуется. Как правило, нужно чтобы не менялась только та часть данных ключа, которая учитывается при расчете хэша.
В Python для многих объектов равенство определено не на основе данных, а по факту, что это один экземпляр, поэтому и хэширование для них может быть безопасно определено на основе адреса в памяти.
• Для некоторых встроенных типов, таких как function, type или генераторов, разные экземпляры никогда не равны и хэш определен тривиально.
• Если вы пишете кастомный класс, по дефолту у него есть __eq__ и __hash__ на основе "адреса". Но если вы самостоятельно определяете сравнение в своём классе, то автоматический хэш пропадает.
• Для других типов, таких как tuple и list, равенство определяется содержимым, поэтому и хэш основам на нем. А если данные могут меняться, то стабильный хэш получить для таких типов невозможно.
Дополнительные материалы:
• https://habr.com/ru/articles/830026/
• https://habr.com/ru/articles/555404/
• https://ru.wikipedia.org/wiki/Сюръекция
float и Decimal
Вас никогда не удивляло, что 0.1 + 0.2 != 0.3? Почему float считает с погрешностями, и всем норм?
Дело в том, что 0.1 выглядит как
0 0111111101 11001100110011001100110011001100110011001100110011010.
0 обозначает знак + (и 1 обозначает -)0111111101 обозначает exponent, равную 0^10 + 2^9 + 2^8 + 2^7 + 2^6 + итд = 1019. Вычтем 1023 (размерность double) и получим итоговое значение: 1019 - 1023 = 411001100110011001100110011001100110011001100110011010 обозначет "significand" или "мантису", которая равна: 2^-exp + 2^-exp-1 + 2^-exp-2 + итд ~= 0.1>>> assert 0.1 + 2.220446049250313e-18 == 0.1
>>> import sys
>>> sys.float_info.epsilon
2.220446049250313e-16
>>> assert 1.0 + sys.float_info.epsilon > 1.0
>>> assert 1.0 + 2.220446049250313e-17 == 1.0 # число меньше epsilon
0.1? А вот тут нам уже поможет Decimal для отображения полного числа в десятичной системе:>>> decimal.Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> decimal.Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> decimal.Decimal(0.2)
Decimal('0.200000000000000011102230246251565404236316680908203125')
>>> decimal.Decimal(0.1 + 0.2)
Decimal('0.3000000000000000444089209850062616169452667236328125')
>>> decimal.Decimal(0.3)
Decimal('0.299999999999999988897769753748434595763683319091796875')
>>> from decimal import Decimal, getcontext
>>> getcontext().prec = 6
>>> Decimal(1) / Decimal(7)
Decimal('0.142857')
>>> getcontext().prec = 28
>>> Decimal(1) / Decimal(7)
Decimal('0.1428571428571428571428571429')
Decimal не может в абсолютную точность, потому что есть в целом невыразимые в десятичной системе числа, такие как math.pi, ⅓, тд. С чем-то из них может помочь fractions.Fraction для большей точности, но от существования иррациональных чисел никуда не деться.float такие погрешности в вычислениях? Потому что во многих задачах абсолютная точность недостижима и не имеет смысла. Благодаря плавающей точке мы можем хранить как очень большие, так и очень маленькие числа без существенных затрат памяти. А ещё float - очень быстрый. В том числе за счет аппаратного ускорения.» pyperf timeit -s 'a = 0.1; b = 0.2' 'a + b'
.....................
Mean +- std dev: 8.75 ns +- 0.2 ns
» pyperf timeit -s 'import decimal; a = decimal.Decimal("0.1"); b = decimal.Decimal("0.2")' 'a + b'
.....................
Mean +- std dev: 27.7 ns +- 0.1 ns
Data Transfer Object
Когда мы общаемся с удаленным кодом (посылаем запросы, сообщения), пересылаемые данные в коде зачастую удобнее передавать совместно и представлять не в виде отдельных параметров методов, а в виде некоторой структуры. Она называется DTO - Data Transfer Object (объект передачи данных).
DTO - любой объект/структура данных без своей логики, пригодная для сериализации для передачи по сети. При этом не обговаривается как именно она будет сериализована - она может содержать специальные методы, или этим может заниматься отдельный код (на основе интроспекции, макросов или как угодно).
DTO - это, в первую очередь, назначение объекта. Это данные, которые надо передать. Могут иметься в виду входящие, так и исходящие данные.
1. Для существования DTO не требуется наличие каких-либо доменных моделей, это любые данные. Они могут собираться из других DTO, нескольких бизнес-сущностей или вообще генерироваться на ходу
2. Сериализация возможна в совершенно разные форматы (например: xml, json, protobuf). При это не обязательно использование одного DTO под несколько форматов
3. DTO может использоваться в разных адаптерах приложения: для данных, возвращаемых или принимаемых обработчиком сервера, из клиентов внешних апишек, как результат работы DAO и т.п. В целом, структуры, передаваемые между слоями приложения без удаленных вызовов, могут тоже называться DTO.
4. Если DTO содержит логику сериализации, мы обязаны ограничивать его использование на внешнем слое приложения. То есть, при возврате данных из интерактора мы должны логику их сериализации вынести наружу.
5. DTO не содержит логики, но содержит информацию об структуре данных и общеизвестных типах. Парсер DTO может содержать какие-то универсальные предохранители от загрузки слишком больших данных. Но, например, кастомизация длины строки или допустимого диапазона чисел на каждое поле - однозначно будет ошибкой.
6. DTO на сервере и клиенте могут иметь совершенно разную реализацию и она может меняться независимо, однако структура данных должна быть согласована. Изменение формата представления данных, состава полей и типов на отправителе потенциально могут сломать логику получателя данных и поэтому должны делаться аккуратно.
В качестве примеров объектов, которые можно использовать в качестве DTO можно назвать датаклассы (в python или kotlin). При этом, например, Pydantic-модели, из-за наличия логики сериализации в них самих, должны оставаться на уровне адаптеров (view-функций, обработчиков запросы) и не должны переиспользоваться между адаптерами совершенно разного назначения.
Дополнительные материалы:
• https://martinfowler.com/eaaCatalog/dataTransferObject.html
• https://docs.python.org/3/library/dataclasses.html
• https://www.oracle.com/technical-resources/articles/java/javareflection.html
• https://go.dev/blog/laws-of-reflection
Аутентификация и IdentityProvider
Для реализации идентификации и аутентификации мы неизбежно используем данные, не нужные основной логике приложения, а логика может быть достаточно сложной сама по себе:
• Для событий телеграм идентификация происходит на основе данных из события. Аутентификация пользователя не производится - мы только проверяем безопасность соединения с сервером
• Для бэкенда веб приложения мы часто используем сессии. В этом случае мы достаем их из cookie и дальше проверяем в какой-либо базе данных, откуда и достаем идентификатор пользователя, соответствующего сессии.
• Для API в микросервисной среде мы можем использовать JWT-токены, содержащие айди пользователя, которые проверяются на основе подписи.
• В некоторых сервисах мы можем полагаться на пользовательские TLS-сертификаты, заверенные сертифицирующем сервисом
• Проверка токена или сертификата может делаться как в коде приложения, так и на реверс прокси.
• При разработке или тестировании может использоваться фиксированный пользователь с определенными правами.
Множество вариантов реализации усложняется тем, что они могут использоваться одновременно с одной и той же бизнес логикой. Это приводит к необходимости выделения интерфейса (IdentityProvider), скрывающего эти детали. Обращаю так же внимание, что такой объект не должен возвращать данные, относящиеся к текущему контексту приложения. Грубо, его можно свести к чему-то такому:
class IdentityProvider(Protocol):
def get_current_user_id(self) -> int: ...
def get_current_user_roles(self) -> list[Role]: ...
IdentityProvider мы:
У ребят из Podlodka Python Crew стартует новый сезон онлайн-конференции, тема — инфраструктура. Всё проходит онлайн, с 3 по 7 июня.
Я буду выступать там с докладом про Dependency Injection и dishka непосредственно.
Все доклады записываются, так что смотреть их день в день необязательно.
Что будет
• Мой доклад про DI
• Погружение в трейсинг: чем он полезен, как работает и как его внедрить.
• Поиск уязвимостей: практические задания с разбором
• Рассказ про неочевидные кейсы оптимизации.
• Обучение эффективному мониторингу: типы метрик, как их собирать и экспортировать.
И ещё много всего.
Конференция платная, но специально для подписчиков промокод INFRA_17 на скидку 1000р
Dishka - IoC-контейнер для Python
Когда мы следуем подходу Dependency Injection, а особенно - слоистой архитектуре, у нас образуется отдельная группа функций и классов, выполняющих только одну задачу - создание других объектов. Такой код лучше держать поближе к main, так как он связывает воедино разные части приложения и связан с конфигурацией запуска.
В сложном приложении такой компонент может содержать большое количество функций, контролировать как создание, так и корректную очистку объектов и, что самое главное, их взаимосвязь. Для упрощения работы с такими фабриками придумали отдельный тип библиотек - IoC-контейнеры (DI-фреймворки).
В Python меня долго не устраивали существующие контейнеры и я решил сделать свой:
Хочу представить вам Dishka 1.0
Цель этого проекта - предоставить простой и удобный IoC-контейнер, который сможет забрать всю работу с зависимостями. Мне кажется, на текущий момент это самый функциональный вариант контейнера, имеющий при этом самое простое API.
• Вы можете использовать его с любым фреймворком, но для некоторых мы уже подготовили хелперы
• Для создания зависимости можно указать отдельную функцию или использовать __init__ класса
• Зависимости имеют ограниченное время жизни (скоуп) и вы сами управляете им
• Зависимости кэшируются, поэтому один и тот же объект может быть переиспользован пока он жив. Так можно передать одно соединение с БД в несколько гейтвеев
• Фабрики зависимостей можно группировать в классы и компоненты, что позволяет делать контейнер модульным
• Можно декорировать объекты, использовать один объект для нескольких типов
• При старте проверяется корректность конфигурации контейнера, что позволяет исключить многие ошибки
Что значит версия 1.0?
У библиотеки было 9 промежуточных релизов, мы рады объявить, что закончена вся работа по стабилизации её интерфейса и исправлению ошибок. И у нас есть планы по развитию, уникальные фичи сами себя не напишут.
Будем рады новым пользователям, багрепортам, запросам фич и звездам на гитхабе
• Github
• Pypi
• Документация
Виртуальные окружения Python
Во многих случаях при разработке приложений на Python нам требуются сторонние библиотеки. Однако, если мы будем их устанавливать в глобальное окружение, мы в какой-то момент столкнемся с конфликтами между разными проектами, нам будет сложнее производить очистку такого окружения. А в некоторых ситуациях мы можем даже сломать системные приложения.
Чтобы избежать таких проблем, рекомендуется практически всегда использовать виртуальные окружения. Это специальная папка, куда устанавливаются библиотеки и которых может быть больше одной на вашем компьютере. В python 3 есть встроенное средства для управления ими - пакет venv, но есть и сторонние популярные решения такие как virtualenv, poetry и многие другие.
Для того чтобы создать новое виртуальное окружение, выполните команду с указанием нужного вам питона:
python -m venv имя_папки
pip. Часто в качестве папки указывают venv или .venv../имя_папки/bin/python, на Windows - имя_папки\Scripts\python (пути могут быть относительные или абсолютные). Это бывает удобно внутри скриптов или файлов сервисов. В этом случае, sys.path будет содержать каталог библиотек внутри виртуального окружения. Учтите, что так как переменная PATH не меняется, то запуск других команд (например, через subprocess) без указания пути будет фактически происходить вне виртуального окружения.source ./имя_папки/bin/activate. Для Windows CMD - имя_папки\Scripts\activate.bat. После этого в рамках сессии вашего шелла будет изменена переменная окружения PATH, что приведет к изменению команд, доступных без указания пути. Соответственно, если таким образом будет запущена команда (python, pip и т.д.) из виртуального окружения, то и sys.path будет изменен, как и в прошлом способе. Так же будет задана переменная окружения VIRTUAL_ENV. Для заверешния работы с виртуальным окружением введите команду deactivate.venv не из таких. Окружение просто сломается и будет вести себя непредсказуемо. Исключение - копирование между идентичными образами ОС, например, при сборке контейнеров.
Первичные ключи в БД
Чтобы отличать записи в реляционной БД, у них должны быть уникальные поля. Это может быть как одно поле, которое для всех записей принимает разные значения, так и целый набор.
Любой набор колонок, в которых значения будут уникальны для всех записей, называется суперключом. Имеется ввиду группа значений по всем колонкам, а не в каждой по отдельности.
Если же, выкидывая из такого набора любую колонку, мы теряем уникальность - это называется потенциальный ключ. То есть, потенциальный ключ - уникальный набор колонок, который нельзя уменьшить.
В БД может быть много потенциальных ключей, и поэтому мы выбираем один из них как основной, который мы будем использовать - это первичный ключ (primary key, PK, ПК). То есть:
• Первичный ключ в таблице всегда один.
• Первичный ключ - это не обязательно одна колонка (простой ключ), а может быть и группа из нескольких колонок (составной или композитный ключ).
• Даже если у нас простой ключ, он не обязательно называется id, имя колонки может быть любым, хотя стоит придерживаться стандартных названий.
Иногда мы записываем в БД данные, в которых естественным образом уже есть потенциальные ключи, мы выбираем из них один как первичный - это естественный ключ. Но иногда потенциальных ключей сразу не наблюдается или они какие-то неудобные для использования (никто ведь не захочет везде таскать первичный ключ из 5 колонок?), в этом случае под первичный ключ заводят отдельную колонку со сгенерированными уникальным значениями - это суррогатный ключ.
Суррогатный ключ можно генерировать разными способами. Два наиболее популярных - псевдослучайный (например, с помощью uuid4) и автоинкремент.
• Псевдослучайный ключ позволяет использовать его ещё до обращений в БД, что бывает полезно. Его использование усложняет перебор ключей и определение количества записей, что может быть важно. Но из-за алгоритма генерации может быть неэффективен для поиска в БД.
• Автоинкремент требует обращения в БД, которая в том или ином виде запоминает какие были уже выданы номера.
При использовании автоинкремента номера не обязаны идти по порядку и даже по возрастанию. С точки зрения целей использования первичного ключа это не требуется, поэтому для большей эффективности БД не пытается за этим следить. А конкретно есть несколько причин:
• При удалении записей номера освобождаются, но номера остальных записей не меняются. Если бы БД просматривала какие номера освободились, это заняло бы много времени.
• При конкурентных транзакциях будут сгенерированы несколько номеров одновременно. Но одна из транзакций может быть не зафиксирована и тогда номер не будет фактически использован. Следить за такими номерами тоже было бы достаточно не эффективно. Кроме того транзакции могут быть открыты и зафиксированы в разном порядке, что будет отличаться от порядка генерации ключей.
• При определенной настройке некоторые СУБД генерируют автоинкрементные PK не по одной, а несколько за раз и хранит внутри сессии. Тогда конкурентные вставки будут использовать номера из разных наборов, что сохранит уникальность, но нарушит порядок.
Дополнительные материалы
• https://ru.wikipedia.org/wiki/Нормальная_форма
• https://habr.com/ru/articles/572700/
• https://habr.com/ru/articles/747348/
Базы данных и компоненты
База данных - любое собрание связанных данных. Коллекция аниме, телефонный справочник, каталог выпусков playboy, реестр windows, файлы на диске - примеры баз данных.
СУБД (система управления базами данных) - специальное ПО, обеспечивающее работу самой базы данных, предоставляющее доступ к данным и управление самими базами.
Базы данных можно разделить, во-первых, по способу организации данных: реляционные, графовые, документоориентированные, ключ-значение и др.
Во-вторых, по способу взаимодействия с ними:
• Встраиваемые - когда код, обслуживающий БД, работает полностью внутри нашего процесса
• Клиент-серверные - когда наше приложение обращается с помощью удаленных вызовов (например, по сети) к отдельному серверу баз данных
• Облачные - развитие идеи клиент-серверных БД, когда сервер баз данных расположен вне нашего контроля под управлением провайдера
Структурно при работе с БД можно выделить следующие компоненты:
• СУБД - отдельный сервер или библиотека (в случае встраиваемых БД). В случае сетевых клиент-серверных баз данных, взаимодействие с ним идет с помощью специализированного протокола, который отличается для разных СУБД. Так же обычно отдельно имеется язык запросов, благодаря которому можно оперировать данными. Для реляционных БД этот язык - SQL. Примеры: серверы PostgreSQL, MongoDB, Redis.
• Клиентская библиотека - служебный код, который позволяет скрыть детали реализации сетевого взаимодействия с СУБД и оперировать вызовами в терминах языках программирования. Как правило, она не работает с языком запросов сама, а передает его серверу СУБД. Для встраиваемых баз неотделим от самой СУБД. Примеры: psycopg, asyncpg.
• Query builder - специальный набор функций или классов, помогающих строить запросы на языке СУБД. Для реляционных БД это обычно часть ORM.
• ORM (Object Relation mapping) - библиотека, предоставляющая доступ к реляционной СУБД в объектно-ориентированном стиле, позволяющая оперировать классами и их атрибутами вместо сырых кортежей и языка SQL. ORM среди прочего часто имеет возможности по отслеживанию изменений в моделях для прозрачного сохранения их в базу данных, а так же умеет подгружать связанные данные без детального конструирования необходимого запроса. ORM часто делят на Active Record и Data mapper в зависимости от подхода к работе с данными. Для документоориентированных СУБД используется термин object document mapping, хотя он несколько отличается по возможностям. Примеры: sqlalchemy.
• Gateway, DAO, Repository - ваши компоненты, изолирующие работу с базой данных и предоставляющие к ней доступ в терминах бизнес логики. Термины могут отличаться в зависимости от используемого подхода к разработке.
Дополнительные материалы:
• https://ru.wikipedia.org/wiki/Система_управления_базами_данных
• https://www.sqlite.org/whentouse.html
• https://www.martinfowler.com/eaaCatalog/repository.html
• https://stepik.org/course/63054/promo
Создание telegram-ботов с интерактивным меню
Я знаю, что среди мои читателей есть те, кто сталкивается с разработкой телеграм ботов.
Я выложил на Habr статью, где рассматриваю несколько проблем организации UI с помощью inline-меню и способы их решения.
> https://habr.com/ru/articles/757236/
Так же там упоминается мой проект aiogram-dialog, реализующий указанные в статье подходы, у которого на днях вышла версия 2.0.
Дополнительные материалы:
* https://dev.to/marwan8/getting-started-with-the-viper-architecture-pattern-for-ios-application-development-2oee
* https://freecontent.manning.com/http-session-management/
* https://en.wikipedia.org/wiki/Graphical_widget
* https://developer.android.com/guide/components/activities/tasks-and-back-stack
* https://aiogram-dialog.readthedocs.io/
Тонкости использования AlembicAlembic - это python-инструмент для управления миграциями реляционной БД. Если вы используете SQLAlchemy, скорее всего вы выберете именно alembic для миграций, так как они неплохо интегрированы. Если вы используете его первый раз, кое-что может показаться неочевидным. Рассмотрим некоторые моменты:
1. Иногда alembic не может импортировать ваш пакет. Как упоминалось в статье про структуру проекта, работа импортов зависит от того, какой скрипт мы запускаем. Правильным способом будет сделать ваш пакет устанавливаемым. Тогда после установки он будет импортироваться так же, как и сторонние библиотеки, независимо от расположения запускаемого скрипта.
2. Иногда alembic генерирует пустые миграции или не учитывает в миграциях какие-то из ваших моделей. Это может быть связано с тем, что код создания классов моделей не выполнялся. Как упоминалось в статье про побочные эффекты импортов, стоит избегать импорта только ради выполнения кода (например, добавления классов в глобальный реестр). Это не является проблемой при использовании алхимии, так как мы импортируем классы в наш код для их использования, однако выстреливает при работе с Alembic, в который мы обычно импортируем только базовую модель. Удобным способом решить эту проблему будет импорт классов в __init__.py файл в пакете с моделями алхимии. Дополнительно это позволит скрыть внутреннюю структуру пакета оставляя доступ только к необходимым классам.
3. alembic генерирует миграцию, удаляющую вашу БД. Инструменты миграции рассчитаны на то что именно с помощью них создается ВСЯ структура базы данных. Каждая миграция переводит БД из прошлого состояния, полученного с помощью alembic в новое состояние, соответствующее новой версии кода. Таким образом для генерации первой миграции вы должны взять пустую БД. Для генерации кода миграции M - БД в состоянии после применения миграции M-1 (то есть последней на момент генерации). Все изменения в БД, сделанные в обход механизма миграций, будут приводить к ошибкам генерации и применения миграций.
4. alembic может генерировать некорректные или неполные миграции. Так же происходит с любым инструментом, генерирующим миграции, кто-то работает лучше, кто-то делает больше допущений, но задача не может быть решена автоматически. Всегда проверяйте и редактируйте сгенерированные миграции. Примеры:
* не происходит обновление или удаление Enum
* не происходит изменение типа поля
* не пересоздаются индексы
5. Укажите naming_convention для MetaData алхимии. Иногда alembic не генерирует название constraint/index и поэтому downgrade миграции не работает. Кроме того, при обновлении SQLALChemy могут измениться встроенные соглашения об именовании, что приведет к проблемам при генерации новых миграций.
6. Порядок файлов миграции непонятен. Вы можете указать собственные правила генерации имен файлов, например, добавив туда дату и время. Это позволит понимать, в каком порядке они были созданы. Однако в этом случае будьте аккуратны при слиянии веток, возможно потребуется ручное редактирование имен файлов кроме down_revision.
7. Не импортируйте основной код приложения в миграциях. Основной код приложения будет меняться, что повлияет на работоспособность старых миграций. Миграция же не должна менять своё поведение после создания. При необходимости вы можете скопировать небольшую часть основного кода в конкретную миграцию. Однако следите за тем, чтобы миграции не имели общего кода.
8. Тестируйте миграции, однако нет смысла их включать в регресс. Миграции обязательно должны проверяться после создания, однако уже выпущенные миграции обычно не меняют и не нужно уделять много внимания их повторной проверке. Хорошей мыслью может быть развертывание тестовой БД с помощью миграций и удаление с помощью их отката. Также имеет смысл добавить отдельные проверки на наличие единого head и возможность повторного применения миграций после отката.
Дополнительные материалы:
* https://habr.com/ru/company/yandex/blog/498856/#5
* https://alembic.sqlalchemy.org/en/latest/
Декоратор как паттерн и как сахар в Python
Декоратор в Python близок по своему смыслу к одноименному шаблону проектирования, но имеет некоторые отличия.
Суть паттерна "Декоратор" в том, что при наличии некоторого объекта и желания выполнять дополнительную работу, мы не хотим изменять его код. Для этого мы создаем объект-обертку, который:
* во-первых, совместим с исходным объектом по интерфейсу
* во-вторых, при необходимости, делегирует ему работу
Декоратор в таком виде очень хорошо ложится на концепцию Dependency Injection и всяческие фабрики: мы меняем код создания объекта, но не меняем использующий его код. Благодаря совместимости интерфейсов мы можем заменять объект на его декорированную версию в процессе работы программы.
Типичный пример такого объекта - кэширование запросов во внешнюю систему.
Декоратор в Python, примененный через @, выполняет схожую роль, с несколькими оговорками:
1. Он может быть применен только к функции и классу, а не к произвольному объекту
2. Он применяется в момент декларации функции/класса (то есть, при инициализации кода), а не в произвольный момент во время работы
3. Он не требует совместимости интерфейсов и хотя часто это будет хорошей идеей так же часто оно нарушается
Зачастую питоновские декораторы используются отличным от паттерна образом:
* они меняют API объекта (@staticmethod, @property),
* выполняют регистрацию (@app.route("/"))
* или вообще заменяют тело объекта (@dataclass или мой проект).
Так же можно выделить типичные ошибки использования питоновских декораторов:
1. Простой перенос вызова из тела функции в декоратор. Сравните две функции
pythonКажется, что разница между ними не значительна, но второй вариант достаточно сложно реализовать корректно, так как декоратору придется работать с функциями имеющими разные сигнатуры. Кроме того, в первом случае мы имеем больше возможностей по выбору в какой момент вызывать эту логику
def spam(arg1, arg2):
eggs(arg1+arg2)
...
@with_eggs
def spam(arg1, arg2):
...
Router в aiogram3 дополнительно к Dispatcher) или отказаться от декоратора в пользу обычного вызова функции регистрации.