Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +20 XP

Итоговый проект: чат с комнатами

Чат websocket проект собирает курс в один сервис: Hub с комнатами и presence-картой, Client с двумя насосами, JWT при апгрейде, reconnect с экспоненциальным backoff в браузере и graceful shutdown — всё, что строилось по частям с третьего урока. Здесь это складывается в структуру по файлам, которую можно вынести в продакшен и расширять.

Содержание урока · 9 разделов

Сервис из учебных примеров отличается от проекта одним свойством: он переживает небрежность. Ему можно обрывать сеть, закрывать вкладки, деплоить версию под нагрузкой, отправлять кадры в десять раз больше лимита — и он продолжает работать, логировать происходящее и отпускать соединения вежливо. Именно такой сервис собирается здесь: комнаты с маршрутизацией, авторизация до апгрейда, 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

GO
func (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 целиком

GO
func (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 остаётся одинаковым в любом сервисе.

Апгрейд с JWT и reconnect в браузере#

Вход в проект закрыт в auth.go: HandleUpgrade достаёт токен из query, как в тринадцатом уроке, парсит его тем же секретом, что и REST-часть, и отказывает до апгрейда — рукопожатие с 401 дешевле разобранного соединения без прав. Claims кладутся в Client: userID, комната из заявки, срок жизни токена для отложенной проверки. CheckOrigin держит белый список доменов из пятнадцатого урока. Браузерная сторона соединяет reconnect из семнадцатого урока с авторизацией: onclose считает попытки и растит паузу экспоненциально, а после восстановления клиент повторяет подписки — комнаты заявили при апгрейде, но typing и приватные каналы возобновляются явно. Код ниже — каркас, который переживает рестарты сервера и Wi-Fi без ёлочной гирлянды presence.

Браузер: reconnect с backoff

GO
var 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 в readPump14
Строгий OriginCheckOrigin в auth.go15
Токен при апгрейдеquery token и parse в HandleUpgrade13
HeartbeatpongWait и ping-тикер в writePump06
Reconnect с backoffonclose с экспоненциальной паузой17
Оффлайн-сообщенияstore-and-forward в hub12
Несколько инстансовмост Redis Pub/Sub23
Тесты протоколаDialer против httptest21

Проверка: кто закрывает 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-кадр
        }

Соберите readPump

Впишите четыре пропуска: лимит кадра, дедлайн под pongWait, продление дедлайна и чтение конверта.

c.conn.(4096) c.conn.SetReadDeadline(time.Now().Add()) c.conn.SetPongHandler(func(string) error { return c.conn.(time.Now().Add(pongWait)) }) for { var env Envelope if err := c.conn.(&env); err != nil { return } c.hub.dispatch(c, &env) }

Соберите порядок запуска main.go

Расставьте строки старта: контекст, hub, маршруты и сервер.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: итоговый проект

Термин

Hub.Run и четыре ветки

нажмите, чтобы перевернуть
Определение

select по ctx.Done, register, unregister, broadcast: владелец карт users, rooms, presence. Никакая карта не трогается из двух горутин.

1 / 5

Проект собран: файлы разложены, 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 и убедитесь, что сервер переживает его без паники.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Итоговый проект: чат с комнатами)

Вопросы и ответы по уроку

Куда ставить typing-индикаторы и другие lossy-события?

В отдельный тип конверта внутри того же send, но без гарантий: хаб может коалить их по ключу пользователя, а при переполнении очереди выбрасывать первыми. Критичные кадры вроде разлогина летят мимо таких очередей, как в двадцатом уроке. Смешивать typing с гарантированной доставкой чата в одной очереди не стоит: один всплеск курсоров вытеснит сообщения.

Как масштабировать этот проект до кластера?

Изменений два: hub.broadcast начинает публиковать конверты в Redis, как в двадцать третьем уроке, а релей каждого инстанса раздаёт их локальной карте; presence-карта синхронизируется через ws:presence. Тесты из hub_test.go продолжают проходить без изменений: интерфейс hub'а не меняется, меняется транспорт доставки. На этом уровне проект уже готов к Kubernetes-деплою с вежливым завершением.

Стоит ли вынести presence в Redis вместо карты в памяти?

На одном инстансе карта быстрее и проще: она обновляется в той же горутине, что и комнаты, без сетевых прыжков. Redis окупается с кластером: там карта всё равно разъезжается, и общий источник статусов с ttl на ключах заменяет ручную синхронизацию. Начинайте с карты и шины событий, а прямой переезд в Redis оставляйте на момент, когда presence-трафик станет заметным в профиле.

Термины урока

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

Следующая ступень учебного пути

RabbitMQ25 уроков~11 чпрактика25 уроков уже доступны

RabbitMQ в Go: очереди сообщений на amqp091-go

Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.

  • Подключаться к RabbitMQ из Go и управлять connection и channel
  • Объявлять очереди, обменники и привязки с нужными свойствами
  • Публиковать и потреблять сообщения с ручными ack
  • Настраивать prefetch и справедливое распределение между воркерами