Введение
Real-time системы и протоколы — тема, которая меня давно интересовала. В рамках эксперимента я решил попробовать свои силы:
разработал собственный протокол с форматом данных,
использовал вебсокеты для доставки обновлений в реальном времени.
Основные вызовы, с которыми пришлось столкнуться:
масштабируемость — как обслуживать большое количество соединений и обновлений,
дизайн формата данных — как сделать его универсальным,
синхронность — чтобы все клиенты получали изменения мгновенно.
Для теста я взял простую модель: миллион ячеек, которые можно обновлять из любого места. Обновления сразу распространяются на всех клиентов, что превращает задачу в хороший stress-test для real-time архитектуры.
Архитектура проекта
В основе проекта три ключевых компонента:
Протокол и формат данных — описывает структуру сообщений. Он должен быть простым, расширяемым и не привязанным к конкретному транспорту. Благодаря этому его можно использовать не только поверх вебсокетов, но и, например, через TCP или другие каналы.
Сервер вебсокетов — принимает подключения клиентов, обрабатывает входящие события и рассылает обновления. Его задача — минимальная задержка при передаче сообщений и поддержка большого числа соединений.
Механизм синхронизации — отвечает за то, чтобы изменения, поступающие от одного клиента, мгновенно доходили до всех остальных. Именно здесь возникает вопрос масштабируемости: когда серверов несколько, они должны делиться обновлениями между собой.
На клиентской стороне реализована простая визуализация: миллион ячеек, которые можно изменять. Каждое изменение формируется в сообщение по протоколу и отправляется на сервер, а затем мгновенно транслируется всем остальным.
Архитектура кода
При проектировании кода я постарался сделать его максимально абстрактным и понятным. Основные части разделены по ответственностям:
Доменный слой — описание сущностей и операций (например, изменение состояния ячейки). Здесь нет привязки к транспорту или конкретной базе.
Протокол — слой сериализации и десериализации сообщений. Отвечает только за упаковку и разбор данных.
Инфраструктура — вебсокет-сервер, синхронизация между инстансами и работа с внешними сервисами.
Приложение — связывает доменную логику, протокол и инфраструктуру, реализуя сценарии реального времени.
Такое разделение упрощает чтение кода и даёт возможность подменять отдельные части. Например, можно заменить транспорт (вместо вебсокетов использовать другой канал), не меняя логику обработки данных.
Чтобы сразу было понятнее, о чём идёт речь, начнём с фронтенда. На скриншоте видно простую визуализацию — миллион ячеек, каждая из которых может быть изменена, и обновление мгновенно отразится у всех подключённых клиентов.


Этот интерфейс хорошо иллюстрирует основную идею: real-time обновления большого количества элементов и синхронное распространение изменений.
Проект состоит из четырёх ключевых компонентов:
-
Клиенты (Browser)
Отправляют изменения через WebSocket и получают обновления обратно.
-
Backend
WebSocket-сервер — принимает подключения, обрабатывает сообщения, рассылает обновления.
Протокол — сериализация/десериализация сообщений (транспорт-независимый).
Доменная логика — применяет изменения и контролирует консистентность.
-
Redis (Cache + Pub/Sub)
Кэширует состояние ячеек для быстрого доступа.
Синхронизирует изменения между разными инстансами backend.
-
База данных
Хранит текущее состояние ячеек или логи изменений для персистентности.

Реализация
Честно говоря, я не уделял много внимания мониторингу нагрузки, так что точных метрик нет. Главный фокус проекта — собственный протокол и его работа с 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)
dabrahabra
27.08.2025 18:01Да и протокола тут нет, shit gpt походу
BoburF Автор
27.08.2025 18:01export const box = SchemaBuilder.schemaDefinition({ index: SchemaBuilder.number32(), value: SchemaBuilder.string(), });
Это и есть протокол)
Вы хотите видеть прям как он хранится?
00 00 00 10 00 00 00 00 00 00 00 01 62
Так хранится в редисе) И через запросы эти данные)
ogregor
А чё с где Редис? Или я что то упустил
BoburF Автор
Не понял вопрос)
Там же говорится что Redis используется для кеша и ивентов(pub/sub)