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

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

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

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

Комнаты: broadcast внутри группы

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

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

До сих пор наш хаб знал только один адресат — «все». В седьмом уроке сообщение улетало по карте соединений, в восьмом карта переехала в горутину run, и любая строка от любого клиента доставалась каждому подключению. Для эха и глобального чата это честная модель, но живой продукт так не устроен: чат сообщества делится на каналы, игроки сидят за отдельными столами, кооп-редактор открывает по документу на сессию, служба поддержки группирует диалоги по тикетам. Каждая из этих сущностей — комната: подмножество клиентов, внутри которого действует собственный broadcast. Хорошая новость в том, что для комнат не нужен новый компонент — нужен новый уровень вложенности данных. Мы не порождаем вторую горутину и не дублируем каналы: остаётся тот же цикл run и тот же select, меняется только форма хранения — вместо плоской карты клиентов появляется карта карт. Поэтому комнаты логично ставить сразу после хаба: это первое расширение, которое архитектура восьмого урока встречает без единой переделки.

Комнаты websocket: вложенная карта в хабе#

Поле Hub меняется на rooms map[string]map[*Client]bool: внешний ключ — имя комнаты, внутренняя карта — множество её клиентов. Внутренняя карта со значениями bool — идиоматичный набор в Go без отдельного типа set: проверить членство можно за константное время, удалить клиента — одной строкой delete, обойти всех — range по карте. Срез решал бы те же задачи хуже: поиск членства упирался бы в перебор всех элементов, а удаление из середины — в сдвиг хвоста. Владелец состояния не меняется: обе карты читает и пишет только горутина run, конкурентного доступа нет, мьютекс не нужен. Комнаты websocket живут ровно в той дисциплине, что и общий список клиентов восьмого урока: любые изменения состояния — через каналы и ветки select. Отдельный список всех подключений можно оставить рядом с комнатами, если глобальная рассылка ещё нужна; в наших примерах он больше не требуется, и мы его убираем, чтобы не платить за дублирование.

Хаб с комнатами: register и unregister

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

Смена комнаты и рассылка внутри группы

GO
func (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)
                }
        }

Команды 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

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

func (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 { (h.rooms, c.room) } } if h.rooms[name] == nil { h.rooms[name] = (map[*Client]bool) } h.rooms[name][c] = c.room = }

Соберите метод join

Расставьте строки метода в правильном порядке: проверка наличия комнаты, создание множества, добавление клиента.

Строки

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

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

Карточки: комнаты

Термин

rooms map[string]map[*Client]bool

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

Вложенная карта комнат: внешний ключ — имя комнаты, внутренняя карта — набор участников. Идиома set в Go без отдельного типа.

1 / 5

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

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

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

Почему внутренняя карта со значениями bool, а не срез клиентов?

Набор через map со значениями bool даёт проверку членства и удаление за константное время, а range по карте — обход всех участников. Срез на маленьких комнатах тоже сработал бы, но при частых входах и выходах расходы на поиск и сдвиги растут, и код становится заметно длиннее.

Можно ли клиенту сидеть в нескольких комнатах сразу?

Да, если заменить поле room срезом подписок и добавлять или удалять записи во внутренних картах по одной. Рассылка обойдёт комнаты по очереди, и клиент получит по копии кадра на каждую свою комнату — если это нежелательно, дедупликация ложится на отправителя.

Что делать с комнатами, которые никто не покидает неделями?

Если комнаты — сессии и столы, их чистит уход последнего участника. Для тематических чатов имеет смысл ещё и TTL: фоновая горутина раз в минуту через канал просит владельца удалить комнаты без участников и без сообщений. Главное — делать это внутри владельца, а не из чужой горутины.

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

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

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

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

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

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

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