Обработка внешних сервисов (БД, кэш, очереди) как присоединённых ресурсов
Обрабатывайте внешние сервисы как присоединённые ресурсы
Backing Services — четвёртый фактор 12-Factor App. Принцип гласит:
Внешние сервисы (базы данных, кэш, очереди, SMTP) должны рассматриваться как присоединённые ресурсы. Приложение не должно знать, где они находятся — только URL для подключения.
# ❌ Неправильно: приложение знает о расположении сервиса
DATABASE_HOST = 'localhost'
DATABASE_PORT = 5432
DATABASE_NAME = 'myapp'
# ✅ Правильно: сервис как ресурс по URL
import os
DATABASE_URL = os.environ['DATABASE_URL']
# postgres://user:pass@db.example.com:5432/myappBacking Service — это любой сервис, который приложение использует как сервис:
| Сервис | Примеры | Назначение |
|---|---|---|
| Базы данных | PostgreSQL, MySQL, MongoDB | Хранение данных |
| Кэш | Redis, Memcached | Кэширование, сессии |
| Очереди | RabbitMQ, Kafka, SQS | Асинхронные задачи |
| Почта | SendGrid, Mailgun, SMTP | Отправка email |
| Хранилища | S3, GCS, Azure Blob | Файлы, медиа |
| Мониторинг | Sentry, Datadog, New Relic | Логирование, метрики |
Ключевая идея: не различать локальные и внешние сервисы.
# ❌ Неправильно: разная логика для локальных и внешних сервисов
# Локальная БД — специальное подключение
if ENV == 'development':
db = SQLite('local.db')
else:
# Production — PostgreSQL
db = PostgreSQL(host='prod-db', port=5432)
# Локальный кэш в памяти
if ENV == 'development':
cache = {} # Dict в памяти
else:
# Production — Redis
cache = Redis(host='redis-cluster')Проблемы:
# ✅ Правильно: единый интерфейс через URL
import os
from urllib.parse import urlparse
# БД — просто ресурс по URL
database_url = os.environ['DATABASE_URL']
# postgres://user:pass@localhost:5432/dev
# postgres://user:pass@prod-db:5432/prod
# Кэш — просто ресурс по URL
cache_url = os.environ['REDIS_URL']
# redis://localhost:6379/0
# redis://redis-cluster:6379/0Преимущества:
DATABASE_URL=postgres://user:password@host:5432/database_name
# Примеры:
# Локально: postgres://postgres:postgres@localhost:5432/myapp
# Production: postgres://app:secret@prod-db.aws:5432/myappDATABASE_URL=mysql://user:password@host:3306/database_name
# Пример:
DATABASE_URL=mysql://root:secret@localhost:3306/myappREDIS_URL=redis://host:6379/database_number
# Примеры:
# Локально: redis://localhost:6379/0
# Production: redis://redis-cluster:6379/0
# С паролем: redis://:password@redis:6379/0MONGODB_URL=mongodb://user:password@host:27017/database
# Пример:
MONGODB_URL=mongodb://localhost:27017/myappMAIL_URL=smtp://user:password@host:587
# Примеры:
# Gmail: smtp://user@gmail.com:password@smtp.gmail.com:587
# SendGrid: smtp://apikey:SENDGRID_API_KEY@smtp.sendgrid.net:587import os
from urllib.parse import urlparse
# Получаем URL из окружения
database_url = os.environ['DATABASE_URL']
# Парсим URL
parsed = urlparse(database_url)
# Извлекаем компоненты
db_config = {
'host': parsed.hostname,
'port': parsed.port,
'database': parsed.path.lstrip('/'),
'username': parsed.username,
'password': parsed.password,
}
# Подключение
import psycopg2
conn = psycopg2.connect(**db_config)const { parse } = require('url');
// Получаем URL из окружения
const databaseUrl = process.env.DATABASE_URL;
// Парсим URL
const parsed = parse(databaseUrl);
// Извлекаем компоненты
const config = {
host: parsed.hostname,
port: parsed.port,
database: parsed.pathname.slice(1),
user: parsed.auth.split(':')[0],
password: parsed.auth.split(':')[1],
};
// Подключение
const { Pool } = require('pg');
const pool = new Pool(config);# dj-database-url для Django
import dj_database_url
DATABASES = {
'default': dj_database_url.config(
default=os.environ['DATABASE_URL']
)
}
# redis-url для Redis
import redis
redis_client = redis.from_url(os.environ['REDIS_URL'])# Явное указание URL
export DATABASE_URL=postgres://user:pass@db:5432/app
export REDIS_URL=redis://redis:6379/0
export MAIL_URL=smtp://user:pass@smtp:587
python app.pyВ некоторых платформах (Heroku, Cloud Foundry) есть механизм resource binding:
# Heroku: привязка сервиса к приложению
heroku addons:create heroku-postgresql:hobby-dev
heroku addons:create rediscloud:30
# Платформа автоматически создаёт переменные окружения
# DATABASE_URL, REDIS_URL доступны приложению# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/app
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=app
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:Преимущества:
# Код не меняется — меняется только DATABASE_URL
# До: SQLite локально
DATABASE_URL=sqlite:///local.db
# После: PostgreSQL локально (как в production)
DATABASE_URL=postgres://localhost:5432/myapp# Код использует абстракцию кэша
from cache import Cache
cache = Cache.from_url(os.environ['CACHE_URL'])
cache.set('key', 'value')
# Меняется только URL:
# До: CACHE_URL=redis://localhost:6379/0
# После: CACHE_URL=memcached://localhost:11211# До: самодельный PostgreSQL на VM
DATABASE_URL=postgres://user:pass@vm-ip:5432/app
# После: Amazon RDS
DATABASE_URL=postgres://user:pass@rds-endpoint.amazonaws.com:5432/app
# После: Heroku Postgres
DATABASE_URL=postgres://user:pass@heroku-db.com:5432/app
# Код не меняется!# ❌ Неправильно: хосты в коде
class Database:
def __init__(self):
if ENV == 'prod':
self.host = 'prod-db.example.com'
else:
self.host = 'localhost'Проблема: изменение хоста требует изменения кода.
# ❌ Неправильно: разное поведение
if ENV == 'development':
# Локальный файл
storage = FileSystemStorage('/tmp/uploads')
else:
# Production: S3
storage = S3Storage('my-bucket')Правильно: использовать один интерфейс, разная реализация через конфигурацию.
# ❌ Неправильно: нет обработки ошибок подключения
redis = Redis(host='localhost', port=6379)
cache_value = redis.get('key') # Упадёт, если Redis недоступенПравильно: обрабатывать недоступность сервиса.
# ✅ Правильно: graceful degradation
try:
redis = Redis.from_url(os.environ['REDIS_URL'])
cache_value = redis.get('key')
except ConnectionError:
# Кэш недоступен — работаем без кэша
cache_value = None# ❌ Неправильно: каждое место создаёт своё подключение
def get_user(user_id):
db = create_database_connection() # Новое подключение
return db.query('SELECT * FROM users WHERE id = ?', user_id)
def save_user(user):
db = create_database_connection() # Ещё одно подключение
db.execute('INSERT INTO users VALUES (?)', user)Правильно: использовать пул подключений.
# ✅ Правильно: пул подключений
from psycopg2 import pool
db_pool = pool.SimpleConnectionPool(
minconn=1,
maxconn=10,
dsn=os.environ['DATABASE_URL']
)
def get_user(user_id):
conn = db_pool.getconn()
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM users WHERE id = %s', (user_id,))
return cur.fetchone()
finally:
db_pool.putconn(conn)# RDS (PostgreSQL)
DATABASE_URL=postgres://user:pass@mydb.abc123.us-east-1.rds.amazonaws.com:5432/app
# ElastiCache (Redis)
REDIS_URL=redis://mycache.abc123.use1.cache.amazonaws.com:6379/0
# SQS (очереди)
SQS_URL=https://sqs.us-east-1.amazonaws.com/123456789/my-queue
# S3 (хранилище)
S3_BUCKET=my-app-bucket
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...# Cloud SQL (PostgreSQL)
DATABASE_URL=postgres://user:pass@/app?host=/cloudsql/project:region:instance
# Memorystore (Redis)
REDIS_URL=redis://10.0.0.3:6379/0
# Pub/Sub (очереди)
PUBSUB_PROJECT_ID=my-project
PUBSUB_TOPIC=my-topic
PUBSUB_SUBSCRIPTION=my-subscription
# GCS (хранилище)
GCS_BUCKET=my-app-bucket# Azure Database for PostgreSQL
DATABASE_URL=postgres://user:pass@mydb.postgres.database.azure.com:5432/app
# Azure Cache for Redis
REDIS_URL=redis://mycache.redis.cache.windows.net:6380/0
# Service Bus (очереди)
SERVICEBUS_CONNECTION_STRING=Endpoint=sb://...
# Blob Storage
AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;...Задайте себе вопросы:
# ❌ Неправильно: код зависит от конкретных сервисов
# Жёстко закодированный localhost
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
# Разная логика для dev/prod
if socket.gethostname() == 'prod-server':
DB_HOST = 'prod-db.example.com'
else:
DB_HOST = 'localhost'
# Прямое создание подключений
def get_redis():
return redis.Redis(host=REDIS_HOST, port=REDIS_PORT)Проблемы:
# ✅ Правильно: сервисы как ресурсы
import os
import redis
from redis.exceptions import ConnectionError
# URL из окружения
REDIS_URL = os.environ['REDIS_URL']
# Ленивое подключение с пулом
_redis_pool = None
def get_redis():
global _redis_pool
if _redis_pool is None:
_redis_pool = redis.ConnectionPool.from_url(REDIS_URL)
try:
return redis.Redis(connection_pool=_redis_pool)
except ConnectionError:
# Graceful degradation
return None
# Использование
redis_client = get_redis()
if redis_client:
cache_value = redis_client.get('key')
else:
# Работаем без кэша
cache_value = compute_expensive_value()# Переменные окружения для разных сред
# Development
REDIS_URL=redis://localhost:6379/0
# Docker Compose
REDIS_URL=redis://redis:6379/0
# Production
REDIS_URL=redis://redis-cluster.aws:6379/0Результат:
Ключевой вывод: Backing services должны быть абстрагированы как присоединённые ресурсы. Приложение получает URL сервиса из конфигурации и не знает, где он находится — локально или в облаке. Это обеспечивает переносимость и заменяемость сервисов без изменения кода.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.