До сих пор наш хаб знал только один адресат — «все». В седьмом уроке сообщение улетало по карте соединений, в восьмом карта переехала в горутину run, и любая строка от любого клиента доставалась каждому подключению. Для эха и глобального чата это честная модель, но живой продукт так не устроен: чат сообщества делится на каналы, игроки сидят за отдельными столами, кооп-редактор открывает по документу на сессию, служба поддержки группирует диалоги по тикетам. Каждая из этих сущностей — комната: подмножество клиентов, внутри которого действует собственный broadcast. Хорошая новость в том, что для комнат не нужен новый компонент — нужен новый уровень вложенности данных. Мы не порождаем вторую горутину и не дублируем каналы: остаётся тот же цикл run и тот же select, меняется только форма хранения — вместо плоской карты клиентов появляется карта карт. Поэтому комнаты логично ставить сразу после хаба: это первое расширение, которое архитектура восьмого урока встречает без единой переделки.
Комнаты websocket: вложенная карта в хабе#
Поле Hub меняется на rooms map[string]map[*Client]bool: внешний ключ — имя комнаты, внутренняя карта — множество её клиентов. Внутренняя карта со значениями bool — идиоматичный набор в Go без отдельного типа set: проверить членство можно за константное время, удалить клиента — одной строкой delete, обойти всех — range по карте. Срез решал бы те же задачи хуже: поиск членства упирался бы в перебор всех элементов, а удаление из середины — в сдвиг хвоста. Владелец состояния не меняется: обе карты читает и пишет только горутина run, конкурентного доступа нет, мьютекс не нужен. Комнаты websocket живут ровно в той дисциплине, что и общий список клиентов восьмого урока: любые изменения состояния — через каналы и ветки select. Отдельный список всех подключений можно оставить рядом с комнатами, если глобальная рассылка ещё нужна; в наших примерах он больше не требуется, и мы его убираем, чтобы не платить за дублирование.
Хаб с комнатами: register и unregister
GOtype Hub struct {
rooms map[string]map[*Client]bool
broadcast chan *Envelope
register chan *Client
unregister chan *Client
}
func (h *Hub) run() {
for {
select {
case c := <-h.register:
if h.rooms[c.room] == nil {
h.rooms[c.room] = make(map[*Client]bool)
}
h.rooms[c.room][c] = true
case c := <-h.unregister:
if h.rooms[c.room] != nil {
delete(h.rooms[c.room], c)
if len(h.rooms[c.room]) == 0 {
delete(h.rooms, c.room)
}
}
close(c.send)
}
}
}Новый клиент попадает в комнату, записанную в его поле room, — при необходимости хаб создаёт внутреннюю карту. При уходе клиент удаляется из своего множества, а опустевшая комната вычищается целиком, чтобы не копить пустые ключи. Close(send) остался на месте: сигнал смерти писателя от комнат не зависит. Веток для перехода между комнатами здесь ещё нет — они появятся в следующем блоке.
У клиента появляется поле room — текущее имя комнаты. Мы сознательно упрощаем: один клиент сидит ровно в одной комнате, как игрок за одним столом. Переход — это удалить себя из старой карты и добавить в новую, и делать это нужно одной операцией, иначе после смены комнаты клиент начнёт получать дубликаты. В задачах, где подписок много — например, пользователь слушает уведомления трёх проектов сразу, — поле room заменяется срезом подписок, а операции входа и выхода становятся действиями над этим срезом. Сама карта rooms от такой замены не меняется: она и есть список подписчиков каждой комнаты. Выбор между «одна комната на соединение» и «множество подписок» лучше сделать до релиза: первый вариант проще рассуждать и отлаживать, второй гибче, но каждая операция дороже и требует внимательности к порядку удаления и добавления.
Кейс: чат по интересам на сто комнат#
Возьмём учебную платформу: общий чат делится на потоки, у каждого потока своя комната, всего их около сотни. При такой шкале вложенная карта работает идеально: память комнаты — это заголовок карты и записи по числу участников, килобайты даже на полный поток; сотня комнат не стоит практически ничего. Общее число клиентов по-прежнему одна-две тысячи, и весь диспетчеринг выполняет одна горутина run — комната лишь сужает круг адресатов рассылки. Полезное правило из этого кейса: комната — это про адресацию, а не про изоляцию нагрузки. Если в одном потоке случился всплеск сообщений, select с default из восьмого урока по-прежнему отключит медленных получателей, а остальным рассылка продолжится без паузы. Проблемы начинаются на другом масштабе — тысячи комнат с разной интенсивностью и соединения, которые часами перекочёвывают между комнатами; к этому порогу вернёмся в блоке про спорное решение ниже. История последних кадров из домашнего задания тоже живёт в этом владельце: срез на комнату, обрезаемый по длине при каждом кадре.
Карта rooms трогается только горутиной run — правило то же, что для clients в восьмом уроке. Частая ошибка при доработках: хендлер HTTP решает удалить пустую комнату сам или readPump лезет в rooms за списком участников для своей логики. Конкурентная запись в карту — фатальная паника concurrent map writes, и проявляется она под нагрузкой, а не на стенде. Любой вопрос к комнатам оформляйте веткой select или отдельным каналом.
Смена комнаты и рассылка внутри группы
GOfunc (h *Hub) switchRoom(c *Client, name string) {
if h.rooms[c.room] != nil {
delete(h.rooms[c.room], c)
if len(h.rooms[c.room]) == 0 {
delete(h.rooms, c.room)
}
}
if h.rooms[name] == nil {
h.rooms[name] = make(map[*Client]bool)
}
h.rooms[name][c] = true
c.room = name
}
case env := <-h.broadcast:
for client := range h.rooms[env.Sender.room] {
select {
case client.send <- env:
default:
close(client.send)
}
}SwitchRoom удаляет клиента из старой комнаты, подчищает опустевшую и прописывает новую — всё внутри владельца, поэтому гонок нет. Broadcast берёт круг адресатов из внутренней карты комнаты отправителя, а не из общего списка: кадр видят только свои. Select с default сохранился — медленный клиент отключается так же, как в глобальном чате, просто при меньшем круге свидетелей.
Спорное мнение: одна карта или горутина на комнату#
У вложенной карты есть конкурент — архитектура «горутина-владелец на каждую комнату»: каждая комната получает собственный цикл select со своими каналами входа, выхода и рассылки, а глобальный хаб превращается в роутер между комнатами. Это шаг к изоляции: всплеск в одной комнате больше не проходит через общий цикл, а состояние комнаты можно выгружать из памяти целиком вместе с её горутиной. Моё правило простое: пока комнат меньше сотен и логика рассылки одинаковая, общая карта выигрывает — меньше движущихся частей, один владелец, проще отлаживать. Отдельные владельцы оправдываются, когда комнат тысячи, у комнат разная логика или нагрузка, либо когда комнаты живут и умирают как независимые сущности — столы в игре, сессии редактирования документа. Начинать с пер-комнатных горутин я бы не стал: это усложнение, которое покупается заранее и оплачивается вниманием каждый день.
Глобальный broadcast против комнат#
| Критерий | Общий список (уроки 07-08) | Комнаты (этот урок) |
|---|---|---|
| Круг адресатов | все подключения | только участники комнаты |
| Память на группу | ничего лишнего | карта-множество участников |
| Пустые группы | нет ключей | удалять в ветке ухода |
| Приватность | отсутствует | кадр не покидает комнату |
| Медленный клиент | отключается select/default | то же, но круг свидетелей меньше |
Проверка: опустевшая комната
Клиент отключился и оказался последним в комнате go-talk. Что делает корректная ветка unregister?
Проверка: переход между комнатами
Клиент переходит из комнаты general в support через switchRoom. Что должно случиться со старой комнатой?
Кто получит кадр в комнате
Прочитайте код и выберите, что он выведет:
// все клиенты зарегистрированы
// A: room = "general"
// B: room = "support"
// C: room = "general"
// A отправил конверт в broadcast
for client := range h.rooms[env.Sender.room] {
client.send <- env
}Медленный клиент в комнате
Прочитайте код и выберите, что он выведет:
// A и B в комнате general, буфер send клиента B полон:
// он давно не вычитывает свою очередь
case env := <-h.broadcast:
for client := range h.rooms[env.Sender.room] {
select {
case client.send <- env:
default:
close(client.send)
}
}Соберите хаб с комнатами в отдельном модуле: go mod init, go get github.com/gorilla/websocket, запуск локального Go — и две вкладки браузера, где каждая вкладка при подключении шлёт join со своим именем комнаты. Смените комнату в одной вкладке и убедитесь, что кадры больше не прилетают в другую.
Команды join и leave в readPump
GO// readPump: конверт из десятого урока
var env Envelope
if err := json.Unmarshal(message, &env); err != nil {
continue
}
switch env.Type {
case "join":
hub.switchRoom(c, env.Room)
case "leave":
hub.switchRoom(c, "lobby")
default:
env.Sender = c
hub.broadcast <- env
}Протокол десятого урока обрастает командами: join несёт имя комнаты, leave возвращает клиента в общий лобби, всё остальное — сообщение своей группе. Теги JSON при желании добавьте по правилам десятого урока — имена полей совпадают с JSON-ключами и без них. Обратите внимание: смена комнаты и отправка кадра — разные операции, обе живут в readPump, и ни одна не пишет в conn напрямую.
Соберите switchRoom
Впишите операции над картами комнат: вычистка пустой комнаты, создание множества, добавление клиента и запись текущего имени.
Соберите метод join
Расставьте строки метода в правильном порядке: проверка наличия комнаты, создание множества, добавление клиента.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: комнаты
Проверьте себя по трём вопросам: кто владеет картой комнат, что происходит с пустой комнатой и что получает клиент при переходе между комнатами. Если ответы даются без заглядывания в код — каркас готов к усложнениям. Дальше по курсу: в личных сообщениях вместо комнаты адресатом станет конкретный userID, и там же появится presence — список тех, кто в сети. Сериализацию кадров по-прежнему держит JSON-протокол из десятого урока, а пары насосов — девятый урок. Карты, наборы через map со значениями bool и стоимость операций над ними — тема продвинутого курса Go; без этой базы вложенные карты выглядят магией. Запуск по-прежнему локальный: сервер на Go и две-три вкладки браузера, песочница TCP не поднимет. Отдельно проверьте поведение при переполнении: поставьте буфер send на два сообщения и пошлите десять кадров в подвешенную вкладку — комната не должна тормозить из-за одного клиента, а остальные участники не заметят его отключения. План остальных уроков, включая итоговый чат с комнатами, лежит на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Комнаты — вложенная карта map[string]map[*Client]bool внутри того же хаба: владелец один, гонок нет.
- Внутренняя карта со значениями bool — идиоматичный набор: проверка членства и delete за константное время.
- Переход между комнатами — одна операция: удалить из старой, подчистить пустую, добавить в новую.
- Broadcast берёт круг адресатов из внутренней карты комнаты отправителя.
- Пустые комнаты удаляются в ветке ухода, иначе внешняя карта копит мусорные ключи.
- Select с default работает и внутри комнаты: медленный клиент отключается, остальные не страдают.
- На сотнях комнат общей карты достаточно; отдельные владельцы-горутины — решение для тысяч комнат.
Домашнее задание
Добавьте к хабу этого урока команду list: по запросу клиента сервер возвращает имена комнат с числом участников. Затем сделайте так, чтобы клиент без join при подключении попадал в комнату lobby и получал её историю из последних двадцати кадров, которую хаб держит в срезе на комнату. Проверьте локально с тремя вкладками, что история выдаётся только членам комнаты и не уезжает в соседние.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему внутренняя карта со значениями bool, а не срез клиентов?
Набор через map со значениями bool даёт проверку членства и удаление за константное время, а range по карте — обход всех участников. Срез на маленьких комнатах тоже сработал бы, но при частых входах и выходах расходы на поиск и сдвиги растут, и код становится заметно длиннее.
Можно ли клиенту сидеть в нескольких комнатах сразу?
Да, если заменить поле room срезом подписок и добавлять или удалять записи во внутренних картах по одной. Рассылка обойдёт комнаты по очереди, и клиент получит по копии кадра на каждую свою комнату — если это нежелательно, дедупликация ложится на отправителя.
Что делать с комнатами, которые никто не покидает неделями?
Если комнаты — сессии и столы, их чистит уход последнего участника. Для тематических чатов имеет смысл ещё и TTL: фоновая горутина раз в минуту через канал просит владельца удалить комнаты без участников и без сообщений. Главное — делать это внутри владельца, а не из чужой горутины.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами