Сервис из учебных примеров отличается от проекта одним свойством: он переживает небрежность. Ему можно обрывать сеть, закрывать вкладки, деплоить версию под нагрузкой, отправлять кадры в десять раз больше лимита — и он продолжает работать, логировать происходящее и отпускать соединения вежливо. Именно такой сервис собирается здесь: комнаты с маршрутизацией, авторизация до апгрейда, presence, reconnect и graceful shutdown, проверенные тестами из двадцать первого урока и отлаженные инструментами из двадцать второго. Ничего нового по механике не появится — весь материал курса уже написан; новая часть только одна: разложить код по файлам так, чтобы через полгода было ясно, где что живёт, и соединить куски так, чтобы ни один не ронял соседний. Проект ниже я использую как шаблон: копия, переименование конвертов под предметную область, и через час сервис уже принимает соединения. Осторожность нужна в двух местах: порядок завершения и права на close, о них ниже — именно на них чаще всего и падают первые сборки.
Структура проекта по файлам#
- main.go — конфиг, signal.NotifyContext, запуск hub'а и HTTP-сервера, перехват завершения.
- hub.go — Hub, горутина Run, карты users и rooms, presence, маршрутизация конвертов.
- client.go — Client, readPump и writePump, дедлайны, лимит кадра, pong-обработчик.
- auth.go — HandleUpgrade с проверкой JWT из query и строгим CheckOrigin.
- envelope.go — конверты протокола и команды клиента: join, chat, typing, leave.
- hub_test.go — Dialer против httptest: table-driven кейсы протокола.
Как устроен чат websocket проект: Hub и его карты#
Hub остаётся владельцем всех состояний — это стержень проекта, и он не меняется с восьмого урока: одна горутина Run читает каналы register, unregister и broadcast, и никакая карта не трогается из двух горутин. Внутри три карты: users по userID, rooms по имени комнаты, presence с уровнями из девятнадцатого урока. Ветвь register ставит клиента в users, помечает online и рассылает системный конверт в комнату; ветвь unregister удаляет, очищает комнаты и гасит статус. roomcast — рабочая лошадь маршрутизации: она проходит по карте комнаты и кладёт конверт в send каждого, а select с default отключает неплатёжеспособных. Команда клиента разбирается в dispatch: join и leave двигают между картами комнат, chat уезжает в roomcast, typing — отдельный lossy-канал, который не заслуживает гарантий.
hub.go: ядро Run
GOfunc (h *Hub) Run() {
for {
select {
case <-h.ctx.Done():
h.drain()
return
case c := <-h.register:
h.users[c.userID] = c
h.presence[c.userID] = StatusOnline
h.roomcast(c.room, systemEnv(c.userID + " присоединился"))
case c := <-h.unregister:
if _, ok := h.users[c.userID]; ok {
delete(h.users, c.userID)
h.leaveRoom(c)
h.presence[c.userID] = StatusOffline
}
case env := <-h.broadcast:
h.roomcast(env.Room, env)
}
}
}Четыре ветки select закрывают весь жизненный цикл: завершение, вход, выход и рассылка. Ветвь unregister проверяет наличие клиента перед удалением: двойной unregister после багованного reconnect не паникует, а тихо выходит. drain из ветки завершения разобран ниже — это прощание с клиентами на деплое.
Client: два насоса на соединение#
Каждое соединение получает две горутины по образцу девятого урока: readPump читает сеть и отправляет конверты в hub, writePump пишет в сеть и ждёт send. Правило владения жёсткое: в conn пишет только writePump, и любой, кто хочет что-то отправить, кладёт конверт в канал send. Сам writePump внутри — select из трёх ветвей: конверт из send уходит WriteJSON под дедлайном writeWait, тикер ping шлёт управляющий кадр и держит соединение живым, а закрытие send завершает горутину и запускает close handshake. В readPump стартовая обвязка одинакова для всех сервисов курса: SetReadLimit в четыре килобайта для чата — для передачи вложений лимит поднимается отдельным конфигом, ReadDeadline под pongWait, pong-обработчик продлевает дедлайн. Разбор конверта ведётся ReadJSON в структуру envelope, а маршрутизация делегируется hub'у через dispatch, чтобы readPump не знал про карты. Ошибка чтения — норма мобильной сети, и её лог с кодом и userID обсуждался в двадцать втором уроке.
client.go: readPump целиком
GOfunc (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
close(c.send)
}()
c.conn.SetReadLimit(4096)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
var env Envelope
if err := c.conn.ReadJSON(&env); err != nil {
return // 1006 у клиента: норма мобильной сети
}
c.hub.dispatch(c, &env)
}
}Три установки до цикла: лимит кадра, дедлайн чтения и продление через pong. Возврат из readPump отправляет клиента в unregister и закрывает send — это единственное место, где send закрывается, и нарушение этого правила разбирается в warn ниже. dispatch уводит знание протокола в hub: readPump остаётся одинаковым в любом сервисе.
Классический кейс из продакшена хабов: разработчик добавляет close(c.send) в ветвь unregister на всякий случай, а readPump уже закрывает канал при выходе. Второй close паникует и роняет весь процесс: горутина Run умирает, вместе с ней все соединения на инстансе. Правило одно: send закрывает readPump и только он — владелец чтения знает, что соединение кончилось. Hub в unregister не закрывает ничего: он удаляет из карт и раздаёт прощальные конверты. Проверка в тестах из двадцать первого урока ловит это мгновенно: disconnect того же клиента дважды и ожидание живого сервера после.
Апгрейд с JWT и reconnect в браузере#
Вход в проект закрыт в auth.go: HandleUpgrade достаёт токен из query, как в тринадцатом уроке, парсит его тем же секретом, что и REST-часть, и отказывает до апгрейда — рукопожатие с 401 дешевле разобранного соединения без прав. Claims кладутся в Client: userID, комната из заявки, срок жизни токена для отложенной проверки. CheckOrigin держит белый список доменов из пятнадцатого урока. Браузерная сторона соединяет reconnect из семнадцатого урока с авторизацией: onclose считает попытки и растит паузу экспоненциально, а после восстановления клиент повторяет подписки — комнаты заявили при апгрейде, но typing и приватные каналы возобновляются явно. Код ниже — каркас, который переживает рестарты сервера и Wi-Fi без ёлочной гирлянды presence.
Браузер: reconnect с backoff
GOvar attempts = 0;
function connect(token) {
const socket = new WebSocket("ws://localhost:8080/ws?token=" + token);
socket.onopen = function () { attempts = 0; };
socket.onclose = function (event) {
if (event.code === 1000) { return; } // сервер попрощался штатно
attempts = attempts + 1;
const delay = Math.min(30000, 1000 * Math.pow(2, attempts));
setTimeout(function () { connect(token); }, delay);
};
socket.onmessage = function (event) { handleEvent(JSON.parse(event.data)); };
return socket;
}Пауза растёт от двух секунд до потолка в тридцать секунд и сбрасывается при живом onopen: сервер после рестарта собирает клиентов за минуту, а не разом. Штатный close с кодом 1000 не переоткрывается — так работает graceful shutdown: клиент уважает прощание. Конверты уходят в тот же handleEvent, что и у одиночных кадров из двадцатого урока.
Presence-карта и изящное завершение#
Presence живёт в hub'е и обвязывает вход и уход: register ставит online, unregister гасит уровень, а гистерезис из девятнадцатого урока прячет мигание при кратких переподключениях — его клиентский backoff как раз и есть тот тридцатисекундный зазор. Завершение сервиса идёт по схеме шестнадцатого урока: NotifyContext ловит сигнал, ветвь ctx.Done в Run зовёт drain — hub рассылает close с кодом 1001 going away, клиенты видят штатный 1000 у себя и не переоткрываются, насосы дожимают очереди под WaitGroup с таймаутом, и только после этого процесс уходит. Таймаут дожимания берётся из бюджета деплоя: если инфраструктура даёт тридцать секунд на остановку, десять уходят на дожим, остальное — на завершение HTTP-обработчиков; превысить бюджет хуже, чем оборвать хвост очередей: убитый SIGKILL-ом процесс всё равно обрывает сокеты внезапно. Порядок важен: сначала перестаём принимать новые рукопожатия через Shutdown, затем прощаемся с живыми, иначе новосоединённые умирают молча. После деплоя в нескольких инстансах мост из двадцать третьего урока доводит этот же сервис до кластера.
Чек-лист продакшена и где что живёт#
| Требование | Где живёт | Урок |
|---|---|---|
| Лимит кадра 4 КБ | SetReadLimit в readPump | 14 |
| Строгий Origin | CheckOrigin в auth.go | 15 |
| Токен при апгрейде | query token и parse в HandleUpgrade | 13 |
| Heartbeat | pongWait и ping-тикер в writePump | 06 |
| Reconnect с backoff | onclose с экспоненциальной паузой | 17 |
| Оффлайн-сообщения | store-and-forward в hub | 12 |
| Несколько инстансов | мост Redis Pub/Sub | 23 |
| Тесты протокола | Dialer против httptest | 21 |
Проверка: кто закрывает send
Где законно закрывается канал c.send?
Проверка: порядок graceful shutdown
Пришёл SIGTERM. В каком порядке правильный сервис закрывается?
u42 покидает комнату
Прочитайте код и выберите, что он выведет:
// hub.Run, ветвь unregister:
case c := <-h.unregister:
if _, ok := h.users[c.userID]; ok {
delete(h.users, c.userID)
h.leaveRoom(c) // шлёт system "покинул комнату"
h.presence[c.userID] = StatusOffline // setStatus шлёт presence-кадр
}Песочница курса сеть не умеет — проект собирается на локальном Go: go mod init, go get github.com/gorilla/websocket и github.com/golang-jwt/jwt/v5, затем go run . и две вкладки с разными токенами. Погоните полный сценарий: обмен в комнате, Ctrl-C сервера — клиенты должны увидеть штатный 1000, рестарт — backoff вернёт их в комнату, presence не мигнёт. Затем прогоните go test -v из hub_test.go и откройте DevTools на вкладке WS: проект курса доверяет глазам больше, чем зелёному логу.
Соберите readPump
Впишите четыре пропуска: лимит кадра, дедлайн под pongWait, продление дедлайна и чтение конверта.
Соберите порядок запуска main.go
Расставьте строки старта: контекст, hub, маршруты и сервер.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: итоговый проект
Проект собран: файлы разложены, hub владеет картами, насосы делят соединение, JWT закрывает вход, presence и reconnect работают в связке, а завершение вежливо. Что дальше: нагрузочный тест с генератором клиентов, метрики числа соединений и горутин из темы про сборщики в продвинутом курсе Go, мост на Redis из двадцать третьего урока и сценарии из двадцать четвёртого, когда продукт вырастет из чата. Каркас насосов описан в девятом уроке, авторизация — в тринадцатом, graceful shutdown — в шестнадцатом, presence — в девятнадцатом, тесты — в двадцать первом. Соберите проект на локальном Go, сломайте его Wi-Fi'ем и убедитесь, что он возвращается. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Файлы проекта: main.go, hub.go, client.go, auth.go, envelope.go, hub_test.go — и ни одна карта не трогается из двух горутин.
- Hub.Run — четыре ветки select: завершение, вход, выход, рассылка; roomcast — рабочая лошадь маршрутизации.
- send закрывает только readPump: второй close паникует и роняет весь процесс вместе со всеми соединениями.
- HandleUpgrade проверяет JWT до апгрейда: отказ 401 дешевле соединения без прав, claims едут в Client.
- Reconnect с экспоненциальным backoff и потолком тридцать секунд: штатный 1000 клиент уважает и не переоткрывается.
- Graceful shutdown: Shutdown принимает конец рукопожатий, drain рассылает 1001, насосы дожимают очереди под таймаутом.
Домашнее задание
Соберите проект по структуре урока: hub с четырьмя ветками Run, клиент с двумя насосами, HandleUpgrade с JWT и CheckOrigin, readPump с лимитом и pong-обработчиком, браузер с backoff. Прогоните сценарий: две вкладки в комнате, Ctrl-C сервера — клиенты увидят 1000 и не вернутся, рестарт — вернутся с backoff и presence не мигнёт. Затем добейте: тест disconnect дважды из hub_test.go и убедитесь, что сервер переживает его без паники.
Личные заметкиПусто
Вопросы и ответы по уроку
Куда ставить typing-индикаторы и другие lossy-события?
В отдельный тип конверта внутри того же send, но без гарантий: хаб может коалить их по ключу пользователя, а при переполнении очереди выбрасывать первыми. Критичные кадры вроде разлогина летят мимо таких очередей, как в двадцатом уроке. Смешивать typing с гарантированной доставкой чата в одной очереди не стоит: один всплеск курсоров вытеснит сообщения.
Как масштабировать этот проект до кластера?
Изменений два: hub.broadcast начинает публиковать конверты в Redis, как в двадцать третьем уроке, а релей каждого инстанса раздаёт их локальной карте; presence-карта синхронизируется через ws:presence. Тесты из hub_test.go продолжают проходить без изменений: интерфейс hub'а не меняется, меняется транспорт доставки. На этом уровне проект уже готов к Kubernetes-деплою с вежливым завершением.
Стоит ли вынести presence в Redis вместо карты в памяти?
На одном инстансе карта быстрее и проще: она обновляется в той же горутине, что и комнаты, без сетевых прыжков. Redis окупается с кластером: там карта всё равно разъезжается, и общий источник статусов с ttl на ключах заменяет ручную синхронизацию. Начинайте с карты и шины событий, а прямой переезд в Redis оставляйте на момент, когда presence-трафик станет заметным в профиле.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами