---
title: "Личные сообщения и presence"
description: "Личные сообщения websocket на Go: маршрутизация по userID, карта пользователей в хабе, presence онлайн и доставка оффлайн. Код, кейсы и типичные ошибки."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/12-lichnye-soobscheniya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

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

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

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

> **Не верьте env.From от клиента.** Если сервер берёт From из тела JSON, любой подключённый пишет личные сообщения от имени кого угодно: { type: direct, from: boss, to: hr }. Правило: From заполняется из поля Client.userID, которое сервер получил при авторизации на рукопожатии, а поле из тела игнорируется целиком. Проверить легко: шлите чужой 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 для личного конверта?

- Оба поля — из тела JSON, которое прислал клиент
- ✅ From — из поля Client.userID, узнанного при авторизации; To — из тела сообщения
- Оба поля — из заголовков HTTP-запроса
- From — из карты users, To — из карты rooms

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

### Проверка: конверт для оффлайн

**Вопрос:** Получателя нет в карте users. Какая стратегия не требует ничего, кроме кода хаба?

- Гарантированная доставка после подключения — очередь в памяти процесса
- ✅ Системный конверт отправителю о недоставке
- Гарантированная доставка с хранением в базе
- Хранение конверта до истечения суток

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

### Две вкладки одного пользователя

```go
// пользователь u42 открыл чат в двух вкладках
case c := <-h.register:
        h.clients[c] = true
        h.users[c.userID] = c
// вторая вкладка подключилась позже первой
// u42 отправляет личное сообщение самому себе
```

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

- Обе вкладки получат конверт
- ✅ Только вторая вкладка: первая затёрта в карте users
- Только первая вкладка: карта хранит первое значение
- Конверт потеряется: себе писать нельзя

Присваивание users[c.userID] = c перезаписывает значение: в карте остаётся последняя вкладка, и доставка уходит только ей. Именно поэтому в проде используют вложенную карту устройств из блока выше — тогда конверт получают обе вкладки, а статус не мигает при закрытии старой.

> **Проверьте адресацию локально.** Соберите хаб с users и direct, запустите локальный Go и откройте две вкладки браузера с разными userID в токене. Отправьте личный конверт второй вкладке — он должен появиться только там. Затем закройте вторую вкладку и отправьте ещё раз: сервер должен ответить системным конвертом о недоставке.

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

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

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

Подсказка: Чтение из карты возвращает значение и флаг наличия; адресат лежит в поле To конверта; переполненный send получателя закрывается close, как и в рассылке.

Правильные ответы в пропусках: `online`, `To`, `close`

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

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

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

1. `case c := <-h.unregister:`
2. `if _, ok := h.clients[c]; ok {`
3. `delete(h.clients, c)`
4. `if h.users[c.userID] == c {`
5. `delete(h.users, c.userID)`
6. `}`

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

- **users map[string]*Client** — Карта пользователей хаба: userID на текущее соединение. По ней идёт маршрутизация личных конвертов и список онлайн.
- **Канал direct** — Почта адресных конвертов: readPump кладёт, хаб ищет получателя в users и доставляет в его send через select с default.
- **From ставит сервер** — Поле отправителя заполняется из Client.userID, полученного при авторизации. From из тела сообщения игнорируется — иначе подделка.
- **Presence** — Производное карты users: появление записи — «в сети», удаление — «не в сети». Lossy по определению: чинится повторным запросом списка.
- **Мультидевайс** — users map[string]map[*Client]bool: множество устройств на пользователя. Личный конверт получают все, статус уходит, когда опустело множество.

Личные сообщения и presence — это две половины одной карты, и обе построены на дисциплине единственного владельца. Прежде чем идти дальше, проверьте периметр: userID обязан приходить из авторизации, и поэтому следующий урок — [авторизация соединения](/courses/websocket/lessons/13-avtorizaciya-ws/), где токен проверяется на рукопожатии и превращается в поле Client. Комнаты из [одиннадцатого урока](/courses/websocket/lessons/11-komnaty/) остаются на месте: личные конверты и групповые сосуществуют в одном select. Углублённая версия статусов — [девятнадцатый урок про presence](/courses/websocket/lessons/19-onlajn-statusy/), а хранение недоставленных конвертов упирается в тему [масштабирования на Redis](/courses/websocket/lessons/23-masshtabirovanie/). Откуда берётся сам userID и как устроен токен с подписью — курс [JWT в Go](/courses/jwt-go/); для этого урока достаточно знать, что после проверки там остаётся идентификатор пользователя. Проверяйте всё локально: Go и две вкладки с разными пользователями покажут адресацию быстрее любых логов. Полный план — на странице [курса WebSocket](/courses/websocket/).

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

- 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 можно терять, а личные сообщения нельзя?

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