---
title: "Итоговый проект: чат с комнатами"
description: "Итоговый чат websocket проект: Hub и Client, комнаты, JWT при апгрейде, presence-карта, reconnect с браузера и graceful shutdown. Структура по файлам."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/25-itogovyj-proekt-ws/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/25-itogovyj-proekt-ws/*

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

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

> **Паника: close of closed channel.** Классический кейс из продакшена хабов: разработчик добавляет 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

```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 в 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?

- В ветвь unregister хаба, чтобы клиент ушёл из всех карт сразу
- ✅ В readPump при выходе — и нигде больше
- В writePump после последней записи в сокет
- В main.go после Shutdown сервера

Второй close на закрытом канале паникует и роняет процесс, поэтому закрытие закреплено за одной стороной — readPump, который первым узнаёт о смерти соединения. Хаб в unregister только удаляет из карт, writePump завершается по закрытию send, а Shutdown сервера не касается каналов клиентов.

### Проверка: порядок graceful shutdown

**Вопрос:** Пришёл SIGTERM. В каком порядке правильный сервис закрывается?

- Сразу close всем сокетам, потом Shutdown
- Дождаться всех клиентов, потом Shutdown, потом close 1001
- ✅ Запустить Shutdown, потом drain с close 1001 и дожидаться насосов с таймаутом
- Ничего не делать: процесс умрёт, клиенты переподключатся сами

Сначала сервер перестаёт принимать новые рукопожатия — Shutdown, затем hub прощается с живыми конвертом going away, и насосы дожимают очереди под таймаутом. Закрывать сокеты до Shutdown молча обрывает новых клиентов, а игнорировать сигнал превращает деплой в обрыв: клиенты получат 1006 и побегут reconnect'ом волной.

### u42 покидает комнату

```go
// 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-кадр
        }
```

**Что выведет программа?**

- Оставшиеся получат один кадр: system о выходе
- ✅ Оставшиеся получат два кадра: system о выходе и presence offline
- Оставшиеся получат только close frame от сервера
- Никто ничего не получит: события ухода рассылаются только уходящему

Уход порождает два конверта: системное сообщение от leaveRoom и presence-кадр от setStatus, потому что уровень изменился. Гистерезис из девятнадцатого урока задерживает offline на тридцать секунд, если клиент вернётся раньше, — тогда в потоке останется один system и пусто в presence. Закрытие соединения уходит только уходящему клиенту.

> **Соберите и погоните проект на локальном Go.** Песочница курса сеть не умеет — проект собирается на локальном 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, продление дедлайна и чтение конверта.

```go
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)
}
```

Подсказка: Лимит ставит SetReadLimit, дедлайн держится на pongWait, pong-обработчик продлевает тем же SetReadDeadline, а конверт читается ReadJSON.

Правильные ответы в пропусках: `SetReadLimit`, `pongWait`, `SetReadDeadline`, `ReadJSON`

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

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

Правильный порядок строк:

1. `ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)`
2. `hub := NewHub(ctx)`
3. `go hub.Run()`
4. `mux := http.NewServeMux()`
5. `mux.HandleFunc("/ws", hub.HandleUpgrade)`
6. `srv.ListenAndServe()`

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

- **Hub.Run и четыре ветки** — select по ctx.Done, register, unregister, broadcast: владелец карт users, rooms, presence. Никакая карта не трогается из двух горутин.
- **Единственный close send** — Канал закрывает readPump при выходе: второй close паникует и роняет весь процесс. Хаб в unregister карты только чистит.
- **HandleUpgrade с JWT** — Токен из query, parse тем же секретом, claims в Client, отказ 401 до апгрейда. CheckOrigin держит белый список доменов.
- **drain на завершении** — Ветвь ctx.Done: рассылка close 1001 going away, дожим насосов под WaitGroup с таймаутом, клиенты видят 1000 и не reconnect'ят.
- **Backoff с потолком** — Пауза растёт от секунды до тридцати и сбрасывается при onopen: рестарт сервера собирает клиентов за минуту без волны.

Проект собран: файлы разложены, hub владеет картами, насосы делят соединение, JWT закрывает вход, presence и reconnect работают в связке, а завершение вежливо. Что дальше: нагрузочный тест с генератором клиентов, метрики числа соединений и горутин из [темы про сборщики в продвинутом курсе Go](/courses/go-advanced/lessons/04-gorutiny/), мост на Redis из [двадцать третьего урока](/courses/websocket/lessons/23-masshtabirovanie/) и сценарии из [двадцать четвёртого](/courses/websocket/lessons/24-scenarii-real-time/), когда продукт вырастет из чата. Каркас насосов описан в [девятом уроке](/courses/websocket/lessons/09-readpump-writepump/), авторизация — в [тринадцатом](/courses/websocket/lessons/13-avtorizaciya-ws/), graceful shutdown — в [шестнадцатом](/courses/websocket/lessons/16-graceful-ws/), presence — в [девятнадцатом](/courses/websocket/lessons/19-onlajn-statusy/), тесты — в [двадцать первом](/courses/websocket/lessons/21-testy-ws/). Соберите проект на локальном Go, сломайте его Wi-Fi'ем и убедитесь, что он возвращается. План курса — на странице [курса WebSocket](/courses/websocket/).

## Коротко: что запомнить

- Файлы проекта: 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-трафик станет заметным в профиле.
