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

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

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

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

Личные сообщения и presence

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

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

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

GO
type 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, тип события и нагрузку. Никаких служебных полей вроде внутренних идентификаторов сессий — соединение может читать ровно то, что адресовано ему.

Кейс: получатель не в сети#

Первая же реализация упирается в неприятный вопрос: пользователь закрыл вкладку, конверт для него пришёл — что делать? Три честных ответа, и выбор между ними — продуктовое решение, а не техническое. Первый — молча выбросить: допустимо для присутствия и уведомлений «пользователь зашёл в комнату», потеря которых ничего не стоит. Второй — ответить отправителю системным конвертом «получатель не в сети», чтобы человек не ждал ответа, которого не будет. Третий — положить в очередь и доставить при следующем подключении: это уже настоящий store-and-forward, и тут важна граница. Очередь в памяти процесса теряется при рестарте — если сообщения нельзя терять, хранить её надо в базе или Redis, а хаб должен только помечать недоставленное. Для учебного чата хватит второго варианта, для мессенджера нужен третий с постоянным хранилищем. Ошибкой будет смешать всё сразу: сначала определите цену потери сообщения, потом выбирайте очередь.

Регистрация и presence в тех же ветках

GO
case 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 отправляет личное сообщение самому себе

Соберите ветку маршрутизации

Впишите три пропуска: вторая переменная чтения из карты, ключ-адресат и закрытие канала переполненного получателя.

case env := <-h.direct: target, := h.users[env.] if !online { h.queueOffline(env) continue } select { case target.send <- env: default: (target.send) }

Соберите ветку ухода клиента

Расставьте строки ветки unregister: вычистка из общего списка, из карты пользователей и защита от чужой вкладки.

Строки

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

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

Карточки: личные сообщения

Термин

users map[string]*Client

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

Карта пользователей хаба: userID на текущее соединение. По ней идёт маршрутизация личных конвертов и список онлайн.

1 / 5

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

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Личные сообщения и presence)

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

Нужна ли отдельная карта users, если есть rooms?

Да: комнаты отвечают на вопрос «кто в этой группе», а users — «какой соединение у этого человека». Попытка выразить личную адресацию через комнаты приводит к комнате на пару пользователей и грязному протоколу. Обе карты живут в одном владельце и стоят копейки.

Как делать typing-индикатор «печатает»?

Это личный конверт с типом typing, который рассылается без гарантий: буфер полон — выбрасываем, а не отключаем клиента. Для таких событий заводят отдельный канал с политикой потери, потому что отключать собеседника из-за несрочного индикатора жестоко.

Почему presence можно терять, а личные сообщения нельзя?

Потерянный статус чинится повторным запросом списка: он самовосстанавливающийся и опирается на состояние, которое всегда можно перечитать. Личное сообщение уникально и необратимо: потеря — это пропавший текст в переписке, и ему нужна очередь с хранилищем, а не надежда на рассылку.

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

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

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

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

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

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

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