Советы разработчикам (python и не только)
8.5K subscribers
5 photos
60 links
Советы для разработчиков ПО от @Tishka17

Поддержать материально https://www.tinkoff.ru/cf/2NkdXaljivI

Programming, python, software architecture и все такое
Download Telegram
Аутентификация и IdentityProvider

Для реализации идентификации и аутентификации мы неизбежно используем данные, не нужные основной логике приложения, а логика может быть достаточно сложной сама по себе:

• Для событий телеграм идентификация происходит на основе данных из события. Аутентификация пользователя не производится - мы только проверяем безопасность соединения с сервером
• Для бэкенда веб приложения мы часто используем сессии. В этом случае мы достаем их из cookie и дальше проверяем в какой-либо базе данных, откуда и достаем идентификатор пользователя, соответствующего сессии.
• Для API в микросервисной среде мы можем использовать JWT-токены, содержащие айди пользователя, которые проверяются на основе подписи.
• В некоторых сервисах мы можем полагаться на пользовательские TLS-сертификаты, заверенные сертифицирующем сервисом
• Проверка токена или сертификата может делаться как в коде приложения, так и на реверс прокси.
• При разработке или тестировании может использоваться фиксированный пользователь с определенными правами.

Множество вариантов реализации усложняется тем, что они могут использоваться одновременно с одной и той же бизнес логикой. Это приводит к необходимости выделения интерфейса (IdentityProvider), скрывающего эти детали. Обращаю так же внимание, что такой объект не должен возвращать данные, относящиеся к текущему контексту приложения. Грубо, его можно свести к чему-то такому:
class IdentityProvider(Protocol):
def get_current_user_id(self) -> int: ...
def get_current_user_roles(self) -> list[Role]: ...

В простом случае реализация этого интерфейса является небольшим инфраструктурным сервисом, но в перспективе является прослойкой между бизнес логикой приложения и отдельным контекстом, занятым различными вопросами управления пользовательскими сессиями и авторизационными данными. Например, обработчики этого контекста могут заниматься обработкой процедуры логина в сервис, очисткой пользовательских сессий по его команде и т.п. Наши классы бизнес логики приложения будут зависеть от этого протокола, а реализация будет передаваться путем Dependency-injection.

Таким образом, связывая бизнес логику и логику аутентификации через протокол IdentityProvider мы:

• Скрываем всю работу с аутентификацией и идентификацией за простым интерфейсом
• Оставляем возможность разной реализации, в том числе использующей базы данных или ключи шифрования
• Не обращаемся к внешним ресурсам самостоятельно из слоя представления
• Разделяем входные данные интерактора и контекст вызова

Дополнительные материалы:
https://www.keycloak.org/docs/latest/authorization_services/index.html
https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
https://ru.wikipedia.org/wiki/Компоновщик_(шаблон_проектирования)
🔥58👍227🐳2🦄2👌1🍌1🤝1
Полиморфизм при наследовании и LSP.

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

Если же родительский класс содержит такой метод, то у нас есть следующие варианты:

1. Реализация в дочернем классе полностью сохраняет внешнее поведение метода (параметры, результат, побочные эффекты), но отличается реализацией и как следствие нефункциональными характеристиками (например, производительностью). В этом случае классы полностью взаимозаменяемы.
2. Реализация в дочернем классе полностью сохраняет поведение родительского класса, но делает дополнительную работу или меняет поля, отсутствующие в родительском классе. Мы все также можем использовать дочерний класс там, где ожидается родительский, но в других частях программы мы получаем дополнительные возможности.
3. Мы меняем поведение метода родительского класса, но не нарушаем его важные характеристики. В этом случае мы должны четко понимать, какие требования есть к базовому методу, чтобы не нарушить совместимость. Если мы не соблюдаем принцип инверсии зависимости и базовый класс не является абстрактным, может получиться, что требования к методу слишком конкретные и тогда этот вариант фактически сводится к предыдущему. При этом мы можем расширять область значений параметров метода (снимая некоторые ограничения или переходя к родительским типам), а иногда и сужать область значений результата.
4. Мы сохраняем формальные характеристики метода (сигнатуру, возвращаемое значение), но сильно меняем его поведение. Как правило, это происходит когда требования к методу не выделены или по ошибке. В этом случае инструменты, предоставляемые языком программирования, могут предполагать что методы все ещё совместимы, что не является правдой на самом деле.
5. Мы меняем даже сигнатуру метода несовместимым образом. Например, произвольно меняем тип результата или параметров, но не так как в п.3. Класс однозначно нельзя использовать там, где ожидается родительский и это могут обнаружить автоматические инструменты.

Если мы наследуемся от какого-то объекта, согласно принципу подстановки Барбары Лисков (LSP) мы не должны нарушать совместимость. То есть, если в каком-то коде ожидается экземпляр базового класса, а мы туда подставляем дочерний, код должен работать корректно и согласно нашим ожиданиям.

Этот принцип предполагает, что у нас есть определенные требования к поведению нашего кода, сформулированные для базового класса. Они могут быть выражены в документации, а также в виде тестов. Дочерний класс должен полностью соблюдать эти требования и соответственно проходить тесты, причем это относится к поведению объекта на протяжении всей его жизни. Требования не могут быть нарушены даже после вызовов методов дочернего класса, отсутствующих у родителя.

Может показаться, что в точности соблюдая требования, мы лишаемся полиморфизма, однако это не так. Обратите внимание на предложенные выше варианты. (Пример в комментариях)

Таким образом,
• LSP требует совместимости между родительским и дочерними классами на уровне выполнения требований.
• Дочерний объект должен сохранять ожидаемое поведение всегда, после вызова любых методов, включая отсутствующие у родителя.
• Дочерний объект должен проходить тесты, ожидающе экземпляр базового класса
• Использование абстракций позволяет нам добиться большей гибкости при реализации дочерних классов
• Даже при конкретных требованиях у нас есть альтернативные варианты реализации

Дополнительные материалы:
https://news.mit.edu/2009/turing-liskov-0310
https://ru.wikipedia.org/wiki/Абстрактный_тип_данных
https://t.me/advice17/58
https://en.wikipedia.org/wiki/Dependency_inversion_principle
👍547💩5🐳4🤓3🔥1
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
🔥73👍1811❤‍🔥5🤓4😢1🐳1🤣1
Паттерны работы с базами данных

В большинстве проектов мы храним какие-то данные. Для этого используются разные виды баз данных: реляционные, 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
🔥76👍37🦄76🥴3🐳3👎21🍓1
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 = 4
11001100110011001100110011001100110011001100110011010 обозначет "significand" или "мантису", которая равна: 2^-exp + 2^-exp-1 + 2^-exp-2 + итд ~= 0.1

Вот так мы можем примерно представить 0.1 в виде float. Примерно – потому что все вычисления идут с погрешностью. Мы можем проверить данное утверждение, добавив погрешность вручную:

>>> assert 0.1 + 2.220446049250313e-18 == 0.1

Значение внешне не изменилось при добавлении погрешности. Посмотрим на sys.float_info.epsilon, который устанавливает необходимый порог для минимальных отличий 1.0 от следующего float числа.

>>> 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')

И вот ответ про 0.1 + 0.2, полное демо с битиками:

>>> 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')

Числа не равны друг другу, потому что их разница больше предельной точности float. А сам Decimal может использовать любую точность под задачу.

>>> 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

Разница в 3 раза.

Про то, как устроен float внутри – рассказывать не буду. У Никиты Соболева недавно было большое и подробное видео на тему внутреннего устройства float. У него действительно хороший технический контент, советую подписаться: @opensource_findings

Итого
Если у вас нет требований по работе именно с десятичной записью числа (как, например, в бухгалтерии), то используйте float. Он даст достаточную точность и хорошую скорость. Если вы хотите, чтобы расчеты велись в десятичных цифрах и ваши расчеты построены так, что абсолютная точность достижима, то используйте Decimal.

Дополнительные материалы:
https://www.youtube.com/@sobolevn/
https://0.30000000000000004.com
https://en.wikipedia.org/wiki/X87
http://aco.ifmo.ru/el_books/numerical_methods/lectures/app_1.html
66👍337🤡4🤯2🐳2🔥1
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, но не получаем его преимуществ.

Дополнительные материалы:
https://martinfowler.com/articles/dipInTheWild.html
https://blog.cleancoder.com/uncle-bob/2016/01/04/ALittleArchitecture.html
https://martinfowler.com/bliki/TestDouble.html
70👍22🦄11🤩5👻2👎1🐳1💯1🍓1
Словари

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

Основная задача словаря - обеспечивать доступ по ключу, поэтому мы хотим, чтобы он делал это эффективно. Если хранить пары как обычный неупорядоченный список, для поиска придется перебирать его каждый раз, но можно сделать быстрее.

Одна из возможных реализаций словаря - использование сбалансированных деревьев поиска (обычно красно-черных). Принцип работы заключается в том, что для каждого узла дерева все что в правой ветке - больше него, а всё что в левой - меньше. Если ветки не сильно разной длины, мы можем достаточно быстро так найти нужный элемент. Единственное, что требуется от ключей - возможность их сравнивать на <,> и =. Результат сравнения не должен меняться в процессе жизни дерева, чтобы порядок сохранялся. Примеры: 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/Сюръекция
47👍35🐳2🤝211
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
👍6610🤡9🤔6🥱4🐳4👾2
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. То есть мы как бы крутим уже вложенный генератор, а не оригинальный. Нашему циклу не важно, откуда берутся шаги внутри функции: то ли она сама их выдумала, то ли получила из вложенных вызовов.

Проводя аналогию с asyncio наш главный цикл - это event loop, а генераторы - корутины. Корутины могут друг друга вызвать и циклу норм, он продолжает их крутить. Разница чисто синтаксическая: в asyncio используется await вместо yield.

Для полнцоенного использования тут не хватает одной важной детали.

На текущий момент у нас есть цикл, который постоянно дёргает генератор и при завершении шага переходит сразу к следующему. В реальности же мы хотим приостанавливать выполнение до получения данных из сети, наступления определенного времени и т.п. В норме мы хотим отдать управление циклу событий, чтобы он там что-то ещё мог поделать своё пока мы ждем. Но, как только слип завершится или сокет будет готов, надо вернуться назад и продолжить выполнение.

Итого, мы крутим цикл. Там генераторы, вызывающие генераторы. Мы это все крутим, крутим. Получаем оттуда объект, который наш loop умеет ожидать (Future). И каждый раз, прежде чем крутить дальше, ждём что он будет готов.

А раз нас есть цикл, который умеет ждать между вызовами next, он может в это время дёрнуть другой генератор, если у него есть их список. То есть, такие независимые генераторы - это таски asyncio.

Кратко:
1. Когда ты эвейтишь специальный объект, ты делаешь yield и отдаешь управление циклу событий
2. Когда ты эвейтишь другую корутину, ты просто проваливаешся внутрь
3. Когда цикл событий получает управление, он может заняться другими делами
4. У цикла событий дел два: дергать разные таски и ждать если все просили подождать внешнего мира

Дополнительные материалы:
https://peps.python.org/pep-3156
https://peps.python.org/pep-0380
https://t.me/opensource_findings/869
https://www.gevent.org/intro.html
94👍43🔥13🐳6🤯3👨‍💻2🤝21💯1
Стейт при работе с базами данных

Подходы работы с БД можно разделить на две группы:

• Ориентированные на состояние в памяти (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
53👍28🎉4🤡2🐳2🦄2💊2🤔1🥴1
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
48👍31🔥7👎4🤯4🐳1
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
👍105🤡29🔥149🤓5❤‍🔥3🤔2🐳2👎1🤝1