Три фреймворка, одна задача: быстрый API
Когда нужно построить API-сервис — не монолит с ORM и шаблонами, а чистый JSON API на 10-50 эндпоинтов с минимальной latency — выбор стека определяет всё: от производительности до стоимости инфраструктуры.
Три фреймворка борются за этот сегмент: FastAPI (Python) — с автодокументацией и типами, Go Fiber — с производительностью на уровне C, Node Fastify — с экосистемой npm и async/await. Каждый обещает скорость. Каждый решает задачу по-разному.
Я работал с FastAPI на ML-пайплайне (inference API для моделей), Go Fiber на микросервисе авторизации (50K+ rps), Fastify на BFF-слое (backend-for-frontend, агрегация 5 микросервисов). Вот что я вынес из каждого.
Архитектура и философия
FastAPI — типизация и документация из коробки
FastAPI построен на Starlette (ASGI) и Pydantic. Главная идея: описываешь типы — получаешь валидацию, сериализацию и документацию автоматически.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from datetime import datetime
app = FastAPI(title="User API", version="1.0.0")
class UserCreate(BaseModel):
name: str
email: EmailStr
age: int | None = None
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# Pydantic уже валидировал входные данные
# email проверен, age — опциональный int
db_user = await db.users.insert(user.model_dump())
return UserResponse(**db_user)
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await db.users.find_one(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserResponse(**user)
Запустил — и на /docs уже есть Swagger UI с интерактивной документацией. На /redoc — ReDoc. Типы из Pydantic автоматически превращаются в JSON Schema.
Go Fiber — минимализм и скорость
Fiber вдохновлён Express.js, но написан на Go с использованием fasthttp (быстрее стандартного net/http в 5-10 раз).
package main
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
type UserCreate struct {
Name string `json:"name" validate:"required,min=2"`
Email string `json:"email" validate:"required,email"`
Age *int `json:"age,omitempty" validate:"omitempty,gte=0,lte=150"`
}
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
app := fiber.New(fiber.Config{
Prefork: true, // multi-process (как nginx workers)
CaseSensitive: true,
StrictRouting: true,
})
app.Use(logger.New())
app.Use(recover.New())
app.Post("/users", createUser)
app.Get("/users/:id", getUser)
app.Listen(":3000")
}
func createUser(c *fiber.Ctx) error {
var input UserCreate
if err := c.BodyParser(&input); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid JSON"})
}
// Валидация через go-playground/validator
if err := validate.Struct(input); err != nil {
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
}
user := db.CreateUser(input)
return c.Status(201).JSON(UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt,
})
}
func getUser(c *fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
user, err := db.FindUser(id)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "User not found"})
}
return c.JSON(UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt,
})
}
Нет автодокументации из коробки (нужен swagger-gen или swaggo). Нет автовалидации (нужен validator). Но: горутины вместо event loop, скомпилированный бинарник, минимальный overhead.
Node Fastify — структурированный Express
Fastify — «фреймворк для тех, кому Express слишком хаотичен». Schema-based валидация через JSON Schema, плагинная архитектура, встроенный логгер (Pino).
import Fastify from 'fastify';
const app = Fastify({ logger: true });
// JSON Schema для валидации и документации
const createUserSchema = {
body: {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 2 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0, maximum: 150 },
},
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
email: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
},
},
},
};
app.post('/users', { schema: createUserSchema }, async (request, reply) => {
const { name, email, age } = request.body as any;
const user = await db.users.create({ name, email, age });
reply.code(201).send({
id: user.id,
name: user.name,
email: user.email,
created_at: user.created_at,
});
});
app.get('/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await db.users.findById(parseInt(id));
if (!user) {
reply.code(404).send({ error: 'User not found' });
return;
}
reply.send(user);
});
app.listen({ port: 3000, host: '0.0.0.0' });
Fastify использует JSON Schema для валидации (Ajv) и сериализации (fast-json-stringify). Схема описывает и входные данные, и формат ответа — Fastify генерирует оптимизированный сериализатор.
Сравнение архитектуры
| Аспект | FastAPI | Go Fiber | Node Fastify |
|---|---|---|---|
| Язык | Python 3.10+ | Go 1.21+ | TypeScript/JS |
| Runtime | uvicorn (ASGI) | fasthttp (Go) | Node.js (V8) |
| Конкурентность | async/await (asyncio) | Горутины (green threads) | Event loop + async/await |
| Валидация | Pydantic (встроенная) | go-playground/validator | JSON Schema (Ajv) |
| Документация | Swagger UI (встроенная) | swaggo (сторонний) | @fastify/swagger |
| Типизация | Python type hints | Go structs | TypeScript interfaces |
| Middleware | ASGI middleware | Fiber middleware | Fastify hooks + plugins |
| Экосистема | PyPI (500K+ пакетов) | Go modules | npm (2M+ пакетов) |
CRUD API: полный пример
FastAPI
from fastapi import FastAPI, HTTPException, Query, Depends
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
import databases
import sqlalchemy
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
database = databases.Database(DATABASE_URL)
app = FastAPI()
# Модели
class ProductCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str | None = None
price: float = Field(..., gt=0)
category: str
in_stock: bool = True
class ProductUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: float | None = Field(None, gt=0)
category: str | None = None
in_stock: bool | None = None
class ProductResponse(BaseModel):
id: int
name: str
description: str | None
price: float
category: str
in_stock: bool
created_at: datetime
updated_at: datetime
class PaginatedResponse(BaseModel):
items: list[ProductResponse]
total: int
page: int
per_page: int
# Эндпоинты
@app.get("/products", response_model=PaginatedResponse)
async def list_products(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
category: str | None = None,
min_price: float | None = None,
max_price: float | None = None,
search: str | None = None,
):
query = products_table.select()
if category:
query = query.where(products_table.c.category == category)
if min_price is not None:
query = query.where(products_table.c.price >= min_price)
if max_price is not None:
query = query.where(products_table.c.price <= max_price)
if search:
query = query.where(products_table.c.name.ilike(f"%{search}%"))
total = await database.fetch_val(
query.with_only_columns(sqlalchemy.func.count())
)
rows = await database.fetch_all(
query.offset((page - 1) * per_page).limit(per_page)
)
return PaginatedResponse(
items=[ProductResponse(**dict(r)) for r in rows],
total=total,
page=page,
per_page=per_page,
)
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: ProductCreate):
now = datetime.utcnow()
query = products_table.insert().values(
**product.model_dump(), created_at=now, updated_at=now
)
product_id = await database.execute(query)
return ProductResponse(id=product_id, **product.model_dump(),
created_at=now, updated_at=now)
@app.patch("/products/{product_id}", response_model=ProductResponse)
async def update_product(product_id: int, product: ProductUpdate):
updates = product.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(400, "No fields to update")
updates["updated_at"] = datetime.utcnow()
await database.execute(
products_table.update()
.where(products_table.c.id == product_id)
.values(**updates)
)
row = await database.fetch_one(
products_table.select().where(products_table.c.id == product_id)
)
if not row:
raise HTTPException(404, "Product not found")
return ProductResponse(**dict(row))
@app.delete("/products/{product_id}", status_code=204)
async def delete_product(product_id: int):
result = await database.execute(
products_table.delete().where(products_table.c.id == product_id)
)
if not result:
raise HTTPException(404, "Product not found")
@app.on_event("startup")
async def startup():
await database.connect()
@app.on_event("shutdown")
async def shutdown():
await database.disconnect()
Go Fiber
// handlers/products.go
func ListProducts(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
perPage := c.QueryInt("per_page", 20)
category := c.Query("category")
search := c.Query("search")
query := db.Model(&Product{})
if category != "" {
query = query.Where("category = ?", category)
}
if search != "" {
query = query.Where("name ILIKE ?", "%"+search+"%")
}
var total int64
query.Count(&total)
var products []Product
query.Offset((page - 1) * perPage).Limit(perPage).Find(&products)
return c.JSON(fiber.Map{
"items": products,
"total": total,
"page": page,
"per_page": perPage,
})
}
func CreateProduct(c *fiber.Ctx) error {
var input ProductCreate
if err := c.BodyParser(&input); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid JSON"})
}
if err := validate.Struct(input); err != nil {
return c.Status(422).JSON(fiber.Map{"error": formatValidationErrors(err)})
}
product := Product{
Name: input.Name,
Description: input.Description,
Price: input.Price,
Category: input.Category,
InStock: input.InStock,
}
result := db.Create(&product)
if result.Error != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to create"})
}
return c.Status(201).JSON(product)
}
func UpdateProduct(c *fiber.Ctx) error {
id, _ := c.ParamsInt("id")
var product Product
if err := db.First(&product, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Not found"})
}
var input map[string]interface{}
if err := c.BodyParser(&input); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid JSON"})
}
db.Model(&product).Updates(input)
return c.JSON(product)
}
func DeleteProduct(c *fiber.Ctx) error {
id, _ := c.ParamsInt("id")
result := db.Delete(&Product{}, id)
if result.RowsAffected == 0 {
return c.Status(404).JSON(fiber.Map{"error": "Not found"})
}
return c.SendStatus(204)
}
Node Fastify
import Fastify from 'fastify';
import { PrismaClient } from '@prisma/client';
const app = Fastify({ logger: true });
const prisma = new PrismaClient();
// Schemas
const productSchema = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
price: { type: 'number' },
category: { type: 'string' },
in_stock: { type: 'boolean' },
created_at: { type: 'string', format: 'date-time' },
},
};
// List
app.get('/products', {
schema: {
querystring: {
type: 'object',
properties: {
page: { type: 'integer', default: 1, minimum: 1 },
per_page: { type: 'integer', default: 20, minimum: 1, maximum: 100 },
category: { type: 'string' },
search: { type: 'string' },
},
},
},
}, async (request) => {
const { page, per_page, category, search } = request.query as any;
const where: any = {};
if (category) where.category = category;
if (search) where.name = { contains: search, mode: 'insensitive' };
const [items, total] = await Promise.all([
prisma.product.findMany({
where,
skip: (page - 1) * per_page,
take: per_page,
orderBy: { created_at: 'desc' },
}),
prisma.product.count({ where }),
]);
return { items, total, page, per_page };
});
// Create
app.post('/products', {
schema: {
body: {
type: 'object',
required: ['name', 'price', 'category'],
properties: {
name: { type: 'string', minLength: 1 },
description: { type: 'string' },
price: { type: 'number', exclusiveMinimum: 0 },
category: { type: 'string' },
in_stock: { type: 'boolean', default: true },
},
},
response: { 201: productSchema },
},
}, async (request, reply) => {
const product = await prisma.product.create({
data: request.body as any,
});
reply.code(201).send(product);
});
// Update
app.patch('/products/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const product = await prisma.product.update({
where: { id: parseInt(id) },
data: request.body as any,
});
reply.send(product);
} catch {
reply.code(404).send({ error: 'Not found' });
}
});
// Delete
app.delete('/products/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
await prisma.product.delete({ where: { id: parseInt(id) } });
reply.code(204).send();
} catch {
reply.code(404).send({ error: 'Not found' });
}
});
app.listen({ port: 3000, host: '0.0.0.0' });
Бенчмарки
Условия
- Машина: 4 ядра (AMD Ryzen 7), 16 GB RAM, Ubuntu 24.04
- PostgreSQL 16 с 1M записей в таблице products
- HTTP load: wrk, 8 потоков, 256 соединений, 30 секунд
- Каждый фреймворк в Docker, single process (кроме Fiber с Prefork)
JSON response (без базы, статический JSON)
| Метрика | FastAPI | Go Fiber | Node Fastify |
|---|---|---|---|
| Requests/sec | 18,500 | 185,000 | 62,000 |
| Latency p50 | 12ms | 1.2ms | 3.5ms |
| Latency p99 | 45ms | 4ms | 12ms |
| Memory | 85 MB | 12 MB | 65 MB |
Go Fiber в 10 раз быстрее FastAPI на чистом JSON. Fastify — в 3 раза быстрее FastAPI. Но это синтетика — в реальности bottleneck всегда база данных.
CRUD с PostgreSQL (SELECT + JOIN)
| Метрика | FastAPI | Go Fiber | Node Fastify |
|---|---|---|---|
| Requests/sec | 4,200 | 12,800 | 8,500 |
| Latency p50 | 55ms | 18ms | 28ms |
| Latency p99 | 180ms | 45ms | 85ms |
| Memory | 120 MB | 25 MB | 95 MB |
С базой данных разрыв сокращается: Go Fiber в 3 раза быстрее FastAPI, Fastify — в 2 раза. Bottleneck перемещается на PostgreSQL.
Тяжёлый запрос (агрегация + внешний API call)
| Метрика | FastAPI | Go Fiber | Node Fastify |
|---|---|---|---|
| Requests/sec | 850 | 2,100 | 1,800 |
| Latency p50 | 280ms | 115ms | 135ms |
| Latency p99 | 900ms | 350ms | 420ms |
На I/O-bound задачах Fastify подтягивается к Go благодаря event loop. FastAPI теряет из-за GIL и overhead asyncio.
Startup time
| Метрика | FastAPI | Go Fiber | Node Fastify |
|---|---|---|---|
| Cold start | 1.2s | 0.05s | 0.8s |
| Docker image | 150 MB | 12 MB | 80 MB |
Go — мгновенный старт. Критично для serverless (Lambda, Cloud Functions), где cold start = деньги.
Middleware и плагины
FastAPI: Dependency Injection
from fastapi import Depends, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: Database = Depends(get_db),
) -> User:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user = await db.users.find_one(payload["user_id"])
if not user:
raise HTTPException(401, "Invalid token")
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != "admin":
raise HTTPException(403, "Admin only")
return user
@app.get("/admin/users")
async def admin_list_users(admin: User = Depends(require_admin)):
return await db.users.find_all()
FastAPI DI — одна из лучших реализаций в любом фреймворке. Зависимости цепляются, кэшируются, типизируются. Тестирование — через app.dependency_overrides.
Go Fiber: Middleware chain
func AuthMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
token := c.Get("Authorization")
if token == "" {
return c.Status(401).JSON(fiber.Map{"error": "No token"})
}
claims, err := jwt.Parse(strings.TrimPrefix(token, "Bearer "))
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "Invalid token"})
}
c.Locals("user_id", claims.UserID)
return c.Next()
}
}
func AdminOnly() fiber.Handler {
return func(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int)
user, _ := db.FindUser(userID)
if user.Role != "admin" {
return c.Status(403).JSON(fiber.Map{"error": "Admin only"})
}
c.Locals("user", user)
return c.Next()
}
}
// Применение
admin := app.Group("/admin", AuthMiddleware(), AdminOnly())
admin.Get("/users", listUsers)
Node Fastify: Hooks и Decorators
// Auth plugin
app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
reply.code(401).send({ error: 'No token' });
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
request.user = payload as UserPayload;
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
});
// Route-level hook
app.get('/admin/users', {
preHandler: [app.authenticate],
}, async (request) => {
if (request.user.role !== 'admin') {
throw { statusCode: 403, message: 'Admin only' };
}
return prisma.user.findMany();
});
Тестирование
FastAPI: TestClient
from fastapi.testclient import TestClient
import pytest
@pytest.fixture
def client():
app.dependency_overrides[get_db] = lambda: test_db
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
def test_create_product(client):
response = client.post("/products", json={
"name": "Test Product",
"price": 29.99,
"category": "test",
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Product"
assert data["price"] == 29.99
def test_create_product_invalid(client):
response = client.post("/products", json={
"name": "", # too short
"price": -5, # negative
})
assert response.status_code == 422
Go Fiber: httptest
func TestCreateProduct(t *testing.T) {
app := setupTestApp()
body := `{"name":"Test","price":29.99,"category":"test"}`
req := httptest.NewRequest("POST", "/products", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, 201, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, "Test", result["name"])
}
Node Fastify: inject
import { test } from 'node:test';
import assert from 'node:assert';
test('POST /products creates product', async () => {
const response = await app.inject({
method: 'POST',
url: '/products',
payload: { name: 'Test', price: 29.99, category: 'test' },
});
assert.strictEqual(response.statusCode, 201);
const data = response.json();
assert.strictEqual(data.name, 'Test');
});
Fastify inject() — запрос без сети, самый быстрый способ тестирования HTTP. FastAPI TestClient — тоже in-process. Go app.Test() — аналогично.
Деплой и Docker
FastAPI
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY . /app
WORKDIR /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Go Fiber
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /server
FROM scratch
COPY --from=builder /server /server
CMD ["/server"]
Node Fastify
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && cp -R node_modules /prod_modules && npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=deps /prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Когда что выбирать
FastAPI — когда
- ML/AI inference API: интеграция с PyTorch, TensorFlow, scikit-learn
- Data-heavy API: pandas, numpy, scipy в pipeline
- Быстрое прототипирование: автодокументация экономит дни
- Команда знает Python лучше других языков
- Нужна OpenAPI-документация для внешних клиентов
- Нагрузка < 5K rps (для большего — добавить workers или перейти на Go)
Go Fiber — когда
- High-performance API: > 10K rps, низкая latency критична
- Microservices: маленький Docker-образ, мгновенный cold start
- Serverless (Lambda): cold start 50ms vs 1200ms у Python
- Инфраструктурные сервисы: proxy, gateway, auth service
- Команда знает Go или готова учить
- Нужна предсказуемая производительность без GC-пауз
Node Fastify — когда
- BFF (Backend-for-Frontend): агрегация нескольких API
- Real-time: WebSocket + HTTP в одном процессе
- Fullstack TypeScript: общие типы между фронтом и бэком
- npm-экосистема критична: конкретная библиотека есть только в npm
- Команда фронтендеров пишет бэкенд
- Нагрузка 5-15K rps: быстрее FastAPI, проще Go
Ни один из трёх
- Монолит с ORM, миграциями, шаблонами → Rails, Laravel, Django
- Enterprise с CQRS, DDD → Spring Boot, .NET
- Embedded/системное программирование → Rust (Actix, Axum)
Итоговый чеклист
Выбор
- [ ] Определить основную нагрузку: CPU-bound → Go, I/O-bound → Fastify/FastAPI, ML → FastAPI
- [ ] Определить target rps: < 5K → любой, 5-15K → Fastify/Go, > 15K → Go
- [ ] Определить экосистему: PyPI (data/ML), npm (frontend libs), Go modules (infra)
- [ ] Оценить cold start requirements: serverless → Go, containers → любой
Архитектура
- [ ] Валидация на уровне фреймворка: Pydantic / JSON Schema / go-validator
- [ ] Структурированные ошибки: единый формат
{"error": ..., "code": ...} - [ ] Health check endpoint:
GET /healthс проверкой зависимостей - [ ] Graceful shutdown: обработка SIGTERM
Production
- [ ] Rate limiting: встроенный или через nginx
- [ ] CORS: настроить для конкретных доменов
- [ ] Structured logging: JSON-логи для сборщика (ELK, Loki)
- [ ] Metrics: Prometheus endpoint
/metrics - [ ] Tracing: OpenTelemetry для distributed tracing
- [ ] Docker: multi-stage build, non-root user
Комментарии (0)