8350
Советы для разработчиков ПО от @Tishka17 Поддержать материально https://www.tinkoff.ru/cf/2NkdXaljivI Programming, python, software architecture и все такое
Концепции, связанные с декораторами в Python
Декоратор в Python введен на уровне синтаксиса языка, но за ним лежат несколько важных концепций, которые необходимо понимать:
1. Функции - такие же объекты, как и любые другие. Имя функции - такая же переменная, как и любая другая. Круглые скобки - оператор вызова, а не часть имени функции. То же самое касается классов.
Когда вы определяете функцию с помощью ключевого слова def, происходят две вещи:
* в памяти создается объект функции, который в атрибуте __name__ содержит имя функции
* создается переменная, содержащая ссылку на функцию.
С этой переменной можно работать так же как с переменной, содержащей любые другие данные - пытаться работать с атрибутами объекта, передавать в другую функцию и даже присваивать её другое значение.
Для того чтобы вызвать функцию мы используем оператор (), передавая при необходимости параметры. Вы можете вызывать любую функцию независимо от того, откуда она у вас - получили вы параметром, достали из списка, создали на месте или присвоили из другой переменной. Если вы не написали скобки, вы не вызвали функцию (точно так же, как вы не складываете числа, если не напишете +).
Следующий код синтаксически абсолютно корректен:
def foo():2. Функции могут принимать переменное число параметров. Позиционных или именованных.
print("inside foo")
funcs = [foo]
foo = 1
print(funcs)
bar = funcs[0]
bar()
*args или именованных **kwargs (имена общепринятые, но не обязательные). Тогда внутри её тела мы будем иметь доступ к двум переменным, содержащим все переданные аргументы в виде кортежа и словаря соответственно. def foo(*args, **kwargs):3. Функция может передавать другой функции переменное число параметров. Используя оператор
print(args, kwargs)
foo(1, 2, x=3, y=4)
* мы можем распаковать любой Iterable (список, кортеж и т.п.) и передать как отдельные позиционные параметры функции и аналогично ** для передачи именованных параметров из словаря. Это хорошо комбинируется с прошлым пунктомdef foo(a, b, c):4. Функции и классы можно создавать внутри других функций. Вложенная функция в этом случае создается при каждом вызове объемлющей, и при этом просто создается локальная переменная с ней. Новую функцию можно вызвать, вернуть с помощью
print(a, b, c)
x=[1]
y={"b": 2, "c": 3}
foo(*x, **y)
return. Аналогично работает и с классами.def foo():5. Замыкания. Когда мы создаем функцию внутри другой, она может иметь доступ к переменным объемлющей функции. Именно к переменным, значения не копируются в нее при создании.
def bar():
print("foo bar")
return bar
b = foo()
b()
def foo():Дополнительные материалы:
a = 1
def bar():
print(a)
a = 2
bar()
foo()
Стратегии загрузки связанных данных из РСУБД
Во многих случаях запрашивая данные из реляционной БД, мы хотим получать их не из одной таблицы, а из нескольких.
Предположим, у нас есть две связанные таблицы 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
* clementgrimault/optimize-the-way-you-fetch-relationships-with-postgresql-7711fe6457d2" rel="nofollow">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
Веб приложение и масштабирование
Использование протоколов, основанных на HTTP, не требующих постоянного соединения и содержащих всю необходимую информацию в каждом прикладном пакете, позволяет проще масштабировать приложения горизонтально и восстанавливаться после сбоев. Однако, это требует, чтобы и приложение было написано соответствующе.
Чтобы проверить, правильно ли спроектировано ваше веб-приложение (или телеграм-бот), подумайте, будет ли оно корректно функционировать в таких ситуациях:
* если после любого обработанного события произойдет перезапуск приложения?
* если одновременно придет несколько событий?
* если будет запущено несколько процессов приложения и события будут приходить в один или в другой попеременно?
Перезапуски приложения происходят в любой момент - как при программных ошибках, так и при стандартной процедуре обновления. Запуск же нескольких копий может понадобиться для задействования дополнительных аппаратных ресурсов и увеличения производительности сервиса.
В общем случае, имеет смысл делать сам процесс приложения не имеющим состояния. При этом все данные, которые надо сохранить между событиями - хранить в специально спроектированной внешней системе (фактически, в БД).
Типичные проблемные места при разработке таких приложений:
* Донастройка правил выбора обработчика в процессе работы процесса в фреймворке (роуты, фильтры диспатчера). При перезапуске эти настройки будет сброшены. Вместо этого стоит настроить правила один раз (при запуске процесса) таким образом, чтобы они могли учитывать изменяющееся состояние, которое вы будете загружать из хранилища любого типа (БД, сессия, специальный FSM Storage).
* Хранение данных в глобальной переменной. При обработке нескольких событий они могут перепутаться, значение сбросится при рестарте, а так же несколько процессов не смогут разделять эти данные. При необходимости хранить данные бизнес-логики, стоит использовать БД или какое-то временное хранилище (сессия, FSM) в зависимости от сценария их использования. Однако это может не касаться данных, относящихся к самому процессу: счетчики для мониторинга, кэши.
* Запуск задач, не связанных с обработкой запросов в рамках процесса веб-сервиса. Если они запускаются из обработчика, они будут просто сброшены при рестарте. Если же они запускаются при старте - вы получите несколько копий таких задач при запуске нескольких процессов веб-приложения. В первом случае стоит задуматься об использовании очереди или отдельно запускаемого планировщика. Во втором - об отдельном процессе, который будет масштабировать по своим правилам. И снова речь не идет о служебных задачах, связанных с обслуживанием самого процесса приложения, таких как обновление in-memory кэша.
В определенных случаях мы можем сделать некоторые допущения для оптимизации наших сервисов. Например, предположение, что перезапуск будет не очень часто, позволяет нам сделать in-memory кэш или промежуточный буфер перед записью во внешнюю систему. Однако, внедрение таких решений должно быть обоснованным и учитывать потенциальные проблемы.
Дополнительные материалы:
* https://habr.com/ru/company/dcmiran/blog/487424/
* https://ru.wikipedia.org/wiki/Конечный_автомат
* ermakovichdmitriy/определения-понятий-stateful-и-stateless-в-контексте-веб-сервисов-перевод-18a910a226a1" rel="nofollow">https://medium.com/@ermakovichdmitriy/определения-понятий-stateful-и-stateless-в-контексте-веб-сервисов-перевод-18a910a226a1
Текущий каталог и пути
Как правило, мы используем два типа путей к файлам:
* Абсолютный путь - путь целиком, начиная от корня файловой системы и со всем промежуточным папками до указанного файла. Он хорош тем, что стабилен и дает однозначный путь к файлу, не зависящий от текущего состояния процесса. Однако он достаточно длинный и не всегда может быть использован. Например, если мы не знаем заранее, где будет лежать какая-то папка. Путь /usr/bin/python - абсолютный.
* Относительный путь - часть пути, которая самостоятельно не может быть использована для нахождения файла, но при наличии другого известного пути может быть посчитана относительно него. Пути .venv/bin/python и ../file.dat - относительные.
В некоторых случаях относительный путь может быть посчитан относительно другого явно указанного пути, но зачастую он используется сам по себе, и в этом случае считается относительно текущего каталога.
Текущий каталог (текущая директория, рабочий каталог) - каталог, использующийся для разрешения относительных путей процессом.
Текущий каталог не имеет никакого отношения к расположению вашего кода или файлов интерпретатора, он задается процессу независимо.
* При запуске дочернего процесса текущий каталог наследуется от родительского, но может быть изменен в процессе работы
* Если вы запускаете процесс через systemd, в качестве текущего каталога будет использован тот, что указан в service-файле (либо корень файловой системы)
* Если вы используете терминал с bash, то текущий каталог процесса командой оболочки вы можете узнать с помощью команды pwd. Смена каталога - команда cd
* В python вы можете узнать текущий каталог через os.getcwd() и изменить через os.chdir()
Рассмотрим пример.
Пусть ваша программа myapp.py лежит в папке /opt/app. Допустим, в коде программы есть строка open("filename").
Вы открыли консоль и перешли в домашний каталог пользователя root, то есть сделали cd /root. И теперь запустили программу командой python /opt/app/myapp.py. Программа попытается открыть файл filename, и искать его будет относительно текущего каталога. То есть фактически /root/filename. И не важно, где находится ваша программа, из какого количества файлов она состоит.
Хотя вам доступно API для изменения текущего каталога вашего приложения, рекомендуется не пользоваться этой возможностью, если все части программы не разрабатывались специально с учетом этого. Если где-то вы использовали относительный путь и он работал, то после изменения текущего каталога он начнет указывать на другое место.
Использовать пути относительно текущего каталога - неплохой вариант для пользовательских данных. В других случаях могут быть более корректными другие варианты:
* Используйте tempfile для работы с временными файлами, которые смогут располагаться в соответствующей системной директории
* Используйте importlib.resources для доступа к статическим данным, распространяемым вместе с вашим пакетом
* Ознакомьтесь с тем, где в вашей ОС принято хранить пользовательские данные приложения, пользовательские конфиги и прочие файлы. Это может быть что-то вроде %LOCALAPPDATA%, ~/.config и т.п.
* Подумайте о возможности принимать пути от пользователя через параметры командной строки (sys.argv)
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Рабочий_каталог
* https://learn.microsoft.com/en-us/dotnet/api/system.environment.specialfolder?view=netframework-4.8
* https://homepages.uc.edu/~thomam/Intro_Unix_Text/File_System.html
* https://wiki.archlinux.org/title/XDG_user_directories
* https://man7.org/linux/man-pages/man3/posix_spawn.3.html
Цели написания частей кода
В хорошей программе каждая строка и каждая сущность в коде создана для какой-то цели. Эта цель должна быть понятной как автору кода, так и тому, кто будет его в дальнейшем поддерживать.
Это касается как и крупных блоков кода, так и его структуры и даже стиля написания кода.
С одной стороны в коде не должно быть вещей, которые не имеют конкретной цели создания (это может функциональность, увеличение понятности кода или покрытия тестами), не используются и ни на что не влияют:
* Выполнение ненужных действий в коде замедляет его и вызывает вопросы у читающего. Например, код message_title = f"{str(name)}" может быть сокращен до message_title = f"{name}". Скорее всего name - уже строка, поэтому может иметь смысл убрать и f-строку, но она имеет тут конкретный смысл - формирование другого по смыслу текста и скорее всего шаблон может быть модифицирован.
* Написание неиспользуемых функций или недостижимого сейчас кода приводит к увеличению усилий на поддержку кода, которые не дают профита. К тому же, если этот код попытаются использовать, он может оказаться нерабочим ввиду более слабого тестирования.
* Лишние абстракции делают код запутаннее, а рефакторинг сложнее. При этом код с неверно выделенными абстракциями исправить не проще, чем при их недостатке, поэтому иногда лучше их не вводить.
* Использование дополнительных компонентов в инфраструктуре увеличивает затраты на их поддержку и аппаратные ресурсы.
Комментарии в коде должны давать новую информацию читающему. Сравните этот комментарий:
total_requests = rps * 3600 # multiple RPS by 3600
requests_per_hour = rps * 3600 # approximate requests per hour using current RPS value
class Constants:
DEFAULT_LENGTH = "64px"
DEFAULT_COLOUR = "red"
line.set_length(Constants.DEFAULT_LENGTH)
Enum, чтобы определить фиксированный набор экземпляров класса. Но не вносите в него логику разных частей приложения. Так же не стоит его использовать для хранения разнородной информации как класс в предыдущем примере@staticmethod и @classmethod для определения методов, которые ни в коем случае не должны иметь доступа к экземплярам. Даже если сейчас метод не обращается к self, возможно в наследнике или после изменения он захочет получить туда доступ__ в названии атрибута, если хотите запретить его переопределение при наследовании, иначе достаточно одной__init__.py для инициализации и настройки экспорта из пакета. Не стоит в этот файл помещать основной код[print(i) for i in collection]def is_price_valid(price: int) -> bool
return 0 < price
...
if is_price_valid(price):
...
Способы параллелизации задач
Мы регулярно сталкиваемся с необходимостью создания нескольких конкурентно работающих задач для более эффективной работы нашего кода. Причины могут быть разные: увеличение количества обрабатываемых запросов, ускорение получения результата вычислений или всё вместе.
С точки зрения реализации можно выделить, например, такие варианты:
* Географически распределенная система
* Вычислительный кластер из нескольких серверов
* Несколько процессов в рамках одного сервера
* Несколько потоков в рамках одного процесса
* Таски asyncio
Выбор подхода зависит от требуемых объемов ресурсов, наличия необходимой инфраструктуры, компетенции и самой решаемой задачи и алгоритма.
Эти подходы отличаются как используемыми технологиями, так и накладными расходами на взаимодействие между обработчиками.
* Например, если конкурентные обработчики должны постоянно обмениваться большим количеством информации, то выгоднее всего чтобы они имели общую память (т.е. работали в одном процессе). Однако при этом, мы можем быть ограничены физическим количеством ОЗУ на одном сервере. Если же наши обработчики работают относительно независимо, то, используя несколько серверов, мы можем задействовать максимальное количество ресурсов. Например, раздача статических файлов по HTTP может отлично работать в географически распределенной системе.
* Тяжелые вычислительные задачи вроде моделирования физических процессов хорошо работают в рамках одного процесса, но для использования большего количества ресурсов приходится строить специализированные кластеры с высокопроизводительной сетью.
* Использование нескольких процессов по сравнению с потоками так же имеет дополнительных расходы на пересылку данных через shared memory/pipes/sockets.
Кроме того, исполняющая среда тоже имеет свои расходы на управление и работу с несколькими задачами.
* Например, время запуска ещё одного сервера в облаке может быть существенным, но в некоторых случаях оно может быть ничтожным по сравнению со временем его работы.
* Для вычислительных задач может оказаться важным наличие GIL (который блокирует интерпретатор Python от параллельного выполнения нескольких инструкций), но и он может не играть роли, если мы используем нативные библиотеки вроде numpy.
* А вот при работе с очень большим количеством сетевых соединений, например, в web-прокси, для нас может оказаться существенным даже время необходимое ядру ОС для переключения потока, в то время как userspace потоки (как в asyncio) переключаются по другому.
И наконец, выбор средства "параллелизации" зависит также от целей, которых мы хотим достичь. Улучшая один показатель мы можем ухудшать другой. Например, использование asyncio поможет нам работать с очень большим количеством соединений, но ухудшить время отклика системы.
В связи с большим количеством вариантов выбрать правильный вариант реализации может быть сложно. Решение тут одно: профилируйте. Запускайте в разных конфигурациях, замеряйте важные для вас показатели и исходите из реального поведения системы на ваших задачах, часто оно может быть не таким, как вы предполагали.
Дополнительные материалы:
* https://en.wikipedia.org/wiki/Global_interpreter_lock
* https://ru.wikipedia.org/wiki/Кластер_(группа_компьютеров)
* https://habr.com/ru/company/selectel/blog/463915/
* https://docs.python.org/3/library/multiprocessing.html#synchronization-between-processes
Моки, стабы и патчи
При разработке и тестировании нам периодически нужно заменить настоящий сложный объект на другой, который лишь будет имитировать поведение. Вот основные две группы таких объектов:
* Stub (стаб), заглушка. Такие объекты максимально простые и делают минимум, необходимый чтобы их можно было применить по месту. Зачастую методы не делают ничего или возвращают фиксированные значения. Бывает полезен как в тестировании и отладке, так и в реальной логике - например для отключения определенной функциональности.
* Mock (мок) - специальный объект, имитирующий разное поведение в разных условиях и имеющий методы для проверки, были ли вызовы. В первую очередь применяется при написании автотестов. В python в основном используется реализация из unittest.mock.
Для того чтобы мы могли использовать такие фиктивные объекты в нашем коде обычно используется Dependency Injection. В этом случае мы знаем контракт используемого нами кода и передаем туда созданный объект, не вникая в детали реализации и оформления кода.
В реальных проектах мы не всегда так можем сделать, однако python позволяет использовать такой хак как monkey patching для того, чтобы внедрить зависимость туда, где это не предусмотрено. Под манки-патчем подразумевают обычно подмену инициализированных объектов, функций или методов без использования стандартных механик будь то DI или наследование. Это может быть замена класса в существующем модуле на свой или замена метода в уже созданном объекте или прямо в классе. И хотя этот метод действительно применяется, стоит помнить, что каждый такой случай - признание, что ваша архитектура плохо продумана. Манкипатчи в тестах полагаются на информацию о внутреннем устройстве кода вместо ожиданий определенного поведения и тесты получаются более хрупкими. И уж точно они не помогают при рефакторинге кода.
Рассмотрим простой пример monkey-patch. Представьте, что у вас есть два модуля a и b.
a.py:
def foo(): ...
import a
def bar():
a.foo()
b.bar, но вы не хотите полагаться на то, как ведет себя a.foo и вы решили её замокать (заменить на mock). Но так как, автор кода не предусмотрел способов внедрить эту зависимость, вы решили сделать манкипатч: a.foo=Mock(...)unittest.mock.patch частично решает эту проблему ограничивая время работы патча.a.foo, может быть произведен рефакторинг с сохранением поведения, но уже без её вызовов. Тест в этом случае сломаетсяfrom a import foo
def bar():
foo()
foo. Соответственно, тест так же сломается.
Управление памятью в Python
В некоторых языках необходимость выделять и освобождать память лежит на программисте, в некоторых это происходит автоматически, в некоторых доступны оба способа. В Python всё управление памятью происходит полностью автоматически: отсутствуют операторы выделения памяти и её освобождения.
Когда вы создаете какой-то объект, Python сам решает как выделить ему память. Как было упомянуто в прошлой статье, иногда вместо выделения новой ячейки памяти, Python может вернуть ссылку на уже созданный экземпляр.
Когда же объект становится не нужен, он удаляется и память освобождается. Под "ненужным" имеется ввиду тот, на который нет активных ссылок, либо есть только циклические ссылки с другими объектами. В зависимости от реализации удаление объекта может происходить сразу, как только пропадут все ссылки на него, или с задержкой (например, при использовании периодического сборщика мусора). В частности, CPython использует счетчики ссылок (именно их защищает GIL).
Есть несколько мест, которые часто понимаются неверно:
* оператор del - удаляет ссылку на объект. Это может быть удаление переменной, ключа в словаре, элемента/слайса списка. Сам объект при этом не меняется и не удаляется, если нет других причин для этого. Как правило, нет причин делать del имяпеременной, вместо этого лучше ограничить скоуп существования переменной введя дополнительную функцию.
* магический метод __del__ - вызывается при удалении объекта. В подавляющем большинстве случаев вы не должны его переопределять. Так как мы не знаем, когда произойдет удаление объекта, лучше использовать контекстные менеджеры для финализации работы с объектом. Кроме того, в некоторых ситуациях (например, при завершении процесса интерпретатора), __del__ вообще не будет вызван.
* модуль gc в CPython предоставляет интерфейс к сборщику циклических ссылок. Его можно отключить и это не повлияет на удаление объектов при достижении нуля счетчиком ссылок. Самостоятельные вызовы gc.collect() при включенном сборщике скорее всего не имеют смысла.
* удаление Python-объектов не обязано сразу уменьшить количество занимаемой процессом ОЗУ. CPython запрашивает у ОС память крупным блоками и самостоятельно в них располагает свои объекты, соответственно и возврат этих областей памяти происходит не сразу.
Дополнительные материалы:
* https://habr.com/ru/company/ruvds/blog/441568/
* https://habr.com/ru/post/417215/
* https://en.cppreference.com/w/cpp/memory/shared_ptr
* https://habr.com/ru/company/vk/blog/559794/
Pull, poll, pool, spool
Есть несколько терминов, которые для русского уха звучат одинаково:
1. pull - с английского переводится как "тянуть". Идет в паре с термином push.
Как правило, термином pull обозначают команду получения данных с сервера. Соответственно, push отправляет их на сервер.
Ещё pull может подразумевать режим, когда получатель данных сам стягивает их к себе из источника. В случае же push режима, источник данных самостоятельно засылает их получателю.
Термин применяется, например, когда мы говорим о методике сбора метрик работы приложения.
2. poll, polling - опрос.
Означает необходимость повторять вызовы для получения данных. Это может быть системный вызов poll, повторные запросы к базе данных или удаленному серверу.
В случае HTTP API клиент производит периодический опрос сервера на предмет наличия новых данных. В противоположность этому, при использовании же websockets, между клиентом и сервером есть постоянно соединение по которому передаются сообщения Если же используется webhook, то сервер сам соединяется с нашим приложением по HTTP для уведомления о наличии новых данных. В зависимости от реализации, такой запрос может содержать сами данные или только уведомление об их наличии.
Так же есть режим long polling, который от обычного polling отличается тем, что при отсутствии новых данных сервер не возвращает пустоту сразу, а ещё какое-то время держит соединение открытым.
Эти термины применяются при разработке веб приложений, платежных сервисов, телеграм-ботов и т.п.
3. pool - обычно означает паттерн "Объектный пул", когда мы не создаем объекты заново, а переиспользуем ранее созданные. см. также /channel/advice17/19
4. spooling — спулинг, буферизация задач.
Технология, когда мы не сразу отправляем задачи на обработку между устройствами, а сначала собираем в каком-то промежуточном буфере. Является комбинацией очереди и буфера.
Применяется, например, когда идет речь о выводе на печать.
Дополнительные материалы:
* https://man7.org/linux/man-pages/man2/poll.2.html
* https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-prsod/7262f540-dd18-46a3-b645-8ea9b59753dc
* https://git-scm.com/docs/git-pull
* https://prometheus.io/docs/introduction/faq/#why-do-you-pull-rather-than-push
Механика импорта и побочные эффекты
В отличие от других языков, питон не имеет отдельной механики декларации функций и объектов. Это такой же код, как сложение чисел и присвоение значения переменной. Соответственно, в питоне и нет отдельной механики определения экспорта имен из модуля.
Каждый раз, когда вы импортируете новый, ранее не импортированный, модуль, питон выполняет его код. Даже если вы делаете from .. import, это все равно требует однократного выполнения исходного кода модуля.
Питон хранит в памяти все импортированные модули, поэтому код будет выполнен один раз. Они доступны через sys.modules
Фактически строка import x означает:
1. Найди модуль x в кэше, если там его нет - выполни его код и сохрани модуль в кэш;
2. Создай в текущей области видимости переменную x и присвой ей загруженный модуль в качестве значения.
Соответственно, from x import y означает:
1. Найди модуль x в кэше, если там его нет - выполни его код и сохрани модуль в кэш;
2. Создай в текущей области видимости переменную y и присвой ей в качестве значения атрибут y из модуля x.
Ну и наконец, from x import y as z означает:
1. Найди модуль x в кэше, если там его нет - выполни его код и сохрани модуль в кэш;
2. Создай в текущей области видимости переменную z и присвой ей в качестве значения атрибут y из модуля x.
То, что при этом выполняется код, позволяет нам делать различную динамику при инициализации модуля: будь то загрузка данных, инициализация констант, создание различных реализаций кода в зависимости от окружения. Однако стоит быть осторожным и не писать в глобальной области видимости код, который влияет на что-то ещё кроме текущего модуля.
Импорты изначально предназначены для получения доступа к каким-то именам (константам, функциям, классам), поэтому ожидают что среди них не будет лишних и что порядок не важен. Исходя из этого можно ввести определенные правила форматирования импортов: PEP8 рекомендует нам группировать импорты по типам, многие линтеры также просят придерживаться алфавитного порядка и удалять неиспользуемые импорты. Никто не ожидает, что порядок импортов модулей может повлиять на работу кода (есть несколько исключений, но они общеизвестны и все равно сомнительны). Никто не ожидает, что удаление импорта модуля, который не используется ниже по коду, может повлиять на работу кода.
Если вы импортируете модуль ради выполнения какой-то логики в коде модуля, то это будет неожиданно для поддерживающего код и может сломаться при любом изменении в порядке импортов. Вместо этого стоит такой код поместить в функцию и вызывать её уже по месту, например в функции main.
Дополнительные материалы:
* https://peps.python.org/pep-0008/#imports
* https://www.flake8rules.com/rules/F401.html
* https://docs.python.org/3/reference/import.html
Обработка исключений
Мы хотим, чтобы наши программы работали стабильно, и необработанные исключения - одно из проявлений нестабильности. При этом мы так же хотим, чтобы программы работали предсказуемо, не портили данные и не содержали логических ошибок.
Говоря о каждой конкретной строке кода можно выделить два вида исключений:
* ожидаемые (возможные) - исключения, которые мы предполагаем, что могут возникнуть в данном месте, и знаем какие действия принимать в этой ситуации
* неожиданные - исключения, которые не должны были возникнуть в этом месте, но возникли из-за неверно написанного кода и мы не имеем стратегии поведения в этой ситуации
Отсюда следуют следующие советы:
1. Всегда указывайте исключение, которое вы ловите. Если вы не знаете, что за исключение может возникнуть - вы не знаете корректно ли обрабатывать его вашим способом.
2. Указывайте максимально конкретный класс исключения.
3. Оборачивайте в try/except наименьший возможный код. Если требуется - разбивайте его на несколько выражений
4. Обрабатывайте исключения именно там, где у вас достаточно информации для принятия решения, что делать в данной ситуации.
Частой ошибкой новичков бывает написать просто except: или except Exception - не делайте так.
* Обработка всех подряд исключений, как правило, вообще не корректна, так как туда входит, например, KeyboardInterrupt, по которому ожидается как раз завершение программы. Но это допустимо, если после такой обработки вы пробросите исключение дальше.
* Обработка же Exception актуальна на уровне фреймворка, когда у нас есть стандартный способ реакции на неизвестные ошибки - выдача клиенту ответа с кодом 500, повтор обработки сообщения из очереди т.п.
Другая распространенная ошибка - поймать исключение и заменить его на возврат специального значения. В некоторых случаях это допустимо, но зачастую таким образом мы просто меняем способ доставки информации о наличии исключительной ситуации вызывающему коду, что приводит к появлению дополнительного кода, не делающего реальной работы, а только занимающегося пробросом ошибки вверх по стеку. Это применимо в других языках программирования, но в питоне обычно используется механизм исключений.
Говоря о реализации адаптеров для различных сервисов или БД хорошей идеей будет ввести свои классы исключений
* Реализуя адаптер мы хотим скрыть детали реализации. Например, мы реализовали класс, реализующий хранение определенных сущностей в БД. Затем после очередного рефакторинга мы вынесли это в отдельный микросервис. Интерфейс адаптера при этом не изменился и мы ожидаем что использующий такой адаптер код не будет меняться. И если исключения вроде OSError или ValueError достаточно нейтральны и почти не говорят о реализации, то классы исключения, принадлежащие конкретной используемой библиотеки не стоит прокидывать извне такого адаптера.
* Вторая причина для введения своих классов исключений - изменение детализации. С одной стороны, разные сторонние исключения могут означать одну ошибку с точки зрения нашей логики (как например, разные виды сетевых ошибок могут означать просто недоступность внешнего сервиса). С другой - наоборот, возникновение исключения одного типа в разных местах кода адаптера может означать разное для вызывающего кода.
Дополнительные материалы:
* https://martinfowler.com/articles/replaceThrowWithNotification.html
* https://peps.python.org/pep-0008/#programming-recommendations
Пулы объектов и соединений.
Наши приложения зачастую используют однотипные объекты, которые мы постоянно создаем и удаляем. И пусть во многих случаях это время достаточно мало, иногда оно становится важным.
Для решения проблемы долгой инициализации объекты придуман паттерн "пул объектов". В этом случае у вас есть заготовленный набор инициализированных объектов. Каждый раз, когда вам нужен такой объект, вы не создаете его сами, а запрашиваете из пула. Соответственно, возвращаете туда после использования. Пул может создавать эти объекты сразу при старте или быть ленивым - создавать по запросу и запоминать те, что уже были им созданы. В зависимости от ситуации пул может также иметь свою логику по управлению жизненным циклом таких объектов - будь то сброс состояния, ограничение времени жизни или размера пула.
Стандартная практика - один раз (при старте приложения) создать пул, в дальнейшем использовать его в разных частях кода. При этом вы можете иметь несколько пулов, если того требует логика. Но просто так регулярно пересоздавать пул бессмысленно.
Один из важных случаев, когда такая оптимизация может быть полезна - работа с сетевыми подключениями: установка соединения по TCP занимает некоторое время, использование TLS только увеличивает его. При это многие сетевые протоколы не требуют физического разрыва соединения после завершения ваших запросов и позволяют продолжить его использовать для дальнейшего общения.
Примеры:
* requests.Session - кроме дополнительной логики по управлению куками содержит внутри пул соединений с серверами, по которыми в дальнейшем посылаются HTTP(s) запросы. Использовать requests без Session скорее всего будет плохой идеей
* aiohttp.ClientSession - аналогичный объект для асинхронного "аналога" - библиотеки aiohttp. Несмотря на то, что в примерах из документации зачастую сессия создается по месту запроса, рекомендуется инициализировать её один раз и в дальнейшем переиспользовать
* psycopg2.pool - модуль с несколькими вариантами пулов соединений с СУБД Postgresql.
* Engine из SQLAlchemy также использует пул соединений. При этом возможна настройка таких параметров как время жизни соединения, дополнительные проверки его доступности, размер пула. В том числе возможно и использование NullPool, который по факту не является пулом, но совместим с ним по интерфейсу.
Дополнительные материалы:
* https://habr.com/ru/company/otus/blog/443312/
* https://habr.com/ru/post/443378/
* https://docs.sqlalchemy.org/en/14/core/pooling.html
* https://docs.aiohttp.org/en/stable/client_reference.html
* https://docs.python-requests.org/en/latest/user/advanced/#session-objects
Запуск программ в фоне на Linux
Периодически при разработке приложений возникает необходимость запускать какое-то приложение или скрипт так, чтобы оно в дальнейшем работало без нашего участия и даже при закрытой консоли. Грубо эти сценарии можно разделить на две группы:
1. Разовый запуск команд, которые долго работают;
2. Запуск сервиса, который должен постоянно или периодически работать.
Если в первом случае речь идет о запуске команды из консоли, то, возможно, мы захотим увидеть её вывод или продолжить работать в этой консоли через какое-то время. Если мы работаем в консоли нашего компьютера, ничего дополнительно делать не требуется. Однако если мы подключаемся к серверу по SSH, то при разрыве соединения приложение через какое-то время будет закрыто. Для таких случаев актуально использовать такие программы как screen или tmux, которые позволяют запустить сессию консоли, не привязанную к конкретному терминалу. Кроме того они умеют эмулировать несколько консолей в рамках одной. При этом вы сначала запускаете screen, в котором уже вводите нужные команды. Если потом вы отключитесь от терминала, вы сможете вывести список открытых сессий screen и подключиться к ним для продолжения работы.
Альтернативным вариантом для запуска долгой команды, без необходимости продолжить взаимодействие со скриптом может стать systemd-run.
В случае запуска постоянно работающего сервиса частыми требованиями будут:
* автоматический старт после перезагрузке сервера;
* перезапуск в случае аварийного завершения;
* просмотр статуса;
* ручная остановка и перезапуск;
* сбор логов;
* ограничение прав, настройка последовательности запуска и т.п.
В современных серверных дистрибутивах Linux для этого используется systemd. Это предустановленное приложение, которое занимается обслуживанием всех системных фоновых сервисов и связанных с этим задач. Так же вместе с ним идет journald, который с этих сервисов собирает логи. Добавление своего сервиса сводится к созданию service-файла и использованию команд типа systemctl или journalctl.
Альтернативные системы инициализации или менеджеры сервисов используются достаточно редко и, как правило, нужны только в очень специфических условиях.
В случае запуска периодических задач раньше использовался cron, однако сейчас его задачи также выполняется systemd (systemd-timers). В отличие от предшественника он имеет более богатые возможности настройки и возможность сбора логов. Настройка делается практически так же, как для запуска постоянно работающих сервисов. Более того, в современных системах cron на самом деле эмулируется тем же systemd.
Отдельно стоит также отметить возможности контейнеризации приложений. В частности, docker или podman. Они позволяют изолировать окружение, в котором работает приложение (отделить файловую систему, доступ к процесса, сеть и т.д.). Использование контейнеров также упрощает процесс дистрибуции приложения. Так же для создания масштабируемых систем с помощью Kubernetes используются технологии контейнеризации.
В целом, использование контейнеров будет хорошей идеей для запуска сервиса, который не связан с управлением компьютером, это упрощает его инсталляцию и делает окружение более стабильным.
Дополнительные материалы:
* https://habr.com/ru/post/503816/
* https://github.com/tmux/tmux/wiki/Getting-Started
* https://systemd.io/
* https://github.com/tmux/
* https://www.gnu.org/software/screen/manual/
* https://docs.docker.com/
Доп материалы:
* https://ru.wikipedia.org/wiki/Внедрение_зависимости
* https://martinfowler.com/articles/injection.html
* https://fastapi.tiangolo.com/tutorial/dependencies/
* https://github.com/adriangb/di
Оглавление
* Недостатки глобальных переменных
* Ошибки при обработке настроек
* Вложенные классы и функции
* Объекты и словари
* Приватные и публичные атрибуты
* Форматирование и инъекции
* FastAPI и Dependency Injection
* SQL, соединения и слои абстракции
* Запуск программ в фоне на Linux
* Потокобезопасность и конкурентный доступ
* Пулы объектов и соединений
* БД и Миграции
* Обработка исключений
* Работа import и структура проекта
* Механика импорта и побочные эффекты
* Переменные окружения и dotenv
* Pull, poll, pool и spool
* Ссылки и is
* Управление памятью в Python
* Конструктор и __init__
* Моки, стабы и патчи
* Виды многозадачности
* Способы параллелизации задач
* Микрооптимизации кода и AST
* Цели написания частей кода
* Логирование
* Текущий каталог и пути
* Сетевые протоколы
* Веб приложение и масштабирование
* Терминал, консоль и командная оболочка
* Стратегии загрузки связанных данных из РСУБД
* Концепции, связанные с декораторами в Python
* Декоратор как паттерн и как сахар в Python
* Компоненты web-приложения
* Тонкости использования alembic
* Запуск программ и команд
* Создание ботов с интерактивным меню
* Generic-репозиторий - просто ленивый антипаттерн
* Базы данных и компоненты
* Двухфазная инициализация
* Первичные ключи в БД
!!! 🎓Онлайн курс по Python 🎓!!!
Имея многолетний опыт помощи новичкам, мы решили открыть свою школу по Python.
🫣Мы знаем как трудно выбрать наставника и курсы, которые действительно вам помогут вкатиться в столько сложную сферу и поэтому подготовили свой, авторский курс.
📝 Материалы курса были много раз проверены и перепроверены на живой аудитории, дополнены кодом из реальных проектов.
🧑💻Состав преподавателей пока держится в тайне, но это те люди, которых вы регулярно видите в чате и кто уже неоднократно себя зарекомендовал как высококлассный специалист, способный донести свои знания до любого заинтересованного, независимо от его бэкграунда и стартовых навыков.
🐍 Мы представляем Школу ru_python!
Новый курс стартует на днях, спешите записаться!
Программа и условия участия:
🔗 https://rupython.okolo.dev/🤡 Всех с праздником!
Терминал, консоль и командная оболочка
Для взаимодействия человека с компьютером кроме графического интерфейса одним из часто используемых является текстовый. В этом случае пользователь выводит текст с помощью клавиатуры и видит также символьную информацию где-то на экране.
Текстовый интерфейс может содержать меню и окна, реализованные с помощью "псевдографики", в этом случае сценарии использования похожи на работу с GUI. Альтернативой является интерфейс командной строки (command-line interface, CLI), когда пользователь вводит команды, а потом наблюдает ответ, а весь вывод представлен в виде постоянно прокручивающегося текста.
На уровне ОС это реализовано в виде многоуровневой системы. На примере Linux "обычная командная строка" это на самом деле несколько вещей:
1. Объект ядра, представляющий устройство терминала. Реальное (/dev/ttyX) или виртуальное (псевдо-терминал, /dev/pts/X). Ядро передает байты между работающей прикладной программой и тем местом, где реально идёт взаимодействие с юзером.
2. Терминал.
* Экран и клавиатура в случае физического терминала, подключенные в соответствующие порты компа.
* Программа-эмулятор виртуального терминала. Это не часть ядра, а именно отдельное приложение, работающее в пространстве пользователя. Она может выводить текст в окне внутри графического интерфейса, другого терминала, перенаправлять ввод вывод в сеть, эмулировать пользователя или делать всё, на что хватит фантазии автора. Примеры: xterm, konsole, gnome terminal, tmux, ssh.
3. Прикладная программа, организующая интерфейс пользователя. Как правило, первое, что мы видим - это командная оболочка. Это программа, реализующая сценарии CLI, которая умеет понимать команды пользователя (те самые cd, ls, pwd). По факту, это интерпретатор, который взаимодействует с терминалом и умеет запускать другие программы. Примеры: bash, sh, но ещё есть csh, ash или можно вообще тут использовать произвольную "консольную программу".
Чтобы прикладная программа могла взаимодействовать с устройством терминала (реальным или виртуальным), ей передаются стандартные потоки ввода-вывода:
* стандартный поток ввода (stdin, номер 0) - входные данные
* стандартный поток вывода (stdout, номер 1) - предназначен для основного вывода программы
* стандартный поток ошибок (stderr, номер 2) - предназначен для вывода отладочной информации и ошибок
Таким образом,
1. пользователь взаимодействует с физическим устройством или программой-эмулятором терминала.
2. они передают информацию ядру ОС,
3. которое дальше через стандартные потоки ввода-вывода организует взаимодействие с прикладной программой.
Во многих случаях, запуская консольную программу мы можем в качестве стандартных потоков ввода-вывода использовать файлы, пайпы или потоки других программ.
Например, если мы запускаем программу из bash, так мы можем перенаправить stdout программы в файл
echo "hello" > file.logА так перенаправить вывод одной программы (
ls) на ввод другой (grep). (Параметр -l здесь не имеет отношения к перенаправлению, он задан для придания смысла действиям)ls -l | grep .txtБолее детально вы можете посмотреть в документации к вашей командной оболочке.
Сетевые протоколы
Выбирая сетевой протокол для использования в приложении, мы должны ориентироваться на множество факторов: безопасность, доступность на используемой платформе и сетевом окружении, возможность масштабирования и логику работы протокола с данными.
Рассмотрим несколько вариантов:
TCP (Transmission Control Protocol)
Рассчитан на передачу непрерывного потока байтов. Он требует установки соединения между двумя сторонами и гарантирует, что данные не будут теряться и перемешиваться, пока оно живо. То есть:
* вы не передаете никакие прикладные пакеты, вы передаете отдельные байты один за другим
* ещё раз: принимающая сторона получает поток байтов, она не знает по сколько байт за раз было отправлено
* прежде чем начать прием/передачу данных, вы должны корректно установить соединение
* данные могут теряться только в момент разрыва соединения
* предусмотрена процедура корректного закрытия соединения, гарантирующая, что все данные доставлены
* входящий и исходящий поток байтов логически не связаны друг с другом, кроме наличия соединения
* для обеспечения целостности потока данных внутри предусмотрена буферизация, повторы доставки и подтверждения, что может влиять на производительность
* технически разделяет клиент (устанавливающий соединение) и сервер (принимающий их)
* используются номера портов для разделения сервисов, работающих на одном IP-адресе
* работает поверх протокола IP
* как правило, реализован в ядре ОС
UDP (User Datagram Protocol)
Рассчитан на передачу отдельных независимых пакетов (датаграмм). Так как между ними нет никакой связи, то все проблемы доставки каждый пакет затрагивают независимо.
* каждая датаграмма посылается самостоятельно на адрес получателя
* порядок доставки датаграмм не отслеживается (они независимые)
* гарантий доставки датаграмм не предусмотрено
* получение пакета не гарантирует, что вы сможете ответить отправителю
* начала и конца передачи набора пакетов не предусмотрено
* используются номера портов для разделения сервисов, работающих на одном IP-адресе
* работает поверх протокола IP
* как правило реализован в ядре ОС
HTTP (Hyper-Text Transfer Protocol)
Рассчитан на сценарий Запрос-Ответ (до HTTP2).
* имеет структурированный пакет с данными различной семантики
* логически разделяет клиент (посылающий запросы) и сервер (отвечающий на них)
* каждый запрос считается независимым
* для передачи состояния между запросами использует механизм Cookie - специальные данные, которые посылаются в каждом запросе и ответе.
* использует URL для разделения ресурсов, обслуживаемых одним веб-сервером
* работает поверх протокола TCP или TLS (до HTTP3)
* может использовать несколько соединений TCP для отправки нескольких запросов или посылать их через одно. Начиная с HTTP1.1 это работает для запросов, отправляющихся по очереди, а с HTTP2 поддерживается мультиплексирование.
* как правило реализован в прикладных библиотеках
Зачастую TCP бывает ошибочно выбран без учета того фактора, что он рассчитан именно на потоковую передачу данных. В многих случаях мы работаем с чем-то более гранулярным. Использование напрямую TCP может привести к изобретению собственного протокола, что приводит к ошибкам на всех уровнях: от проектирования до реализации и ограничениям в возможностях его использования.
Если у вас стоит выбор между использованием высокоуровневого протокола типа HTTP/WebSocket/ZeroMQ/etc или написания своего поверх TCP, всегда выбирайте первое.
Дополнительные материалы:
* https://habr.com/ru/company/badoo/blog/329722/
* https://habr.com/ru/company/southbridge/blog/575464/
* https://habr.com/ru/company/webo/blog/326258/
* https://ru.wikipedia.org/wiki/Мультивещание
Логирование
Подсистема логирования - то, что проходит через все слои приложения, включая сторонние модули.
Она не является частью основной логики приложения и используется только для сбора диагностической информации для последующего анализа.
* Доступ к записи логов должен быть у любой части приложения, поэтому логично иметь стандартный механизм для этого. В питоне это модуль logging. Если вы собираетесь использовать в приложении сторонний модуль логирования, он должен интегрироваться с logging, так как внешние библиотеки скорее всего не будут знать о нем. Если же вы пишете библиотеку, скорее всего не стоит использовать сторонние модули логирования.
* Так как запись логов может производиться из различных частей приложения, должна быть возможность включать или выключать их логи группами. В logging это реализуется через ирерахию логгеров, путем указания имени с точками в середине. Если у вас нет заведомо хорошей идеи о структуре ваших логгеров, проще всего использовать имя текущего модуля. То есть logger = logging.getLogger(__name__).
* Разные приложения могут использовать разную конфигурацию системы логирования. Её настройка должна производиться в инфраструктурном слое: там, где мы настраиваем и запускаем остальные части приложения (например, в начале функции main). Если вы пишете библиотеку - ни в коем случае не занимайтесь в ней настройкой логов. Она должна только получить логгер и использовать его.
* При использовании logging разделяются понятия logger и handler. Логгер используется как интерфейс для записи в лог, а хэндлер - для детальной настройки поведения. Таким образом, вы можете указывать разные способы отправки логов (в файл, в потоки вывода, в in-memory очередь или что вы придумаете), не меняя записывающего их кода. Стандартные хэндлеры так же имеют formatter - объект, отвечающий за текстовое представление логов. Также стоит обратить внимание на filter, если управления логами по уровням недостаточно.
* GUI приложения обычно запускаются без терминала пользователем, который не хочет видеть служебную информацию, пока это не потребуется явно. Поэтому для них актуально писать логи в файле или системный журнал (при наличии).
* Консольные скрипты дополнительно имеют возможность писать стандартный вывод для ошибок (stderr).
* Автономные серверные приложения запускаются системой и работают достаточно долго. Иногда они запускаются в нескольких копиях (например для лучшего использования аппаратных ресурсов). Запись в файл из таких сервисов приводит к сложностям в его управлении, совместном чтении с другими логами и попросту не работает корректно при наличии ротации и нескольких процессов. Для них актуально использование централизованной системы логирования с автоматическим удалением старых логов, а так же сбор вывода о необработанных ошибках. Таким образом стандартным способом будет записывать логи в stdout/stderr и позволить внешней системе их собирать. При запуске через systemd это будет journald, при запуске через docker - аналогичную роль выполняет он сам, в том числе он может отправлять их во внешний fluentd сервис, так же это может быть отправка в ELK стек и т.п.
Дополнительные материалы:
* https://docs.python.org/3/howto/logging-cookbook.html
* https://www.rapid7.com/blog/post/2016/07/12/keep-your-code-clean-while-logging/
* https://12factor.net/logs
С каждой новой версией Python добавляются оптимизации, которых было достаточно мало.
В этом видео я рассказывал о том как это может работать, какие оптимизации были доступны год назад и как вообще происходит интерпретация кода
Во второй половине видео - эксперимент по написанию своего оптимизатора.
https://youtu.be/Z1Br93A-Mp4
Виды многозадачности
Многозадачность - способность исполняющей среды (ОС, виртуальной машины, интерпретатора) выполнять в течение одного промежутка времени несколько кодовых последовательностей (задач), не дожидаясь окончания других задач. Иными словами, задачи выполняются конкурентно. В качестве примеров реализации задач можно назвать thread, asyncio task, goroutine.
Как правило, речь идет о количестве задач большем, чем доступно ресурсов для параллельного выполнения, поэтому среде приходится переключаться между задачами. При этом можно выделить два типа многозадачности:
* вытесняющая. Переключение между задачами происходит по инициативе исполняющей среды безотносительно логики самой задачи. Так работают современные операционные системы при переключении между потоками/процессами.
* кооперативная. Сами задачи говорят среде, в какой момент их можно прервать, среда же может выбирать какой задаче дальше предоставить время на выполнение. Такая модель была реализована в MS DOS и так работает asyncio.
Как правило, наша ОС уже поддерживает многозадачность, но это может быть неэффективно при работе большого количества прикладных задач из-за каких-то накладных расходов или отсутствия контроля над логикой выбора задач. Из-за этого бывает актуально реализовать поддержку многозадачности так же и на уровне нашего приложения/интерпретатора/виртуальной машины. Это так же называют N:M многопоточностью, подразумевая N потоков в пространстве пользователя использующих M потоков ядра ОС (системных). Можно разделить это на 3 группы:
* 1:1: один прикладной поток соответствует одному потоку ОС. Все переключения задач осуществляют операционной системой. Так работает multithreading/multiprocessing в python. Недостаток такого подхода: отсутствия контроля за логикой переключения задач, необходимость обеспечения синхронизации для обеспечения корректности выполнения. Зато в этом случае все задачи будут выполняться даже если одна зависнет. Так же в этом подходе можно в отдельный поток вынести любой код без особых модификаций, в том числе реализованный на другом языке.
* N:1: все прикладные потоки выполняются в одном потоке ОС. Все переключения задач осуществляются самим процессом программы. Так работает asyncio или async код в javascript. Один из недостатков такого подхода - невозможность утилизировать больше одного CPU (для этого потребуется запуск дополнительных системных тредов, что выходит за рамки модели N:1). Так же, для этой модели код должен быть соответствующим образом написан или быть завязан на конкретный рантайм, чтобы тот мог заниматься переключением задач (вызывать await или использовать специальную версию системной библиотеки).
. Достоинства же - наличие контроля за переключением задач, упрощение подходов к синхронизации, возможность запуска очень большого количества задач.
* N:M (гибридная модель): прикладные потоки выполняются в некотором количестве системных потоков. Такой подход используется в golang (вытесняющая с оговорками), rust (кооперативная) и опционально доступен в kotlin. Таким образом, мы можем утилизировать все CPU, сохраняя некоторый контроль над переключением задач. Недостатки же: сложности встраивания произвольного нативного кода, необходимость использования тех же подходов к синхронизации, что и в модели 1:1.
Дополнительные материалы:
* https://kotlinlang.org/docs/multiplatform-mobile-concurrency-and-coroutines.html#multithreaded-coroutines
* https://pkg.go.dev/runtime#GOMAXPROCS
* https://docs.python.org/3/library/asyncio-task.html
* https://habr.com/ru/company/embox/blog/219431/
Конструктор и __init__
В некоторых языках класс содержит конструктор - специальный метод, вызывающийся автоматически только при создании экземпляра. В Python похожую роль выполняет метод __init__, но есть несколько особенностей:
* Можно изменить процесс конструирования объекта так, что __init__ не будет вызываться.
* Метод __init__ хоть и является "магическим", является таким же как и любой другой и доступен для прямого обращения (хотя это и не рекомендуется).
* Конструирование объекта состоит из нескольких этапов, которые включают в себя вызов метода __call__ метакласса, который в свою очередь обычно вызывает __new__ и __init__.
Термин конструктор в Python обычно применяется только к вызову класса, создающему объект. Classname() - вызов конструктора. Иногда в классе так же создают классметоды, скрывающие дополнительную работу при создании объекта и вызывающие самостоятельно обычный конструктор, их могут называть "альтернативными конструкторами".
Несмотря на гибкость процесса конструирования, как правило, не стоит его менять кардинально. Дополнительно можно дать следующие советы:
* После завершения работы __init__ объект должен быть готов к использованию. В частности, должны быть созданы все возможные атрибуты.
* Не смешивайте логику, создания рабочего экземпляра и логику, связанную с получением или конвертацией необходимых для этого объектов в конкретном сценарии использования.
* Так как метод __init__ не async, он не должен напрямую обращаться к loop и вызывать корутины. Если есть такая необходимость, стоит сделать "альтернативный конструктор" или фабрику, а инит параметризовать уже результатом их вызова.
* В целом стоит избегать операций I/O (ввода/вывода) в __init__. И уж точно не стоит открывать в нем соединения или файлы.
* Иногда при наследовании актуально переопределять часть логики, выполняющейся при конструировании объекта. Вынесите её в отдельный метод, вызываемый из __init__.
* Не забывайте про Dependency Injection. Зачастую хорошей идеей может быть не создавать объекты в __init__, а принимать их извне.
* Не кладите в __init__ бизнес логику. Его задача именно в инициализации объекта.
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Фабричный_метод_(шаблон_проектирования)
* https://docs.python.org/3/reference/datamodel.html#metaclasses
* https://docs.python.org/3/library/functions.html#classmethod
Ссылки и `is`
В Python переменная - это имя, ссылающееся на какой-то объект в памяти. Каждый раз, когда вы присваиваете переменную, вы заставляете указывать её на другой объект, при этом предыдущее значение переменной продолжает жить своей жизнью. Когда же вы обращаетесь к объекту и используете специфические для него операции - они уже могут менять сам объект. Например, append меняет список, добавляя туда новую ссылку. А вот оператор +, как правило, не меняет исходный объект, а возвращает новый.
Стоит отметить, что некоторые операции могут для разных типов объектов быть как мутирующими, так и нет. В частности, оператор += является комбинацией оператора присвоения = и вызова метода __iadd__, который ведет себя по-разному для изменяемых и неизменяемых типов данных (например списков и строк). Таким образом, вызывая += вы одновременно что-то делаете с объектом и присваиваете новую ссылку.
Кроме оператора присвоения ссылки могут появляться при передаче значений в функцию, создании функции, при добавлении в коллекцию, задании атрибута, импорте и т.п. Механика работы ссылок одинаковая и не зависит от типа объекта - изменяемые и неизменяемые передаются одинаково. Отличается только логика работы самих классов.
В отличие от других языков, имеющих механику передачи по указателю или по значению, питон всегда передает объект "по указателю". Копирование объекта делается только явно, для этого если отдельный модуль copy и соответствующие дандер методы в классе __copy__ и __deepcopy__.
Для работы со ссылками в питоне есть ещё один оператор - is. Он нужен для того, чтобы удостовериться, что два его аргумента ссылаются на один и тот же объект в памяти. Его поведение невозможно переопределить в отличие от __eq__ и это помогает нам при проверках на None или значений Enum типа, которые имеют конкретные экземпляры, существующие в рамках процесса в единственном числе (так же в рамках процесса могут существовать только по одному экземпляру True и False, но проверять их при помощи оператора is не рекомендуется согласно PEP8). В отличие от Enum, многие типы не имеют фиксированного набора значений, не могут быть заранее созданы в памяти и поэтому одни и те же значения могут создаваться в памяти много раз. Для изменяемых типов это определяется логикой их работы, для неизменяемых же возможны оптимизации.
Например, если мы создали в памяти два пустых списка [] - обязаны быть разные объекты, так как предполагается, что они будут наполняться независимо. Однако, если мы создаем в памяти два числа 1, они будут постоянны и в некоторых случаях Python может создать только один объект и дать на него две ссылки. Это поведение не гарантировано, зависит от используемых чисел, способа запуска кода, интерпретатора и часто служит предметом споров, манипуляций на собеседованиях и причиной ошибок.
Оператор is актуально использовать только если ваша логика действительно требует отличать один и тот же объект и равные, либо в таких особенных случаях как проверка на None, Enum.
Дополнительные материалы:
https://docs.python.org/3/reference/datamodel.html
https://en.cppreference.com/w/cpp/memory/shared_ptr
https://docs.python.org/3/library/enum.html#comparisons
Переменные окружения и dotenv
Когда мы пишем какие-то сервисы (веб-приложения, боты, обработчики задач), им бывает необходимо передать какие-то настройки. Частыми вариантами будут: реквизиты для доступа к базе данных, токен для сторонних API и т.д.
В общем случае у нас есть два стандартных варианта передачи настроек:
* файлы конфигурации
* переменные окружения
Файлы конфигурации удобны, когда настроек много и они имеют сложную структуру. Но при запуске сервиса в некоторых окружениях, таких как AWS Lambda, Kubernetes и Heroku доставка таких файлов с настройками до работающего экземпляра приложения может быть нетривиальна.
В противовес этому, во многих случаях такие сервисы позволяют через свои способы настройки указать переменные окружения, с которыми будет запущен процесс. Да, мы не сможем передать в этом случае сложные иерархические структуры, но зачастую это не нужно.
Переменные окружения задаются как правило при старте процесса вызывающей его стороной или наследуются от родительского процесса. Так же можем менять их для своего процесса в процессе его работы, но скорее всего это будет неожиданно, так как многие настройки считываются при старте.
Чтобы передать переменные окружения нашему приложению, мы можем:
* При использовании bash/sh/zsh сделать export этих переменных или указать их перед командой которую мы выполняем
* Так же для bash удобно подготовить один или несколько файлов с инструкциями export и применять их с помощью команды source
* При запуске через Pycharm переменные окружения можно задать в настройках конфигурации запуска. Иногда удобно иметь несколько таких конфигураций, чтобы отлаживать софт с разными настройками. Другие IDE имеют аналогичные возможности.
* Так же вы всегда можете задать переменные окружения глобально средствами вашей ОС. Но тогда для смены их возможно придется перезайти в учетную запись.
* При запуске сервиса через systemd вы можете указать переменные окружения прямо в service файле или указать из какого файла их необходимо прочитать
* При запуске через docker так же они указываются в команде запуска контейнера docker run напрямую или через --env-file. В случае`docker-compose` эти возможности сохраняются
Стоит отметить относительную популярность библиотек типа python-dotenv. Они позволяют во время работы приложения прочитать конфигурационный файл своего формата и поменять переменные окружения текущего процесса согласно этому файлу. Опасность этого подхода в том, что в момент загрузки этого файла приложение уже работает и при наличии других архитектурных проблем эти значения могут конфликтовать с уже инициализированными объектами, что потребует дополнительных усилий для поддержания работоспособности кода. Некоторые реализации библиотек для чтения .env-файлов могут искать файлы не только в текущем каталоге, но и вверх в каждом родительском каталоге, что может приводить к непредсказуемому поведению кода.
Хочу также обратить внимание, что хотя формат конфига python-dotenv похож на используемый docker, systemd или bash файл, эти все форматы не совместимы. Где-то вы можете ставить пробелы около знака =, где-то допустимо или требуется писать export, где-то невозможно задать многострочные значения и т.д.
Дополнительные материалы:
* https://12factor.net/
* https://www.freedesktop.org/software/systemd/man/systemd.exec.html
* https://docs.docker.com/compose/env-file/
* https://ru.wikipedia.org/wiki/Переменная_среды
Про импорты и структуру проекта
Когда вы импортируете какой-то модуль в вашем коде, питон не учитывает, в каком файле этот импорт находится, влияет только то, как был запущен код.
Если модуль не был раньше загружен, питон пытается его найти по очереди в нескольких папках, которые можно посмотреть в переменной sys.pathПо умолчанию она содержит примерно такие каталоги (в некоторых ситуациях, например, при использовании embedded версии python, состав может отличаться):
* каталог, добавляемый при запуске
* каталоги указанные в переменной окружения PYTHONPATH* каталог текущего активированного виртуального окружения
* каталог установки python
1. Если вы запускаете ваш скрипт командой python scriptname.py, то первым в списке будет тот каталог, где находится запускаемый скрипт. Текущий каталог не имеет значения.
2. Если вы запускаете ваш код командой python -m packagename, то первым в списке будет текущий каталог. При запуске питон попытается найти и импортировать packagename по общим правилам.
3. Если вы запускаете код с помощью других инструментов вроде pytest, они тоже могут сами добавлять что-то в sys.path.
Скорее всего, вам не стоит самостоятельно менять sys.path, так как алгоритм его заполнения стандартный и привычен для всех. Если по каким-то причинам вас он не устраивает, возможно у вас неверная структура проекта.
Так как поиск пакетов для импорта происходит сначала в каталоге "проекта", стоит быть аккуратным именованием ваших файлов и каталогов. Если вы случайно назовете ваш модуль так же как встроенный или сторонний, при любом импорте такого модуля будет грузиться именно ваш, что сломает работу кода.
Иногда используемые нами фреймворки поддерживают только определенную, не всегда оптимальную, структуру проекта. В остальных случаях я могу предложить два подхода:
1. Вынести запускаемые скрипты на верхний уровень, а остальной код упаковать в пакет.
Упаковка кода в пакет с уникальным именем позволяет исключить конфликты имен. А вынесение всех запускаемых файлов на один уровень делает состав sys.path предсказуемым.
Выглядеть это будет примерно так:
├── appname2. Создать распространяемый пакет (рекомендую).
│ ├── __init__.py
│ ├── other_module.py
│ └── some_module.py
├── cli_module.py
└── requirements.txt
python -m appname.cli_module или заполнить секцию entry_points в файле с описанием проекта (setup.cfg, pyproject.toml), после чего иметь свои кастомные консольные команды. В обоих случаях вы сможете запускать код, находясь в любом каталоге, без необходимости указывать полные пути к файлам.pip install -e . ├── pyproject.tomlДополнительные материалы:
└── src
└── appname
├── __init__.py
├── cli_module.py
├── other_module.py
└── some_module.py
БД и миграции
При работе с базами данных нам необходимо, чтобы структура данных в БД соответствовала коду:
Во-первых, мы должны изначально создать необходимые таблицы или коллекции с правильными индексами и т.п.
Во-вторых, при изменении кода приложения мы должны актуализировать эту структуру, создать новые таблицы, переложить данные по другому и т.п. Даже если сама СУБД не подразумевает фиксированной схемы лежащих в ней данных, вы можете захотеть уменьшить разнообразие вариантов хранения.
Кроме этого у нас есть дополнительные ограничения:
* Код не должен иметь права отключать проверки в СУБД, менять и создавать индексы и настраивать связи;
* Запуск нескольких копий кода одновременно (актуально для веб-приложений) не должен приводить БД в неработающее состояние;
* Приложение может быть развернуто на нескольких окружениях, которые обновляются независимо;
* Иногда должна быть возможность вернуть базу данных в предыдущее состояние из-за ошибок;
* Иногда мы хотим, чтобы несколько версий кода работали одновременно. Например, при green-blue/canary deployment.
Таким образом я бы выделил следующие подходы:
1. Состав и структура таблиц должны определяться на момент проектирования/реализации версии кода. Таблицы не должны генерироваться динамически во время работы приложения;
2. Для приведения структуры БД в нужное состояние пишутся скрипты миграции;
3. Скрипты миграции вызываются администратором при деплое приложения. Приложение не должно самостоятельно вызывать скрипты миграции при старте или в другой момент во время работы;
4. Каждый скрипт миграции должен содержать все необходимые данные для его работы. Скрипт миграции не должен обращаться к основному коду приложения, так как код будет меняться, а миграция должна оставаться работоспособной;
5. Скрипт миграции не должен редактироваться после выпуска очередной версии приложения. Если вы забыли мигрировать часть данных, придется делать ещё одну миграцию;
6. Миграции необходимо проверять/тестировать. Тестовые базы данных должны обновляться только с помощью миграций;
7. Инструменты для автоматической генерации миграций могут помочь в работе, но вы должны проверять и редактировать сгенерированный код.
Если есть требование обновления без простоя, то миграции должны сохранять структуру БД совместимой для нескольких версий приложения. Иногда это потребует разбивать миграцию на несколько частей. Например, если вам необходимо переименовать колонку БД, в одной миграции вы добавите новую колонку, а старую сделаете вычислимой. Затем, только после полной выкатки новой версии кода в прод, можно будет применить вторую миграцию, удаляющую старую колонку.
Если же вы делаете эти вещи, вероятно вы используете БД неправильно:
1. Создаете таблицы во время работы программы;
2. Вызываете meta.create_all() (или аналог для вашей ORM) для создания структур БД для ваших моделей;
3. Вызываете миграции автоматически при старте приложения;
4. Импортируете в миграциях модели или другой код из основной части проекта;
5. Меняете код миграций после того как они могли быть использованы;
6. Не запускаете миграции нигде кроме прода;
7. Не читаете код автоматически сгенерированных миграций.
Дополнительно хочу отметить, что миграции - это не обязательно простые изменения структуры, такие как добавление или удаление колонки/таблицы. Иногда вам потребуется произвести какую-то длительную работу по модификации данных (например, посчитать значение колонки для БД из миллиарда записей).
И хотя обычно эти советы дают для реляционных СУБД, так как те требуют соблюдения структуры таблиц, они также применимы и для документо-ориентированных баз данных. Вы можете обойтись без миграции для добавления nullable поля в MongoDB, но скорее всего вам потребуется её делать в том или ином виде, если вы захотите разбить колонку на две или вместо одного числа начать хранить список.
Дополнительные материалы:
* https://habr.com/ru/company/yandex/blog/511892/
* https://habr.com/ru/company/flant/blog/471620/
* https://alembic.sqlalchemy.org/en/latest/
Потокобезопасность и конкурентный доступ
Большинство приложений, которые мы пишем, используют конкурентность.
Это могут быть многопоточные веб-приложения, телеграм-боты с asyncio, GUI приложения с фоновой обработкой и т.п.
При разработке таких приложений стоит задумываться, к каким объектам вы имеете доступ только из одного логического потока, а какие из них используются конкурентно. Если вы работаете с одним объектом из нескольких потоков/asyncio тасков возможна ситуация, называющаяся "состоянием гонки" (race condition). Это состояние, когда результат работы кода зависит от того в какой последовательности выполняются действия внутри конкурентных операций.
Приведу пример:
counter = 0Если вы запустите такой код последовательно несколько раз, значение counter будет равно 1. Однако, если вы запустите его в несколько потоков, то возможны произвольные значения. Замена
def do():
global counter
if counter < 1:
sleep(0.01)
counter += 1
threading на asyncio проблему в данном случае не решит.await), что несколько уменьшает вероятность появления гонок, но не исключает их полностью.segmentation fault. Есть разные методы борьбы с таким состоянием: можно использовать блокировки, _compare-and-swap_ алгоритмы, но самое надежное - отказаться от использования общих данных совсем.requests.Session - потокобезопасный объект. Вы можете посылать запросы из нескольких потоков используя одну сессию. Однако тут возможны логические ошибки, если сервер пришлет разные куки в ответ на конкурентные запросы.asyncio.Queue - не потокобезопасен, однако безопасен для использования в конкурентных тасках asyncio.sqlite3.Connection - не потокобезопасен. По умолчанию, sqlite дополнительно выдает ошибку, если вы попытаетесь использовать соединение не из того потока, где вы его создали. Отключение этой проверки не сделает соединение безопасным для использования из нескольких потоков, это просто дополнительная защита. commit), а второй - откатить (rollback) результат будет неизвестен.Session из SQLAlchemy - не потокобезопасна по тем же причинам, как и соединения с СУБД. А вот Engine, который используется для создания сессий, уже потокобезопасен. tkinter, Qt, Android SDK и других GUI фреймворков также не рассчитаны на использование из нескольких потоков. В этом случае у вас, как правило, есть один поток для работы с GUI и только из него вы можете обновлять элементы интерфейса. Также эти фреймворки предоставляют инструменты для передачи в этот поток информации о необходимости обновить интерфейс (например, механизм signal-slot).
SQL, соединения и слои абстракции
При написании приложения, работающего с БД без использования ORM возникают вопросы о его структурировании и жизненном цикле объектов БД.
Если не рассматривать другие сущности, то условно такое приложение можно поделить на следующие слои (да простит меня Дядюшка Боб):
* Адаптеры для базы данных
* Бизнес-логика
* Контроллеры и представления
* Интеграционный слой
Рассмотрим чуть подробнее:
Адаптеры для базы данных
Это классы или функции, скрывающие в себе детали построения запросов в базу данных. Именно тут пишется SQL код, происходит разбор данных, полученных из курсора в понятные остальному коду классы.
Зачастую удобно сделать класс, который будет содержать текущее соединение с БД в своем поле и методы, делающие внутри один или несколько SQL-запросов, имеющих смысл с точки зрения основной логики программы. Этот код НЕ должен сам создавать соединение, воспользуйтесь Dependency Injection. Он так же не должен управлять транзакциями. Благодаря этому можно в дальнейшем комбинировать вызовы его методов. Также стоит избегать использования в интерфейсе этого класса слишком абстрактных методов, чтобы не переносить детали работы с БД в слой бизнес-логики.
Бизнес-логика
Этот слой содержит код обработки конкретных сценариев использования программы (use cases), то есть основную логику программы. Она абстрагирована от деталей работы базы данных или представления данных для пользователя. Для работы с базой данных она обращается к соответствующим адаптерам. Именно бизнес-логика знает о том, какие операции с базой данных являются неделимыми и управляет транзакциями.
Бизнес-логика не знает о том, откуда взялся адаптер БД, а просто использует его.
Контроллеры и представления
По сути это адаптеры, связывающие ваше приложение с его пользователями. Это могут быть view-функции веб-фреймворка, хэндлеры в телеграм боте, задачи celery, cron джобы и т.п.
Они реагируют на возникающие в вашем фреймворке события и вызывают бизнес-логику для его обработки. Контроллеры обрабатывают входящие данные, трансформируют их в понятные бизнес-логике структуры, а представления делают обратные преобразования.
Так как обрабатываемые события имеют понятный жизненный цикл, а также потому что мы технически не можем конкурентно использовать одно соединение БД, именно тут мы можем создавать наши соединения. Однако, так как настройка соединения выходит за рамки задачи контроллера, он должен создавать его не сам, а с помощью какой-то фабрики, получаемой из интеграционного слоя.
Если же фреймворк предоставляет механизм middleware, то такой объект также имеет представление о жизненном цикле события и может создавать соединение и передавать в контроллеры нужные объекты. Зачастую именно такой подход удобнее.
Интеграционный слой
Задача этого слоя собрать наше приложение из вышеописанных частей. Здесь может быть чтение конфигурации, создание экземпляров основных классов, связь их друг с другом, внедрение зависимостей и т.п.
Сюда можно так же отнести некоторые мидлвари, которые мы используем только для создания и внедрения зависимостей, привязанных к жизненному циклу обрабатываемых событий.
Итого:
1. Адаптер БД реализует единичные действия с базой и скрывает SQL код
2. Бизнес-логика оперирует транзакциями и вызывает методы адаптеров
3. Контроллер вызывает один или несколько use case в рамках соединения которое он получил сам или из мидлвари.
В начале обработки чего-либо (веб-запроса, сообщения в телеге и т.п.) достаем соединение, в конце обработки - возвращаем туда, откуда достали.
Также стоит отметить, что соединение с БД не обязательно физически открывать каждый раз, стоит использовать пулы соединений для ускорения.
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Адаптер_(шаблон_проектирования)
* https://martinfowler.com/eaaCatalog/repository.html
* https://www.ozon.ru/product/144499396/
FastAPI и Dependency Injection.
В таком популярном фреймворке как FastAPI есть механизм для автоматизации управления зависимостями в рамках концепции DI.
Однако в документации автор местами путается, плюс есть некоторые особенности данного фреймворка, влияющие на удобство использования.
Что такое Dependency Injection?
Это достаточно простая концепция, которая говорит: если объекту что-то нужно, он не должен знать, как оно создается.
Пример:
Ваш класс User хочет уметь ходить в Shop.
Он мог бы создать магазин сам, но тогда мы не сможем всем пользователям дать один магазин. И вообще придется переносить в него всю логику строительства магазинов со всеми вариантами её применения.
Вместо этого предлагается чтобы User получал экземпляр Shop в __init__.
И уже наш код, создающий пользователей, сможет передать туда нужный магазин. При чем если у нас пользователи бывают в разных местах системы мы сможем в каждом случае иметь правильные магазины и свою логику работы с ними.
Таким образом весь механизм DI состоит из двух частей:
1. Есть класс/функция, которая от чего-то зависит
2. Есть логика, которая подставляет эту зависимость
Замечание:
DI - хорошая вещь, но не увлекайтесь слишком сильным дроблением кода. Не стоит применять эту концепцию для частей, являющихся деталью реализации самого класса.
В случае FastAPI, у нас есть механизм автоматической подстановки зависимостей в наши view-функции (это обычно называется IoC-контейнер).
Он состоит аналогично из двух частей
* С помощью Depends мы обозначаем зависимость. Если зависимость идентифицируется по аннотации типа параметра функции, то Depends используется без параметров.
* С помощью app.dependency_overrides мы определяем фабрику, возвращающую эту зависимость или генераторную функцию
В простом случае это может выглядеть вот так:
# определяем viewТипичной ошибкой, которую допускает даже автор fastapi является указать настоящую функцию создания зависимостей в
@router.get
def my_view_funcion(param: Session = Depends()):
...
# в мейне создаем приложение и настраиваем
app = FastApi()
app.dependency_overrides[Session] = some_session_factory
Depends
# так делать не стоитВ этом случае вы конечно все ещё можете переопределить зависимость в тестах, но ваш код оказывается сцеплен с кодом создания объекта
def my_view_funcion(param: Session = Depends(create_session)): ...
Session и более того, вы не можете настроить логику работы функции create_session при создании приложения. Depends в fastapi можно использовать не только для DI, но и для переиспользования части логики или даже параметров запроса и это накладывает свой отпечаток.Session) имеет конструктор с параметрами, мы увидим их в swagger-спецификации и для отключения этого нет встроенных средств.class MyProto(abc.ABC):Второй способ - так:
...
@router.get
def my_view_funcion(param: MyProto = Depends()):
...
app = FastApi()
app.dependency_overrides[MyProto] = some_session_factory
def get_session_stub():Второй способ можно несколько уницифировать, например, так:
raise NotImplementedError # это реально тело этой функции
@router.get
def my_view_funcion(param: Session = Depends(get_session_stub)):
...
app = FastApi()
app.dependency_overrides[get_session_stub] = some_session_factory
Про форматирование и инъекции.
В Python есть минимум 3 разных способа форматирования строк:
* f-строки
* .format
* %
Мы их часто используем для формирования текста, как человеко-читаемого, так и понятного компьютеру.
Если с формированием текста для человека скорее всего больших проблем нет. В худшем случае человеку придется приложить дополнительные усилия, чтобы понять, что он видит.
То с формированием текста для компьютера мы рискуем получить строку, которую он просто не сможет понять или поймет неверно. В том числе это может привести к порче или утечке данных.
Компьютер ожидает, что обрабатываемый текст будет оформлен согласно определенным правилам и вы не можете просто так вставить туда произвольную строку не нарушив при этом структуру текста.
Речь идет о формировании SQL, XML, HTML, JSON, консольной команды, URL, регулярных выражений и ещё кучи различных типов строк.
Как правило такая проблема решается одним из двух способов:
1. Ручное экранирование данных
2. Вызов специальных библиотечных методов, подставляющих данные безопасно.
Как правило лучше выбирать второй способ, так он проще и оставляет меньше шансов ошибиться.
Рассмотрим примеры.
1. SQL
Допустим вы хотите прочитать данные из БД и пишете такой запрос:cur.execute(f"SELECT * FROM users WHERE login = '{somelogin}'")Он будет работать в каких-то случаях и обязательно сломается, если переменная somelogin будет содержать, например, кавычку.
В некоторых случаях это может привести к исполнению произвольного SQL кода.
Замена f-строки на .format или форматирование через % ничего не изменит.
Правильно тут было бы сделать так (использовать тут ? или другой символ зависит от вашей СУБД):cur.execute("SELECT * FROM users WHERE login = ?", (somelogin,))2. HTTP
Если вы делаете GET запрос, вы можете написать такой код:requests.get(f"http://site.com?search={query}")Он будет работать некорректно если переменная query, например, содержит знаки &?
Правильно написать так:requests.get("http://site.com", params={"search": query})3. Shell
Следующий код сломается, если имя папки содержит пробел или точку с запятой, и может привести к исполнению произвольных команд.subprocess.run(f"ls {dirname}", shell=True)Его стоит заменить наsubprocess.run(["ls", dirname])4. HTML
В случае подстановки данных в HTML стоит воспользоваться специальными шаблонизаторами. Например, jinja.
Доп материалы:
* https://xkcd.ru/327/
* https://ru.wikipedia.org/wiki/Внедрение_SQL-кода
* https://ru.wikipedia.org/wiki/Межсайтовый_скриптинг