Удалённая конфигурация в Node.js: от основ до продакшена
Хардкодить значения конфигурации удобно — пока не понадобится изменить их в продакшене в 2 часа ночи. В этом руководстве мы научимся реализовывать удалённую конфигурацию в Node.js, начиная с простого подхода с polling и заканчивая решениями, готовыми к продакшену.
Проблема статической конфигурации
Большинство Node.js-приложений начинаются с такой конфигурации:
// Читается один раз при старте
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT ?? '100', 10)
const FEATURE_NEW_UI = process.env.FEATURE_NEW_UI === 'true'
const CACHE_TTL = parseInt(process.env.CACHE_TTL ?? '3600', 10)Это работает, пока не понадобится:
- Изменить лимит запросов во время всплеска трафика
- Немедленно отключить сломанную функцию
- Раскатить функцию на 10% пользователей
Всё это требует изменения переменных окружения и редеплоя. С удалённой конфигурацией изменения применяются за секунды.
Подход 1: Простой polling
Простейший способ реализовать удалённую конфигурацию — polling: получение конфигурации с сервера через регулярные интервалы.
interface Config {
[key: string]: unknown
}
class RemoteConfig {
private url: string
private pollInterval: number
private config: Config = {}
private running = false
constructor(url: string, pollInterval = 30000) {
this.url = url
this.pollInterval = pollInterval
}
async start(): Promise<void> {
this.running = true
await this.fetch() // Первоначальное получение
// Запуск фонового polling
this.pollLoop()
}
stop(): void {
this.running = false
}
get<T>(key: string, defaultValue?: T): T {
return (this.config[key] as T) ?? defaultValue!
}
private async fetch(): Promise<void> {
try {
const response = await fetch(this.url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
this.config = await response.json()
console.log(`Config updated: ${Object.keys(this.config).length} keys`)
} catch (error) {
console.error('Failed to fetch config:', error)
}
}
private async pollLoop(): Promise<void> {
while (this.running) {
await new Promise((resolve) => setTimeout(resolve, this.pollInterval))
if (this.running) {
await this.fetch()
}
}
}
}Использование:
const config = new RemoteConfig('https://api.example.com/config')
await config.start()
// Позже в коде
const rateLimit = config.get('rate-limit', 100)
const newUiEnabled = config.get('new-ui-enabled', false)У этого подхода есть компромиссы:
| Плюсы | Минусы | |-------|--------| | Просто реализовать | Обновления задерживаются на интервал polling | | Работает с любым HTTP-сервером | Тратит ресурсы, когда конфиг редко меняется | | Легко отлаживать | Каждый экземпляр опрашивает независимо |
Что происходит при polling-подходе, если сервер конфигурации недоступен?
Подход 2: Server-Sent Events (SSE)
Для обновлений в реальном времени Server-Sent Events лучше, чем polling. Клиент поддерживает постоянное соединение, а сервер отправляет обновления по мере их появления.
import { EventSource } from 'eventsource'
type Callback<T> = (value: T) => void
interface Config {
[key: string]: unknown
}
class SSEConfig {
private url: string
private config: Config = {}
private subscribers: Map<string, Callback<unknown>[]> = new Map()
private eventSource: EventSource | null = null
constructor(url: string) {
this.url = url
}
connect(): void {
this.eventSource = new EventSource(this.url)
this.eventSource.addEventListener('initial', (event) => {
this.config = JSON.parse(event.data)
console.log(`Initial config loaded: ${Object.keys(this.config).length} keys`)
})
this.eventSource.addEventListener('config_update', (event) => {
const { key, value } = JSON.parse(event.data)
this.handleUpdate(key, value)
})
this.eventSource.addEventListener('error', (error) => {
console.error('SSE connection error:', error)
})
}
disconnect(): void {
this.eventSource?.close()
this.eventSource = null
}
get<T>(key: string, defaultValue?: T): T {
return (this.config[key] as T) ?? defaultValue!
}
subscribe<T>(key: string, callback: Callback<T>): () => void {
const callbacks = this.subscribers.get(key) ?? []
callbacks.push(callback as Callback<unknown>)
this.subscribers.set(key, callbacks)
// Возвращаем функцию отписки
return () => {
const cbs = this.subscribers.get(key) ?? []
const index = cbs.indexOf(callback as Callback<unknown>)
if (index > -1) cbs.splice(index, 1)
}
}
private handleUpdate(key: string, value: unknown): void {
this.config[key] = value
console.log(`Config changed: ${key} = ${value}`)
// Уведомляем подписчиков
const callbacks = this.subscribers.get(key) ?? []
for (const callback of callbacks) {
try {
callback(value)
} catch (error) {
console.error('Subscriber error:', error)
}
}
}
}Использование с подписками:
const config = new SSEConfig('https://api.example.com/config/stream')
config.connect()
// Реагируем на изменения
config.subscribe<number>('rate-limit', (newValue) => {
console.log(`Rate limit changed to ${newValue}`)
rateLimiter.updateLimit(newValue)
})С SSE обновления приходят за миллисекунды, а не дожидаясь следующего poll.
Подход 3: Использование production SDK
Для продакшена лучше использовать проверенный SDK, а не строить свой. Вот пример с использованием JavaScript SDK Replane:
Сначала установите его:
npm install @replanejs/sdkЗатем используйте в коде:
import { Replane } from '@replanejs/sdk'
// Определите типы конфигурации
interface Configs {
'rate-limit': number
'new-ui-enabled': boolean
'cache-ttl': number
}
// Создайте клиент со значениями по умолчанию (используются, если сервер недоступен)
const replane = new Replane<Configs>({
defaults: {
'rate-limit': 100,
'new-ui-enabled': false,
'cache-ttl': 3600,
},
})
// Подключитесь к серверу
await replane.connect({
sdkKey: process.env.REPLANE_SDK_KEY!,
baseUrl: 'https://cloud.replane.dev',
})
// Получите значения конфигурации — типизировано!
const rateLimit = replane.get('rate-limit') // number
// С контекстом для пользовательских override'ов
const userRateLimit = replane.get('rate-limit', {
context: {
userId: user.id,
plan: user.subscription,
},
})
// Подписка на изменения
replane.subscribe('rate-limit', (config) => {
console.log(`Rate limit changed: ${config.value}`)
})
// При завершении
replane.disconnect()SDK обрабатывает:
- SSE-соединение с автоматическим переподключением
- Локальное кеширование для мгновенного чтения
- Вычисление override’ов на основе контекста
- Graceful fallback к значениям по умолчанию
Почему стоит использовать production SDK вместо собственного polling-клиента?
Интеграция с Express
Как интегрировать удалённую конфигурацию с Express-приложением:
import express from 'express'
import { Replane } from '@replanejs/sdk'
interface Configs {
'rate-limit': number
}
const app = express()
// Инициализируем один раз при старте
const replane = new Replane<Configs>({
defaults: { 'rate-limit': 100 },
})
await replane.connect({
sdkKey: process.env.REPLANE_SDK_KEY!,
baseUrl: 'https://cloud.replane.dev',
})
// Делаем конфигурацию доступной в обработчиках запросов
app.get('/api/data', (req, res) => {
const rateLimit = replane.get('rate-limit')
// Используем rateLimit для throttling...
res.json({ data: 'hello' })
})
// Очистка при завершении
process.on('SIGTERM', () => {
replane.disconnect()
process.exit(0)
})
app.listen(3000)Интеграция с Fastify
Для Fastify можно использовать паттерн плагина:
import Fastify from 'fastify'
import { Replane } from '@replanejs/sdk'
interface Configs {
'rate-limit': number
}
const fastify = Fastify()
// Инициализируем конфигурацию
const replane = new Replane<Configs>({
defaults: { 'rate-limit': 100 },
})
await replane.connect({
sdkKey: process.env.REPLANE_SDK_KEY!,
baseUrl: 'https://cloud.replane.dev',
})
// Декорируем fastify доступом к конфигурации
fastify.decorate('config', replane)
fastify.get('/api/data', async (request, reply) => {
const rateLimit = fastify.config.get('rate-limit')
return { data: 'hello', rateLimit }
})
// Hook для очистки
fastify.addHook('onClose', async () => {
replane.disconnect()
})
await fastify.listen({ port: 3000 })Лучшие практики
При реализации удалённой конфигурации в Node.js:
Всегда устанавливайте значения по умолчанию. Приложение должно работать, даже если сервер конфигурации недоступен:
// Хорошо: есть значение по умолчанию
const rateLimit = replane.get('rate-limit', { default: 100 })
// Плохо: выбросит ошибку, если ключ отсутствует
const rateLimit = replane.get('rate-limit')Не делайте запросы на каждый запрос. Кешируйте значения конфигурации в памяти и обновляйте через SSE или polling:
// Плохо: сетевой вызов на каждый запрос
app.get('/api', async (req, res) => {
const config = await fetch('https://config.example.com').then(r => r.json())
})
// Хорошо: читаем из кеша
app.get('/api', (req, res) => {
const rateLimit = replane.get('rate-limit')
})Логируйте изменения конфигурации. При отладке нужно знать, какой конфиг был активен:
replane.subscribe('rate-limit', (config) => {
logger.info(`Config changed: rate-limit=${config.value}`)
})Храните секреты отдельно. Удалённая конфигурация для feature flags и операционных настроек, а не для API-ключей:
// Хорошо: секреты из окружения
const apiKey = process.env.STRIPE_API_KEY
// Хорошо: feature flags из удалённой конфигурации
const stripeEnabled = replane.get('stripe-enabled')Итоги
Удалённая конфигурация позволяет менять поведение Node.js-приложения без редеплоя. Мы рассмотрели три подхода:
- Polling — просто, но с задержкой
- SSE — в реальном времени, но сложнее
- Production SDK — лучший выбор для реальных приложений
Для продакшена рассмотрите Replane (open-source, self-hosted) или аналогичные инструменты вместо разработки с нуля.
Упражнения
-
Создайте polling-клиент: Расширьте класс
RemoteConfigподдержкой callback’ов при изменении определённых ключей. -
Добавьте кеширование: Модифицируйте polling-клиент для сохранения конфигурации на диск, чтобы приложение могло запускаться даже при недоступности сервера конфигурации.
-
Реализуйте override’ы на основе контекста: Добавьте возможность, при которой разные пользователи получают разные значения конфигурации в зависимости от их ID или плана подписки.
Если хотите всегда быть в курсе последних новостей в мире программирования и IT, подписываетесь на мой Telegram-канал, где я делюсь свежими статьями, новостями и полезными советами. Буду рад видеть вас среди подписчиков!
Обсуждение