Комнаты решили задачу «не показывать чужим», но породили соседнюю: адресация внутри группы всё ещё отсутствует. В чате поддержки клиент пишет оператору, в мессенджере коллега шлёт вам личное, а в игре сервер сообщает ход конкретному участнику — ни один из этих кадров не должен вылетать в общий broadcast. Механика при этом почти та же, что с комнатами: не новая горутина, а ещё одна карта в хабе. Только вместо имени комнаты ключом станет userID, а значением — единственный клиент, которому адресованы личные кадры. Заодно эта карта решает вторую задачу, о которой продукт всегда спрашивает: кто сейчас онлайн. Присутствие в сети перестаёт быть отдельной системой — это производное от того, есть ли пользователь в карте. В этом уроке мы свяжем обе вещи: маршрутизацию по userID и события presence, которые рождаются в тех же ветках register и unregister, где клиент появляется и уходит.
Личные сообщения websocket: карта пользователей по userID#
Поле Hub пополняется картой users map[string]*Client: ключ — идентификатор пользователя, значение — его текущее соединение. Отдельный канал direct принимает конверты с адресатом: readPump кладёт туда конверт, хаб смотрит в карту и решает, кому доставить. Личные сообщения websocket не требуют новых типов клиентов — только новое поле и новую ветку select. Общий список clients оставляем: он нужен для комнат из одиннадцатого урока и для полных рассылок. Ключевой вопрос — откуда берётся userID, и ответ на него важнее любого кода: не из тела сообщения, которое шлёт клиент, а из данных, которые сервер сам узнал при подключении. Клиентская часть недоверенна по определению: любой человек может открыть devtools и отправить конверт с чужим From. Поэтому поле From конверта сервер заполняет сам, из записи об авторизованном соединении, а userID живёт в структуре Client с момента рукопожатия.
Хаб с картой пользователей и каналом direct
GOtype Hub struct {
rooms map[string]map[*Client]bool
users map[string]*Client // userID -> соединение
broadcast chan *Envelope
direct chan *Envelope
register chan *Client
unregister chan *Client
}
case env := <-h.direct:
target, online := h.users[env.To]
if !online {
h.queueOffline(env)
continue
}
select {
case target.send <- env:
default:
close(target.send)
}Ветка direct — это маршрутизация в чистом виде: поиск по userID, честная проверка второй переменной из карты и доставка в send получателя. Полный буфер означает, что получатель не вычитывает очередь, — его отключает тот же select с default, что и в рассылке. Если пользователя в карте нет, конверт уходит в очередь оффлайн — варианты этого решения разберём ниже по тексту.
Проверка второй переменной при чтении из карты — не украшение: users может не иметь записи под этим ключом, и без проверки вы получите нулевой указатель на месте. С From мы уже договорились: его ставит сервер. Осталась пара гигиенических правил протокола. Во-первых, To валидируется как любое пользовательское имя: длина, допустимые символы, существование аккаунта — карта вернёт false для всего мёртвого и несуществующего, но формат адресата стоит ограничить до того, как он станет ключом. Во-вторых, личный конверт не должен содержать ничего, кроме того, что вы готовы показать получателю: From, тип события и нагрузку. Никаких служебных полей вроде внутренних идентификаторов сессий — соединение может читать ровно то, что адресовано ему.
Если сервер берёт From из тела JSON, любой подключённый пишет личные сообщения от имени кого угодно: { type: direct, from: boss, to: hr }. Правило: From заполняется из поля Client.userID, которое сервер получил при авторизации на рукопожатии, а поле из тела игнорируется целиком. Проверить легко: шлите чужой From и смотрите в логи — сервер должен его перезаписывать, а не доверять.
Кейс: получатель не в сети#
Первая же реализация упирается в неприятный вопрос: пользователь закрыл вкладку, конверт для него пришёл — что делать? Три честных ответа, и выбор между ними — продуктовое решение, а не техническое. Первый — молча выбросить: допустимо для присутствия и уведомлений «пользователь зашёл в комнату», потеря которых ничего не стоит. Второй — ответить отправителю системным конвертом «получатель не в сети», чтобы человек не ждал ответа, которого не будет. Третий — положить в очередь и доставить при следующем подключении: это уже настоящий store-and-forward, и тут важна граница. Очередь в памяти процесса теряется при рестарте — если сообщения нельзя терять, хранить её надо в базе или Redis, а хаб должен только помечать недоставленное. Для учебного чата хватит второго варианта, для мессенджера нужен третий с постоянным хранилищем. Ошибкой будет смешать всё сразу: сначала определите цену потери сообщения, потом выбирайте очередь.
Регистрация и presence в тех же ветках
GOcase c := <-h.register:
h.clients[c] = true
h.users[c.userID] = c // второе устройство затирает первое
h.broadcast <- &Envelope{Type: "system", Body: c.userID + " в сети"}
case c := <-h.unregister:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
if h.users[c.userID] == c {
delete(h.users, c.userID)
h.broadcast <- &Envelope{Type: "system", Body: c.userID + " не в сети"}
}
close(c.send)
}Presence рождается бесплатно: появление записи в users — событие «в сети», удаление — «не в сети». Сравнение указателя h.users[c.userID] == c защищает от того, что вторая вкладка того же пользователя уже перезаписала запись: старая вкладка уйдёт тихо, не выключая статус свежему соединению. Строки Body здесь для наглядности — в проде это поля структуры.
Presence: зелёная точка и её цена#
Список онлайн-пользователей — это просто ключи карты users, и первая версия presence отдаёт их по запросу: клиент шлёт конверт who, хаб отвечает перечнем. Следующий шаг — события: в ветках register и unregister мы уже рассылаем system-конверты, и клиентская часть рисует по ним точки. Тут появляется вопрос масштаба, о котором редко думают заранее: при десяти тысячах пользователей вход каждого — это рассылка десяти тысячам. Решают сужением круга: статус рассылается только контактам, которые явно подписались на присутствие этого человека, — тем же механизмом комнат или личных карт подписок. Полное описание статусов, статусов «отошёл» и heartbeats для них живёт в девятнадцатом уроке, здесь достаточно каркаса. Нюанс, который стоит зафиксировать: presence по определению lossy — потерянное событие «в сети» чинится повторным запросом списка, и потому его можно рассылать без очередей и подтверждений.
Что делать с конвертом оффлайн-получателю#
- Выбросить молча — для presence и лёгких уведомлений, потеря которых безболезненна.
- Ответить отправителю системным конвертом о недоставке — чтобы не ждать ответ впустую.
- Очередь в памяти — для учебных задач: теряется при рестарте процесса.
- Очередь в базе или Redis — для сообщений, которые нельзя терять: хаб помечает недоставленное.
- Отложить отправку до reconnect клиента — тогда очередь живёт до следующего подключения.
Кейс: диалог поддержки и тикет вместо комнаты#
Служба поддержки — частный случай личных сообщений, и он показывает границу модели. Клиент пишет оператору, но операторов несколько, и смена дежурного не должна терять переписку: адресат сообщения — не человек, а тикет. Хаб здесь снова спасается вложенной картой: подписчики тикета — map[*Client]bool, и личный конверт уходит всем, кто сидит на тикете, а не конкретному сотруднику. UserID при этом остаётся в конверте как From: интерфейсу он нужен, чтобы показать аватар, а маршрутизация идёт по тикету. Ошибка, которую делают в такой системе, — хранить тикеты в памяти процесса: дежурство длится часы, рестарт выкинет подписки, и диалог зависнет без видимой причины. Состояние тикета живёт в базе, websocket только знает, кто из живых слушает, и очередь оффлайн-сообщений из этого урока становится мостом между ними. Такой гибрид — типичная форма зрелого чата: состояние в хранилище, транспорт в памяти.
Осталась неувязка, которую заметят внимательные: map[string]Client хранит одно соединение на пользователя, а человек открывает чат на телефоне и в браузере одновременно. Решение вам уже знакомо по одиннадцатому уроку — вложенная карта: users map[string]map[Client]bool, где внешний ключ userID, а внутренняя — множество его устройств. Личный конверт рассылается всем устройствам сразу, а presence-событие «не в сети» уходит, когда опустела внутренняя карта, — тот же приём, что с пустыми комнатами. Заодно это чинит ветку unregister: сравнение указателя становится ненужным, потому что устройств много и каждое удаляется независимо. Многодевайсность — редкий случай, когда усложнение окупается с первого дня: два соединения на пользователя — это не гипотетика, а поведение по умолчанию любого мессенджера.
Проверка: откуда берётся userID
Откуда сервер должен брать From и To для личного конверта?
Проверка: конверт для оффлайн
Получателя нет в карте users. Какая стратегия не требует ничего, кроме кода хаба?
Две вкладки одного пользователя
Прочитайте код и выберите, что он выведет:
// пользователь u42 открыл чат в двух вкладках
case c := <-h.register:
h.clients[c] = true
h.users[c.userID] = c
// вторая вкладка подключилась позже первой
// u42 отправляет личное сообщение самому себеСоберите хаб с users и direct, запустите локальный Go и откройте две вкладки браузера с разными userID в токене. Отправьте личный конверт второй вкладке — он должен появиться только там. Затем закройте вторую вкладку и отправьте ещё раз: сервер должен ответить системным конвертом о недоставке.
Соберите ветку маршрутизации
Впишите три пропуска: вторая переменная чтения из карты, ключ-адресат и закрытие канала переполненного получателя.
Соберите ветку ухода клиента
Расставьте строки ветки unregister: вычистка из общего списка, из карты пользователей и защита от чужой вкладки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: личные сообщения
Личные сообщения и presence — это две половины одной карты, и обе построены на дисциплине единственного владельца. Прежде чем идти дальше, проверьте периметр: userID обязан приходить из авторизации, и поэтому следующий урок — авторизация соединения, где токен проверяется на рукопожатии и превращается в поле Client. Комнаты из одиннадцатого урока остаются на месте: личные конверты и групповые сосуществуют в одном select. Углублённая версия статусов — девятнадцатый урок про presence, а хранение недоставленных конвертов упирается в тему масштабирования на Redis. Откуда берётся сам userID и как устроен токен с подписью — курс JWT в Go; для этого урока достаточно знать, что после проверки там остаётся идентификатор пользователя. Проверяйте всё локально: Go и две вкладки с разными пользователями покажут адресацию быстрее любых логов. Полный план — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- users map[string]*Client в хабе: маршрутизация личных конвертов по userID через канал direct.
- From ставит сервер из Client.userID — From из тела сообщения игнорируется, иначе подделка отправителя.
- Получателя нет в карте — выбор: выбросить, ответить о недоставке или очередь с постоянным хранилищем.
- Presence — производное карты: записи есть, пользователь в сети; события уходят из register и unregister.
- Второе устройство затирает первое: для мультидевайса — вложенная карта устройств, как комнаты в одиннадцатом уроке.
- Select с default работает и в личной доставке: переполненный send получателя закрывается.
Домашнее задание
Реализуйте личные сообщения поверх хаба с комнатами: команда direct с To в конверте, системный ответ отправителю, если получателя нет в сети, и защита From от подделки из тела. Затем добавьте мультидевайс через вложенную карту устройств и проверьте локально: две вкладки одного пользователя должны получить один и тот же личный конверт, а статус «в сети» — держаться, пока жива хотя бы одна вкладка.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужна ли отдельная карта users, если есть rooms?
Да: комнаты отвечают на вопрос «кто в этой группе», а users — «какой соединение у этого человека». Попытка выразить личную адресацию через комнаты приводит к комнате на пару пользователей и грязному протоколу. Обе карты живут в одном владельце и стоят копейки.
Как делать typing-индикатор «печатает»?
Это личный конверт с типом typing, который рассылается без гарантий: буфер полон — выбрасываем, а не отключаем клиента. Для таких событий заводят отдельный канал с политикой потери, потому что отключать собеседника из-за несрочного индикатора жестоко.
Почему presence можно терять, а личные сообщения нельзя?
Потерянный статус чинится повторным запросом списка: он самовосстанавливающийся и опирается на состояние, которое всегда можно перечитать. Личное сообщение уникально и необратимо: потеря — это пропавший текст в переписке, и ему нужна очередь с хранилищем, а не надежда на рассылку.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами