fast-clean 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fast_clean/contrib/healthcheck/router.py +1 -1
- fast_clean/{schemas/status_response.py → contrib/healthcheck/schemas.py} +1 -4
- fast_clean/middleware.py +3 -0
- fast_clean/repositories/crud/__init__.py +3 -2
- fast_clean/services/cryptography/__init__.py +2 -1
- fast_clean/settings.py +4 -4
- fast_clean-1.2.2.dist-info/METADATA +1426 -0
- {fast_clean-1.2.0.dist-info → fast_clean-1.2.2.dist-info}/RECORD +10 -10
- fast_clean-1.2.0.dist-info/METADATA +0 -43
- {fast_clean-1.2.0.dist-info → fast_clean-1.2.2.dist-info}/WHEEL +0 -0
- {fast_clean-1.2.0.dist-info → fast_clean-1.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1426 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: fast-clean
|
3
|
+
Version: 1.2.2
|
4
|
+
Summary: FastAPI Clean Architecture implementation
|
5
|
+
Author-email: Luferov Victor <luferovvs@yandex.ru>, Orlov Artem <squakrazv@yandex.ru>, Kashapov Rustam <hardtechnik91@gmail.com>
|
6
|
+
Requires-Python: >=3.13
|
7
|
+
Description-Content-Type: text/markdown
|
8
|
+
Requires-Dist: aiofiles>=24.1.0
|
9
|
+
Requires-Dist: aiokafka>=0.12.0
|
10
|
+
Requires-Dist: aioprometheus>=23.12.0
|
11
|
+
Requires-Dist: alembic>=1.16.2
|
12
|
+
Requires-Dist: cryptography>=44.0.1
|
13
|
+
Requires-Dist: dishka>=1.6.0
|
14
|
+
Requires-Dist: fastapi>=0.115.8
|
15
|
+
Requires-Dist: fastapi-cache2[redis]>=0.2.2
|
16
|
+
Requires-Dist: faststream>=0.5.34
|
17
|
+
Requires-Dist: flatten-dict>=0.4.2
|
18
|
+
Requires-Dist: miniopy-async>=1.21.1
|
19
|
+
Requires-Dist: overrides>=7.7.0
|
20
|
+
Requires-Dist: psycopg[binary]>=3.2.4
|
21
|
+
Requires-Dist: pydantic>=2.10.6
|
22
|
+
Requires-Dist: pydantic-settings>=2.8.0
|
23
|
+
Requires-Dist: pyyaml>=6.0.2
|
24
|
+
Requires-Dist: sentry-sdk>=2.32.0
|
25
|
+
Requires-Dist: snakecase>=1.0.1
|
26
|
+
Requires-Dist: sqlalchemy-utils>=0.41.2
|
27
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.38
|
28
|
+
Requires-Dist: stringcase>=1.2.0
|
29
|
+
Requires-Dist: typer>=0.15.1
|
30
|
+
|
31
|
+
# FastClean
|
32
|
+
|
33
|
+
FastAPI clean architecture implementation
|
34
|
+
|
35
|
+
|
36
|
+
## Contribution
|
37
|
+
|
38
|
+
```
|
39
|
+
git clone git@github.com:Luferov/fast-clean.git
|
40
|
+
uv sync
|
41
|
+
uv run pre-commit install
|
42
|
+
```
|
43
|
+
|
44
|
+
Python является высокоуровневым языком программирования с простой и понятной синтаксической структурой, что значительно ускоряет процесс разработки и облегчает поддержку кода. Его читаемость и лаконичность позволяют командам разработчиков быстро вводить новые функции и исправлять ошибки без потери качества. Это особенно важно при создании архитектуры сложных систем, где ясность и поддерживаемость кода имеют первостепенное значение.
|
45
|
+
## Инструментарий
|
46
|
+
В качестве пакетных менеджеров используются два основных:
|
47
|
+
1. [uv](https://docs.astral.sh/uv/) - рекомендуемый
|
48
|
+
|
49
|
+
Стандартный менеджер пакетных зависимостей [pip](https://pip.pypa.io/) используется в случаях, когда проект является точечным, "одноразовым" и не планирется к развитию после окончания разработки.
|
50
|
+
|
51
|
+
Основной инструментарий для разработки бэкенд сервисов можно разделить на два условных раздела: для непосредственной работы приложения, а также для помощи в разработке. Прежде, чем приступать к разработке необходимо ознакомиться с основным инструментарием, который представлен ниже:
|
52
|
+
1. Основные инструменты для использования при реализации сервисов:
|
53
|
+
- [FastAPI](https://fastapi.tiangolo.com/)
|
54
|
+
- [Pydantic](https://docs.pydantic.dev/latest/)
|
55
|
+
- [taskiq](https://taskiq-python.github.io)
|
56
|
+
- [SqlAlchemy](https://www.sqlalchemy.org/)
|
57
|
+
- [sqlmodel](https://sqlmodel.tiangolo.com)
|
58
|
+
- [Alembic](https://alembic.sqlalchemy.org/en/latest/)
|
59
|
+
- [Httpx](https://www.python-httpx.org/)
|
60
|
+
- [Python-Jwt](https://pyjwt.readthedocs.io/en/stable/)
|
61
|
+
- [Prefect](https://www.prefect.io/)
|
62
|
+
- [FastStream](https://faststream.airt.ai/latest)
|
63
|
+
- [uvicorn](https://www.uvicorn.org/)
|
64
|
+
- [Dishka](https://dishka.readthedocs.io/en/stable/)
|
65
|
+
- [apscheduler](https://github.com/agronholm/apscheduler)
|
66
|
+
- [sentry](https://docs.sentry.io/platforms/python/)
|
67
|
+
2. Вспомогательные инструменты для разработки:
|
68
|
+
- [pytest](https://docs.pytest.org/en/stable/)
|
69
|
+
- [pytest-coverage](https://github.com/pytest-dev/pytest-cov)
|
70
|
+
- [ruff](https://github.com/astral-sh/ruff)
|
71
|
+
- [mypy](https://www.mypy-lang.org/)
|
72
|
+
- [pre-commit](https://pre-commit.com/)
|
73
|
+
- [black](https://black.readthedocs.io/en/stable/)
|
74
|
+
- [ipykernel](https://ipython.readthedocs.io/en/)
|
75
|
+
|
76
|
+
## Типовая структура сервисов
|
77
|
+
|
78
|
+
Ниже представлена типовая структура сервисов, а также описание дирректорий и файлов. Выход за пределы структуры позволяется только с разрешения техлида или лидера компетенций соответствующего направления, а также лидера разработки.
|
79
|
+
```
|
80
|
+
📁 {{ project name }} // Название проекта
|
81
|
+
| 📁 .vscode // Конфигурации vscode
|
82
|
+
| |- tasks.json // Файл с командами для расширения TaskExprorer
|
83
|
+
| |- launch.json // Файл с командами для запуска сервиса с дебаггером
|
84
|
+
| |- settings.json // Файл конфигурации редактора vscode
|
85
|
+
| 📁 migrations // Файл с миграциями базы данных alembic
|
86
|
+
| | 📁 versions
|
87
|
+
| | | 2024_09_11_comment.py // Файл с миграцией базы данных
|
88
|
+
| | __init__.py
|
89
|
+
| | env.py // Настройки окружения для генерации и накатывания миграций
|
90
|
+
| | script.py.mako // Шаблон для генерации миграций
|
91
|
+
| | utils.py // Вспомогательные утилиты для генерации миграций
|
92
|
+
| 📁 {{ service name }} // Название сервиса, например: portal, users, processing ...
|
93
|
+
| | 📁 apps
|
94
|
+
| | | 📁 healthcheck // Приложение для проверки healthcheck
|
95
|
+
| | | | __init__.py // Испорты модуля healthcheck
|
96
|
+
| | | | router.py // Роуты приложения healthcheck
|
97
|
+
| | | | schemas.py // DTO схемы приложения healthcheck
|
98
|
+
| | | 📁 users // Приложение пользователей
|
99
|
+
| | | | 📁 repositories // Слой репозиториев приложения users
|
100
|
+
| | | | | __init__.py // Экспорты репозитория пользователей
|
101
|
+
| | | | | users.py // Репозиторий пользователей
|
102
|
+
| | | | | company.py // Репозиторий компаний
|
103
|
+
| | | | 📁 services // Сервисный слой приложения users
|
104
|
+
| | | | | __init__.py // Экспорты сервисного слоя приложения users
|
105
|
+
| | | | | users.py // Сервис пользователей
|
106
|
+
| | | | | company.py // Сервис компаний
|
107
|
+
| | | | 📁 use_cases // Слой UseCase (UserStory)
|
108
|
+
| | | | | __init__.py // Экспорты UseCases
|
109
|
+
| | | | | user_list.py // Получение списка пользователей
|
110
|
+
| | | | | create_user.py // Создание пользователя
|
111
|
+
| | | | | update_user.py // Обновление пользователя
|
112
|
+
| | | | | delete_user.py // Удаление пользователя
|
113
|
+
| | | | 📁 schemas // Схемы проекта
|
114
|
+
| | | | | __init__.py // Экспорт схем приложения users
|
115
|
+
| | | | | users.py // Схемы пользователей
|
116
|
+
| | | | | company.py // Схемы компаний
|
117
|
+
| | | | __init__.py
|
118
|
+
| | | | container.py // Построение IoC контейнера
|
119
|
+
| | | | enums.py // Константы перечислений
|
120
|
+
| | | | models.py // Модели схемы базы данных приложения users
|
121
|
+
| | | | serializers.py // Правила и механизм сериализации объектов
|
122
|
+
| | | | router.py // Роутер и контроллеры
|
123
|
+
| | | | exceptions.py // Исключения, наследуемые от core/exceptions
|
124
|
+
| | | 📁 other... // Другие приложения в сервисе
|
125
|
+
| | 📁 certs // Сертификаты для локальной разработки
|
126
|
+
| | | 📁 elasticsearh // Сертификаты для elastic search
|
127
|
+
| | | | ca.pem // Корневой сертификт
|
128
|
+
| | | 📁 kafka // Серитфикаты для kafka
|
129
|
+
| | | | ca.pem // Корневой сертификат
|
130
|
+
| | | | cert.pem // Сертификат пользователя
|
131
|
+
| | | | key.crt // Ключ
|
132
|
+
| | 📁 entrypoints // Точки входа в приложение
|
133
|
+
| | | | __init__.py
|
134
|
+
| | | | rest.py // Запуска rest приложений
|
135
|
+
| | | | grpc.py // Запуска grpc приложений
|
136
|
+
| | | __init__.py
|
137
|
+
| | | db.py // Настройки подключения к базе данных
|
138
|
+
| | | depends.py // Построение IoC контейнера
|
139
|
+
| | | enums.py // Список Enums
|
140
|
+
| | | exceptions.py // Набор общих исключений
|
141
|
+
| | | loggers.py // Настройки логгера
|
142
|
+
| | | models.py // Миксины моделей схем баз данных
|
143
|
+
| | | schemas.py // Общие схемы (DTO)
|
144
|
+
| | | use_cases.py // Общие элементы UseCases
|
145
|
+
| | 📁 tools // Cli утилиты
|
146
|
+
| | | 📁 core // Модуль общих команд
|
147
|
+
| | | | __init__.py // Модель общих команд
|
148
|
+
| | | | cryptography.py // Команда cryptography
|
149
|
+
| | | app.py // Точка входа в cli приложение
|
150
|
+
| | | utils.py // Утилиты cli приложения
|
151
|
+
| | __init__.py
|
152
|
+
| | bootstrap.py // Файл сборки приложения
|
153
|
+
| | exceptions.py // Файл с набором обработчиков исключений
|
154
|
+
| | main.py // Точка входа в приложение
|
155
|
+
| | middleware.py // Добавление сервисных прослоек
|
156
|
+
| | router.py // root роутер приложения
|
157
|
+
| | settings.py // Файл с набором конфигураций приложения
|
158
|
+
| | swagger.py // Файл для доп настрйоки свагера
|
159
|
+
| 📁 seed // Пресеты данных для заполнения в СУБД
|
160
|
+
| | 001.users.json // Заготовка пользователей
|
161
|
+
| | 002.groups.json // Группы пользователей
|
162
|
+
| 📁 tests // Unit тесты сервиса
|
163
|
+
| | 📁 apps // Набор приложений
|
164
|
+
| | | 📁 healthcheck // Тесты для приложения healthcheck
|
165
|
+
| | | | conftest.py // Фикстуры, относящиеся к приложению healthcheck
|
166
|
+
| | | | test_router.py // Тесты роута
|
167
|
+
| | | | test_repositories.py // Тесты слоя репозиториев
|
168
|
+
| | | | test_services.py // Тесты сервисного слоя
|
169
|
+
| | conftest.py // Файл, содержащий фикстуры всего проекта
|
170
|
+
| .dockerignore // Игнорирование при сборке докер образов
|
171
|
+
| .env.example // Пример файла .env
|
172
|
+
| .gitignore // Игнорирование git
|
173
|
+
| .isort.cfg // Параметры сортировки импортов
|
174
|
+
| .logging.dev.yaml // Параметры логгирования при локальной разработки
|
175
|
+
| .logging.yaml // Параметры логгирования в прод окружении
|
176
|
+
| .pre-commit-config.yaml // Конфигурация прекоммит файлов
|
177
|
+
| .python-version // Версия python, для pyenv
|
178
|
+
| .alembic.ini // Конфигцрационный файл для alembic
|
179
|
+
| .docker-compose.yaml // Файл инфраструктуры для изолированной разработки
|
180
|
+
| Dockerfile // Файл описания Docker контейнера
|
181
|
+
| entrypoint.sh // Docker entrypoint
|
182
|
+
| Makefile // Файл вспомогательных команд
|
183
|
+
| manage.py // Точка входа для cli утилит
|
184
|
+
| poetry.lock // Устанавливаемые зависимости и их версии
|
185
|
+
| pyproject.toml // Файл конфигурации проекта
|
186
|
+
| pytest.ini // Конфигурация unit-тестов
|
187
|
+
| README.md // Описание проекта и предметной области
|
188
|
+
```
|
189
|
+
### Файлы проекта
|
190
|
+
|
191
|
+
```
|
192
|
+
📁 .devcontainer // Файлы для организации разработки в контейнера
|
193
|
+
📁 .vscode // Настройки редактора vscode
|
194
|
+
📁 migrations // Миграции alembic для sqlalchemy
|
195
|
+
📁 seed // Загрузки начальных данных, словарей
|
196
|
+
📁 src | {{ service name }} // Файлы со структурой проекта
|
197
|
+
📁 tests // Тесты проекта
|
198
|
+
.dockerignore // Файл для игнорирования при копировании файлов внутрь образов
|
199
|
+
.env.example // Пример файла для
|
200
|
+
.gitignore // Игнорирование файлов в VCS
|
201
|
+
.pre-commit-config.yaml // Конфигурация прекоммита
|
202
|
+
.python-version // Указание версии python для пакетных менеджеров
|
203
|
+
alembic.ini // Конфигурация для исполнения миграций
|
204
|
+
docker-compose.yaml // Файл поднятия инфраструктуры
|
205
|
+
Dockerfile // Файл сборки докер образа
|
206
|
+
entrypoint.sh // Точка входа в приложение
|
207
|
+
Makefile // Список вспомогательных команд
|
208
|
+
manage.py // Точка входа в CLI команды
|
209
|
+
pyproject.toml // Конфигурация зависимостей и проекта
|
210
|
+
README.md // Описание проекта
|
211
|
+
```
|
212
|
+
|
213
|
+
## Модели (_Models_)
|
214
|
+
**Модель БД** — это структурированное описание данных, хранящихся в СУБД. Она определяет, какие таблицы существуют, какие у них поля (колонки), типы данных этих полей, связи между таблицами, ограничения (constraints), индексы и т. д. Модель может включать в себя следующие сущности:
|
215
|
+
- Таблицы - основные структуры хранения данных;
|
216
|
+
- Столбцы - поля в таблицах с различными типами данных, например, VARCHAR, INTEGER, BOOLEAN, TIMESTAMP и другие;
|
217
|
+
- Первичный ключ - уникальные идентификаторы записей, должны однозначно определять запись в таблицы и быть минимальными;
|
218
|
+
- Внешние ключ - реляционные связи между таблицами;
|
219
|
+
- Индексы - набор полей, служащий для ускорения поиска и фильтрации (замедляют вставку);
|
220
|
+
- Представления, триггеры, функции и схемы - дополнительные элементы модели БД.
|
221
|
+
**ORM (Object-Relational Mapping)** — это технология, которая позволяет программисту работать с базой данных **через обычные объекты и классы**, а не через SQL-запросы. Необходимо также помнить об объектно-реляционном разрыве:
|
222
|
+
|
223
|
+
| В ООП | В реляционной БД |
|
224
|
+
| ---------------------------- | ------------------------------ |
|
225
|
+
| Классы и объекты | Таблицы и строки |
|
226
|
+
| Наследование классов | Нет прямого аналога |
|
227
|
+
| Связи через атрибуты/объекты | Связи через foreign keys |
|
228
|
+
| Инкапсуляция | Данные открыты и плоские |
|
229
|
+
| Иерархии | Требуют JOIN’ов и нормализации |
|
230
|
+
В качестве primary key могут использоваться следующие ключи:
|
231
|
+
- **BigInt** - AutoIncrement;
|
232
|
+
- **UUID** - только uuid версии 7.
|
233
|
+
Пример таблицы пользователей с использованием [SqlAlchemy](https://www.sqlalchemy.org) представлен ниже.
|
234
|
+
|
235
|
+
```python
|
236
|
+
# apps/users/models.py
|
237
|
+
|
238
|
+
import uuid
|
239
|
+
from datetime import datetime
|
240
|
+
import sqlalchemy as sa
|
241
|
+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
|
242
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
243
|
+
from sqlalchemy.sql import func
|
244
|
+
from sqlalchemy_utils.types import UUIDType
|
245
|
+
from va_core.db import Base
|
246
|
+
from va_core.models import TimestampMixin
|
247
|
+
|
248
|
+
|
249
|
+
class User(Base):
|
250
|
+
"""
|
251
|
+
Модель пользователя.
|
252
|
+
"""
|
253
|
+
|
254
|
+
__tablename__ = 'users'
|
255
|
+
|
256
|
+
id: Mapped[uuid.UUID] = mapped_column(UUIDType(binary=False), primary_key=True, default=uuid.uuid7, server_default=func.gen_random_uuid())
|
257
|
+
username: Mapped[str] = mapped_column(String(length=100), unique=True, index=True, nullable=False)
|
258
|
+
email: Mapped[str] = mapped_column(String(length=320), unique=True, index=True, nullable=False)
|
259
|
+
hashed_password: Mapped[str] = mapped_column(String(length=1024), nullable=True)
|
260
|
+
first_name: Mapped[str] = mapped_column(String(length=100), nullable=False)
|
261
|
+
second_name: Mapped[str | None] = mapped_column(String(length=100), nullable=True)
|
262
|
+
last_name: Mapped[str] = mapped_column(String(length=100), nullable=False)
|
263
|
+
avatar: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
264
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
|
265
|
+
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
|
266
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.false(), nullable=False)
|
267
|
+
login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
268
|
+
|
269
|
+
def __repr__(self: Self) -> str:
|
270
|
+
return f'User(id={self.id!r}, email={self.email!r}, username={self.username!r})'
|
271
|
+
```
|
272
|
+
|
273
|
+
После описания файла с моделями в каком-то из доменов, необходимо создать миграцию БД с помощью [alembic](https://alembic.sqlalchemy.org/en/latest/).
|
274
|
+
Из файла конфигурации `alembic.ini` нас интересуют пять полей:
|
275
|
+
```ini
|
276
|
+
[alembic]
|
277
|
+
# Дирректория, в которой будут находиться миграции
|
278
|
+
script_location = migrations
|
279
|
+
# Шаблон файла для генерации новой миграции
|
280
|
+
# Например: 2024_09_11_initial.py
|
281
|
+
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(slug)s
|
282
|
+
|
283
|
+
# Добавление в sys.path дирректории
|
284
|
+
prepend_sys_path = .
|
285
|
+
|
286
|
+
# Разделить файлового пути / или \. По умолчанию тот, который в текущей операционной системе
|
287
|
+
version_path_separator = os
|
288
|
+
|
289
|
+
# Строка подключения к СУБД, в частности, postgres
|
290
|
+
# async_fallback - попробует использовать asyncpg, если не получится - перейдет автоматически на psycopg2 (блокирующий синхронный драйвер)
|
291
|
+
# options=-csearch_path= - задание дефолтной схемы для соединения, по умолчанию public
|
292
|
+
sqlalchemy.url = %(DB_PROVIDER)s://%(DB_USER)s:%(DB_PASSWORD)s@%(DB_HOST)s:%(DB_PORT)s/%(DB_NAME)s?async_fallback=True&options=-csearch_path=%(DB_SCHEMA)s
|
293
|
+
```
|
294
|
+
Для работы с SqlAlchemy есть замечательная библиотека [SqlAlchemy-utils](https://sqlalchemy-utils.readthedocs.io/en/latest/). Однако для корректной работы этой библиотеке, нужно помочь `alembic` генерировать типы из [SqlAlchemy-utils](https://sqlalchemy-utils.readthedocs.io/en/latest/):
|
295
|
+
```python
|
296
|
+
import sqlalchemy_utils
|
297
|
+
|
298
|
+
def render_item(type_, obj, autogen_context):
|
299
|
+
"""
|
300
|
+
Apply custom rendering for selected items.
|
301
|
+
"""
|
302
|
+
if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.ChoiceType):
|
303
|
+
autogen_context.imports.add('import sqlalchemy_utils')
|
304
|
+
autogen_context.imports.add(f'from {obj.choices.__module__} import {obj.choices.__name__}') # type: ignore
|
305
|
+
return f'sqlalchemy_utils.types.ChoiceType({obj.choices.__name__}, impl=sa.{obj.impl.__class__.__name__}())' # type: ignore
|
306
|
+
|
307
|
+
if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.JSONType):
|
308
|
+
autogen_context.imports.add('import sqlalchemy_utils')
|
309
|
+
return 'sqlalchemy_utils.types.JSONType()'
|
310
|
+
|
311
|
+
if type_ == 'type' and isinstance(obj, sqlalchemy_utils.types.UUIDType):
|
312
|
+
autogen_context.imports.add('import sqlalchemy_utils')
|
313
|
+
return f'sqlalchemy_utils.types.UUIDType(binary={obj.binary})'
|
314
|
+
|
315
|
+
# Default rendering for other objects
|
316
|
+
return False
|
317
|
+
```
|
318
|
+
По сути, эти строчки помогают `alembic` точнее генерировать миграцию для объектов, взятых из `sqlalchemy_utils`. Однако, нужно указать эту функцию в файле `migrations/env.py`:
|
319
|
+
```python
|
320
|
+
from importlib import import_module
|
321
|
+
from logging.config import fileConfig
|
322
|
+
from typing import TYPE_CHECKING
|
323
|
+
|
324
|
+
from alembic import context
|
325
|
+
from sqlalchemy import engine_from_config, pool
|
326
|
+
from {{ project_name }}.settings import settings
|
327
|
+
from fast_clean.db import Base
|
328
|
+
# Для статического анализатора
|
329
|
+
if TYPE_CHECKING:
|
330
|
+
from .utils import render_item
|
331
|
+
else:
|
332
|
+
from migrations.utils import render_item
|
333
|
+
|
334
|
+
# Импорт моделей
|
335
|
+
import_module('{{ project_name }}.apps.users.models')
|
336
|
+
|
337
|
+
# Доступ к значениям из файла конфигурации alembic.ini
|
338
|
+
config = context.config
|
339
|
+
section = config.config_ini_section
|
340
|
+
# Установка параметров конфигурации, которые потом используются в строке подключения
|
341
|
+
config.set_section_option(section, 'DB_PROVIDER', settings.db.provider)
|
342
|
+
config.set_section_option(section, 'DB_USER', settings.db.user)
|
343
|
+
config.set_section_option(section, 'DB_PASSWORD', settings.db.password)
|
344
|
+
config.set_section_option(section, 'DB_HOST', settings.db.host)
|
345
|
+
config.set_section_option(section, 'DB_PORT', str(settings.db.port))
|
346
|
+
config.set_section_option(section, 'DB_NAME', settings.db.name)
|
347
|
+
config.set_section_option(section, 'DB_SCHEMA', settings.db.scheme)
|
348
|
+
|
349
|
+
# Interpret the config file for Python logging.
|
350
|
+
# This line sets up loggers basically.
|
351
|
+
if config.config_file_name is not None:
|
352
|
+
fileConfig(config.config_file_name)
|
353
|
+
|
354
|
+
# add your model's MetaData object here
|
355
|
+
# for 'autogenerate' support
|
356
|
+
# from myapp import mymodel
|
357
|
+
# target_metadata = mymodel.Base.metadata
|
358
|
+
target_metadata = Base.metadata
|
359
|
+
|
360
|
+
|
361
|
+
def run_migrations_offline() -> None:
|
362
|
+
"""
|
363
|
+
Run migrations in 'offline' mode.
|
364
|
+
|
365
|
+
This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available.
|
366
|
+
Calls to context.execute() here emit the given string to the script output.
|
367
|
+
"""
|
368
|
+
url = config.get_main_option('sqlalchemy.url')
|
369
|
+
context.configure(
|
370
|
+
url=url,
|
371
|
+
target_metadata=target_metadata,
|
372
|
+
literal_binds=True,
|
373
|
+
compare_server_default=True,
|
374
|
+
dialect_opts={'paramstyle': 'named'},
|
375
|
+
)
|
376
|
+
|
377
|
+
with context.begin_transaction():
|
378
|
+
context.run_migrations()
|
379
|
+
|
380
|
+
def run_migrations_online() -> None:
|
381
|
+
"""
|
382
|
+
Run migrations in 'online' mode.
|
383
|
+
|
384
|
+
In this scenario we need to create an Engine and associate a connection with the context.
|
385
|
+
"""
|
386
|
+
connectable = engine_from_config(
|
387
|
+
config.get_section(config.config_ini_section, {}),
|
388
|
+
prefix='sqlalchemy.',
|
389
|
+
poolclass=pool.NullPool,
|
390
|
+
)
|
391
|
+
with connectable.connect() as connection:
|
392
|
+
context.configure(
|
393
|
+
connection=connection,
|
394
|
+
target_metadata=target_metadata,
|
395
|
+
render_item=render_item, # добавляем
|
396
|
+
)
|
397
|
+
with context.begin_transaction():
|
398
|
+
context.run_migrations()
|
399
|
+
|
400
|
+
if context.is_offline_mode():
|
401
|
+
run_migrations_offline()
|
402
|
+
else:
|
403
|
+
run_migrations_online()
|
404
|
+
```
|
405
|
+
|
406
|
+
Миграция генерируется вызовом сдедующей команды:
|
407
|
+
```bash
|
408
|
+
uv run alembic revision --autogenerate -m "$(NAME)" # $(NAME) - название миграции
|
409
|
+
```
|
410
|
+
Накатывается миграция вызовом следующей команды:
|
411
|
+
```python
|
412
|
+
uv run alembic upgrade head
|
413
|
+
```
|
414
|
+
Другие команды можно посмотреть на официальном сайте `alembic`.
|
415
|
+
|
416
|
+
## Схемы (_Schemas_)
|
417
|
+
Для реализации базовых [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) операция для каждой модели необходимо написать как минимум три дополнительных [pydantic](https://docs.pydantic.dev/latest/) схемы ([DTO](https://en.wikipedia.org/wiki/Data_transfer_object)):
|
418
|
+
- **ReadSchema** - схема для чтения данных;
|
419
|
+
- **CreateSchema** - схема, которая содержит необходимые поля для создания записи в БД;
|
420
|
+
- **UpdateSchema** - схема для обновления полей в БД.
|
421
|
+
|
422
|
+
Схемы для работы с пользователем представлены ниже:
|
423
|
+
```python
|
424
|
+
# {{ project_name }}/apps/users/schemas/users.py
|
425
|
+
|
426
|
+
import uuid
|
427
|
+
from datetime import datetime
|
428
|
+
from pydantic import ConfigDict, EmailStr, Field
|
429
|
+
|
430
|
+
# Базовые схемы, содержащие общий контракт
|
431
|
+
from fast_clean.schemas import (
|
432
|
+
CreateSchema,
|
433
|
+
ReadSchema,
|
434
|
+
UpdateSchema,
|
435
|
+
)
|
436
|
+
|
437
|
+
|
438
|
+
class UserCreateSchema(CreateSchema):
|
439
|
+
"""
|
440
|
+
Схема для создания пользователя.
|
441
|
+
"""
|
442
|
+
|
443
|
+
username: str
|
444
|
+
email: EmailStr
|
445
|
+
hashed_password: str | None
|
446
|
+
first_name: str
|
447
|
+
second_name: str | None
|
448
|
+
last_name: str
|
449
|
+
avatar: str | None
|
450
|
+
is_active: bool
|
451
|
+
is_superuser: bool
|
452
|
+
|
453
|
+
|
454
|
+
class UserReadSchema(ReadSchema):
|
455
|
+
"""
|
456
|
+
Схема для чтения пользователя.
|
457
|
+
"""
|
458
|
+
|
459
|
+
model_config = ConfigDict(from_attributes=True)
|
460
|
+
|
461
|
+
username: str
|
462
|
+
email: EmailStr
|
463
|
+
hashed_password: str | None
|
464
|
+
first_name: str
|
465
|
+
second_name: str | None
|
466
|
+
last_name: str
|
467
|
+
avatar: str | None
|
468
|
+
is_superuser: bool
|
469
|
+
is_active: bool
|
470
|
+
is_deleted: bool
|
471
|
+
login_at: datetime | None
|
472
|
+
created_at: datetime
|
473
|
+
updated_at: datetime
|
474
|
+
|
475
|
+
|
476
|
+
class UserUpdateSchema(UpdateSchema):
|
477
|
+
"""
|
478
|
+
Схема для обновления пользователя.
|
479
|
+
"""
|
480
|
+
|
481
|
+
username: str | None = None
|
482
|
+
email: EmailStr | None = None
|
483
|
+
hashed_password: str | None = None
|
484
|
+
first_name: str | None = None
|
485
|
+
second_name: str | None = None
|
486
|
+
last_name: str | None = None
|
487
|
+
avatar: str | None = None
|
488
|
+
is_superuser: bool | None = None
|
489
|
+
is_active: bool | None = None
|
490
|
+
is_deleted: bool | None = None
|
491
|
+
login_at: datetime | None = None
|
492
|
+
```
|
493
|
+
|
494
|
+
Правилом хорошего тона будет сделать реэкспорт из `{{ project_name }}/apps/users/schemas/__init__.py`, чтобы в файле `users.py` не прописывать `__all__ = ('UserCreateSchema' , 'UserReadSchema', 'UserUpdateSchema',)`:
|
495
|
+
```python
|
496
|
+
# {{ project_name }}/apps/users/schemas/__init__.py
|
497
|
+
|
498
|
+
from .users import UserCreateSchema as UserCreateSchema
|
499
|
+
from .users import UserReadSchema as UserReadSchema
|
500
|
+
from .users import UserUpdateSchema as UserUpdateSchema
|
501
|
+
```
|
502
|
+
## Перечисления (_Enums_)
|
503
|
+
Типы перечислений записываются в соответствующих файлах `enums.py` в каждом приложении. В конце названия типа должны быть суфикс `Enum`:
|
504
|
+
```python
|
505
|
+
"""
|
506
|
+
Модуль, содержащий перечисления приложения auth.
|
507
|
+
"""
|
508
|
+
from enum import StrEnum, auto
|
509
|
+
|
510
|
+
class PasswordResetStatusEnum(StrEnum):
|
511
|
+
"""
|
512
|
+
Статус сброс пароля.
|
513
|
+
"""
|
514
|
+
|
515
|
+
ACTIVE = auto()
|
516
|
+
APPLIED = auto()
|
517
|
+
REJECTED = auto()
|
518
|
+
```
|
519
|
+
Использовать `ENUM` в PostgreSQL **не рекомендуется в большинстве продакшен-сценариев** по следующим причинам:
|
520
|
+
- Добавить новое значение в `ENUM` можно только **через команду `ALTER TYPE`**, это **DDL-операция**, которая:
|
521
|
+
- блокирует доступ к типу во время выполнения;
|
522
|
+
- не может быть выполнена внутри транзакции (`BEGIN ... COMMIT`) до PostgreSQL 12.
|
523
|
+
- Если вы используете миграции (например, Alembic), изменение `ENUM` требует **ручного вмешательства** или нестандартных паттернов.
|
524
|
+
- Трудно откатить миграцию или обновить enum-состояние без "костылей".
|
525
|
+
- Если вы экспортируете/импортируете данные или работаете с несколькими СУБД — `ENUM` не всегда переносится корректно.
|
526
|
+
- В коде (например, на Python с SQLAlchemy) `ENUM` иногда ведёт себя непредсказуемо при сериализации/десериализации, особенно если значения изменяются.
|
527
|
+
- Нельзя переименовать существующие значения без костылей.
|
528
|
+
- Нельзя удалить значение.
|
529
|
+
- Нельзя изменить порядок.
|
530
|
+
- `ENUM`- значения не поддаются "мягкому" контролю бизнес-логики (например, нельзя легко сделать "архивным").
|
531
|
+
## Репозитории (_Repositories_)
|
532
|
+
**Репозиторий** — это интерфейс, описывающий набор операций для работы с сущностями предметной области, который отделяет бизнес-логику от инфраструктурных деталей хранения данных.
|
533
|
+
#### Репозиторий СУБД
|
534
|
+
Выше описана модель `User`, которая в базу данных проецируется как таблица `users`.
|
535
|
+
В библотеке [fast_clean](https://github.com/Luferov/fast-clean) описан базовый [CRUDRepository](https://github.com/Luferov/fast-clean/blob/main/fast_clean/repositories/crud/db.py), которые содержит общие методы для всех моделей. Каждый репозиторий, который отражает таблицу в СУБД должен быть отнаследован от базового репозитория `DbCrudRepository`:
|
536
|
+
```python
|
537
|
+
class DbCrudRepository(
|
538
|
+
DbCrudRepositoryBase[ModelType, ReadSchemaType, CreateSchemaType, UpdateSchemaType, uuid.UUID],
|
539
|
+
Generic[ModelType, ReadSchemaType, CreateSchemaType, UpdateSchemaType],
|
540
|
+
):
|
541
|
+
"""
|
542
|
+
Репозиторий для выполнения CRUD операций над моделями нового типа в базе данных.
|
543
|
+
"""
|
544
|
+
|
545
|
+
__abstract__ = True
|
546
|
+
```
|
547
|
+
|
548
|
+
Класс для репозитория сервиса пользователей будет с учетом описанных выше схем DTO будет выглядеть следующим образом:
|
549
|
+
```python
|
550
|
+
class UserRepository(
|
551
|
+
DbCrudRepository[User, UserReadSchema, UserCreateSchema, UserUpdateSchema]
|
552
|
+
):
|
553
|
+
|
554
|
+
async def exists_with_username(self: Self, username: str) -> bool:
|
555
|
+
"""
|
556
|
+
Проверяем существует ли пользователь с заданным username.
|
557
|
+
"""
|
558
|
+
async with self.session_manager.get_session() as s:
|
559
|
+
statement = sa.exists().where(self.model_type.username == username).select()
|
560
|
+
return bool((await s.execute(statement)).scalar())
|
561
|
+
```
|
562
|
+
|
563
|
+
В данном случае, класс вместе со стандартными методами из класса `DbCrudRepository` содержит также метод проверки существования пользователя по `username`. Список методов из DbCrudRepository следующий:
|
564
|
+
- `async def get(self: Self, id: IdType) -> ReadSchemaBaseType` - получение записи по идентификатору;
|
565
|
+
- `async def get_or_none(self: Self, id: IdType) -> ReadSchemaBaseType | None: ` - получение записи, если запись не найдена `None`;
|
566
|
+
- `async def get_by_ids(self: Self, ids: Sequence[IdType], *, exact: bool = False) -> list[ReadSchemaBaseType]:` - получение записей по списку идентификаторов;
|
567
|
+
- `async def get_all(self: Self) -> list[ReadSchemaBaseType]:` - получение всех записей (_использовать осторожно_);
|
568
|
+
- реализация _offset_ пагинации
|
569
|
+
```python
|
570
|
+
async def paginate(
|
571
|
+
self: Self,
|
572
|
+
pagination: PaginationSchema,
|
573
|
+
user: Any,
|
574
|
+
policies: list[str],
|
575
|
+
*,
|
576
|
+
search: str | None = None,
|
577
|
+
search_by: Iterable[str] | None = None,
|
578
|
+
sorting: Iterable[str] | None = None,
|
579
|
+
) -> PaginationResultSchema[ReadSchemaBaseType]:
|
580
|
+
```
|
581
|
+
- `async def create(self: Self, create_object: CreateSchemaBaseType) -> ReadSchemaBaseType:` - создание записи;
|
582
|
+
- `async def bulk_create(self: Self, create_objects: list[CreateSchemaBaseType]) -> list[ReadSchemaBaseType]:` - массовое создание записей;
|
583
|
+
- `async def update(self: Self, update_object: UpdateSchemaBaseType) -> ReadSchemaBaseType:` - обновление записи в БД;
|
584
|
+
- `async def bulk_update(self: Self, update_objects: list[UpdateSchemaBaseType]) -> None:` - массовое обновление записей в БД по единой транзакцией;
|
585
|
+
- `async def upsert(self: Self, create_object: CreateSchemaBaseType) -> ReadSchemaBaseType:` - попытка обновить, в случае неуспеха запись создается;
|
586
|
+
- `async def delete(self: Self, ids: Sequence[IdType]) -> None:` - удаление записи;
|
587
|
+
- `def select(cls) -> sa.Select[tuple[ModelBaseType]]:` - выбор базовой модели и всех полей наследника, если такие есть (_classmethod_).
|
588
|
+
#### Остальные репозитории
|
589
|
+
Репозитории, которые отвечают за взаимодействие системы с внешним миром могут работать в двух режимах:
|
590
|
+
1. [C персистентным соединением соединением](https://ru.wikipedia.org/wiki/Постоянное_HTTP-соединение) - устанавливать соединение и держать его до завершения всех операций соединения.
|
591
|
+
```plantuml
|
592
|
+
@startuml
|
593
|
+
autonumber
|
594
|
+
|
595
|
+
participant Client
|
596
|
+
participant Server
|
597
|
+
|
598
|
+
|
599
|
+
Client -> Client: Открываем соединения
|
600
|
+
activate Client
|
601
|
+
|
602
|
+
Client -> Server: Запрос
|
603
|
+
activate Server
|
604
|
+
Server -> Client: Ответ
|
605
|
+
Client -> Server: Запрос
|
606
|
+
Server -> Client: Ответ
|
607
|
+
Client -> Server: Запрос
|
608
|
+
Server -> Client: Ответ
|
609
|
+
destroy Server
|
610
|
+
|
611
|
+
Client -> Client: Закрываем соединение
|
612
|
+
deactivate Client
|
613
|
+
@enduml
|
614
|
+
```
|
615
|
+
2. С постоянным разрывом соединение
|
616
|
+
```plantuml
|
617
|
+
@startuml
|
618
|
+
autonumber
|
619
|
+
|
620
|
+
participant Client
|
621
|
+
participant Server
|
622
|
+
|
623
|
+
|
624
|
+
== 1 действие ==
|
625
|
+
Client -> Client: Открываем соединения
|
626
|
+
activate Client
|
627
|
+
Client -> Server: Запрос
|
628
|
+
activate Server
|
629
|
+
Server -> Client: Ответ
|
630
|
+
deactivate Server
|
631
|
+
Client -> Client: Закрываем соединение
|
632
|
+
destroy Client
|
633
|
+
|
634
|
+
== 2 действие ==
|
635
|
+
Client -> Client: Открываем соединения
|
636
|
+
activate Client
|
637
|
+
Client -> Server: Запрос
|
638
|
+
activate Server
|
639
|
+
Server -> Client: Ответ
|
640
|
+
deactivate Server
|
641
|
+
Client -> Client: Закрываем соединение
|
642
|
+
destroy Client
|
643
|
+
@enduml
|
644
|
+
```
|
645
|
+
Очевидно при любой возможности необходимо использовать первый вариант. В таком случае, репозиторий должен представлять из себя контекстный менеджер, внутри которого будет обеспечиваться все взаимодействие. Пример представлен ниже:
|
646
|
+
```python
|
647
|
+
"""
|
648
|
+
Репозиторий по отправке файлов в VoiceAI.
|
649
|
+
"""
|
650
|
+
import uuid
|
651
|
+
from dataclasses import dataclass
|
652
|
+
from contextlib import AbstractAsyncContextManager
|
653
|
+
from typing import Any, Self
|
654
|
+
|
655
|
+
import httpx
|
656
|
+
|
657
|
+
from ..schemas import (
|
658
|
+
UserCreateSchema,
|
659
|
+
UserResultScheme,
|
660
|
+
UserRepositoryParams,
|
661
|
+
UserCreateSchema,
|
662
|
+
)
|
663
|
+
|
664
|
+
@dataclass
|
665
|
+
class UserRepository(AbstractAsyncContextManager):
|
666
|
+
"""
|
667
|
+
Репозиторий взаимодействия с пользователями через http соединение.
|
668
|
+
"""
|
669
|
+
self.params = params
|
670
|
+
self.client: httpx.AsyncClient | None = None
|
671
|
+
|
672
|
+
async def __aenter__(self: Self) -> Self:
|
673
|
+
if self.client is None:
|
674
|
+
self.client = self.make_client()
|
675
|
+
return self
|
676
|
+
|
677
|
+
async def __aexit__(
|
678
|
+
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any | None
|
679
|
+
) -> None:
|
680
|
+
if self.client:
|
681
|
+
await self.client.aclose()
|
682
|
+
self.client = None
|
683
|
+
return None
|
684
|
+
|
685
|
+
def make_client(self: Self, retries: int = 3, verify: bool = False) -> httpx.AsyncClient:
|
686
|
+
"""
|
687
|
+
Создаем экземпляр класса соединения.
|
688
|
+
"""
|
689
|
+
transport = httpx.AsyncHTTPTransport(retries=retries, verify=verify)
|
690
|
+
return httpx.AsyncClient(transport=transport)
|
691
|
+
|
692
|
+
async def create_user(self: Self, user: UserCreateSchema) -> UserResultScheme:
|
693
|
+
"""
|
694
|
+
Создаем user запись.
|
695
|
+
"""
|
696
|
+
assert self.client
|
697
|
+
response = await self.client.post(self.params.destination, json=user.model_dump(by_alias=True))
|
698
|
+
response.raise_for_status()
|
699
|
+
return UserResultScheme.model_validate(response.json())
|
700
|
+
|
701
|
+
async def delete(self: Self, user_id: uuid.UUID) -> None:
|
702
|
+
"""
|
703
|
+
Удаляем пользователя.
|
704
|
+
"""
|
705
|
+
assert self.client
|
706
|
+
await self.client.delete(f'{self.params.destination}/users/{user_id}')
|
707
|
+
|
708
|
+
```
|
709
|
+
При такой реализации использование будет выглядеть следующим образом:
|
710
|
+
```python
|
711
|
+
|
712
|
+
async with user_repository:
|
713
|
+
# Внутри keep-alive соединения
|
714
|
+
result = await user_repository.create_user(user)
|
715
|
+
await user_repository.delete(result.id)
|
716
|
+
|
717
|
+
```
|
718
|
+
## Сервисы (_Services_)
|
719
|
+
**Сервисный слой** — это архитектурное ядро, где живет _чистая бизнес-логика_ приложения. Сервисы инкапсулируют ключевые правила и процессы: валидацию данных, управление транзакциями, координацию между источниками информации, интеграцию с внешними системами и выполнение сложных вычислительных задач.
|
720
|
+
**Его главные достоинства — тестируемость и универсальность.** Сервисы легко проверять изолированно, а их независимость от деталей ввода/вывода (HTTP, UI) и конкретных пользовательских сценариев (_User Story_) позволяет гибко переиспользовать их в различных _UseCases_.
|
721
|
+
|
722
|
+
В качестве примера рассмотрим UserService, в котором реализована функция для создания пользователя.
|
723
|
+
```python
|
724
|
+
from dataclasses import dataclass
|
725
|
+
|
726
|
+
|
727
|
+
@dataclass
|
728
|
+
class UserService:
|
729
|
+
user_repository: UserRepository
|
730
|
+
password_service: PasswordService
|
731
|
+
|
732
|
+
async def create(self: Self, create_schema: UserCreateRequestSchema) -> UserReadSchema:
|
733
|
+
"""
|
734
|
+
Создаем пользователя.
|
735
|
+
"""
|
736
|
+
if await self.user_repository.exists_with_username(create_schema.username):
|
737
|
+
raise ModelAlreadyExistsError('username', 'Пользователь с таким логином уже существует')
|
738
|
+
if await self.user_repository.exists_with_email(create_schema.email):
|
739
|
+
raise ModelAlreadyExistsError('email', 'Пользователь с таким email уже существует')
|
740
|
+
register_dict = create_schema.model_dump()
|
741
|
+
password = register_dict.pop('password')
|
742
|
+
register_dict['hashed_password'] = None
|
743
|
+
register_dict['is_active'] = False
|
744
|
+
if settings.allow_set_password and password is not None:
|
745
|
+
if not await self.password_service.check_strength(create_schema.password):
|
746
|
+
raise WeakPasswordError(self.password_service.MIN_PASSWORD_LENGTH)
|
747
|
+
register_dict['hashed_password'] = await run_in_processpoll(self.password_service.hash, password)
|
748
|
+
register_dict['is_active'] = True
|
749
|
+
register_dict['avatar'] = None
|
750
|
+
register_dict['is_superuser'] = False
|
751
|
+
user_create_schema = UserCreateSchema.model_validate(register_dict)
|
752
|
+
return await self.user_repository.create(user_create_schema)
|
753
|
+
```
|
754
|
+
|
755
|
+
Как видно, сервис выполняет следующие действия обеспечивая создание пользователя:
|
756
|
+
- проверку на существования пользователя с _username_;
|
757
|
+
- проверка на существование пользователя с _email_;
|
758
|
+
- проверяет адекватность пароля и хеширует его с помощью _PasswordService_;
|
759
|
+
- заполняет поля по умолчанию, затем создает пользователя и возвращает пользователя выше.
|
760
|
+
Как видно из примера, в сервисе реализована валидация, а уже само создание реализуется с помощью _UserRepository_.
|
761
|
+
### Использование внешней библиотеки
|
762
|
+
Все внешние библиотеки в проекте должны иметь единую точку входа. Это означает, что в проекте должен существовать лишь один файл, где библиотека импортируется и все использование должно идти через этот модуль или класс.
|
763
|
+
Для примера, возьмём библиотеку _python-jose_, которая имплементируют в себе две функции `encode` и `decode`:
|
764
|
+
```python
|
765
|
+
from jose import jwt
|
766
|
+
token = jwt.encode({'key': 'value'}, 'secret', algorithm='HS256')
|
767
|
+
# token == u'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSJ9.FG-8UppwHaFp1LgRYQQeS6EDQF7_6-bMFegNucHjmWg'
|
768
|
+
jwt.decode(token, 'secret', algorithms=['HS256'])
|
769
|
+
# {u'key': u'value'}
|
770
|
+
```
|
771
|
+
Казалось бы, API библиотеки достаточно простое и почему бы сразу не использовать эти вызовы в необходимых местах. Однако, у нас возникает ситуация, при которой замена этой библиотеки на что-то другое приведет к необходимости поиска импортов по всему проекту, а также в тестах и велика вероятность что-то пропустить. Чтобы этого избежать, необходимо сделать "обертку" над этой библиотекой, поместив вызов этих функциой в методы сервисного класса:
|
772
|
+
```python
|
773
|
+
from typing import Self
|
774
|
+
from jose import jwt # единственное место импорта из библиотеки jwt
|
775
|
+
from dataclasses import dataclass
|
776
|
+
from {{ project_name }} import Settings
|
777
|
+
|
778
|
+
@dataclass
|
779
|
+
class CryptoService:
|
780
|
+
settings: Settings
|
781
|
+
|
782
|
+
def encode(self: Self, value: dict, *, algorithm: str = 'HS256') -> str:
|
783
|
+
return jwt.encode(value, self.settings.secret_key, algorithm=algorithm)
|
784
|
+
|
785
|
+
def decode(self: Self, token: str, *, algorithm: str = 'HS256') -> dict:
|
786
|
+
return jwt.decode(token, self.settings.secret_key, algorithms=[algorithm])
|
787
|
+
```
|
788
|
+
Использование во всех сервисах _CryptoService_ вместо импорта библиотеки позволит в случае изменения библиотеки поменять имплементацию только в одном месте. Кроме этого, такое использование окажет положительное влияние на процесс тестирования.
|
789
|
+
## Варианты использования (_UseCases_)
|
790
|
+
|
791
|
+
В отличие от сервисного слоя, который группирует логику по доменным областям. **_UseCase_** — это следующий шаг декомпозиции, фокусирующийся на **одном конкретном бизнес-сценарии**, **полностью изолируя чистую бизнес-логику** от инфраструктуры. Это делает приложение ещё более гибким, тестируемым и понятным, особенно в сложных доменах. UseCase не всегда заменяет сервисный слой, но он поднимает уровень абстракции бизнес-правил на новую ступень.
|
792
|
+
Для примера, реализуем сервис регистрации пользователей:
|
793
|
+
|
794
|
+
```python
|
795
|
+
from dataclasses import dataclass
|
796
|
+
|
797
|
+
@dataclass
|
798
|
+
class UserRegisterUseCase:
|
799
|
+
user_service: UserService
|
800
|
+
me_service: MeService
|
801
|
+
notify_service: NotifyService
|
802
|
+
|
803
|
+
async def __call__(self: Self, user_schema: UserCreateRequestSchema) -> UserResponseSchema:
|
804
|
+
"""
|
805
|
+
Регистрация пользователя.
|
806
|
+
"""
|
807
|
+
current_session = await self.me_service.get_session()
|
808
|
+
if current_session:
|
809
|
+
raise LogoutRequiredError()
|
810
|
+
user = await self.user.service.create(user_schema)
|
811
|
+
await self.notify_service.notify_register(user)
|
812
|
+
return UserResponseSchema.model_validate(user, from_attributes=True)
|
813
|
+
```
|
814
|
+
Создание пользователя (его валидация) это реализация сервисного действия, отсылка уведомления это не часть сервиса по созданию пользователя, это часть пользовательской истории регистрации.
|
815
|
+
## Роуты и контроллеры
|
816
|
+
Роутеры организуют группу связанных маршрутов. Они нужны для разделения кода по функциональности, упрощение масштабирования больших приложений, повторное использование префиксов, тегов и зависимостей. Также они определяют внешний контракт взаимодействия по API. Пример роута для FastAPI:
|
817
|
+
```python
|
818
|
+
from fastapi import APIRouter
|
819
|
+
|
820
|
+
router = APIRouter(
|
821
|
+
prefix='/users', # Автоматический префикс для всех входящих маршрутов
|
822
|
+
tags=['Users'], # Группировка в документации Swagger
|
823
|
+
)
|
824
|
+
```
|
825
|
+
Функция, которая используется с этим роутером является контроллером:
|
826
|
+
```python
|
827
|
+
|
828
|
+
@router.get('')
|
829
|
+
async def get_all_users() -> list[UserResponseSchema]:
|
830
|
+
"""Получаем всех пользователей."""
|
831
|
+
...
|
832
|
+
```
|
833
|
+
Функция `get_all_users` является контроллером в привычном понимании этого слова, в виду того, что она может быть записана и так:
|
834
|
+
```python
|
835
|
+
|
836
|
+
async def get_all_users() -> list[UserResponseSchema]:
|
837
|
+
"""Получаем всех пользователей."""
|
838
|
+
...
|
839
|
+
|
840
|
+
@router.get('')(get_all_users)
|
841
|
+
# ^ роутер
|
842
|
+
# ^ контроллер
|
843
|
+
```
|
844
|
+
Связка роута и контроллера представляет внешний контаркт API по которому будет происходить общение с внешними системами, по сути должна содержать следующее:
|
845
|
+
- путь обращения;
|
846
|
+
- _UseCase_, который отражает пользовательскую _UserStory_;
|
847
|
+
- параметры запроса (Path, QueryParams, RequestSchema, Headers, etc);
|
848
|
+
- параметры ответа.
|
849
|
+
|
850
|
+
Рассмотрим пример для регистрации пользователя:
|
851
|
+
```python
|
852
|
+
|
853
|
+
from dishka.integrations.fastapi import FromDishka, inject
|
854
|
+
|
855
|
+
@router.post('')
|
856
|
+
@inject
|
857
|
+
async def register(
|
858
|
+
register_schema: UserCreateRequestSchema, # Параметры запроса
|
859
|
+
user_register_use_case: FromDishka[UserRegisterUseCase] # UseCase
|
860
|
+
) -> MeResponseSchema: # параметры ответа
|
861
|
+
"""
|
862
|
+
Регистрация пользователя.
|
863
|
+
"""
|
864
|
+
user = await user_register_use_case(register_schema)
|
865
|
+
return MeResponseSchema.model_validate(user, from_attributes=True)
|
866
|
+
```
|
867
|
+
|
868
|
+
## Исключения (_Exceptions_)
|
869
|
+
Классы ошибок можно разделить на три части:
|
870
|
+
- Слой приложения - http ошибки со статус кодами:
|
871
|
+
- 4XX - ошибки клиента;
|
872
|
+
- 5XX - ошибки сервера;
|
873
|
+
- Слой доменной логики - ошибки бизнес логики;
|
874
|
+
- Слой внешних обращений - внешние ошибки, возникающие при обращении к внешним системам.
|
875
|
+
Наименование также является важной частью, в виду того, что для обозначений классов ошибок можно использовать суффиксы _**Exception**_ и _**Error**_:
|
876
|
+
- Exception — непредвиденные ошибки, которые возникают в результате работы программы при неконтролируемых действиях;
|
877
|
+
- Error — ошибки бизнес-логики, пользовательские ошибки.
|
878
|
+
> PEP8 говорит:
|
879
|
+
> Because exceptions should be classes, the class naming convention applies here. However, you should use the suffix “Error” on your exception names (if the exception actually is an error)
|
880
|
+
|
881
|
+
Линтер Ruff транслирует необходимость использования вместо `class Validation(Exception)` использовать `class ValidationError(Exception)`.
|
882
|
+
|
883
|
+
В центре нашего приложения должен находиться главный и единственный класс ошибки, например:
|
884
|
+
```python
|
885
|
+
class BusinessLogicException(Exception, ABC):
|
886
|
+
"""
|
887
|
+
Базовое исключение бизнес-логики.
|
888
|
+
"""
|
889
|
+
|
890
|
+
@property
|
891
|
+
def type(self: Self) -> str:
|
892
|
+
"""
|
893
|
+
Тип ошибки.
|
894
|
+
"""
|
895
|
+
return snakecase(type(self).__name__.replace('Error', ''))
|
896
|
+
|
897
|
+
@property
|
898
|
+
@abstractmethod
|
899
|
+
def msg(self: Self) -> str:
|
900
|
+
"""
|
901
|
+
Сообщение ошибки.
|
902
|
+
"""
|
903
|
+
...
|
904
|
+
|
905
|
+
def __str__(self: Self) -> str:
|
906
|
+
return self.msg
|
907
|
+
|
908
|
+
def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
|
909
|
+
"""
|
910
|
+
Получаем схему исключения.
|
911
|
+
"""
|
912
|
+
return BusinessLogicExceptionSchema(
|
913
|
+
type=self.type,
|
914
|
+
msg=self.msg,
|
915
|
+
traceback=(''.join(traceback.format_exception(type(self), self, self.__traceback__)) if debug else None),
|
916
|
+
)
|
917
|
+
```
|
918
|
+
Его название может быть любым, как `BusinessLogicException`, так и `DomainError`. Базовый класс должен содержать **контракт** для всех ошибок, возвращаемых по API. Ошибки второго уровня должны быть связаны с ошибками статус кодов, например:
|
919
|
+
```python
|
920
|
+
|
921
|
+
class PermissionDeniedError(BusinessLogicException):
|
922
|
+
"""
|
923
|
+
Ошибка, возникающая при недостатке прав для выполнения действия.
|
924
|
+
"""
|
925
|
+
|
926
|
+
@property
|
927
|
+
def msg(self: Self) -> str:
|
928
|
+
return 'Недостаточно прав для выполнения действия'
|
929
|
+
|
930
|
+
class ModelNotFoundError(BusinessLogicException):
|
931
|
+
"""
|
932
|
+
Ошибка, возникающая при невозможности найти модель.
|
933
|
+
"""
|
934
|
+
|
935
|
+
def __init__(
|
936
|
+
self,
|
937
|
+
model: type[ModelType] | str,
|
938
|
+
*args: object,
|
939
|
+
model_id: int | uuid.UUID | Iterable[int | uuid.UUID] | None = None,
|
940
|
+
model_name: str | Iterable[str] | None = None,
|
941
|
+
message: str | None = None,
|
942
|
+
) -> None:
|
943
|
+
super().__init__(*args)
|
944
|
+
self.model = model
|
945
|
+
self.model_id = model_id
|
946
|
+
self.model_name = model_name
|
947
|
+
self.custom_message = message
|
948
|
+
|
949
|
+
@property
|
950
|
+
def msg(self: Self) -> str:
|
951
|
+
if self.custom_message is not None:
|
952
|
+
return self.custom_message
|
953
|
+
msg = f'Не удалось найти модель {self.model if isinstance(self.model, str) else self.model.__name__}'
|
954
|
+
if self.model_id is not None:
|
955
|
+
if isinstance(self.model_id, Iterable):
|
956
|
+
return f'{msg} по идентификаторам: [{", ".join(map(str, self.model_id))}]'
|
957
|
+
return f'{msg} по идентификатору: {self.model_id}'
|
958
|
+
if self.model_name is not None:
|
959
|
+
if isinstance(self.model_name, Iterable):
|
960
|
+
return f'{msg} по именам: [{", ".join(self.model_name)}]'
|
961
|
+
return f'{msg} по имени: {self.model_name}'
|
962
|
+
return msg
|
963
|
+
|
964
|
+
|
965
|
+
class ModelAlreadyExistsError(BusinessLogicException):
|
966
|
+
"""
|
967
|
+
Ошибка, возникающая при попытке создать модель с существующим уникальным полем.
|
968
|
+
"""
|
969
|
+
|
970
|
+
def __init__(self, field: str, message: str, *args: object) -> None:
|
971
|
+
super().__init__(*args)
|
972
|
+
self.field = field
|
973
|
+
self.message = message
|
974
|
+
|
975
|
+
@property
|
976
|
+
def msg(self: Self) -> str:
|
977
|
+
return self.message
|
978
|
+
|
979
|
+
def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
|
980
|
+
return ModelAlreadyExistsErrorSchema.model_validate(
|
981
|
+
{**super().get_schema(debug).model_dump(), 'field': self.field}
|
982
|
+
)
|
983
|
+
|
984
|
+
```
|
985
|
+
|
986
|
+
Чтобы связать эти ошибки с http статусами ошибок необходимо использовать функционал [FastAPI exception handlers](https://fastapi.tiangolo.com/tutorial/handling-errors/):
|
987
|
+
```python
|
988
|
+
async def business_logic_exception_handler(
|
989
|
+
settings: CoreSettingsSchema, request: Request, exception: BusinessLogicException
|
990
|
+
) -> Response:
|
991
|
+
"""
|
992
|
+
Обработчик базового исключения бизнес-логики.
|
993
|
+
"""
|
994
|
+
return await http_exception_handler(
|
995
|
+
request,
|
996
|
+
HTTPException(
|
997
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
998
|
+
detail=[exception.get_schema(settings.debug).model_dump()],
|
999
|
+
),
|
1000
|
+
)
|
1001
|
+
|
1002
|
+
async def permission_denied_error_handler(
|
1003
|
+
settings: CoreSettingsSchema, request: Request, error: PermissionDeniedError
|
1004
|
+
) -> Response:
|
1005
|
+
"""
|
1006
|
+
Обработчик ошибки, возникающей при недостатке прав для выполнения действия.
|
1007
|
+
"""
|
1008
|
+
return await http_exception_handler(
|
1009
|
+
request,
|
1010
|
+
HTTPException(
|
1011
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
1012
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
1013
|
+
),
|
1014
|
+
)
|
1015
|
+
|
1016
|
+
|
1017
|
+
async def model_not_found_error_handler(
|
1018
|
+
settings: CoreSettingsSchema, request: Request, error: ModelNotFoundError
|
1019
|
+
) -> Response:
|
1020
|
+
"""
|
1021
|
+
Обработчик ошибки, возникающей при невозможности найти модель.
|
1022
|
+
"""
|
1023
|
+
return await http_exception_handler(
|
1024
|
+
request,
|
1025
|
+
HTTPException(
|
1026
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
1027
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
1028
|
+
),
|
1029
|
+
)
|
1030
|
+
|
1031
|
+
|
1032
|
+
async def model_already_exists_error_handler(
|
1033
|
+
settings: CoreSettingsSchema, request: Request, error: ModelAlreadyExistsError
|
1034
|
+
) -> Response:
|
1035
|
+
"""
|
1036
|
+
Обработчик ошибки, возникающей при попытке создать модель с существующим уникальным
|
1037
|
+
полем.
|
1038
|
+
"""
|
1039
|
+
return await http_exception_handler(
|
1040
|
+
request,
|
1041
|
+
HTTPException(
|
1042
|
+
status_code=status.HTTP_409_CONFLICT,
|
1043
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
1044
|
+
),
|
1045
|
+
)
|
1046
|
+
|
1047
|
+
|
1048
|
+
def use_exceptions_handlers(app: FastAPI, settings: CoreSettingsSchema) -> None:
|
1049
|
+
"""
|
1050
|
+
Регистрируем глобальные обработчики исключений.
|
1051
|
+
"""
|
1052
|
+
app.exception_handler(BusinessLogicException)(partial(business_logic_exception_handler, settings))
|
1053
|
+
app.exception_handler(PermissionDeniedError)(partial(permission_denied_error_handler, settings))
|
1054
|
+
app.exception_handler(ModelNotFoundError)(partial(model_not_found_error_handler, settings))
|
1055
|
+
app.exception_handler(ModelAlreadyExistsError)(partial(model_already_exists_error_handler, settings))
|
1056
|
+
```
|
1057
|
+
Бизнес ошибки должны наследоваться от ошибок, которые связаны с HTTP кодами, чтобы при бросании ошибок пользовательских они автоматически трансформировались в HTTP ошибки и отдавались клиенту.
|
1058
|
+
Дерево ошибок выглядит как показано ниже:
|
1059
|
+
```plantuml
|
1060
|
+
@startmindmap
|
1061
|
+
* BusinessLogicException \n(все ошибки должны наследоваться отсюда)
|
1062
|
+
** PermissionDeniedError \n(ошибка привязанная к 403 статусу)
|
1063
|
+
** ModelNotFoundError \n(ошибка привязання к 404 статусу)
|
1064
|
+
*** UserNotFoundError \n(бизнес ошибка)
|
1065
|
+
*** CompanyNotFoundError \n(бизнес ошибка)
|
1066
|
+
@endmindmap
|
1067
|
+
```
|
1068
|
+
|
1069
|
+
Кроме этого, если вы однозначно знаете какие ошибки могут возникнуть при обработке данных, рекомендуется писать их явно для генерации документации в _Swagger_ на основе _OpenAPI_ 3.0.
|
1070
|
+
```python
|
1071
|
+
|
1072
|
+
@router.post(
|
1073
|
+
'',
|
1074
|
+
responses={
|
1075
|
+
403: {'description': 'Доступ запрещен'},
|
1076
|
+
404: {'description': 'Не найдено'},
|
1077
|
+
418: {'description': '# I\'m a teapot'}
|
1078
|
+
}
|
1079
|
+
)
|
1080
|
+
```
|
1081
|
+
## Dependency Injection
|
1082
|
+
|
1083
|
+
Dependency Injection (DI, **внедрение зависимостей**) — это паттерн проектирования, который делает код более гибким, тестируемым и поддерживаемым. Вот ключевые причины его использования:
|
1084
|
+
1. Уменьшение связности (Coupling);
|
1085
|
+
2. Упрощение тестирования;
|
1086
|
+
3. Гибкость и расширяемость;
|
1087
|
+
4. Читаемость и прозрачность;
|
1088
|
+
5. Соблюдение правил [SOLID](https://ru.wikipedia.org/wiki/SOLID_(программирование).
|
1089
|
+
Хотелось бы DI & IoC-контейнер, который:
|
1090
|
+
- Представляет собой реестр (контейнер) объектов, которыми он управляет
|
1091
|
+
- Позволяет декларативно конфигурировать объекты и их свойства
|
1092
|
+
- Код классов не должен зависеть от IoC-фреймворка
|
1093
|
+
- Берёт на себя:
|
1094
|
+
- Управление жизненным циклом объектов: создание, удаление
|
1095
|
+
- Управление зависимостями между объектами.
|
1096
|
+
В качестве DI фреймворка выбран [Dishka](https://dishka.readthedocs.io/en/stable/). Эта библиотека предоставляет контейнер IoC, который действительно полезен. Если вы устали от бесконечной передачи объектов только для того, чтобы создать другие объекты, только для того, чтобы эти объекты создавали еще больше - вы не одиноки, и у нас есть решение. Не для каждого проекта требуется контейнер IoC, но посмотрите, что мы предлагаем.
|
1097
|
+
IoC-контейнер - это специальный объект (или фреймворк, предоставляющий такой объект), который предоставляет необходимые объекты в соответствии с правилами внедрения зависимостей и управляет их сроком службы. DI-framework - это другое название для таких фреймворков.
|
1098
|
+
Прежде, чем читать дальше крайне рекомендуется прочитать документацию DI фреймворка [Dishka](https://dishka.readthedocs.io/en/stable/).
|
1099
|
+
|
1100
|
+
Давайте опишем провайдер для регистрации пользователя:
|
1101
|
+
```python
|
1102
|
+
from dishka import Provider as DishkaProvider
|
1103
|
+
from dishka import Scope, provide
|
1104
|
+
|
1105
|
+
|
1106
|
+
class Provider(DishkaProvider):
|
1107
|
+
scope = Scope.REQUEST
|
1108
|
+
|
1109
|
+
user_repository = provide(UserRepository)
|
1110
|
+
user_service = provide(UserService)
|
1111
|
+
|
1112
|
+
# use a function
|
1113
|
+
@provide
|
1114
|
+
@staticmethod
|
1115
|
+
def get_me_service(user_repository: UserRepository) -> MeService:
|
1116
|
+
return MeService(user_repository)
|
1117
|
+
|
1118
|
+
user_register_use_case = provide(UserRegisterUseCase)
|
1119
|
+
|
1120
|
+
provider = Provider() # Объявляем провайдер
|
1121
|
+
```
|
1122
|
+
После этого создаем IoC контейнер и запускаем FastAPI приложение:
|
1123
|
+
```python
|
1124
|
+
import os
|
1125
|
+
from collections.abc import AsyncGenerator
|
1126
|
+
from contextlib import asynccontextmanager
|
1127
|
+
from importlib import import_module
|
1128
|
+
from pathlib import Path
|
1129
|
+
import tochka_jsonrpcapi as jsonrpc
|
1130
|
+
from fastapi import FastAPI
|
1131
|
+
from fast_clean.container import ContainerManager
|
1132
|
+
from fast_clean.contrib.healthcheck.router import router as healthcheck_router
|
1133
|
+
|
1134
|
+
@asynccontextmanager
|
1135
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
1136
|
+
yield
|
1137
|
+
await app.state.dishka_container.close()
|
1138
|
+
|
1139
|
+
app = FastAPI(lifespan=lifespan)
|
1140
|
+
ContainerManager.init_for_fastapi(app)
|
1141
|
+
app.router.route_class = DishkaRoute
|
1142
|
+
|
1143
|
+
app.middleware('http')(context_middleware)
|
1144
|
+
app.include_router(healthcheck_router)
|
1145
|
+
```
|
1146
|
+
## Слои приложения
|
1147
|
+
|
1148
|
+
Если рассматривать слоистость приложения, то его можно представить следующим образом:
|
1149
|
+
```plantuml
|
1150
|
+
@startuml
|
1151
|
+
component Application {
|
1152
|
+
component Router {
|
1153
|
+
component Controller {
|
1154
|
+
component UseCase {
|
1155
|
+
component "Service 1" {
|
1156
|
+
component "Repository 1"
|
1157
|
+
component "Service 3" {
|
1158
|
+
component "Repository 3"
|
1159
|
+
}
|
1160
|
+
component "Service 2" {
|
1161
|
+
component "Repository 2"
|
1162
|
+
}
|
1163
|
+
}
|
1164
|
+
component "Service 2" {
|
1165
|
+
component "Repository 2"
|
1166
|
+
}
|
1167
|
+
component "Service 4" {
|
1168
|
+
component "Repository 4"
|
1169
|
+
}
|
1170
|
+
}
|
1171
|
+
}
|
1172
|
+
}
|
1173
|
+
}
|
1174
|
+
@enduml
|
1175
|
+
```
|
1176
|
+
Кроме этого, возможность зон обращений можно представить следующей диаграммой:
|
1177
|
+
```plantuml
|
1178
|
+
@startmindmap
|
1179
|
+
* Application
|
1180
|
+
** Router
|
1181
|
+
*** Controller
|
1182
|
+
**** UseCase
|
1183
|
+
***** Service 1
|
1184
|
+
****** Service 2
|
1185
|
+
******* Repository 3
|
1186
|
+
****** Repository 1
|
1187
|
+
****** Repository 2
|
1188
|
+
***** Serivce 2
|
1189
|
+
****** Repository 3
|
1190
|
+
@endmindmap
|
1191
|
+
```
|
1192
|
+
## Sync и Async функции
|
1193
|
+
|
1194
|
+
В современных приложениях на Python, особенно при использовании асинхронного программирования с asyncio, часто возникает необходимость выполнять синхронные (блокирующие) функции в асинхронной среде. Чтобы избежать блокировки основного асинхронного потока выполнения, синхронные функции можно запускать в отдельном пуле потоков (_thread pool_) или процессов (_process pool_).
|
1195
|
+
|
1196
|
+
_ThreadPoolExecutor_ подходит для выполнения блокирующих I/O операций, то есть когда функция ожидает ввода-вывода, например:
|
1197
|
+
- Сетевые запросы к _API_ без асинхронных библиотек.
|
1198
|
+
- Чтение и запись файлов на диск.
|
1199
|
+
- Обращение к базам данных без поддержки асинхронности.
|
1200
|
+
|
1201
|
+
Причины использовать _ThreadPoolExecutor_ для _I/O-bound_ задач:
|
1202
|
+
- _GIL (Global Interpreter Lock):_ В Python интерпретатор CPython имеет GIL, который не позволяет нескольким потокам выполнять байт-код одновременно. Однако при I/O операциях потоки освобождают GIL, позволяя другим потокам продолжить выполнение.
|
1203
|
+
- _Низкие накладные расходы:_ Создание и переключение между потоками дешевле, чем между процессами.
|
1204
|
+
- _Простота обмена данными:_ Потоки разделяют общую память, что упрощает передачу данных между ними.
|
1205
|
+
|
1206
|
+
В связи с этим синхронные функции не оборачиваем в async. Оборачивание синхронных операций в `async` добавляет некоторые накладные расходы на формирование `Future`, которая по сути ничего не ожидает.
|
1207
|
+
|
1208
|
+
```python
|
1209
|
+
# ✅ correct
|
1210
|
+
def get_todo_repositories() -> ResultSchema:
|
1211
|
+
return do_smt_sync()
|
1212
|
+
|
1213
|
+
# ❌ incorrect
|
1214
|
+
async def get_todo_repositories() -> ResultSchema:
|
1215
|
+
return do_smt_sync()
|
1216
|
+
```
|
1217
|
+
|
1218
|
+
Синхронные функции в асинхронной среде запускаются в соответствующих функциях для запуска `run_in_processpool` и`run_in_threadpool`:
|
1219
|
+
```python
|
1220
|
+
# run_in_processpool.py
|
1221
|
+
|
1222
|
+
import asyncio
|
1223
|
+
import multiprocessing as mp
|
1224
|
+
from concurrent.futures import ProcessPoolExecutor
|
1225
|
+
from functools import partial
|
1226
|
+
from typing import Callable, TypeVar
|
1227
|
+
|
1228
|
+
from typing_extensions import ParamSpec
|
1229
|
+
|
1230
|
+
P = ParamSpec('P')
|
1231
|
+
R = TypeVar('R')
|
1232
|
+
|
1233
|
+
async def run_in_processpool(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
1234
|
+
"""
|
1235
|
+
Запуск функции в отдельном процессе.
|
1236
|
+
Используем fork в связи с https://github.com/python/cpython/issues/94765.
|
1237
|
+
"""
|
1238
|
+
kwargs_fn = partial(fn, **kwargs)
|
1239
|
+
loop = asyncio.get_running_loop()
|
1240
|
+
with ProcessPoolExecutor(mp_context=mp.get_context('fork')) as executor:
|
1241
|
+
return await loop.run_in_executor(executor, kwargs_fn, *args)
|
1242
|
+
```
|
1243
|
+
|
1244
|
+
```python
|
1245
|
+
# run_in_threadpool.py
|
1246
|
+
|
1247
|
+
import asyncio
|
1248
|
+
import multiprocessing as mp
|
1249
|
+
from concurrent.futures import ProcessPoolExecutor
|
1250
|
+
from functools import partial
|
1251
|
+
from typing import Callable, TypeVar
|
1252
|
+
|
1253
|
+
from typing_extensions import ParamSpec
|
1254
|
+
|
1255
|
+
P = ParamSpec('P')
|
1256
|
+
R = TypeVar('R')
|
1257
|
+
|
1258
|
+
|
1259
|
+
process_pool: ProcessPoolExecutor | None = None
|
1260
|
+
|
1261
|
+
|
1262
|
+
async def run_in_processpool(fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
1263
|
+
"""
|
1264
|
+
Запуск функции в отдельном процессе.
|
1265
|
+
|
1266
|
+
Используем fork в связи с https://github.com/python/cpython/issues/94765.
|
1267
|
+
"""
|
1268
|
+
global process_pool
|
1269
|
+
if process_pool is None:
|
1270
|
+
process_pool = ProcessPoolExecutor(mp_context=mp.get_context('fork'))
|
1271
|
+
kwargs_fn = partial(fn, *args, **kwargs)
|
1272
|
+
loop = asyncio.get_running_loop()
|
1273
|
+
return await loop.run_in_executor(process_pool, kwargs_fn)
|
1274
|
+
```
|
1275
|
+
|
1276
|
+
## FastClean Core
|
1277
|
+
|
1278
|
+
В основном все приложения одинаковые и различаются лишь бизнес фукнционалом. Системный функционал и тулинг выноситься в библиотеку, которая обычно называется core. В нашем представлении ядерная библиотека представляется набором системных утилит и приложений и представлена в репозитории [FastClean](https://github.com/Luferov/fast-clean).
|
1279
|
+
## API Request и Response схемы
|
1280
|
+
|
1281
|
+
Все API схемы для описания входящих запросов должны быть описаны с помощью `Pydantic` и должны быть отнаследованы от `RequestSchema`, которая показана ниже. Однако, исходящие запросы должны приводиться к нотации `camelCase`, описываться с помощью `Pydantic` моделей и наследоваться от `ResponseSchema`.
|
1282
|
+
```python
|
1283
|
+
|
1284
|
+
from __future__ import annotations
|
1285
|
+
|
1286
|
+
import uuid
|
1287
|
+
from typing import Generic, Self, TypeVar
|
1288
|
+
|
1289
|
+
from pydantic import AliasGenerator, BaseModel, ConfigDict, field_validator
|
1290
|
+
from pydantic.alias_generators import to_camel
|
1291
|
+
|
1292
|
+
class RequestSchema(BaseModel):
|
1293
|
+
"""
|
1294
|
+
Схема запроса.
|
1295
|
+
"""
|
1296
|
+
# Настройка добавления алиасов и валидирования из camelCase -> snake_case
|
1297
|
+
model_config = ConfigDict(
|
1298
|
+
alias_generator=AliasGenerator(
|
1299
|
+
validation_alias=to_camel,
|
1300
|
+
)
|
1301
|
+
)
|
1302
|
+
|
1303
|
+
class ResponseSchema(BaseModel):
|
1304
|
+
"""
|
1305
|
+
Схема ответа.
|
1306
|
+
"""
|
1307
|
+
|
1308
|
+
# Настройка генерирования в camelCase из snake_case -> camelCase
|
1309
|
+
model_config = ConfigDict(
|
1310
|
+
alias_generator=AliasGenerator(
|
1311
|
+
serialization_alias=to_camel,
|
1312
|
+
)
|
1313
|
+
)
|
1314
|
+
|
1315
|
+
RemoteResponseSchema = RequestSchema
|
1316
|
+
RemoteRequestSchema = ResponseSchema
|
1317
|
+
|
1318
|
+
```
|
1319
|
+
|
1320
|
+
Межсервисное взаимодействие должно обеспечиваться с помощью этих же трансформаций, в следствие чего должны использоваться классы `RemoteResponseSchema` и `RemoteRequestSchema`.
|
1321
|
+
## Транзакции
|
1322
|
+
**Транзакция** — это единица работы с базой данных, состоящая из одного или нескольких операций _SQL_, которые выполняются как одно целое. Транзакции обладают свойствами, известными как _ACID_:
|
1323
|
+
|
1324
|
+
- **Atomicity (Атомарность):** Все операции внутри транзакции выполняются полностью или не выполняются вовсе. Если происходит ошибка, все изменения откатываются.
|
1325
|
+
- **Consistency (Согласованность):** Транзакции переводят базу данных из одного согласованного состояния в другое, соблюдая все ограничения целостности.
|
1326
|
+
- **Isolation (Изоляция):** Одновременные транзакции не влияют друг на друга, обеспечивая изолированность выполнения.
|
1327
|
+
- **Durability (Долговечность):** После фиксации транзакции ее результаты сохраняются даже в случае сбоев системы.
|
1328
|
+
|
1329
|
+
Типичный код репозитория к базе данных на любой запрос открывает транзакцию. В случае, если транзакция явно не открывается - она все равно открывается внутренностями _SqlAlchemy_.
|
1330
|
+
|
1331
|
+
```python
|
1332
|
+
import uuid
|
1333
|
+
import sqlalchemy as sa
|
1334
|
+
from typing import Protocol, Self
|
1335
|
+
|
1336
|
+
from fast_clean.depends import SessionManagerProtocol
|
1337
|
+
|
1338
|
+
from ..models import check_list_mark_template
|
1339
|
+
|
1340
|
+
|
1341
|
+
class UserRepository:
|
1342
|
+
"""
|
1343
|
+
Реализация репозитория для работы со списком выбранного todo листа.
|
1344
|
+
"""
|
1345
|
+
|
1346
|
+
def __init__(self, session_manager: SessionManagerProtocol) -> None:
|
1347
|
+
self.session_manager = session_manager
|
1348
|
+
|
1349
|
+
async def set_active(self: Self, todo_id: uuid.UUID, status: bool) -> None:
|
1350
|
+
"""
|
1351
|
+
Устанавливаем шаблоны оценки чек-листа.
|
1352
|
+
"""
|
1353
|
+
async with self.session_manager.get_session() as s:
|
1354
|
+
statement = (
|
1355
|
+
sa.update(self.model_type)
|
1356
|
+
.where(self.model_type.id == pk)
|
1357
|
+
.values({'status': status})
|
1358
|
+
.returning(self.model_type)
|
1359
|
+
)
|
1360
|
+
await s.execute(statement)
|
1361
|
+
# Мы все еще находимся в транзакции и она открыта.
|
1362
|
+
```
|
1363
|
+
В виду того, что мы находимся в транзации в момент, когда сессия в _SqlAlchemy_ завершается, поэтому над стандартной сессии пишется обертка, которая в случае, если мы находимся внутри транзакции, создается вложенная транзакция (или возвращает ее же), которая открывается на основе, т.е. по сути вызывается `SAVEPOINT` внутри транзакции. Код сервиса представлен ниже:
|
1364
|
+
|
1365
|
+
```python
|
1366
|
+
from contextlib import asynccontextmanager
|
1367
|
+
from typing import AsyncContextManager, AsyncIterator, Protocol, Self
|
1368
|
+
|
1369
|
+
import sqlalchemy as sa
|
1370
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
1371
|
+
|
1372
|
+
|
1373
|
+
class TransactionService:
|
1374
|
+
"""
|
1375
|
+
Реализация сервиса транзакций.
|
1376
|
+
"""
|
1377
|
+
|
1378
|
+
def __init__(self, session: AsyncSession) -> None:
|
1379
|
+
self.session = session
|
1380
|
+
|
1381
|
+
@asynccontextmanager
|
1382
|
+
async def begin(self: Self, immediate: bool = True) -> AsyncIterator[None]:
|
1383
|
+
"""
|
1384
|
+
Начинаем транзакцию.
|
1385
|
+
"""
|
1386
|
+
async with self.session.begin():
|
1387
|
+
if immediate:
|
1388
|
+
await self.session.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
|
1389
|
+
yield
|
1390
|
+
```
|
1391
|
+
Если несколько репозиториев нужно объединить в одну транзакцию, то стартовать транзакцию необходимо в в сервисе транзакций:
|
1392
|
+
```python
|
1393
|
+
|
1394
|
+
|
1395
|
+
class TodoUseCase(UseCase[ResponseModel]):
|
1396
|
+
|
1397
|
+
def __init__(
|
1398
|
+
self,
|
1399
|
+
transaction_service: TransactionService,
|
1400
|
+
service1: Service1,
|
1401
|
+
service2: Service2
|
1402
|
+
) -> None:
|
1403
|
+
self.transaction_service = transaction_service
|
1404
|
+
self.service1 = Service1Protocol
|
1405
|
+
self.service2 = Service2Protocol
|
1406
|
+
|
1407
|
+
def __call__(self) -> ResponseModel:
|
1408
|
+
async with self.transaction_service.begin():
|
1409
|
+
# Выполнения запросов service1 и service2 происходит внутри одной транзакции
|
1410
|
+
await service1.do_smt()
|
1411
|
+
await service2.do_smt()
|
1412
|
+
```
|
1413
|
+
## Запуск приложения в режиме разработки
|
1414
|
+
|
1415
|
+
```bash
|
1416
|
+
git clone {{ repository }}
|
1417
|
+
cp .env.example .env
|
1418
|
+
|
1419
|
+
# Запускаем приложение
|
1420
|
+
uv run uvicorn {{ service name }}.entrypoints.main:app
|
1421
|
+
```
|
1422
|
+
|
1423
|
+
## Литература
|
1424
|
+
|
1425
|
+
- [Лучшие практики FastAPI](https://github.com/zhanymkanov/fastapi-best-practices)
|
1426
|
+
- [Чистая архитектура](https://www.litres.ru/book/robert-s-martin/chistaya-arhitektura-iskusstvo-razrabotki-programmnogo-obe-39113892/)
|