Введение

Real-time системы и протоколы — тема, которая меня давно интересовала. В рамках эксперимента я решил попробовать свои силы:

  • разработал собственный протокол с форматом данных,

  • использовал вебсокеты для доставки обновлений в реальном времени.

Основные вызовы, с которыми пришлось столкнуться:

  • масштабируемость — как обслуживать большое количество соединений и обновлений,

  • дизайн формата данных — как сделать его универсальным,

  • синхронность — чтобы все клиенты получали изменения мгновенно.

Для теста я взял простую модель: миллион ячеек, которые можно обновлять из любого места. Обновления сразу распространяются на всех клиентов, что превращает задачу в хороший stress-test для real-time архитектуры.

Архитектура проекта

В основе проекта три ключевых компонента:

  • Протокол и формат данных — описывает структуру сообщений. Он должен быть простым, расширяемым и не привязанным к конкретному транспорту. Благодаря этому его можно использовать не только поверх вебсокетов, но и, например, через TCP или другие каналы.

  • Сервер вебсокетов — принимает подключения клиентов, обрабатывает входящие события и рассылает обновления. Его задача — минимальная задержка при передаче сообщений и поддержка большого числа соединений.

  • Механизм синхронизации — отвечает за то, чтобы изменения, поступающие от одного клиента, мгновенно доходили до всех остальных. Именно здесь возникает вопрос масштабируемости: когда серверов несколько, они должны делиться обновлениями между собой.

На клиентской стороне реализована простая визуализация: миллион ячеек, которые можно изменять. Каждое изменение формируется в сообщение по протоколу и отправляется на сервер, а затем мгновенно транслируется всем остальным.

Архитектура кода

При проектировании кода я постарался сделать его максимально абстрактным и понятным. Основные части разделены по ответственностям:

  • Доменный слой — описание сущностей и операций (например, изменение состояния ячейки). Здесь нет привязки к транспорту или конкретной базе.

  • Протокол — слой сериализации и десериализации сообщений. Отвечает только за упаковку и разбор данных.

  • Инфраструктура — вебсокет-сервер, синхронизация между инстансами и работа с внешними сервисами.

  • Приложение — связывает доменную логику, протокол и инфраструктуру, реализуя сценарии реального времени.

Такое разделение упрощает чтение кода и даёт возможность подменять отдельные части. Например, можно заменить транспорт (вместо вебсокетов использовать другой канал), не меняя логику обработки данных.

Чтобы сразу было понятнее, о чём идёт речь, начнём с фронтенда. На скриншоте видно простую визуализацию — миллион ячеек, каждая из которых может быть изменена, и обновление мгновенно отразится у всех подключённых клиентов.

Часть который сразу видно когда открыли сайт
Часть который сразу видно когда открыли сайт
Миллион ячеек, и каждая из них может обновляться в реальном времени (хотя разглядеть отдельные уже невозможно).
Миллион ячеек, и каждая из них может обновляться в реальном времени (хотя разглядеть отдельные уже невозможно).

Этот интерфейс хорошо иллюстрирует основную идею: real-time обновления большого количества элементов и синхронное распространение изменений.

Проект состоит из четырёх ключевых компонентов:

  1. Клиенты (Browser)

    • Отправляют изменения через WebSocket и получают обновления обратно.

  2. Backend

    • WebSocket-сервер — принимает подключения, обрабатывает сообщения, рассылает обновления.

    • Протокол — сериализация/десериализация сообщений (транспорт-независимый).

    • Доменная логика — применяет изменения и контролирует консистентность.

  3. Redis (Cache + Pub/Sub)

    • Кэширует состояние ячеек для быстрого доступа.

    • Синхронизирует изменения между разными инстансами backend.

  4. База данных

    • Хранит текущее состояние ячеек или логи изменений для персистентности.

Вот так выглядит
Вот так выглядит

Реализация

Честно говоря, я не уделял много внимания мониторингу нагрузки, так что точных метрик нет. Главный фокус проекта — собственный протокол и его работа с real-time обновлениями.

Формат протокола

Протокол построен на основе API-ключей и схем данных:

  • apiKey — определяет, какая логика домена будет вызвана на сервере.

  • params — содержит объект с конкретной схемой, которая заранее объявлена для этого типа запроса.

  • Парсинг и сборка — каждое сообщение сначала разбирается (парсится) по соответствующей схеме, затем формируется ответ, тоже по декларативной схеме.

Пример TypeScript для handler:

export const box = SchemaBuilder.schemaDefinition({
    index: SchemaBuilder.number32(),
    value: SchemaBuilder.string(),
});

const paramSchema = box;
const resultSchema = box;

export type ChangeValueBoxeHandlerParam = InferSchema<typeof paramSchema>;
export type ChangeValueBoxeHandlerResult = InferSchema<typeof resultSchema>;

export class ChangeValueBoxeHandler implements RequestHandler<ChangeValueBoxeHandlerParamSchemaType, ChangeValueBoxeHandlerResultSchemaType> {
    public readonly apiKey = ApiKeys.ChangeValue;
    public readonly paramSchema = paramSchema;
    public readonly resultSchema = resultSchema;

    constructor(private readonly ChangeValueBoxeUsecase: ChangeValueBoxeUsecase) {}

    public async handle(data: ChangeValueBoxeHandlerParam): Promise<ChangeValueBoxeHandlerResult> {
        const box = await this.ChangeValueBoxeUsecase.execute(data);

        // presenter
        return box.toPlain();
    }
}

Каждый apiKey в протоколе соответствует отдельному обработчику (handler) на сервере.

  • Когда приходит сообщение, контроллер выбирает соответствующий handler по apiKey.

  • Внутри handler описана схема для входных данных (params) и схема для результата (result), а также сам apiKey.

  • Сначала входные данные парсятся по объявленной схеме params, затем вызывается соответствующая логика домена, после чего формируется ответ по объявленной схеме result.

Такой подход позволяет централизованно управлять всеми типами запросов, гарантирует корректность входных и выходных данных и упрощает расширение протокола.

Протокол разработан для совместной работы WebSocket-сервера и клиента.

  • Клиент отправляет сообщение с apiKey и params, сервер через контроллер выбирает соответствующий handler для apiKey.

  • Внутри этого handler выполняется логика домена.

  • Если происходит изменение значения ячейки, создаётся DomainEvent с самим объектом ячейки.

  • Для этих событий есть отдельный тип handler’ов — Event Handlers, которые подписаны на Domain/Infrastructure события через EventEmitter/Redis(Pub/Sub).

  • Эти Event Handler’ы реагируют на события, например, рассылка обновлений всем подключённым клиентам.

Привер Event Handler'ов

export class BoxChangeValueBroadcastEventHandler implements DomainEventHandler<BoxChangeValueBroadcastEventData> {
    public readonly event: string = BoxChangeValueBroadcastEvent;

    constructor(private publisher: Publisher<BoxChangeValueBroadcastEventData>) {}

    async handler(data: BoxChangeValueBroadcastEventData): Promise<void> {
        await this.publisher.publish(data);
    }
}

// Можете сказать что немного неправильные названия
export class BoxChangeValueReceiveEventHandler implements DomainEventHandler<BoxChangeValueReceiveEventData> {
    public readonly event: string = BoxChangeValueReceiveEvent;

    constructor(
        private readonly wbesocketServer: WebsocketServerImpl,
        private readonly changeValueUsecase: ChangeValueBoxeUsecase,
    ) {}

    async handler(data: BoxChangeValueReceiveEventData): Promise<void> {
        const box = await this.changeValueUsecase.execute(parseData(data, ChangeValueParamSchema));

        const boradcastData = buildData(
            {
                apiKey: ApiKeys.ChangeValue,
                index: box.index,
                value: box.value,
            },
            ChangeValueBroadcastDataSchema,
        );

        this.wbesocketServer.broadcast(boradcastData);
    }
}

Возможно, названия этих handler’ов кажутся немного запутанными, но на это есть причина: эти обработчики работают как с EventEmitter, так и с Redis (Pub/Sub) одинаково, не заботясь о том, через какой канал пришло событие.

Такой подход позволяет объединить обработку событий домена и инфраструктуры, делая код проще, модульнее и прозрачнее для расширения.

Итак, что удалось сделать в рамках проекта:

  • Создан собственный протокол с apiKey, схемой входных данных (params) и схемой результата (result).

  • Разделены два типа обработчиков:

    • apiKey Handlers — для обработки запросов от клиентов,

    • Event Handlers — для обработки Domain/Infrastructure событий через EventEmitter и Redis Pub/Sub.

  • Event Handlers работают одинаково с EventEmitter и Redis, не различая источник события, что упрощает масштабирование и синхронизацию между серверами.

  • Вся система построена для реального времени, где изменения ячеек мгновенно доходят до всех подключённых клиентов через WebSocket и DomainEvents.

Проект показал, как можно строить масштабируемые real-time системы с реактивной обработкой событий и унифицированным протоколом, даже без активного мониторинга нагрузки.

Если вы не поняли, как я планирую масштабировать WebSocket-серверы:

  • Redis Pub/Sub используется для синхронизации между инстансами backend.

  • Когда на одном сервере изменяется значение ячейки, это событие публикуется в Redis.

  • Другие серверы, которые подписаны на этот канал, получают событие и рассылают обновление своим клиентам.

Такой подход позволяет поддерживать миллион ячеек в реальном времени на нескольких серверах без сложной логики синхронизации между ними.

Что я вынес из проекта

Этот проект был просто фановой экспериментальной задачей, но он дал много полезного опыта:

  • Я понял, как масштабировать WebSocket-серверы для большого количества клиентов и обновлений в реальном времени.

  • Научился разрабатывать собственный протокол и формат данных, которые можно использовать независимо от транспорта.

  • Осознал, как эффективно использовать инфраструктуру через абстракции в коде, не затрагивая логику домена.

  • В итоге, я просто писал код и получал удовольствие от эксперимента — и это тоже важно!

Если что-то осталось непонятным или есть своё мнение — секция комментариев всегда открыта ?

Комментарии (4)


  1. ogregor
    27.08.2025 18:01

    А чё с где Редис? Или я что то упустил


    1. BoburF Автор
      27.08.2025 18:01

      Не понял вопрос)

      Там же говорится что Redis используется для кеша и ивентов(pub/sub)


  1. dabrahabra
    27.08.2025 18:01

    Да и протокола тут нет, shit gpt походу


    1. BoburF Автор
      27.08.2025 18:01

      export const box = SchemaBuilder.schemaDefinition({
          index: SchemaBuilder.number32(),
          value: SchemaBuilder.string(),
      });

      Это и есть протокол)

      Вы хотите видеть прям как он хранится?

      00 00 00 10
      00 00 00 00 00 00 00 01
      62

      Так хранится в редисе) И через запросы эти данные)