Привет, Хабр! Меня зовут Сергей, я техлид в команде PT BlackBox. Мы с коллегами разрабатываем продукт, который позволяет обнаруживать уязвимости в приложениях методом черного ящика. Фактически мы сами и пишем веб-приложения, и именно о них пойдет речь в статье.
Я бы хотел с вами поделиться своими наработками по теме бэкенд-приложений и предоставить вам шаблон-прототип, который, как мне кажется, может закрыть подавляющее большинство потребностей при их разработке.
Для написания прототипа я буду использовать Litestar, FastStream и dishka. Эта статья будет особенно полезна тем, кто пишет на Tornado, Django, Flask или AIOHTTP и хочет перейти на более актуальные технологии для дальнейшего развития своих проектов на современных рельсах.
Для быстрого перехода к разделам
Использование Litestar, FastStream и dishka позволяет успешно следовать принципам чистой архитектуры (сlean architecture) и почти не допускать «протекания» абстракций. В самом прототипе также будет использоваться Pydantic и SQLAlchemy, но я не буду уделять им особого внимания ввиду того, что эти технологии довольно популярны и в сообществе уже есть огромное количество гайдов по их применению.
Litestar
Litestar — это легковесный ASGI-фреймворк с подробной и проработанной документацией, на данный момент один из самых производительных ASGI-фреймворков.
Litestar — перспективный бэкенд-фреймворк с большим количеством «батареек» на разные случаи жизни. Фреймворк активно разрабатывается и поддерживается сообществом, но при всем этом обделен вниманием на Хабре, не считая анонса доклада на PHDays Fest 2.
FastStream
FastStream — это event source фреймворк, который упрощает и унифицирует работу с брокерами, а также избавляет от необходимости каждый раз изобретать велосипед. В нем есть валидация, автогенерация AsyncAPI, CLI-утилита поверх ChatGPT, которая генерирует готовые FastStream-сервисы по вашему описанию. Фреймворк поддерживает такие брокеры, как Kafka, RabbitMQ, NATS, Redis Stream.
Чистая архитектура (SOLID)
Как уже упоминалось выше, прототип построен на принципах чистой архитектуры. При его написании я руководствовался принципами SOLID и делением приложения на слои.
Для тех, кто не знаком с концепцией чистой архитектуры или хочет освежить ее в памяти, я подготовил этот раздел. Хочу заметить, что это не ультимативный гайд по чистой архитектуре и некоторые вещи могут быть намеренно упрощены. Вот иллюстрация чистой архитектуры, созданная Робертом Мартином:
Каждая окружность соответствует различным составляющим приложения. Чем ближе слой к центру, тем менее он подвержен изменениям, поэтому нижестоящие слои не должны зависеть от вышестоящих.
Frameworks and Drivers
В этом слое размещены внешние сущности, например конкретная база данных или интерфейс вашего приложения. В моем прототипе я не завязывал бизнес-логику приложения на этот слой.
Interface Adapters
Здесь размещены конкретные реализации, имплементирующие общение с внешними сервисами, например с базой данных. Можно представить это так:
class BooksGateway:
def get_books(self) -> list[Books]:
query = 'SELECT * FROM books'
rows = self.psql.execute(query)
return [Books(**r) for r in rows]
Application business rules
В этом слое размещен код для реализации пользовательских сценариев. Здесь находятся сущности, которые описывают конкретный use case, агрегировав в себе необходимые зависимости. Схематично это можно изобразить так:
def interactor(self) -> bool:
""" User buy books """
books = self.get_books_from_card()
full_price = sum([b.price for b in books])
self.write_off_money(full_price)
self.send_books_to_user(books)
Enterprise business rules
Доменный слой приложения, в котором располагается основа приложения.
SOLID
Чистая архитектура предполагает не наличие слоев как таковых, а следование принципам SOLID. Слои — это всего лишь следствие, вытекающее из SOLID и парочки других принципов.
Это принципы построения любых приложений, они универсальны и не зависят от языка. Можно назвать их принципами вытекающими из ООП, хотя это не совсем корректно, потому что все эти принципы подходят для проектирования как микросервисной архитектуры, так и конкретного приложения. Исключительно к ООП можно отнести только один из пяти принципов.
S — Single responsibility principle
Принцип единой ответственности: модуль должен иметь одну и только одну причину для изменения.
O — Open-closed principle
Принцип открытости — закрытости: он означает, что должна быть возможность добавлять новые компоненты без изменения уже существующих.
L — Liskov substitution principle
Принцип подстановки Барбары Лисков заключается в том, что дочерний класс должен соответствовать поведению родителя. Это, собственно, тот принцип, который актуален только для ООП.
I — Interface segregation principle
Принцип разделения интерфейсов: компоненты не должны зависеть от методов, которые они не используют.
D — Dependency inversion principle
Принцип инверсии зависимостей: компоненты должны зависеть от абстрактных интерфейсов, а не от конкретных реализаций.
Dependency injection (dishka)
Dependency injection — это принцип, который позволяет уменьшить связанность (coupling) и увеличить сплоченность (cohesion) сущностей в приложении. Это происходит за счет следующего: если сущность А требует В для своей работы, она должна получить ее извне, а не создавать самостоятельно.
Для лучшего понимания можно провести следующую аналогию: связанность — это суперклей, которым мы соединили части нашего приложения, а сплоченность скорее похожа на конструктор. Подробнее об этом принципе можно узнать из статьи.
Пример кода с высокой связанностью:
import os
class ApiClient:
def __init__(self) -> None:
self.api_key = os.getenv("API_KEY") # <-- dependency
self.timeout = int(os.getenv("TIMEOUT")) # <-- dependency
class Service:
def __init__(self) -> None:
self.api_client = ApiClient() # <-- dependency
def main() -> None:
service = Service() # <-- dependency
...
if __name__ == "__main__":
main()
Тот же самый код, но с использованием принципа внедрения зависимостей:
import os
class ApiClient:
def __init__(self, api_key: str, timeout: int) -> None:
self.api_key = api_key # <-- dependency is injected
self.timeout = timeout # <-- dependency is injected
class Service:
def __init__(self, api_client: ApiClient) -> None:
self.api_client = api_client # <-- dependency is injected
def main(service: Service) -> None: # <-- dependency is injected
...
if __name__ == "__main__":
main(
service=Service(
api_client=ApiClient(
api_key=os.getenv("API_KEY"),
timeout=int(os.getenv("TIMEOUT")),
),
),
)
По ходу написания прототипа я придерживался принципа внедрения зависимостей.
Dishka — IoC-контейнер, фреймворк, который позволяет управлять зависимостями в проекте. Несмотря на то что и у Litestar, и у FastStream есть свои решения для этого, использование внешней технологии позволит запустить их в рамках одной кодовой базы.
Подробнее с принципом можно ознакомиться тут. В том же материале можно прочитать о том, что такое IoC-контейнер.
Реализация прототипа
В рамках этой статьи я написал прототип приложения, которое позволяет хранить информацию о книгах. Оно «слушает» очередь, в которую приходят события (events) и записывает информацию из этих событий в PostgreSQL.
Ниже я не буду подробно останавливаться на таких вещах, как написание docker compose или настройка миграций — на GitHub размещена готовая реализация, в которой все это сделано.
.
├── docker-compose.yaml — здесь поднимем RabbitMQ и Postgress
├── .env — здесь опишем переменное окружение
└── book-club
├── domain — доменные сущности
├── application — здесь у нас будет лежать бизнес-логика приложения
├── infrastructure — здесь у нас будут хранится адаптеры и миграции
├── handlers — HTTP- и AMQP-контроллеры и DTO для них
├── ioc.py — контейнер с зависимостями
├── config.py — конфигурация проекта
├── main.py — точка входа в приложение
Первое, с чего хотелось бы начать, — описание домена. Домен является основой любого приложения — того, ради чего оно пишется. Например: приложение по доставке пиццы пишется ради пиццы, приложение автосервиса — ради машины.
Для лучшего понимания я специально сильно упрощаю. На самом деле домен приложения куда шире, он может включать в себя клиентов, которые покупают пиццу, сотрудников, которые обслуживают машину, побочные домены для продажи напитков и салонных елочек. Для книжного клуба я ограничился одной доменной сущностью — BookDM
.
# book_club/domain/entities.py
from dataclasses import dataclass
@dataclass(slots=True)
class BookDM: # DM - Domain model
uuid: str
title: str
pages: int
is_read: bool
Следующим шагом я описал бизнес-логику приложения. Это то, чем приложение занимается. Приложение может сохранить информацию о книге и выдать ее по запросу. Рассмотрим DTO, который необходим для слоя бизнес-логики.
# book_club/application/dto.py
from dataclasses import dataclass
@dataclass(slots=True)
class NewBookDTO:
title: str
pages: int
is_read: bool
DTO — это паттерн, который реализует обмен информацией между слоями. Внутренний слой не должен зависеть от внешнего, поэтому он не может работать с объектами из внешних слоев приложения, такими как pydantic model или объект request. NewBookDTO
необходим, чтобы получать информацию о книгах из внешних слоев приложения.
Далее, исходя из бизнес-логики, я реализовал use-case interactor и описал интерфейсы, от которых они зависят.
# book_club/application/interfaces.py
from abc import abstractmethod
from typing import Protocol
from uuid import UUID
from book_club.domain.entities import BookDM
class BookSaver(Protocol):
@abstractmethod
async def save(self, book: BookDM) -> None:
...
class BookReader(Protocol):
@abstractmethod
async def read_by_uuid(self, uuid: str) -> BookDM | None:
...
class UUIDGenerator(Protocol):
def __call__(self) -> UUID:
...
class DBSession(Protocol):
@abstractmethod
async def commit(self) -> None:
...
@abstractmethod
async def flush(self) -> None:
...
BookSaver
— сохранение информации о книгах.
BookReader
— чтение информации о книгах.
UUIDGenerator
— генерация UUID.
DBSession
— интерфейс для работы с транзакциями.
# book_club/application/interactors.py
from book_club.application import interfaces
from book_club.application.dto import NewBookDTO
from book_club.domain import entities
class GetBookInteractor:
def __init__(
self,
book_gateway: interfaces.BookReader,
) -> None:
self._book_gateway = book_gateway
async def __call__(self, uuid: str) -> entities.BookDM | None:
return await self._book_gateway.read_by_uuid(uuid)
class NewBookInteractor:
def __init__(
self,
db_session: interfaces.DBSession,
book_gateway: interfaces.BookSaver,
uuid_generator: interfaces.UUIDGenerator,
) -> None:
self._db_session = db_session
self._book_gateway = book_gateway
self._uuid_generator = uuid_generator
async def __call__(self, dto: NewBookDTO) -> str:
uuid = str(self._uuid_generator())
book = entities.BookDM(
uuid=uuid,
title=dto.title,
pages=dto.pages,
is_read=dto.is_read
)
await self._book_gateway.save(book)
await self._db_session.commit()
return uuid
Use-case interactor — это реализация конкретного пользовательского действия. У пользователя только два доступных действия, которые и описаны в теле интеракторов.
На этом этапе основа приложения уже готова, теперь рассмотрим детали реализации.
Config
приложения:
# book_club/config.py
from os import environ as env
from pydantic import Field, BaseModel
class RabbitMQConfig(BaseModel):
host: str = Field(alias='RABBITMQ_HOST')
port: int = Field(alias='RABBITMQ_PORT')
login: str = Field(alias='RABBITMQ_USER')
password: str = Field(alias='RABBITMQ_PASS')
class PostgresConfig(BaseModel):
host: str = Field(alias='POSTGRES_HOST')
port: int = Field(alias='POSTGRES_PORT')
login: str = Field(alias='POSTGRES_USER')
password: str = Field(alias='POSTGRES_PASSWORD')
database: str = Field(alias='POSTGRES_DB')
class Config(BaseModel):
rabbitmq: RabbitMQConfig = Field(default_factory=lambda: RabbitMQConfig(**env))
postgres: PostgresConfig = Field(default_factory=lambda: PostgresConfig(**env))
Обращаю внимание, что я разделил config на отдельные составляющие, а потом объединил их путем композиции в классе Config
. Такой подход позволяет более эффективно проводить инъекцию зависимостей. Если класс зависит от конкретных конфигурационных данных, то нужно предоставить ему только эти данные вместо конфигурации всего приложения.
# book_club/infrastructure/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from book_club.config import PostgresConfig
def new_session_maker(psql_config: PostgresConfig) -> async_sessionmaker[AsyncSession]:
database_uri = 'postgresql+psycopg://{login}:{password}@{host}:{port}/{database}'.format(
login=psql_config.login,
password=psql_config.password,
host=psql_config.host,
port=psql_config.port,
database=psql_config.database,
)
engine = create_async_engine(
database_uri,
pool_size=15,
max_overflow=15,
connect_args={
"connect_timeout": 5,
},
)
return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False)
Обратите внимание, new_session_maker
зависит только от PostgresConfig
, он ничего не знает о RabbitMQConfig
.
# book_club/infrastructure/broker.py
from faststream.rabbit import RabbitBroker
from faststream.security import SASLPlaintext
from book_club.config import RabbitMQConfig
def new_broker(rabbitmq_config: RabbitMQConfig) -> RabbitBroker:
return RabbitBroker(
host=rabbitmq_config.host,
port=rabbitmq_config.port,
security=SASLPlaintext(
username=rabbitmq_config.login,
password=rabbitmq_config.password,
),
virtualhost="/",
)
Аналогичная ситуация и для new_broker
.
# book_club/infrastructure/gateways.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text
from book_club.application.interfaces import BookReader, BookSaver
from book_club.domain.entities import BookDM
class BookGateway(
BookReader,
BookSaver,
):
def __init__(self, session: AsyncSession):
self._session = session
async def read_by_uuid(self, uuid: str) -> BookDM | None:
query = text("SELECT * FROM books WHERE uuid = :uuid")
result = await self._session.execute(
statement=query,
params={"uuid": uuid},
)
row = result.fetchone()
if not row:
return None
return BookDM(
uuid=row.uuid,
title=row.title,
pages=row.pages,
is_read=row.is_read,
)
async def save(self, book: BookDM) -> None:
query = text("INSERT INTO books (uuid, title, pages, is_read) VALUES (:uuid, :title, :pages, :is_read)")
await self._session.execute(
statement=query,
params={
"uuid": book.uuid,
"title": book.title,
"pages": book.pages,
"is_read": book.is_read,
},
)
Gateway
инкапсулирует в себе работу с данными и позволяет работать с ними как с коллекцией, не вдаваясь в детали реализации.
Отмечу, что BookGateway
удовлетворяет упомянутым ранее интерфейсам BookSaver
и BookReader
.
После реализации домена и адаптеров, я поместил зависимости в контейнер для их последующей инъекции.
# book_club/ioc.py
from typing import AsyncIterable
from uuid import uuid4
from dishka import Provider, Scope, provide, AnyOf, from_context
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from book_club.application import interfaces
from book_club.application.interactors import (
GetBookInteractor,
NewBookInteractor
)
from book_club.config import Config
from book_club.infrastructure.database import new_session_maker
from dbook_club.infrastructure.gateways import BookGateway
class AppProvider(Provider):
...
Таким образом, я описал провайдер для interfaces.UUIDGenerator
, у него есть параметр scope. Этот параметр определяет срок жизни зависимости (его подробное описание можно найти в документации). Зависимость, у которой область видимости APP, не нуждается в пересоздании. Здесь я использую функцию uuid4
как объект первого типа, он удовлетворяет ранее описанному interfaces.UUIDGenerator
. В случае с uuid4
достаточно создать объект один раз за все время жизни приложения, поэтому у него scope=Scope.APP
.
...
@provide(scope=Scope.APP)
def get_uuid_generator(self) -> interfaces.UUIDGenerator:
return uuid4
...
Далее я описал provider для config, у него тоже выставлен параметр scope=Scope.APP
. Как и в случае с uuid4
, нам достаточно создать объект один раз за все время жизни приложения. Однако from_context
означает, что объект Config
должен быть создан где-то вовне и передан при создании AppProvider
.
...
config = from_context(provides=Config, scope=Scope.APP)
...
Следующий шаг — это создание sessionmaker
из SqlAlchemy
.
...
@provide(scope=Scope.APP)
def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]:
return new_session_maker(config.postgres)
...
Тут я тоже выставил scope=Scope.APP
, поскольку класс async_sessionmaker
нам больше пересоздавать не требуется. Этот провайдер зависит от Config
, что я и указал в сигнатуре функции; async_sessionmaker
нужен для создания AsyncSession
.
...
@provide(scope=Scope.REQUEST)
async def get_session(self, session_maker: async_sessionmaker[AsyncSession]) -> AsyncIterable[AnyOf[
AsyncSession,
interfaces.DBSession,
]]:
async with session_maker() as session:
yield session
...
Здесь уже scope=Scope.REQUEST
. Этот объект будет создаваться каждый раз при вызове, по сути, после на каждый запрос пользователя будет создаваться новая сессия алхимии. Сигнатура этого провайдера говорит о том, что он зависит от async_sessionmaker[AsyncSession]
и возвращаемый объект должен удовлетворять сигнатуре сразу двух интерфейсов: sqlalchemy.AsyncSession
и interfaces.DBSession
.
Использование генераторов позволяет хранить состояние сессии, а после ответа на запрос пользователя — вернуться в этот провайдер и выйти из контекстного менеджера, тем самым вернуть ресуры в connection pool
.
...
book_gateway = provide(
BookGateway,
scope=Scope.REQUEST,
provides=AnyOf[interfaces.BookReader, interfaces.BookSaver]
)
...
Этот провайдер отвечает за создание BookGateway
, который зависит от sqlalchemy.AsyncSession
и должен удовлетворять интерфейсам interfaces.BookReader
и interfaces.BookSaver
. От этих интерфейсов зависят interactors
, создание которых выглядит так:
...
get_book_interactor = provide(GetBookInteractor, scope=Scope.REQUEST)
create_new_book_interactor = provide(NewBookInteractor, scope=Scope.REQUEST)
...
Под капотом dishka
вызывает конструкторы для BookGateway
, GetBookInteractor
и NewBookInteractor
, сигнатура которых зависит от interfaces.BookReader
, interfaces.BookSaver
, interfaces.DBSession
, interfaces.UUIDGenerator
. В случае если у зависимостей выставлен параметр Scope.REQUEST
, они будут созданы, если же выставлен Scope.APP
, то dishka
попытается найти их в своем хранилище. Если в нем нет зависимостей — dishka
создаст их и разместит там.
При получении какого-либо события (будь то запрос по HTTP или сообщение из очереди) вызывается обработчик, который запускает цепочку создания зависимостей, а после окончания события происходит их финализация. В такой схеме на каждое событие будет только одно подключение к базе данных для всех зависимостей в этой цепочке, что позволит достигнуть атомарности транзакций и эффективного использования ресурсов пула подключений.
Теперь обратим внимание на реализацию слоя контроллеров.
BookSchema
— это DTO, который мы используем для общения между слоем контроллеров и внешним слоем. Несмотря на то что в прототипе два контроллера, нам нужна всего одна модель, которая одинаково хорошо поддерживается как FastStream, так и Litestar.
На основе только этой модели строится валидация и автогенерация документации.
# controllers/schemas.py
from pydantic import BaseModel
class BookSchema(BaseModel):
title: str
pages: int
is_read: bool
# book_club/controllers/amqp.py
from dishka.integrations.base import FromDishka as Depends
from faststream.rabbit import RabbitRouter
from book_club.application.dto import NewBookDTO
from book_club.application.interactors import NewBookInteractor
from book_club.controllers.schemas import BookSchema
AMQPBookController = RabbitRouter()
@AMQPBookController.subscriber("create_book")
@AMQPBookController.publisher("book_statuses")
async def handle(data: BookSchema, interactor: Depends[NewBookInteractor]) -> str:
dto = NewBookDTO(
title=data.title,
pages=data.pages,
is_read=data.is_read
)
uuid = await interactor(dto)
return uuid
Выше приведена реализация контроллера для сохранения информации о книге. Как видно из кода, контроллер получает на вход BookSchema
, контроллер «слушает» очередь create_book
и, в случае наличия события в очереди, пытается превратить данные оттуда в BookSchema
. Если не получается, то вызывается исключение pydantic.ValidationError
.
В случае успеха происходит вызов handle
, который зависит от NewBookInteractor
, и создание цепочки зависимостей, о которой я говорил выше. После этого отрабатывает интерактор и в очередь book_statuses
отправляется UUID созданной записи о книге.
# book_club/controllers/http.py
from typing import Annotated
from uuid import UUID
from dishka.integrations.base import FromDishka as Depends
from dishka.integrations.litestar import inject
from litestar import Controller, route, HttpMethod
from litestar import status_codes
from litestar.exceptions import HTTPException
from litestar.params import Body
from book_club.application.interactors import GetBookInteractor
from book_club.controllers.schemas import BookSchema
class HTTPBookController(Controller):
path = "/book"
@route(http_method=HttpMethod.GET, path="/{book_id:uuid}")
@inject
async def get_book(
self,
book_id: Annotated[UUID, Body(description="Book ID", title="Book ID")],
interactor: Depends[GetBookInteractor],
) -> BookSchema:
book_dm = await interactor(uuid=str(book_id))
if not book_dm:
raise HTTPException(status_code=status_codes.HTTP_404_NOT_FOUND, detail="Book not found")
return BookSchema(
title=book_dm.title,
pages=book_dm.pages,
is_read=book_dm.is_read,
)
Выше представлена реализация контроллера, который отвечает за выдачу информации о книгах. Логика построения цепочки зависимостей абсолютно такая же, как и в примере выше. На вход ожидается book_id
в виде UUID, по которому идет поиск книги в базе данных.
Теперь можно все собрать, и точка входа в приложение будет выглядеть следующим образом:
# book_club/main.py
from dishka import make_async_container
from dishka.integrations import faststream as faststream_integration
from dishka.integrations import litestar as litestar_integration
from faststream import FastStream
from litestar import Litestar
from book_club.config import Config
from book_club.controllers.amqp import AMQPBookController
from book_club.controllers.http import HTTPBookController
from book_club.infrastructure.broker import new_broker
from book_club.ioc import AppProvider
config = Config()
container = make_async_container(AppProvider(), context={Config: config})
def get_faststream_app() -> FastStream:
broker = new_broker(config.rabbitmq)
faststream_app = FastStream(broker)
faststream_integration.setup_dishka(container, faststream_app, auto_inject=True)
broker.include_router(AMQPBookController)
return faststream_app
def get_litestar_app() -> Litestar:
litestar_app = Litestar(
route_handlers=[HTTPBookController],
)
litestar_integration.setup_dishka(container, litestar_app)
return litestar_app
def get_app():
faststream_app = get_faststream_app()
litestar_app = get_litestar_app()
litestar_app.on_startup.append(faststream_app.broker.start)
litestar_app.on_shutdown.append(faststream_app.broker.close)
return litestar_app
Config
и container
вынесены в глобальную область видимости, это необходимо, чтобы была возможность запустить HTTP и AMQP-часть изолированно друг от друга в разных процессах, если вдруг возникнет такая необходимость.
Теперь это приложение полностью готово, и его можно запустить и проверить.
Обратите внимание, что для корректной работы приложения необходимы контейнеры c RabbitMQ и Postgress, виртуальное окружение и миграции. Подробные инструкции, docker-compose.yaml и миграции вы найдете в исходном коде проекта.
Запускаем приложение:
uvicorn --factory book_club.main:get_app --reload
Пишем в очередь create_book
сообщение:
rabbitmqadmin -u $RABBITMQ_USER -p $RABBITMQ_PASS \
publish exchange=amq.default routing_key=create_book \
payload='{"title": "The Brothers Karamazov", "pages": 928, "is_read": true}'
Читаем из очереди book_statuses
сообщение и получаем UUID
:
rabbitmqadmin -u $RABBITMQ_USER -p $RABBITMQ_PASS get queue=book_statuses count=1
+---------------+--------------------------------------+
| routing_key | payload |
+---------------+--------------------------------------+
| book_statuses | bc5fe42d-bedd-42e4-b518-89d088808237 |
+---------------+--------------------------------------+
С этим UUID идем на HTTP API и забираем информацию о книге:
curl http://localhost:8000/book/bc5fe42d-bedd-42e4-b518-89d088808237
{"title":"The Brothers Karamazov","pages":928,"is_read":true}
Если вы хотите запустить HTTP и AMQP в разных процессах, это можно сделать вот так:
faststream run --factory book_club.main:get_faststream_app --reload
uvicorn --factory book_club.main:get_litestar_app --reload
Заключение
Спасибо за внимание! Призываю вас зайти в исходный код прототипа и изучить его самостоятельно. Если этот туториал окажется полезным (я это пойму по плюсикам к статье), выпущу вторую часть с демонстрацией, как описанный проект тестировать. Также вступайте в комьюнити — там вы можете встретить контрибьюторов технологий, о которых шла речь в статье, и задать им интересующие вас вопросы: FastAPI / Litestar / BlackSheep (ASGI Community), FastStream Community, Reagento Community.
bomzheg
Какие хорошие библиотеки (фреймворки) выбрал автор. Давно за ними наблюдаю, очень надеюсь на их развитие и популяризацию, нравятся заложенные концепции.
Есть опыт использования в проде? Есть ли какие-то неочевидные особенности по эксплуатации?
Sehat1137 Автор
На прод только планируем затаскивать для одного из сервисов.
Единственное, что вместо pydantic планируем использовать msgspec.
Из неочевидных особенностей хочу отметить, что тащить все батарейки из Litestar к себе в проект не стоит, лучше ограничиться только функционалом http фреймворка.
DeFANCE
Имеем на проде Litestar для обслуживания небольшого функционала бэкофиса. Сначала были воодушевлены, затем расстроились.
Репозитории, которые предлагает использовать advanced alchemy (батарейка, не включена, но рекомендуется разработчиками), годятся разве что для круда. Мало-мальски сложных агрегатов с пятиэтажными джойнами с ним не построить, а итоге мы используем фасад к обычной алхимии, где пишем чуть не не raw-sql, в итоге выгоды никакой.
DI работает только в тех местах, где решили разработчики, а это ровно три слоя. Для под-зависимостей нужно собирать контейнер в контроллере. Причем, контейнер это сильное слово, потому что просто неявно заполняем globals. Их того следует, что все аннотации должны быть вычислены на этапе сборки, иначе не сработает.
Там, где мы думали, что быстро проскочит с его батарейками, в основном боролись именно с фреймворком и его имплементацией. Сильно не сэкономили.
Как уже отметили, лучше использовать его как http фреймворк. Но такой сценарий использования не делает его лучше, чем какой-то fast-api.
Sehat1137 Автор
Ну если говорить о том, чем он лучше FastAPI, то тут можно выделить несколько моментов:
1. Гвоздями не прибит pydantic
2. Есть ClassView (мне нравится)
3. Depends от FastAPI отражается в OpenAPI схеме, в Litestar - нет
bomzheg
Ещё очень важный, возможно самый важный момент - разработка lightstar ведётся сообществом, более чем из одного человека. К сожалению, мы знаем истории библиотек, у которых был единственный мейнтейнер, а потом с ним что-то случилось :(