Удалённая конфигурация в Node.js: от основ до продакшена

Хардкодить значения конфигурации удобно — пока не понадобится изменить их в продакшене в 2 часа ночи. В этом руководстве мы научимся реализовывать удалённую конфигурацию в Node.js, начиная с простого подхода с polling и заканчивая решениями, готовыми к продакшену.

Интересное
Последние новости в мире программирования
Самые свежие новости и полезные материалы в моем telegram канале.
go

Проблема статической конфигурации

Большинство 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-подходе, если сервер конфигурации недоступен?

Приложение падает
Все значения конфигурации становятся undefined
Используется последняя успешно полученная конфигурация
Приложение автоматически перезапускается

Подход 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.

Интересное
Последние новости в мире программирования
Самые свежие новости и полезные материалы в моем telegram канале.
go

Подход 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-клиента?

SDK всегда быстрее
SDK использует меньше памяти
SDK обрабатывает edge cases: переподключение и кеширование
SDK требуется по закону

Интеграция с 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 })
Интересное
Последние новости в мире программирования
Самые свежие новости и полезные материалы в моем telegram канале.
go

Лучшие практики

При реализации удалённой конфигурации в 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-приложения без редеплоя. Мы рассмотрели три подхода:

  1. Polling — просто, но с задержкой
  2. SSE — в реальном времени, но сложнее
  3. Production SDK — лучший выбор для реальных приложений

Для продакшена рассмотрите Replane (open-source, self-hosted) или аналогичные инструменты вместо разработки с нуля.

Упражнения

  1. Создайте polling-клиент: Расширьте класс RemoteConfig поддержкой callback’ов при изменении определённых ключей.

  2. Добавьте кеширование: Модифицируйте polling-клиент для сохранения конфигурации на диск, чтобы приложение могло запускаться даже при недоступности сервера конфигурации.

  3. Реализуйте override’ы на основе контекста: Добавьте возможность, при которой разные пользователи получают разные значения конфигурации в зависимости от их ID или плана подписки.

Если хотите всегда быть в курсе последних новостей в мире программирования и IT, подписываетесь на мой Telegram-канал, где я делюсь свежими статьями, новостями и полезными советами. Буду рад видеть вас среди подписчиков!

Обсуждение