Warning: Undefined array key 0 in /var/www/tgoop/function.php on line 65

Warning: Trying to access array offset on value of type null in /var/www/tgoop/function.php on line 65
- Telegram Web
Telegram Web
Стратегии загрузки связанных данных из РСУБД

Во многих случаях запрашивая данные из реляционной БД, мы хотим получать их не из одной таблицы, а из нескольких.
Предположим, у нас есть две связанные таблицы A и B, мы делаем запрос к таблице A на получение данных и хотим получить соответствующие записи из таблицы B. Чтобы добиться этого, у нас есть несколько способов. Какой именно способ использовать, зависит от количества данных и вида отношений.

1. Ленивая подгрузка (проблема N+1). Получим записи из таблицы A, мы проходимся по ним циклом и для каждой из них делаем отдельный запрос в таблицу B. Это очень неэффективная стратегия, ведь к 1 запросу к таблице A мы добавляем ещё N запросов в таблицу B. Однако эта стратегия будет фактически использована, если при использовании ORM вы не загрузили сразу явным образом связанные данные. Однако она в какой-то степени может упростить работу, если мы по ходу обработки выясняем какие данные нам нужны. Скорее всего, её стоит избегать.

2. Joined load (select_related в Django). Данные из обеих таблиц получаются за один запрос с помощью join и получения колонок из обеих таблиц. Если для каждой записи таблицы A может соответствовать много записей таблицы B (отношение один-ко-многим), то в результате такого запроса каждый элемент из таблицы А будет получен много раз. Во-первых, эти дубли придется обработать на стороне вашей программы (ORM может предоставлять инструменты), а во-вторых это приводит к увеличению размера выборки. Если же у нас одной записи в таблице А может соответствовать только одна запись в таблице B, причем они могут повторяться (отношение многие-к-одному), то такой запрос может привести к повторному получению данных в таблице B, что снова увеличивает размер выборки. Особенно будьте осторожны, когда записи в одной из таблиц содержат Blob.

3. Select in load (prefetch_related в Django). После получения данных из таблицы A генерируется второй запрос на получение записей из таблицы B с передачей ключей для поиска записей. То есть, запрос вида select * from B where someid in (...). В этом случае мы не грузим дубли данных, однако отправка второго запроса может оказаться дольше чем загрузка за один прием. Также стоит быть аккуратным при реализации этой стратегии вручную и передачей большого количества id: в некоторых СУБД потребуется разделять этот список на части и делать больше одного дополнительного запроса.

4. Subquery load. Также для получения записей из связанной таблицы генерирует второй запрос. Похожа на select-in load, но вместо прямой передачи списка id, дублируется первый запрос как подзапрос для их получения. Может пригодиться в каких-то особенных случаях, когда повторное получение id в базе дешевле, чем пересылка полного списка.

5. Array/Json agg (как правило, не реализована в ORM). Похоже на joined load, но вместо увеличения числа колонок и строк, с помощью агрегирующих функций мы получаем массивы/json-поля с данными связанных таблиц. Так же может привести к дублированию данных в случае отношения многие-к-одному. Требует поддержку json/array полей от СУБД. Иногда используется для формирования в БД структуры, пригодной для отправки дальше, что является антипаттерном.


Дополнительные материалы:
* https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html
* https://medium.com/@clementgrimault/optimize-the-way-you-fetch-relationships-with-postgresql-7711fe6457d2
* https://docs.djangoproject.com/en/4.1/ref/models/querysets/#select-related
* https://hygraph.com/blog/graphql-n-1-problem
!!! 🎓Онлайн курс по Python 🎓!!!

Имея многолетний опыт помощи новичкам, мы решили открыть свою школу по Python.

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

📝 Материалы курса были много раз проверены и перепроверены на живой аудитории, дополнены кодом из реальных проектов.

🧑‍💻Состав преподавателей пока держится в тайне, но это те люди, которых вы регулярно видите в чате и кто уже неоднократно себя зарекомендовал как высококлассный специалист, способный донести свои знания до любого заинтересованного, независимо от его бэкграунда и стартовых навыков.

🐍 Мы представляем Школу ru_python!
Новый курс стартует на днях, спешите записаться!

Программа и условия участия:
🔗
https://rupython.okolo.dev/

🤡 Всех с праздником!
Концепции, связанные с декораторами в Python

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

1. Функции - такие же объекты, как и любые другие. Имя функции - такая же переменная, как и любая другая. Круглые скобки - оператор вызова, а не часть имени функции. То же самое касается классов.

Когда вы определяете функцию с помощью ключевого слова def, происходят две вещи:
* в памяти создается объект функции, который в атрибуте __name__ содержит имя функции
* создается переменная, содержащая ссылку на функцию.

С этой переменной можно работать так же как с переменной, содержащей любые другие данные - пытаться работать с атрибутами объекта, передавать в другую функцию и даже присваивать её другое значение.

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

Следующий код синтаксически абсолютно корректен:
def foo(): 
print("inside foo")

funcs = [foo]
foo = 1
print(funcs)
bar = funcs[0]
bar()

2. Функции могут принимать переменное число параметров. Позиционных или именованных.
Часто мы не знаем сколько параметров пользователь захочет передать в нашу функцию, потому что мы их никак не обрабатываем. Зато мы их можем захотеть передать дальше. В этом случае мы можем объявить функцию, как принимающую переменное число позиционных параметров с помощью *args или именованных **kwargs (имена общепринятые, но не обязательные). Тогда внутри её тела мы будем иметь доступ к двум переменным, содержащим все переданные аргументы в виде кортежа и словаря соответственно.

def foo(*args, **kwargs):
print(args, kwargs)

foo(1, 2, x=3, y=4)

3. Функция может передавать другой функции переменное число параметров. Используя оператор * мы можем распаковать любой Iterable (список, кортеж и т.п.) и передать как отдельные позиционные параметры функции и аналогично ** для передачи именованных параметров из словаря. Это хорошо комбинируется с прошлым пунктом

def foo(a, b, c):
print(a, b, c)

x=[1]
y={"b": 2, "c": 3}
foo(*x, **y)

4. Функции и классы можно создавать внутри других функций. Вложенная функция в этом случае создается при каждом вызове объемлющей, и при этом просто создается локальная переменная с ней. Новую функцию можно вызвать, вернуть с помощью return. Аналогично работает и с классами.
def foo():
def bar():
print("foo bar")
return bar

b = foo()
b()

5. Замыкания. Когда мы создаем функцию внутри другой, она может иметь доступ к переменным объемлющей функции. Именно к переменным, значения не копируются в нее при создании.

def foo():
a = 1
def bar():
print(a)
a = 2
bar()

foo()

Дополнительные материалы:
* https://en.wikipedia.org/wiki/First-class_function
* https://ru.wikipedia.org/wiki/Анонимная_функция
* https://peps.python.org/pep-0318/
* https://peps.python.org/pep-3104/
* https://docs.python.org/3/library/functools.html#partial-objects
Декоратор как паттерн и как сахар в 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):
...

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

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

3. Использование глобальных реестров объектов. Иногда это не является проблемой, но может привести к эффектам описанным в статьях про глобальные переменные, настройки или импорты. Возможно, тут стоит разделить реестр на несколько (как Router в aiogram3 дополнительно к Dispatcher) или отказаться от декоратора в пользу обычного вызова функции регистрации.

4. Принудительное применение опциональной или настраиваемой функциональности. Например, добавление кэша к функции через декоратор может показаться хорошей идеей, пока вы не захотите ограничивать время жизни кэша, иметь разные реализации кэша (in-memory или в redis). Так же это усложняет тестирование кода оригинальной функции. В этом случае стоит воспользоваться Dependency Injection и паттерном Декоратор, отделяя логику функции от добавочной логики кэширования и способов их комбинирования.

Дополнительные материалы:
* https://en.wikipedia.org/wiki/Decorator_pattern
* https://peps.python.org/pep-0318/
* https://docs.python.org/3/library/functools.html
Компоненты 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
Тонкости использования Alembic

Alembic - это 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/
Запуск программ и команд

Когда наше приложение запускается, оно получает список аргументов командной строки (argv в параметрах функции main во многих языках или sys.argv в Python). Эти параметры задаются тем, кто запускает программу и зависят от ОС и способа запуска. Нулевой элемент списка - сама программа как её запускали (путь или имя).

C-API
* В Linux мы запускаем программу, указывая её имя или путь и параметры в виде массива. Если указано просто имя, происходит поиск такого файла среди папок, указанных в переменной окружения PATH
* В Windows же мы передаем параметры в виде одной строки, которая разбивается на части уже самим приложением. В отличие от Linux, файл для запуска ищется не только в PATH, но и в текущем каталоге

Desktop GUI
* выбор непосредственно запускаемого файла
* выбор ярлыка, ссылающегося на запускаемый файл (например, .lnk или .desktop). Такой файл может содержать дополнительную информацию о способе и параметрах запуска приложения
* выбор файла, ассоциированного с программой для запуска. Например, кликая по .docx файлу, мы запускаем MS Word, а кто-то - LibreOffice. Ассоциации обычно устанавливаются по расширению или по типу файла, определенному исходя из содержимого. В этом случае сначала по файлу определяется, какая программа должна быть запущена, а затем она запускается и путь к файлу передается в argv
* перетаскивая файл на иконку приложения. Путь к файлу будет передан в argv

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

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

Например,
* cd не является отдельной программой, это именно команда шелла. Так как текущий каталог меняется только для конкретного процесса и не распространяется на родительские, мы бы и не смогли реализовать это в виде отдельного приложения.
* ls - отдельное приложение, которое входит в состав пакета coreutils

Python
Используя 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
Создание 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/
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: ...

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

Это протекающая абстракция

Мартин Фаулер определяет репозиторий как "объект, который является посредником между доменным слоем и слоем Data Mapper". Цель его в том, чтобы изолировать слой бизнес логики от деталей реализации доступа к данным.

Обобщенные (generic) репозитории позволяют разработчикам делать обертки над объектами нижележащей технологии (ORM, Entity Framework). В результате зависимость от технологии доступа к данным может протечь в основную логику приложения.

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

Это слишком сильное обобщение.

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

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

Это определение бессмысленного контракта

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

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

def find(self, query: Any) -> Iterable[T]: ...
def find_customer_by_name(self, name: str) -> Iterable[Customer]: ...

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

Для generic-репозитория есть место... но не на передовой

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

(далее в комментарии)
Базы данных и компоненты

База данных
- любое собрание связанных данных. Коллекция аниме, телефонный справочник, каталог выпусков 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
Двухфазная инициализация

Иногда, по каким-то причинам мы не можем выполнить всю инициализацию при создании класса (в конструкторе или в __init__). Например, это может быть выполнение асинхронного ввода/вывода, простановка циклических ссылок между двумя созданными объектами или особоый механизм обработки ошибок инициализации. В этом случае иногда создают вспомогательный метод, который нужно вызвать сразу после создания объекта. Стоит использовать такой подход с осторожностью.

Например, мы хотим создать гейтвей для работы с БД. Следующий код не будет работать:

class SomeGW:
def __init__(self, db_uri):
self.connection = await asyncpg.connect(db_uri)

gw = SomeGw("postgresql://postgres@localhost/test")

Мы не можем выполнять async код в ините класса, поэтому можно попытаться сделать двухфазную инициализацию:

class SomeGW:
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")

В таком случае необходимо следить, что соединение не будет использовано до завершения второй фазы инициализации (вызова connect). Также, у созданного объекта формально 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")

Проблемы многофазной инициализации:
• Объект может быть инициализирован частично, что приведет к ошибкам выполнения
• Линтеры будут требовать дополнительных проверок в методах
• Корректная последовательность инициализации класса неочевидна из его API. Ситуация становится сложнее, если у нас есть несколько вариантов второй фазы
• Возможно нарушение принципа единственности ответственности: объект смешивает логику, ради которой он создавался, и сложную процедуру инициализации

В общем случае, желательно, чтобы объект был работоспособен сразу после создания без необходимости вызова дополнительных методов. То есть, чтобы невозможно было создать объект в нерабочем состоянии.

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

Дополнительные материалы
http://neo.dmcs.p.lodz.pl/symos/wyklady/04-TwoPhase.pdf
https://wiki.wxpython.org/TwoStageCreation
https://peps.python.org/pep-0489/
Первичные ключи в БД

Чтобы отличать записи в реляционной БД, у них должны быть уникальные поля. Это может быть как одно поле, которое для всех записей принимает разные значения, так и целый набор.

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

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

В БД может быть много потенциальных ключей, и поэтому мы выбираем один из них как основной, который мы будем использовать - это первичный ключ (primary key, PK, ПК). То есть:
• Первичный ключ в таблице всегда один.
• Первичный ключ - это не обязательно одна колонка (простой ключ), а может быть и группа из нескольких колонок (составной или композитный ключ).
• Даже если у нас простой ключ, он не обязательно называется id, имя колонки может быть любым, хотя стоит придерживаться стандартных названий.

Иногда мы записываем в БД данные, в которых естественным образом уже есть потенциальные ключи, мы выбираем из них один как первичный - это естественный ключ. Но иногда потенциальных ключей сразу не наблюдается или они какие-то неудобные для использования (никто ведь не захочет везде таскать первичный ключ из 5 колонок?), в этом случае под первичный ключ заводят отдельную колонку со сгенерированными уникальным значениями - это суррогатный ключ.

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

Псевдослучайный ключ позволяет использовать его ещё до обращений в БД, что бывает полезно. Его использование усложняет перебор ключей и определение количества записей, что может быть важно. Но из-за алгоритма генерации может быть неэффективен для поиска в БД.
Автоинкремент требует обращения в БД, которая в том или ином виде запоминает какие были уже выданы номера.

При использовании автоинкремента номера не обязаны идти по порядку и даже по возрастанию. С точки зрения целей использования первичного ключа это не требуется, поэтому для большей эффективности БД не пытается за этим следить. А конкретно есть несколько причин:
• При удалении записей номера освобождаются, но номера остальных записей не меняются. Если бы БД просматривала какие номера освободились, это заняло бы много времени.
• При конкурентных транзакциях будут сгенерированы несколько номеров одновременно. Но одна из транзакций может быть не зафиксирована и тогда номер не будет фактически использован. Следить за такими номерами тоже было бы достаточно не эффективно. Кроме того транзакции могут быть открыты и зафиксированы в разном порядке, что будет отличаться от порядка генерации ключей.
• При определенной настройке некоторые СУБД генерируют автоинкрементные PK не по одной, а несколько за раз и хранит внутри сессии. Тогда конкурентные вставки будут использовать номера из разных наборов, что сохранит уникальность, но нарушит порядок.

Дополнительные материалы
https://ru.wikipedia.org/wiki/Нормальная_форма
https://habr.com/ru/articles/572700/
https://habr.com/ru/articles/747348/
Dependency Injection

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

Суть его в том, что когда у нас одному из объектов требуется другой, то он не создает или ищет его сам, а принимает извне. Например, если вашей функции нужно соединение с БД, то она не должна ни импортировать его, ни брать из глобальной переменной, ни создавать сама. Ей это соединение должны передать.

Само собой, какой-то код будет создавать эти зависимости, и тут мы стараемся отделять его от кода, использующего их. Благодаря этому:
во-первых, делаем этим зависимости более явными;
во-вторых, можем управлять тем, будет ли использован один экземпляр зависимости или разные;
в-третьих, можем использовать один и тот же код с разными реализациями зависимостей.

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

Можно выделить три способа внедрения зависимостей:
1. Внедрение через параметры функции/метода. Просто передаем зависимость как ещё один параметр:

 def clear_users(cursor):
cursor.execute("TRUNCATE users;")

cursor = connection.cursor()
clear_users(cursor)
clear_users(cursor)

2. Внедрение через параметры конструктора:

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

3. Внедрение через атрибуты экземпляра (см. так же двухфазная инициализация):

 class UsersDAO:
def clear_users(self):
self.cursor.execute("TRUNCATE users;")

dao = UsersDAO()
dao.cursor = connection.cursor()
dao.clear_users()
dao.clear_users()

Популярные заблуждения:
Это что-то из мира java, в моем языке его нет. Неверно. Для того чтобы в языке можно было реализовать внедрение зависимостей, необходимо лишь иметь возможность передать ссылку на функцию или на объект. Это применимо как к Javascript и Python, так и к C и Golang.
Он нужен только большим enterprise приложениям. Скорее, он критичен для больших приложений. Небольшие приложения без автоматических тестов могут действительно существовать без внедрения зависимостей, но по мере усложнения необходимость в нем будет все более и более заметна.
Для него нужен специальный фреймворк/IoC-контейнер. Как показано выше, для DI не нужен никакой фреймворк, это возможность вашего языка. Фреймворки лишь помогут упростить построение графа зависимостей (когда у вас достаточно много разных связанных объектов) или решить какие-то прикладные задачи связанные с этим. Хуже, что неосторожное использование таких фреймворков может наоборот лишить вас DI, хотя вы будете думать что это не так.

Дополнительные материалы:
https://martinfowler.com/articles/dipInTheWild.html
https://www.jamesshore.com/v2/blog/2006/dependency-injection-demystified
Виртуальные окружения Python

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

Чтобы избежать таких проблем, рекомендуется практически всегда использовать виртуальные окружения. Это специальная папка, куда устанавливаются библиотеки и которых может быть больше одной на вашем компьютере. В python 3 есть встроенное средства для управления ими - пакет venv, но есть и сторонние популярные решения такие как virtualenv, poetry и многие другие.

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

python -m venv имя_папки

После этого в каталоге имя_папки будет создано множество служебных файлов и установлен pip. Часто в качестве папки указывают venv или .venv.

Для того чтобы работать внутри виртуального окружения, вы можете:
a. Запустить команду с указанием пути. Например, python, pip или поставляемые сторонними пакетами. На Linux это будет ./имя_папки/bin/python, на Windows - имя_папки\Scripts\python (пути могут быть относительные или абсолютные). Это бывает удобно внутри скриптов или файлов сервисов. В этом случае, sys.path будет содержать каталог библиотек внутри виртуального окружения. Учтите, что так как переменная PATH не меняется, то запуск других команд (например, через subprocess) без указания пути будет фактически происходить вне виртуального окружения.
b. Активировать его внутри вашей командной оболочки (основной сценарий). Для bash/zsh это source ./имя_папки/bin/activate. Для Windows CMD - имя_папки\Scripts\activate.bat. После этого в рамках сессии вашего шелла будет изменена переменная окружения PATH, что приведет к изменению команд, доступных без указания пути. Соответственно, если таким образом будет запущена команда (python, pip и т.д.) из виртуального окружения, то и sys.path будет изменен, как и в прошлом способе. Так же будет задана переменная окружения VIRTUAL_ENV. Для заверешния работы с виртуальным окружением введите команду deactivate.

Типичные ошибки при работе с venv:
Не использовать виртуальные окружения. Задумайтесь, что вы будете делать, когда двум вашим проектам потребуется одна библиотека разных версий. Или когда вы решите удалить библиотеки, не указанные в списке зависимостей, чтобы быть уверенным, что проект требует только их.
Перемещать или копировать виртуальное окружение в другую локацию или на другой компьютер. Некоторые инструменты это позволяют делать в отдельных случаях, но venv не из таких. Окружение просто сломается и будет вести себя непредсказуемо. Исключение - копирование между идентичными образами ОС, например, при сборке контейнеров.
Добавлять папку с виртуальным окружением в систему контроля версий (например, git). Вместо этого стоит фиксировать список зависимостей, чтобы окружение можно было пересоздать в любой момент.
Устанавливать pip вне виртуального окружения. Это может случайно привести к работе с зависимостями вне venv и путанице.
Предполагать, что IDE сама знает, где находится виртуальное окружение. Несмотря на то, что часто это так, иногда бывает недопонимание с ней. Разберитесь, как в вашей IDE настраивать использование виртуального окружения. Также обязательно научитесь работать с окружениями без использования IDE.
Помещать файлы проекта внутрь виртуального окружения. Это достаточно бессмысленное действие, потому что сценарии работы с venv и файлами проекта сильно отличаются (например, он может быть при необходимости пересоздан). Куда лучше хранить venv внутри папки проекта.

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

Дополнительные материалы:
https://docs.python.org/3/library/venv.html
https://python-poetry.org/docs/
https://peps.python.org/pep-0405/
Абстрактные классы и интерфейсы

Если рассуждать, не привязываясь к языку программирования, то:

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

Абстрактный класс может содержать данные, обычные методы. Его отличает именно наличие абстрактных методов. В некоторых языках - это методы без тела (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/
Dishka - IoC-контейнер для Python

Когда мы следуем подходу Dependency Injection, а особенно - слоистой архитектуре, у нас образуется отдельная группа функций и классов, выполняющих только одну задачу - создание других объектов. Такой код лучше держать поближе к main, так как он связывает воедино разные части приложения и связан с конфигурацией запуска.

В сложном приложении такой компонент может содержать большое количество функций, контролировать как создание, так и корректную очистку объектов и, что самое главное, их взаимосвязь. Для упрощения работы с такими фабриками придумали отдельный тип библиотек - IoC-контейнеры (DI-фреймворки).

В Python меня долго не устраивали существующие контейнеры и я решил сделать свой:

Хочу представить вам Dishka 1.0

Цель этого проекта - предоставить простой и удобный IoC-контейнер, который сможет забрать всю работу с зависимостями. Мне кажется, на текущий момент это самый функциональный вариант контейнера, имеющий при этом самое простое API.

• Вы можете использовать его с любым фреймворком, но для некоторых мы уже подготовили хелперы
• Для создания зависимости можно указать отдельную функцию или использовать __init__ класса
• Зависимости имеют ограниченное время жизни (скоуп) и вы сами управляете им
• Зависимости кэшируются, поэтому один и тот же объект может быть переиспользован пока он жив. Так можно передать одно соединение с БД в несколько гейтвеев
• Фабрики зависимостей можно группировать в классы и компоненты, что позволяет делать контейнер модульным
• Можно декорировать объекты, использовать один объект для нескольких типов
• При старте проверяется корректность конфигурации контейнера, что позволяет исключить многие ошибки

Что значит версия 1.0?

У библиотеки было 9 промежуточных релизов, мы рады объявить, что закончена вся работа по стабилизации её интерфейса и исправлению ошибок. И у нас есть планы по развитию, уникальные фичи сами себя не напишут.

Будем рады новым пользователям, багрепортам, запросам фич и звездам на гитхабе.

Видео с Podlodka: http://www.youtube.com/watch?v=gWOBaZ3I4gc

Github
Pypi
Документация
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/
У ребят из Podlodka Python Crew стартует новый сезон онлайн-конференции, тема — инфраструктура. Всё проходит онлайн, с 3 по 7 июня.

Я буду выступать там с докладом про Dependency Injection и dishka непосредственно.

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

Что будет
• Мой доклад про DI
• Погружение в трейсинг: чем он полезен, как работает и как его внедрить.
• Поиск уязвимостей: практические задания с разбором
• Рассказ про неочевидные кейсы оптимизации.
• Обучение эффективному мониторингу: типы метрик, как их собирать и экспортировать.
И ещё много всего.

Конференция платная, но специально для подписчиков промокод INFRA_17 на скидку 1000р

Запись выступления доступна тут:
http://www.youtube.com/watch?v=gWOBaZ3I4gc
Аутентификация и авторизация

Наши приложения выполняют разные сценарии и для некоторых из них может быть важно, что за пользователь перед нами. То есть, для целей бизнес-логики может быть необходимо получить некоторые уникальные данные пользователя, которые позволят его отличить от других - это идентификация. Реализуется она различным способом: иногда мы можем явно спросить у пользователя, кто он, иногда мы получаем информацию из сетевых пакетов или системы. Идентификационные данные дальше могут использоваться по-разному: их можно записать в лог, использовать как ссылку на владельца при создании объектов в системе или в различных проверках внутри нашей логики.

Идентификация должна выполняться безопасно: иногда пользователь может попытаться выдать себя за другого. Процесс проверки, что пользователь не обманывает нас в том, кто он - аутентификация. Она не всегда актуальна: если мы получили сообщение от 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/
Аутентификация и 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/Компоновщик_(шаблон_проектирования)
2024/10/01 20:27:40
Back to Top
HTML Embed Code: