Управление памятью в Python
В некоторых языках необходимость выделять и освобождать память лежит на программисте, в некоторых это происходит автоматически, в некоторых доступны оба способа. В Python всё управление памятью происходит полностью автоматически: отсутствуют операторы выделения памяти и её освобождения.
Когда вы создаете какой-то объект, Python сам решает как выделить ему память. Как было упомянуто в прошлой статье, иногда вместо выделения новой ячейки памяти, Python может вернуть ссылку на уже созданный экземпляр.
Когда же объект становится не нужен, он удаляется и память освобождается. Под "ненужным" имеется ввиду тот, на который нет активных ссылок, либо есть только циклические ссылки с другими объектами. В зависимости от реализации удаление объекта может происходить сразу, как только пропадут все ссылки на него, или с задержкой (например, при использовании периодического сборщика мусора). В частности, CPython использует счетчики ссылок (именно их защищает GIL).
Есть несколько мест, которые часто понимаются неверно:
* оператор
* магический метод
* модуль
* удаление 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/
В некоторых языках необходимость выделять и освобождать память лежит на программисте, в некоторых это происходит автоматически, в некоторых доступны оба способа. В 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/
Конструктор и __init__
В некоторых языках класс содержит конструктор - специальный метод, вызывающийся автоматически только при создании экземпляра. В Python похожую роль выполняет метод
* Можно изменить процесс конструирования объекта так, что
* Метод
* Конструирование объекта состоит из нескольких этапов, которые включают в себя вызов метода
Термин конструктор в Python обычно применяется только к вызову класса, создающему объект.
Несмотря на гибкость процесса конструирования, как правило, не стоит его менять кардинально. Дополнительно можно дать следующие советы:
* После завершения работы
* Не смешивайте логику, создания рабочего экземпляра и логику, связанную с получением или конвертацией необходимых для этого объектов в конкретном сценарии использования.
* Так как метод
* В целом стоит избегать операций I/O (ввода/вывода) в
* Иногда при наследовании актуально переопределять часть логики, выполняющейся при конструировании объекта. Вынесите её в отдельный метод, вызываемый из
* Не забывайте про Dependency Injection. Зачастую хорошей идеей может быть не создавать объекты в
* Не кладите в
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Фабричный_метод_(шаблон_проектирования)
* https://docs.python.org/3/reference/datamodel.html#metaclasses
* https://docs.python.org/3/library/functions.html#classmethod
В некоторых языках класс содержит конструктор - специальный метод, вызывающийся автоматически только при создании экземпляра. В 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
Моки, стабы и патчи
При разработке и тестировании нам периодически нужно заменить настоящий сложный объект на другой, который лишь будет имитировать поведение. Вот основные две группы таких объектов:
*
*
Для того чтобы мы могли использовать такие фиктивные объекты в нашем коде обычно используется Dependency Injection. В этом случае мы знаем контракт используемого нами кода и передаем туда созданный объект, не вникая в детали реализации и оформления кода.
В реальных проектах мы не всегда так можем сделать, однако python позволяет использовать такой хак как monkey patching для того, чтобы внедрить зависимость туда, где это не предусмотрено. Под манки-патчем подразумевают обычно подмену инициализированных объектов, функций или методов без использования стандартных механик будь то DI или наследование. Это может быть замена класса в существующем модуле на свой или замена метода в уже созданном объекте или прямо в классе. И хотя этот метод действительно применяется, стоит помнить, что каждый такой случай - признание, что ваша архитектура плохо продумана. Манкипатчи в тестах полагаются на информацию о внутреннем устройстве кода вместо ожиданий определенного поведения и тесты получаются более хрупкими. И уж точно они не помогают при рефакторинге кода.
Рассмотрим простой пример monkey-patch. Представьте, что у вас есть два модуля
a.py:
b.py:
Вы решили протестировать функцию
Проблема 1:
Если функция foo используется ещё где-то, таким образом вы подмените её для всего кода. В результате код может начать вести себя неожиданно. Использование
Проблема 2:
Так как код никак не заявляет использование функции
Проблема 3.
Может быть произведено чисто декоративное изменение кода, не меняющее логики:
Так как манкипатч будет произведен уже после импорта, он не будет иметь эффекта на импортированную функцию
Иногда манкипатчинг используется и в реально работающем коде, но как правило стоит его рассматривать только как временное грязное решение проблемы, для которого надо искать замену сразу же после применения.
Дополнительные материалы:
* https://docs.python.org/3/library/unittest.mock.html
* https://martinfowler.com/articles/mocksArentStubs.html
* https://ru.wikipedia.org/wiki/Monkey_patch
При разработке и тестировании нам периодически нужно заменить настоящий сложный объект на другой, который лишь будет имитировать поведение. Вот основные две группы таких объектов:
*
Stub
(стаб), заглушка. Такие объекты максимально простые и делают минимум, необходимый чтобы их можно было применить по месту. Зачастую методы не делают ничего или возвращают фиксированные значения. Бывает полезен как в тестировании и отладке, так и в реальной логике - например для отключения определенной функциональности.*
Mock
(мок) - специальный объект, использующийся для проверки, были ли сделаны определнные вызовы. В первую очередь применяется при написании автотестов. В python в основном используется реализация из unittest.mock
(который можно использовать в качестве как Mock, так и Stub).Для того чтобы мы могли использовать такие фиктивные объекты в нашем коде обычно используется Dependency Injection. В этом случае мы знаем контракт используемого нами кода и передаем туда созданный объект, не вникая в детали реализации и оформления кода.
В реальных проектах мы не всегда так можем сделать, однако python позволяет использовать такой хак как monkey patching для того, чтобы внедрить зависимость туда, где это не предусмотрено. Под манки-патчем подразумевают обычно подмену инициализированных объектов, функций или методов без использования стандартных механик будь то DI или наследование. Это может быть замена класса в существующем модуле на свой или замена метода в уже созданном объекте или прямо в классе. И хотя этот метод действительно применяется, стоит помнить, что каждый такой случай - признание, что ваша архитектура плохо продумана. Манкипатчи в тестах полагаются на информацию о внутреннем устройстве кода вместо ожиданий определенного поведения и тесты получаются более хрупкими. И уж точно они не помогают при рефакторинге кода.
Рассмотрим простой пример monkey-patch. Представьте, что у вас есть два модуля
a
и b
.a.py:
def foo(): ...
b.py:
import a
def bar():
a.foo()
Вы решили протестировать функцию
b.bar
, но вы не хотите полагаться на то, как ведет себя a.foo
и вы решили её замокать (заменить на mock). Но так как, автор кода не предусмотрел способов внедрить эту зависимость, вы решили сделать манкипатч: a.foo=Mock(...)
Проблема 1:
Если функция foo используется ещё где-то, таким образом вы подмените её для всего кода. В результате код может начать вести себя неожиданно. Использование
unittest.mock.patch
частично решает эту проблему ограничивая время работы патча.Проблема 2:
Так как код никак не заявляет использование функции
a.foo
, может быть произведен рефакторинг с сохранением поведения, но уже без её вызовов. Тест в этом случае сломаетсяПроблема 3.
Может быть произведено чисто декоративное изменение кода, не меняющее логики:
from a import foo
def bar():
foo()
Так как манкипатч будет произведен уже после импорта, он не будет иметь эффекта на импортированную функцию
foo
. Соответственно, тест так же сломается.Иногда манкипатчинг используется и в реально работающем коде, но как правило стоит его рассматривать только как временное грязное решение проблемы, для которого надо искать замену сразу же после применения.
Дополнительные материалы:
* https://docs.python.org/3/library/unittest.mock.html
* https://martinfowler.com/articles/mocksArentStubs.html
* https://ru.wikipedia.org/wiki/Monkey_patch
Виды многозадачности
Многозадачность - способность исполняющей среды (ОС, виртуальной машины, интерпретатора) выполнять в течение одного промежутка времени несколько кодовых последовательностей (задач), не дожидаясь окончания других задач. Иными словами, задачи выполняются конкурентно. В качестве примеров реализации задач можно назвать 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/
Многозадачность - способность исполняющей среды (ОС, виртуальной машины, интерпретатора) выполнять в течение одного промежутка времени несколько кодовых последовательностей (задач), не дожидаясь окончания других задач. Иными словами, задачи выполняются конкурентно. В качестве примеров реализации задач можно назвать 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/
Способы параллелизации задач
Мы регулярно сталкиваемся с необходимостью создания нескольких конкурентно работающих задач для более эффективной работы нашего кода. Причины могут быть разные: увеличение количества обрабатываемых запросов, ускорение получения результата вычислений или всё вместе.
С точки зрения реализации можно выделить, например, такие варианты:
* Географически распределенная система
* Вычислительный кластер из нескольких серверов
* Несколько процессов в рамках одного сервера
* Несколько потоков в рамках одного процесса
* Таски 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
Мы регулярно сталкиваемся с необходимостью создания нескольких конкурентно работающих задач для более эффективной работы нашего кода. Причины могут быть разные: увеличение количества обрабатываемых запросов, ускорение получения результата вычислений или всё вместе.
С точки зрения реализации можно выделить, например, такие варианты:
* Географически распределенная система
* Вычислительный кластер из нескольких серверов
* Несколько процессов в рамках одного сервера
* Несколько потоков в рамках одного процесса
* Таски 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
С каждой новой версией Python добавляются оптимизации, которых было достаточно мало.
В этом видео я рассказывал о том как это может работать, какие оптимизации были доступны год назад и как вообще происходит интерпретация кода
Во второй половине видео - эксперимент по написанию своего оптимизатора.
https://youtu.be/Z1Br93A-Mp4
В этом видео я рассказывал о том как это может работать, какие оптимизации были доступны год назад и как вообще происходит интерпретация кода
Во второй половине видео - эксперимент по написанию своего оптимизатора.
https://youtu.be/Z1Br93A-Mp4
Цели написания частей кода
В хорошей программе каждая строка и каждая сущность в коде создана для какой-то цели. Эта цель должна быть понятной как автору кода, так и тому, кто будет его в дальнейшем поддерживать.
Это касается как и крупных блоков кода, так и его структуры и даже стиля написания кода.
С одной стороны в коде не должно быть вещей, которые не имеют конкретной цели создания (это может функциональность, увеличение понятности кода или покрытия тестами), не используются и ни на что не влияют:
* Выполнение ненужных действий в коде замедляет его и вызывает вопросы у читающего. Например, код
* Написание неиспользуемых функций или недостижимого сейчас кода приводит к увеличению усилий на поддержку кода, которые не дают профита. К тому же, если этот код попытаются использовать, он может оказаться нерабочим ввиду более слабого тестирования.
* Лишние абстракции делают код запутаннее, а рефакторинг сложнее. При этом код с неверно выделенными абстракциями исправить не проще, чем при их недостатке, поэтому иногда лучше их не вводить.
* Использование дополнительных компонентов в инфраструктуре увеличивает затраты на их поддержку и аппаратные ресурсы.
Комментарии в коде должны давать новую информацию читающему. Сравните этот комментарий:
И такой:
Так же стоит применять сущности только для тех целей, для которых они предназначены. Иначе код будет вызывать вопросы у читающего и увеличивать метрику WTFs/min:
* Используйте классы, чтобы создавать их экземпляры. Не делайте классы, состоящие только из статических/классовых методов или констант. Следующий код плохой:
Эти константы стоило бы вынести на уровень модуля или создать класс, хранящий настройки и один его экземпляр с дефолтными настройками.
* Используйте
* Используйте
* Используйте две черточки
* Используйте
* Не используйте list comprehension как замену циклу for. Так писать точно не нужно:
Однако не бойтесь вводить дополнительные сущности, которые помогают в чтении кода:
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Бритва_Оккама
* https://ru.wikipedia.org/wiki/KISS_(принцип)
* https://martinfowler.com/bliki/Yagni.html
* https://ru.wikipedia.org/wiki/Принцип_единственной_ответственности
* https://blog.pengoworks.com/enclosures/wtfm_cf7237e5-a580-4e22-a42a-f8597dd6c60b.jpg
В хорошей программе каждая строка и каждая сущность в коде создана для какой-то цели. Эта цель должна быть понятной как автору кода, так и тому, кто будет его в дальнейшем поддерживать.
Это касается как и крупных блоков кода, так и его структуры и даже стиля написания кода.
С одной стороны в коде не должно быть вещей, которые не имеют конкретной цели создания (это может функциональность, увеличение понятности кода или покрытия тестами), не используются и ни на что не влияют:
* Выполнение ненужных действий в коде замедляет его и вызывает вопросы у читающего. Например, код
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
Так же стоит применять сущности только для тех целей, для которых они предназначены. Иначе код будет вызывать вопросы у читающего и увеличивать метрику WTFs/min:
* Используйте классы, чтобы создавать их экземпляры. Не делайте классы, состоящие только из статических/классовых методов или констант. Следующий код плохой:
class Constants:
DEFAULT_LENGTH = "64px"
DEFAULT_COLOUR = "red"
line.set_length(Constants.DEFAULT_LENGTH)
Эти константы стоило бы вынести на уровень модуля или создать класс, хранящий настройки и один его экземпляр с дефолтными настройками.
* Используйте
Enum
, чтобы определить фиксированный набор экземпляров класса. Но не вносите в него логику разных частей приложения. Так же не стоит его использовать для хранения разнородной информации как класс в предыдущем примере* Используйте
@staticmethod
и @classmethod
для определения методов, которые ни в коем случае не должны иметь доступа к экземплярам. Даже если сейчас метод не обращается к self
, возможно в наследнике или после изменения он захочет получить туда доступ* Используйте две черточки
__
в названии атрибута, если хотите запретить его переопределение при наследовании, иначе достаточно одной* Используйте
__init__.py
для инициализации и настройки экспорта из пакета. Не стоит в этот файл помещать основной код* Не используйте list comprehension как замену циклу for. Так писать точно не нужно:
[print(i) for i in collection]
Однако не бойтесь вводить дополнительные сущности, которые помогают в чтении кода:
def is_price_valid(price: int) -> bool
return 0 < price
...
if is_price_valid(price):
...
Дополнительные материалы:
* https://ru.wikipedia.org/wiki/Бритва_Оккама
* https://ru.wikipedia.org/wiki/KISS_(принцип)
* https://martinfowler.com/bliki/Yagni.html
* https://ru.wikipedia.org/wiki/Принцип_единственной_ответственности
* https://blog.pengoworks.com/enclosures/wtfm_cf7237e5-a580-4e22-a42a-f8597dd6c60b.jpg
Логирование
Подсистема логирования - то, что проходит через все слои приложения, включая сторонние модули.
Она не является частью основной логики приложения и используется только для сбора диагностической информации для последующего анализа.
* Доступ к записи логов должен быть у любой части приложения, поэтому логично иметь стандартный механизм для этого. В питоне это модуль
* Так как запись логов может производиться из различных частей приложения, должна быть возможность включать или выключать их логи группами. В
* Разные приложения могут использовать разную конфигурацию системы логирования. Её настройка должна производиться в инфраструктурном слое: там, где мы настраиваем и запускаем остальные части приложения (например, в начале функции
* При использовании
* GUI приложения обычно запускаются без терминала пользователем, который не хочет видеть служебную информацию, пока это не потребуется явно. Поэтому для них актуально писать логи в файле или системный журнал (при наличии).
* Консольные скрипты дополнительно имеют возможность писать стандартный вывод для ошибок (
* Автономные серверные приложения запускаются системой и работают достаточно долго. Иногда они запускаются в нескольких копиях (например для лучшего использования аппаратных ресурсов). Запись в файл из таких сервисов приводит к сложностям в его управлении, совместном чтении с другими логами и попросту не работает корректно при наличии ротации и нескольких процессов. Для них актуально использование централизованной системы логирования с автоматическим удалением старых логов, а так же сбор вывода о необработанных ошибках. Таким образом стандартным способом будет записывать логи в stdout/stderr и позволить внешней системе их собирать. При запуске через
Дополнительные материалы:
* 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
Подсистема логирования - то, что проходит через все слои приложения, включая сторонние модули.
Она не является частью основной логики приложения и используется только для сбора диагностической информации для последующего анализа.
* Доступ к записи логов должен быть у любой части приложения, поэтому логично иметь стандартный механизм для этого. В питоне это модуль
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
Текущий каталог и пути
Как правило, мы используем два типа путей к файлам:
* Абсолютный путь - путь целиком, начиная от корня файловой системы и со всем промежуточным папками до указанного файла. Он хорош тем, что стабилен и дает однозначный путь к файлу, не зависящий от текущего состояния процесса. Однако он достаточно длинный и не всегда может быть использован. Например, если мы не знаем заранее, где будет лежать какая-то папка. Путь
* Относительный путь - часть пути, которая самостоятельно не может быть использована для нахождения файла, но при наличии другого известного пути может быть посчитана относительно него. Пути
В некоторых случаях относительный путь может быть посчитан относительно другого явно указанного пути, но зачастую он используется сам по себе, и в этом случае считается относительно текущего каталога.
Текущий каталог (текущая директория, рабочий каталог) - каталог, использующийся для разрешения относительных путей процессом.
Текущий каталог не имеет никакого отношения к расположению вашего кода или файлов интерпретатора, он задается процессу независимо.
* При запуске дочернего процесса текущий каталог наследуется от родительского, но может быть изменен в процессе работы
* Если вы запускаете процесс через systemd, в качестве текущего каталога будет использован тот, что указан в service-файле (либо корень файловой системы)
* Если вы используете терминал с bash, то текущий каталог процесса командой оболочки вы можете узнать с помощью команды
* В python вы можете узнать текущий каталог через
Рассмотрим пример.
Пусть ваша программа
Вы открыли консоль и перешли в домашний каталог пользователя root, то есть сделали
Хотя вам доступно API для изменения текущего каталога вашего приложения, рекомендуется не пользоваться этой возможностью, если все части программы не разрабатывались специально с учетом этого. Если где-то вы использовали относительный путь и он работал, то после изменения текущего каталога он начнет указывать на другое место.
Использовать пути относительно текущего каталога - неплохой вариант для пользовательских данных. В других случаях могут быть более корректными другие варианты:
* Используйте
* Используйте
* Ознакомьтесь с тем, где в вашей ОС принято хранить пользовательские данные приложения, пользовательские конфиги и прочие файлы. Это может быть что-то вроде
* Подумайте о возможности принимать пути от пользователя через параметры командной строки (
Дополнительные материалы:
* 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
Как правило, мы используем два типа путей к файлам:
* Абсолютный путь - путь целиком, начиная от корня файловой системы и со всем промежуточным папками до указанного файла. Он хорош тем, что стабилен и дает однозначный путь к файлу, не зависящий от текущего состояния процесса. Однако он достаточно длинный и не всегда может быть использован. Например, если мы не знаем заранее, где будет лежать какая-то папка. Путь
/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
Сетевые протоколы
Выбирая сетевой протокол для использования в приложении, мы должны ориентироваться на множество факторов: безопасность, доступность на используемой платформе и сетевом окружении, возможность масштабирования и логику работы протокола с данными.
Рассмотрим несколько вариантов:
TCP (Transmission Control Protocol)
Рассчитан на передачу непрерывного потока байтов. Он требует установки соединения между двумя сторонами и гарантирует, что данные не будут теряться и перемешиваться, пока оно живо. То есть:
* вы не передаете никакие прикладные пакеты, вы передаете отдельные байты один за другим
* ещё раз: принимающая сторона получает поток байтов, она не знает по сколько байт за раз было отправлено
* прежде чем начать прием/передачу данных, вы должны корректно установить соединение
* данные могут теряться только в момент разрыва соединения
* предусмотрена процедура корректного закрытия соединения, гарантирующая, что все данные доставлены
* входящий и исходящий поток байтов логически не связаны друг с другом, кроме наличия соединения
* для обеспечения целостности потока данных внутри предусмотрена буферизация, повторы доставки и подтверждения, что может влиять на производительность
* технически разделяет клиент (устанавливающий соединение) и сервер (принимающий их)
* используются номера портов для разделения сервисов, работающих на одном IP-адресе
* работает поверх протокола
* как правило, реализован в ядре ОС
UDP (User Datagram Protocol)
Рассчитан на передачу отдельных независимых пакетов (датаграмм). Так как между ними нет никакой связи, то все проблемы доставки каждый пакет затрагивают независимо.
* каждая датаграмма посылается самостоятельно на адрес получателя
* порядок доставки датаграмм не отслеживается (они независимые)
* гарантий доставки датаграмм не предусмотрено
* получение пакета не гарантирует, что вы сможете ответить отправителю
* начала и конца передачи набора пакетов не предусмотрено
* используются номера портов для разделения сервисов, работающих на одном IP-адресе
* работает поверх протокола
* как правило реализован в ядре ОС
HTTP (Hyper-Text Transfer Protocol)
Рассчитан на сценарий Запрос-Ответ (до
* имеет структурированный пакет с данными различной семантики
* логически разделяет клиент (посылающий запросы) и сервер (отвечающий на них)
* каждый запрос считается независимым
* для передачи состояния между запросами использует механизм
* использует
* работает поверх протокола
* может использовать несколько соединений
* как правило реализован в прикладных библиотеках
Зачастую
Если у вас стоит выбор между использованием высокоуровневого протокола типа
Дополнительные материалы:
* 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/Мультивещание
Выбирая сетевой протокол для использования в приложении, мы должны ориентироваться на множество факторов: безопасность, доступность на используемой платформе и сетевом окружении, возможность масштабирования и логику работы протокола с данными.
Рассмотрим несколько вариантов:
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/Мультивещание
Советы разработчикам (python и не только) pinned Deleted message
Веб приложение и масштабирование
Использование протоколов, основанных на HTTP, не требующих постоянного соединения и содержащих всю необходимую информацию в каждом прикладном пакете, позволяет проще масштабировать приложения горизонтально и восстанавливаться после сбоев. Однако, это требует, чтобы и приложение было написано соответствующе.
Чтобы проверить, правильно ли спроектировано ваше веб-приложение (или телеграм-бот), подумайте, будет ли оно корректно функционировать в таких ситуациях:
* если после любого обработанного события произойдет перезапуск приложения?
* если одновременно придет несколько событий?
* если будет запущено несколько процессов приложения и события будут приходить в один или в другой попеременно?
Перезапуски приложения происходят в любой момент - как при программных ошибках, так и при стандартной процедуре обновления. Запуск же нескольких копий может понадобиться для задействования дополнительных аппаратных ресурсов и увеличения производительности сервиса.
В общем случае, имеет смысл делать сам процесс приложения не имеющим состояния. При этом все данные, которые надо сохранить между событиями - хранить в специально спроектированной внешней системе (фактически, в БД).
Типичные проблемные места при разработке таких приложений:
* Донастройка правил выбора обработчика в процессе работы процесса в фреймворке (роуты, фильтры диспатчера). При перезапуске эти настройки будет сброшены. Вместо этого стоит настроить правила один раз (при запуске процесса) таким образом, чтобы они могли учитывать изменяющееся состояние, которое вы будете загружать из хранилища любого типа (БД, сессия, специальный FSM Storage).
* Хранение данных в глобальной переменной. При обработке нескольких событий они могут перепутаться, значение сбросится при рестарте, а так же несколько процессов не смогут разделять эти данные. При необходимости хранить данные бизнес-логики, стоит использовать БД или какое-то временное хранилище (сессия, FSM) в зависимости от сценария их использования. Однако это может не касаться данных, относящихся к самому процессу: счетчики для мониторинга, кэши.
* Запуск задач, не связанных с обработкой запросов в рамках процесса веб-сервиса. Если они запускаются из обработчика, они будут просто сброшены при рестарте. Если же они запускаются при старте - вы получите несколько копий таких задач при запуске нескольких процессов веб-приложения. В первом случае стоит задуматься об использовании очереди или отдельно запускаемого планировщика. Во втором - об отдельном процессе, который будет масштабировать по своим правилам. И снова речь не идет о служебных задачах, связанных с обслуживанием самого процесса приложения, таких как обновление in-memory кэша.
В определенных случаях мы можем сделать некоторые допущения для оптимизации наших сервисов. Например, предположение, что перезапуск будет не очень часто, позволяет нам сделать in-memory кэш или промежуточный буфер перед записью во внешнюю систему. Однако, внедрение таких решений должно быть обоснованным и учитывать потенциальные проблемы.
Дополнительные материалы:
* https://habr.com/ru/company/dcmiran/blog/487424/
* https://ru.wikipedia.org/wiki/Конечный_автомат
* https://medium.com/@ermakovichdmitriy/определения-понятий-stateful-и-stateless-в-контексте-веб-сервисов-перевод-18a910a226a1
Использование протоколов, основанных на HTTP, не требующих постоянного соединения и содержащих всю необходимую информацию в каждом прикладном пакете, позволяет проще масштабировать приложения горизонтально и восстанавливаться после сбоев. Однако, это требует, чтобы и приложение было написано соответствующе.
Чтобы проверить, правильно ли спроектировано ваше веб-приложение (или телеграм-бот), подумайте, будет ли оно корректно функционировать в таких ситуациях:
* если после любого обработанного события произойдет перезапуск приложения?
* если одновременно придет несколько событий?
* если будет запущено несколько процессов приложения и события будут приходить в один или в другой попеременно?
Перезапуски приложения происходят в любой момент - как при программных ошибках, так и при стандартной процедуре обновления. Запуск же нескольких копий может понадобиться для задействования дополнительных аппаратных ресурсов и увеличения производительности сервиса.
В общем случае, имеет смысл делать сам процесс приложения не имеющим состояния. При этом все данные, которые надо сохранить между событиями - хранить в специально спроектированной внешней системе (фактически, в БД).
Типичные проблемные места при разработке таких приложений:
* Донастройка правил выбора обработчика в процессе работы процесса в фреймворке (роуты, фильтры диспатчера). При перезапуске эти настройки будет сброшены. Вместо этого стоит настроить правила один раз (при запуске процесса) таким образом, чтобы они могли учитывать изменяющееся состояние, которое вы будете загружать из хранилища любого типа (БД, сессия, специальный FSM Storage).
* Хранение данных в глобальной переменной. При обработке нескольких событий они могут перепутаться, значение сбросится при рестарте, а так же несколько процессов не смогут разделять эти данные. При необходимости хранить данные бизнес-логики, стоит использовать БД или какое-то временное хранилище (сессия, FSM) в зависимости от сценария их использования. Однако это может не касаться данных, относящихся к самому процессу: счетчики для мониторинга, кэши.
* Запуск задач, не связанных с обработкой запросов в рамках процесса веб-сервиса. Если они запускаются из обработчика, они будут просто сброшены при рестарте. Если же они запускаются при старте - вы получите несколько копий таких задач при запуске нескольких процессов веб-приложения. В первом случае стоит задуматься об использовании очереди или отдельно запускаемого планировщика. Во втором - об отдельном процессе, который будет масштабировать по своим правилам. И снова речь не идет о служебных задачах, связанных с обслуживанием самого процесса приложения, таких как обновление in-memory кэша.
В определенных случаях мы можем сделать некоторые допущения для оптимизации наших сервисов. Например, предположение, что перезапуск будет не очень часто, позволяет нам сделать in-memory кэш или промежуточный буфер перед записью во внешнюю систему. Однако, внедрение таких решений должно быть обоснованным и учитывать потенциальные проблемы.
Дополнительные материалы:
* https://habr.com/ru/company/dcmiran/blog/487424/
* https://ru.wikipedia.org/wiki/Конечный_автомат
* https://medium.com/@ermakovichdmitriy/определения-понятий-stateful-и-stateless-в-контексте-веб-сервисов-перевод-18a910a226a1
Терминал, консоль и командная оболочка
Для взаимодействия человека с компьютером кроме графического интерфейса одним из часто используемых является текстовый. В этом случае пользователь выводит текст с помощью клавиатуры и видит также символьную информацию где-то на экране.
Текстовый интерфейс может содержать меню и окна, реализованные с помощью "псевдографики", в этом случае сценарии использования похожи на работу с
На уровне ОС это реализовано в виде многоуровневой системы. На примере Linux "обычная командная строка" это на самом деле несколько вещей:
1. Объект ядра, представляющий устройство терминала. Реальное (
2. Терминал.
* Экран и клавиатура в случае физического терминала, подключенные в соответствующие порты компа.
* Программа-эмулятор виртуального терминала. Это не часть ядра, а именно отдельное приложение, работающее в пространстве пользователя. Она может выводить текст в окне внутри графического интерфейса, другого терминала, перенаправлять ввод вывод в сеть, эмулировать пользователя или делать всё, на что хватит фантазии автора. Примеры:
3. Прикладная программа, организующая интерфейс пользователя. Как правило, первое, что мы видим - это командная оболочка. Это программа, реализующая сценарии CLI, которая умеет понимать команды пользователя (те самые
Чтобы прикладная программа могла взаимодействовать с устройством терминала (реальным или виртуальным), ей передаются стандартные потоки ввода-вывода:
* стандартный поток ввода (
* стандартный поток вывода (
* стандартный поток ошибок (
Таким образом,
1. пользователь взаимодействует с физическим устройством или программой-эмулятором терминала.
2. они передают информацию ядру ОС,
3. которое дальше через стандартные потоки ввода-вывода организует взаимодействие с прикладной программой.
Во многих случаях, запуская консольную программу мы можем в качестве стандартных потоков ввода-вывода использовать файлы, пайпы или потоки других программ.
Например, если мы запускаем программу из
Дополнительные материалы:
* https://habr.com/ru/post/417679/
* https://habr.com/ru/post/460257/
* https://linux.die.net/man/3/daemon
* https://www.baeldung.com/linux/pty-vs-tty
* https://ru.wikipedia.org/wiki/Управляющие_последовательности_ANSI
Для взаимодействия человека с компьютером кроме графического интерфейса одним из часто используемых является текстовый. В этом случае пользователь выводит текст с помощью клавиатуры и видит также символьную информацию где-то на экране.
Текстовый интерфейс может содержать меню и окна, реализованные с помощью "псевдографики", в этом случае сценарии использования похожи на работу с
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Более детально вы можете посмотреть в документации к вашей командной оболочке.
Дополнительные материалы:
* https://habr.com/ru/post/417679/
* https://habr.com/ru/post/460257/
* https://linux.die.net/man/3/daemon
* https://www.baeldung.com/linux/pty-vs-tty
* https://ru.wikipedia.org/wiki/Управляющие_последовательности_ANSI
Стратегии загрузки связанных данных из РСУБД
Во многих случаях запрашивая данные из реляционной БД, мы хотим получать их не из одной таблицы, а из нескольких.
Предположим, у нас есть две связанные таблицы
1. Ленивая подгрузка (проблема N+1). Получим записи из таблицы
2. Joined load (
3. Select in load (
4. Subquery load. Также для получения записей из связанной таблицы генерирует второй запрос. Похожа на select-in load, но вместо прямой передачи списка
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
Во многих случаях запрашивая данные из реляционной БД, мы хотим получать их не из одной таблицы, а из нескольких.
Предположим, у нас есть две связанные таблицы
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.
🫣Мы знаем как трудно выбрать наставника и курсы, которые действительно вам помогут вкатиться в столько сложную сферу и поэтому подготовили свой, авторский курс.
📝 Материалы курса были много раз проверены и перепроверены на живой аудитории, дополнены кодом из реальных проектов.
🧑💻Состав преподавателей пока держится в тайне, но это те люди, которых вы регулярно видите в чате и кто уже неоднократно себя зарекомендовал как высококлассный специалист, способный донести свои знания до любого заинтересованного, независимо от его бэкграунда и стартовых навыков.
🐍 Мы представляем Школу ru_python!
Новый курс стартует на днях, спешите записаться!
Программа и условия участия:
🔗
Концепции, связанные с декораторами в Python
Декоратор в Python введен на уровне синтаксиса языка, но за ним лежат несколько важных концепций, которые необходимо понимать:
1. Функции - такие же объекты, как и любые другие. Имя функции - такая же переменная, как и любая другая. Круглые скобки - оператор вызова, а не часть имени функции. То же самое касается классов.
Когда вы определяете функцию с помощью ключевого слова
* в памяти создается объект функции, который в атрибуте
* создается переменная, содержащая ссылку на функцию.
С этой переменной можно работать так же как с переменной, содержащей любые другие данные - пытаться работать с атрибутами объекта, передавать в другую функцию и даже присваивать её другое значение.
Для того чтобы вызвать функцию мы используем оператор
Следующий код синтаксически абсолютно корректен:
Часто мы не знаем сколько параметров пользователь захочет передать в нашу функцию, потому что мы их никак не обрабатываем. Зато мы их можем захотеть передать дальше. В этом случае мы можем объявить функцию, как принимающую переменное число позиционных параметров с помощью
* 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 введен на уровне синтаксиса языка, но за ним лежат несколько важных концепций, которые необходимо понимать:
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()
* 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 объекта (
* выполняют регистрацию (
* или вообще заменяют тело объекта (
Так же можно выделить типичные ошибки использования питоновских декораторов:
1. Простой перенос вызова из тела функции в декоратор. Сравните две функции
2. Создание недостаточно универсальных декораторов. Тесно связан с первым пунктом. Такие декораторы излишне полагаются на сигнатуру и ожидаемое поведение конкретной функции, хотя это и не заявляется явно. В результате становится сложно их применять в других ситуациях и даже модифицировать исходную функцию. Пример такой ошибки - ничем не оправданное ожидание, что среди параметров функции будет один с конкретным именем. Стоит оговориться, что в определенных случаях это ожидание может быть оправданным или вынесено в требования к написанию кода.
3. Использование глобальных реестров объектов. Иногда это не является проблемой, но может привести к эффектам описанным в статьях про глобальные переменные, настройки или импорты. Возможно, тут стоит разделить реестр на несколько (как
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
Декоратор в 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 запросы, но эта функциональность реализована в них частично и имеет ограничения по безопасности и надежности, и может быть использована только в процессе разработки. Примеры:
3. Cервер приложений. Принимает внешние запросы по какому-либо сетевому протоколу и вызывает код приложения (или веб-фреймворка). Он может принимать соединения по HTTP, специализированному протоколу или коммуницировать с отдельно стоящим веб-сервером с помощью стандартных потоков ввода-вывода. В случае python есть стандарт WSGI, описывающий как сервер приложений должен вызывать код на питоне, а также ASGI, аналогично работающий с asyncio кодом. Есть множество совместимых веб-серверов, что дает некоторую свободу выбора. Также сервер приложений, зная о цикле обработки запросов, может следить, чтобы код не завис и при необходимости принимать действия по перезапуску. Сейчас многие веб-сервера позволяют делать множество вызовов запущенного кода, не требуя его перезапуска. Однако, другие, как apache2 с модулем mod_php, могут перезапускать код на каждый HTTP-запрос. При этом, такие веб-сервера всё ещё могут быть уязвимы к определенным видам атак или неэффективно работать с TLS, что требует запуска перед ними реверс-прокси. Примеры:
4. Супервизор. Это системный процесс, который занимается запуском и управлением различными службами. В случае деплоя на выделенный сервер, это скорее всего будет systemd. Однако стоит также упомянуть оркестраторы (например, k8s), позволяющие запускать много копий кода на разных серверах.
5. Балансировщик и реверс-прокси. Специализированные веб-серверы, не выполняющие логики и прямого вызова прикладного кода, но позволяющие оптимизировать работу под нагрузкой. Они могут более эффективно выполнять задачи шифрования трафика, кэшировать контент и перенаправлять запросы на большее количество экземпляров приложения. Кроме балансировки на уровне обработки HTTP-запроса, она может так же выполняться на уровне 3 OSI (протокол ip) или dns-сервера (round-robin, geo-dns). Примеры:
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
Разрабатывая веб-сервис мы пишем код, работающий с данными запросов и реализующий бизнес логику. При этом служебные части, которые необходимы для работы приложения по протоколу 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
1. Иногда
2. Иногда
3.
4.
* не происходит обновление или удаление Enum
* не происходит изменение типа поля
* не пересоздаются индексы
5. Укажите
6. Порядок файлов миграции непонятен. Вы можете указать собственные правила генерации имен файлов, например, добавив туда дату и время. Это позволит понимать, в каком порядке они были созданы. Однако в этом случае будьте аккуратны при слиянии веток, возможно потребуется ручное редактирование имен файлов кроме
7. Не импортируйте основной код приложения в миграциях. Основной код приложения будет меняться, что повлияет на работоспособность старых миграций. Миграция же не должна менять своё поведение после создания. При необходимости вы можете скопировать небольшую часть основного кода в конкретную миграцию. Однако следите за тем, чтобы миграции не имели общего кода.
8. Тестируйте миграции, однако нет смысла их включать в регресс. Миграции обязательно должны проверяться после создания, однако уже выпущенные миграции обычно не меняют и не нужно уделять много внимания их повторной проверке. Хорошей мыслью может быть развертывание тестовой БД с помощью миграций и удаление с помощью их отката. Также имеет смысл добавить отдельные проверки на наличие единого
Дополнительные материалы:
* https://habr.com/ru/company/yandex/blog/498856/#5
* https://alembic.sqlalchemy.org/en/latest/
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/
Запуск программ и команд
Когда наше приложение запускается, оно получает список аргументов командной строки (
C-API
* В
Desktop GUI
* выбор непосредственно запускаемого файла
* выбор ярлыка, ссылающегося на запускаемый файл (например,
* выбор файла, ассоциированного с программой для запуска. Например, кликая по
Командная оболочка имеет свой язык, с помощью которого мы с ней взаимодействуем. Часто это достаточно мощный язык с управляющими конструкциями, циклами, подпрограммами и т.п. Часто правила разбора команды отличаются между оболочками и даже в похожих языках могут быть разные правила для обработки, например, кавычек.
Когда мы вводим строку, происходит её разбор согласно правилам текущего шелла. Из строки выделяется команда, которую надо запустить, её параметры, могут добавляться переменные окружения или перенаправляться потоки ввода-вывода. Команда может быть как встроенной командой шелла, так и внешней программой, которую мы запускаем.
Например,
*
*
Используя
Так же есть параметр
Дополнительные материалы:
* 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
Когда наше приложение запускается, оно получает список аргументов командной строки (
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