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

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

Programming, python, software architecture и все такое
Download Telegram
Channel name was changed to «Советы разработчикам»
Недостатки глобальных переменных:

1. Неконтролируемый доступ. Так как они не передаются явным образом, к ним можно легко получить доступ с помощью из слоев абстракции, которые о них не должны вообще знать.
Пример такой ошибки: обращение в БД из html-шаблонов

2. Усложнение использования кода из-за неявных связей. Невозможно глядя на функцию понять, что ей нужно для работы.
Например, если мы вызываем функцию foo, а она вызывает функцию bar, а та вызывает функцию baz, которая обращается к глобальной переменной XXX, мы не можем догадаться что XXX надо иницилизировать для использования foo.
Это можно документировать, но наличие такой документации невозможно проверять автоматически.

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

4. Сильное сцепление. Так как переменная инициализируется там же откуда её импортируют, в результате косвенно весь код использующий переменную сцеплен с кодом её иницилизации.
В результате код использующий такую переменную невозможно использовать не втаскивая конкретный код инициализации.

5. Невозможность иметь два экземпляра без изменения кода, использующего их.
Пример, раньше использовалось одно соединение с БД, но при повышении нагрузки часть запросов решили делать в read-only реплику.
В случае DI изменения так же потребуются, но есть возможность выбрать, где будет приниматься решение об использовании конкретного соединения, и не менять контракт использующих его частей.

6. Все выше сказанное усложняет тестирование кода.

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

Во всех случаях, когда жизненный цикл переменных не завязан физически на жизненный цикл интерпретатора, стоит воспользоваться концепцией DI.
Иногда фреймворки предлагают свои механизмы передачи зависимостей, но в этом случае надо следить, чтобы эти механизмы были использованы только в слоях, работающих с фреймворком
👍655🤯5🤡5🤮32❤‍🔥2🔥2👎1🐳1
Говоря про настройки приложений новички зачастую исходят из предположения, что всё приложение - это цельный кусок, который будет один раз настроен до запуска основного кода.

На самом деле это не так:
* Приложение состоит из нескольких независимых частей, которые могут быть переиспользованы
* Некоторые части будут существовать в одном экземпляре, некоторые - нет (по крайней мере в будущем).
* Иногда настройки будут загружаться при старте приложения, иногда - перед созданием конкретных экземпляров классов. Например, при тестировании.

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

Одновременно вспоминая принципы SOLID можно записать следующие принципы работы с настройками:

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

Какие есть типичные АНТИПАТТЕРНЫ работы с настройками:

1. Файл settings.py, содержащий все "константы" настроек, редактируется при деплое. Все куски кода его импортируют.
2. Файл settings.py, содержащий глобальные переменные, заполненные сразу из переменных окружения.
3. Глобальная переменная, содержащая настройки, все её импортируют. То же самое что п.1, но в какбы-ООП стиле
4. Функция load_config(), которую все дергают. Для "оптимизации" может быть задекорирована @lru_cache. По сути то же самое что в п.3, но конфиг читается при первом обращении, которое зачастую происходит в глобальном скоупе и мы это не контролируем.
5. Один класс Settings и все классы ожидают его целиком, даже если используют лишь часть.

Из наиболее очевидных последствий этих подходов можно выделить сложность тестирования кода, так к моменту запуска тестов настройки уже прочитаны, или необходимость настроить ВСЁ приложение для теста небольшой его части

Иногда фреймворки предлагаю свои подходы для работы с настройками, но это должно использоваться только в тех частях, которые непосредственно связаны с этим фреймворком. В остальные части же настройки должны передаваться как обычно.
👍47🤮5🤡2👀2👎1
Несмотря на то, что питон позволяет использовать вложенные функции и классы, стоит их использовать с осторожностью.

По вложенными функциями я имею в виду следующий код:

def foo():
def bar(): ...

Недостатки вложенных функций:
* Они создаются при каждом запуске внешней функции
* Их невозможно протестировать
* Они захватывают окружающие переменные, что приводит к появлению неявного изменяемого контекста, который недоступен для инспекции
* Увеличивают вложенность кода, что усложняет чтение
* Их невозможно переиспользовать

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

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


Под вложенными классами я имею в виду:
class A:
class B: ...

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

* Чтобы указать какие-то настройки связанные с поведением окружающего класса. Это создает вопросы, какие опции доступны. Так же такой класс не будет иметь экземпляров, что странно.
Вместо этого лучше воспользоваться полем класса с экземпляром какого-то класса. В определенных случаях можно передать эти параметры в декоратор или метакласс. Так же стоит рассмотреть вариант переноса этих настроек из класса в использующий его слой.
👍28🤡21🤮1
Про объекты и словари.

TLDR:
объект — это фиксированная структура данных с конечным набором полей.
словарь — это маппинг ключей в значения, не более.

Если не углубляться в детали реализации, то в питоне есть два способа объединить набор строк со значениями в одной сущности.
* Можно положить значения как атрибуты какого-то объекта. Для этого есть оператор точка или функции setattr/getattr
* Или можно сделать словарь. В этом случае идет обращение с помощью квадратных скобок или метода .get

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

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

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

То есть это плохой вариант (не делайте так):
def greet_user(user):
print("Hello,", user["nane"])

greet_user({
"id": 1,
"name": "ivan"
})

Этот код лучше оформить так:
@dataclass
class User:
id: int
name: str

def greet_user(user: User):
print("Hello,", user.name)

greet_user(User(
id=1,
name="ivan",
))


Есть ещё одно применение словаря - интеграция с внешними системами.

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

Продолжая тему адаптеров, при реализации их может понадобиться код конвертации класса "в словарь".
Естественным желанием тут будет добавление метода типа to_dict, однако следует подходить к этому подходу с осторожностью.

Дело в том, что один и тот же объект в дальнейшем может передаваться в разные сторонние API (сохранять в БД, возвращаться через REST API нескольких версий и т.п.). И каждый адаптер может иметь свои правила преобразования объекта. Например, если мы поддерживаем несколько версий Web API, они могут отличаться даже именованием одних и тех же полей. В других случаях могут отличаться правила преобразования некоторых типов как datetime.

Поэтому более правильным будет держать функции сериализации в том слое кода, который непосредственно отвечает за конечное представление.
👍248👏3🤡3❤‍🔥1🔥1🤔1🤮1
Про приватные и публичные атрибуты

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

1. Разделение публичного API класса/библиотеки и деталей реализации.
На уровне договоренности принято называть атрибуты, предназначенные "для внутреннего использования" начиная с одинарной черточкой _.
Такое обозначение является лишь соглашением и не подкрепляется никаким механизмом в языке. Но при этом линтеры как правило проверяют доступ к таким атрибутам.

Термин "protected-атрибуты" тут может быть применен с большой натяжкой и не в том же смысле как в Java или C++
С другой стороны, в той же Java есть способы нарушить защиту предоставляемую модификаторами доступа.

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

2. Защита от переопределения атрибута наследниками.
Иногда мы хотим, чтобы методы класса обращались именно к его атрибуту, а если наследник определит свой, это никак не затрагивало нашу логику.
Это корректная ситуация, хотя и не очень частая. Для этого в питоне поддерживается механизм "name mangling", который включается использованием двойного подчеркивания в имени атрибута (__).
Конечно, к такому атрибуту все ещё возможно орбатиться и переопределить его, но это требует явных намерений от разработчика.
Это снова отличается от механики работы "private" атрибутов в других языках, хотя и пересекается с ними по одному из сценариев применения


Таким образом:
* если вы хотите указать разработчику, что атрибут (класс, функция, глобальная переменная) не являются частью API класса/модуля - испольузйте одну черточку
* если вы действительно хотите защититься от переопределения атрибуты наследниками класса - используйте две черточки
* в остальных случаях - работайте над архитектурой проекта

Подробнее:
* https://docs.python.org/3/tutorial/classes.html#private-variables
* https://martinfowler.com/bliki/AccessModifier.html
👍234🤡2🔥1🤮1💊1
Про форматирование и инъекции.

В Python есть минимум 3 разных способа форматирования строк:
* f-строки
* .format
* %

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

Компьютер ожидает, что обрабатываемый текст будет оформлен согласно определенным правилам и вы не можете просто так вставить туда произвольную строку не нарушив при этом структуру текста.
Речь идет о формировании SQL, XML, HTML, JSON, консольной команды, URL, регулярных выражений и ещё кучи различных типов строк.

Как правило такая проблема решается одним из двух способов:
1. Ручное экранирование данных
2. Вызов специальных библиотечных методов, подставляющих данные безопасно.

Как правило лучше выбирать второй способ, так он проще и оставляет меньше шансов ошибиться.

Рассмотрим примеры.

1. SQL
Допустим вы хотите прочитать данные из БД и пишете такой запрос:
cur.execute(f"SELECT * FROM users WHERE login = '{somelogin}'")
Он будет работать в каких-то случаях и обязательно сломается, если переменная somelogin будет содержать, например, кавычку.
В некоторых случаях это может привести к исполнению произвольного SQL кода.

Замена f-строки на .format или форматирование через % ничего не изменит.
Правильно тут было бы сделать так (использовать тут ? или другой символ зависит от вашей СУБД):
cur.execute("SELECT * FROM users WHERE login = ?", (somelogin,))

2. HTTP
Если вы делаете GET запрос, вы можете написать такой код:
requests.get(f"http://site.com?search={query}")
Он будет работать некорректно если переменная query, например, содержит знаки &?
Правильно написать так:
requests.get("http://site.com", params={"search": query})

3. Shell
Следующий код сломается, если имя папки содержит пробел или точку с запятой, и может привести к исполнению произвольных команд.
subprocess.run(f"ls {dirname}", shell=True)
Его стоит заменить на
subprocess.run(["ls", dirname])

4. HTML
В случае подстановки данных в HTML стоит воспользоваться специальными шаблонизаторами. Например, jinja.


Доп материалы:
* https://xkcd.ru/327/
* https://ru.wikipedia.org/wiki/Внедрение_SQL-кода
* https://ru.wikipedia.org/wiki/Межсайтовый_скриптинг
👍61🔥5🤡2🤮1
Оглавление

Архитектура, паттерны, подходы
Недостатки глобальных переменных
Ошибки при обработке настроек
Dependency Injection
FastAPI и Dependency Injection
Обработка исключений
Аутентификация и авторизация
Аутентификация и IdentityProvider
Пулы объектов и соединений
Абстрактные классы и интерфейсы
Вложенные классы и функции
Форматирование и инъекции
Логирование
Цели написания частей кода
Двухфазная инициализация
Моки, стабы и патчи
Полиморфизм при наследовании и LSP
Data Transfer Object (DTO)
Dependency Inversion Principle
Anti Corruption Layer

Базы данных
Первичные ключи в БД
Базы данных и компоненты
БД и Миграции
Тонкости использования alembic
Стратегии загрузки связанных данных из РСУБД
SQL, соединения и слои абстракции
Unit of Work
Generic-репозиторий - просто ленивый антипаттерн
Паттерны работы с базами данных
Стейт при работе с базами данных

Python
Объекты и словари
Приватные и публичные атрибуты
Конструктор и __init__
Ссылки и is
Управление памятью в Python
Работа import и структура проекта
Механика импорта и побочные эффекты
Концепции, связанные с декораторами в Python
Декоратор как паттерн и как сахар в Python
Виртуальные окружения Python
Asyncio и цикл событий
Asyncio и колбэки

ОС, устройство компьютера и сети
Текущий каталог и пути
Переменные окружения и dotenv
Терминал, консоль и командная оболочка
Запуск программ в фоне на Linux
Запуск программ и команд
Потокобезопасность и конкурентный доступ
Виды многозадачности
Способы параллелизации задач
Сетевые протоколы
Веб приложение и масштабирование
Компоненты web-приложения
Pull, poll, pool и spool
float и Decimal
Словари

Авторское
Микрооптимизации кода и AST
Dishka - IoC-контейнер для Python
Создание ботов с интерактивным меню
65👍18🔥15💩2🤡2🆒2🖕1
Советы разработчикам (python и не только) pinned «Оглавление Архитектура, паттерны, подходы • Недостатки глобальных переменных • Ошибки при обработке настроек • Dependency Injection • FastAPI и Dependency Injection • Обработка исключений • Аутентификация и авторизация • Аутентификация и IdentityProvider…»
FastAPI и Dependency Injection.

В таком популярном фреймворке как FastAPI есть механизм для автоматизации управления зависимостями в рамках концепции DI.
Однако в документации автор местами путается, плюс есть некоторые особенности данного фреймворка, влияющие на удобство использования.

Что такое Dependency Injection?
Это достаточно простая концепция, которая говорит: если объекту что-то нужно, он не должен знать, как оно создается.

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

Таким образом весь механизм DI состоит из двух частей:
1. Есть класс/функция, которая от чего-то зависит
2. Есть логика, которая подставляет эту зависимость

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


В случае FastAPI, у нас есть механизм автоматической подстановки зависимостей в наши view-функции (это обычно называется IoC-контейнер).
Он состоит аналогично из двух частей
* С помощью Depends мы обозначаем зависимость. Если зависимость идентифицируется по аннотации типа параметра функции, то Depends используется без параметров.
* С помощью app.dependency_overrides мы определяем фабрику, возвращающую эту зависимость или генераторную функцию

В простом случае это может выглядеть вот так:
# определяем view
@router.get
def my_view_funcion(param: Session = Depends()):
...

# в мейне создаем приложение и настраиваем
app = FastApi()
app.dependency_overrides[Session] = some_session_factory

Типичной ошибкой, которую допускает даже автор fastapi является указать настоящую функцию создания зависимостей в Depends
# так делать не стоит
def my_view_funcion(param: Session = Depends(create_session)): ...

В этом случае вы конечно все ещё можете переопределить зависимость в тестах, но ваш код оказывается сцеплен с кодом создания объекта Session и более того, вы не можете настроить логику работы функции create_session при создании приложения.
То есть это не DI, а только его половинка.


Функционально Depends в fastapi можно использовать не только для DI, но и для переиспользования части логики или даже параметров запроса и это накладывает свой отпечаток.
Дело в том, что FastAPI генерирует open api (swagger) спецификацию для view-функции не только на основе её параметров, но так же на основе параметров её зависимостей.

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

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

Первый способ в коде выглядит так
class MyProto(abc.ABC):
...

@router.get
def my_view_funcion(param: MyProto = Depends()):
...

app = FastApi()
app.dependency_overrides[MyProto] = some_session_factory

Второй способ - так:
def get_session_stub():
raise NotImplementedError # это реально тело этой функции

@router.get
def my_view_funcion(param: Session = Depends(get_session_stub)):
...

app = FastApi()
app.dependency_overrides[get_session_stub] = some_session_factory

Второй способ можно несколько уницифировать, например, так:
https://gist.github.com/Tishka17/9b2625753c80681e8ba688c84d834bb6
👍75🔥114🤮3🤡3❤‍🔥2🥱2🐳21😢1