Зелёная точка рядом с аватаром кажется мелочью, пока не посчитаешь, что стоит за ней. В двенадцатом уроке presence родился как производное карты users: запись есть — в сети, запись удалилась — не в сети, и для учебного чата этого достаточно. Живой мессенджер спрашивает больше: человек открыл вкладку и ушёл за кофе — он онлайн или нет? Отвлёкся на звонок? Сидит с телефона в фоне? Один бит «в сети или нет» на эти вопросы не отвечает, и продуктовые команды добавляют уровни. Заодно всплывает цена: при тысячах пользователей каждое подключение и каждый уход — это потенциальная рассылка, и рассылать её всему серверу нельзя. Presence онлайн статусы — это три вещи: карта с уровнями, механика их смены без ручного труда и круг подписчиков, который делает рассылку дешёвой. Кто онлайн websocket сообщает не списком раз в час, а потоком событий — и весь поток должен родиться в одном владельце.
Presence онлайн статусы: карта userID и уровни#
Хаб получает карту presence map[string]Status: ключ — userID, значение — один из уровней. Три уровня покрывают большинство продуктов: online — есть живые устройства, away — устройства есть, но активности давно не было, offline — устройств нет. Уровень dnd, «не беспокоить», в эту тройку не входит принципиально: его знает только человек, и серверу остаётся принимать его как клиентское состояние, а не угадывать. Смена уровней — обязанность горутины run: тот же владелец карт, что и в восьмом уроке, потому что presence — производное от rooms и users, и вторая горутина, читающая их, сломала бы дисциплину. Кадр смены — обычный конверт из десятого урока с типом presence, полем User и полем Status. Отдельное правило, которое экономит трафик потом: запись в карту меняется только тогда, когда уровень действительно сменился — переход online в online не порождает ни кадра, ни записи.
setStatus: смена уровня и рассылка в комнату
GOfunc (h *Hub) setStatus(c *Client, s Status) {
if h.presence[c.userID] == s {
return // без изменения уровня нет и кадра
}
h.presence[c.userID] = s
env := &Envelope{Type: "presence", User: c.userID, Status: string(s)}
for client := range h.rooms[c.room] {
select {
case client.send <- env:
default:
close(client.send)
}
}
}Метод живёт в владельце: дедупликация, запись в карту presence и рассылка конверта тем, кто сидит в комнате пользователя. Select с default — тот же предохранитель медленных клиентов, что и в обычной рассылке: presence lossy по природе, и потерянное событие чинится запросом снапшота. Круг адресатов — комната из одиннадцатого урока: в большой системе подписки сузят его до контактов.
Уровни online и offline server ставит сам, в тех же ветках register и unregister, где двенадцатый урок уже рассылал системные конверты: подключилось первое устройство — online, ушло последнее — offline. С away интереснее: сервер не спрашивает человека, отвлёкся ли тот, — он измеряет. В readPump на каждом входящем кадре обновляется отметка lastActivity; в select хаба живёт тикер присутствия, который раз в минуту понижает online в away тем, кто молчит дольше порога. Порог — продуктовое решение с границей: два-три минуты для живого чата, десять — для тикетов поддержки. И приятная экономия: lastPong из восемнадцатого урока — уже готовая отметка жизни, heartbeat обновляет её автоматически, и for многих клиентов отдельно трогать lastActivity не нужно — достаточно брать максимум из двух отметок.
Тикер присутствия: понижение до away
GOcase <-h.presenceTick.C: // Ticker раз в минуту
now := time.Now().UnixNano()
for c := range h.clients {
if h.presence[c.userID] != StatusOnline {
continue
}
idle := now - atomic.LoadInt64(&c.lastActivity)
if idle > int64(5*time.Minute) {
h.setStatus(c, StatusAway)
}
}Раз в минуту владелец обходит клиентов, смотрит только на тех, кто числится online, и понижает замолчавших. Атомарная отметка lastActivity обновляется в readPump на каждом кадре, включая явные команды активности от клиента — мобильные приложения шлют их при выходе из фона. Смена уровня идёт через setStatus, поэтому кадр уйдёт ровно один раз, а обратный переход в online поднимет любое движение.
Круг рассылки — то место, где presence обычно и разваливается. Наивный вариант — broadcast всем: при десяти тысячах пользователей каждый вход — это десять тысяч конвертов, и чат превращается в генератор присутствия. Лечение то же, что в двенадцатом уроке: сузить круг. Минимум — комната пользователя: статус видят те, кто сидит с ним рядом, и это почти бесплатно, потому что карта rooms уже есть. Дальше — подписки на контакты: map userID на множество наблюдателей, построенная по явным запросам клиента, — тот же механизм комнат, только круг задаёт не сервер, а человек. Для интерфейса важен ещё снапшот: клиент при входе шлёт команду who и получает текущий список с уровнями, а дальше живут только дельты. Без снапшота подписчик, подключившийся позже события, никогда не узнает, кто онлайн: события потока не восстанавливают прошлое.
Клиент семнадцатого урока пережил рестарт сервера: disconnect, три секунды, reconnect — а подписчики увидели offline и тут же online, и аватар мигнул. На мобильных сетях такое происходит раз в несколько минут и превращает список контактов в ёлочную гирлянду. Лечение — гистерезис: offline публикуем не сразу, а через тридцать секунд после ухода последнего устройства, и если клиент вернулся раньше — кадра нет вовсе. Задержку держит владелец: отложенная ветка в run или отметка времени с проверкой на тикере присутствия.
Два устройства одного человека — второй источник сюрпризов. Пользователь сидит в браузере и с телефона: браузер заснул, телефон в фоне, — по отметкам активности один ушёл в away, второй молчит дольше, и агрегировать их надо в один статус на userID. Правило честной агрегации — лучший из уровней: online, пока живо хоть одно активное устройство, offline, когда ушли все. Вложенная карта устройств из двенадцатого урока даёт это почти даром: статус меняется на границах пустого и непустого множества, и отдельной синхронизации не требует. Заодно решается и обратный вопрос: что показывать, когда устройства расходятся, — пользователь явно выставил dnd на телефоне; если dnd хранить как клиентский уровень с приоритетом, сервер перестаёт угадывать и просто публикует то, что человек сказал о себе. Граница: dnd с таймером «на час» уже требует серверного состояния, и оно живёт в той же карте presence, только источник у него другой.
Кто имеет право видеть статус#
Presence — не публичное свойство сервера, а данные о поведении человека, и круг видимости стоит решить до релиза, а не после жалобы. Три уровня доступа встречаются чаще всего: коллеги в общей комнате видят статусы друг друга — это цена входа в рабочее место; контакты в мессенджере видят друг друга по подписке, и подписка двусторонняя; все остальные не видят ничего, даже наличия в сети. Отдельная настройка — скрыть свой статус: человек включает «невидимку», и сервер перестаёт рассылать его переходы, оставляя только личную переписку; статус в карте presence при этом живёт — он нужен самому серверу для маршрутизации, просто наружу не выходит. Проверка круга происходит в setStatus перед рассылкой: тот же владелец, одна лишняя ветка. Пропустить этот вопрос дороже, чем кажется: список «кто сейчас на сайте» без фильтра становится разведчиком для тех, кто продаёт внимание — маркетинг попросит, поддержка попросит, и через год сервер шлёт каждому о каждом.
Уровни и их источники#
| Уровень | Кто решает | Как определяется |
|---|---|---|
| online | сервер | есть живые устройства, карта users непуста |
| away | сервер | активности нет дольше порога: пять минут по умолчанию |
| dnd | клиент | явный уровень от человека, сервер публикует без споров |
| offline | сервер | последнее устройство ушло плюс гистерезис в тридцать секунд |
Проверка: откуда берётся away
Как сервер узнаёт, что пользователь отошёл?
Проверка: круг рассылки
Пользователь сменил статус на away. Кому уходит presence-кадр?
Дедупликация в setStatus
Прочитайте код и выберите, что он выведет:
// u42 уже числится в away, тикер присутствия тикнул дважды,
// кадров от клиента за это время не было
h.setStatus(c, StatusAway)
h.setStatus(c, StatusAway)Соберите presence поверх хаба с картой устройств на локальном Go и откройте две вкладки с одним userID. Закройте одну — статус должен остаться online, пока жива вторая; закройте обе — через тридцать секунд гистерезиса придёт offline. Затем временно поставьте порог away в одну минуту и перестаньте трогать вкладку: тикер понижает её сам, а любое движение возвращает online. Три вкладки и таймер закрывают весь урок за десять минут.
Соберите setStatus
Впишите три пропуска: сравнение текущего уровня, запись нового и круг адресатов рассылки.
Соберите каркас setStatus
Расставьте строки метода: проверка уровня, запись и возврат без действий.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: presence
Presence готов: карта уровней, автоматическое угасание, гистерезис и круг подписчиков. Кадры статуса пойдут чаще, чем сообщения чата, — и их тоже стоит собирать пачками, чем займётся двадцатый урок про батчинг. Каркас присутствия и очередь оффлайн-сообщений описаны в двенадцатом уроке, отметка lastPong и конвейер смерти — в восемнадцатом про отвалы, а переподключения, из-за которых мигают статусы, — в семнадцатом про reconnect. Тикеры, отметки времени и пороги на duration разобраны в продвинутом курсе Go в теме про time и таймеры. Когда инстансов станет два, карта presence разъедется по процессам — мост между ними построит двадцать третий урок про масштабирование. Проверьте гистерезис на локальном Go с двумя вкладками — мигание видно сразу. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Presence — карта userID на уровень в хабе: online и offline ставит сам сервер в ветках входа и ухода.
- Away рождается из тишины: lastActivity в readPump плюс тикер присутствия, порог два-десять минут.
- lastPong из heartbeat — готовая отметка жизни: для presence хватает максимума двух отметок.
- setStatus дедуплицирует: смена уровня без изменения не пишет карту и не шлёт кадр.
- Круг рассылки сужается до комнаты и подписок: broadcast всем превращает вход в десять тысяч конвертов.
- Гистерезис в тридцать секунд убирает мигание статуса при переподключениях и деплоях.
Домашнее задание
Достройте presence поверх хаба с картой устройств: setStatus с дедупликацией, тикер присутствия с понижением до away и гистерезис offline в тридцать секунд. Добавьте команду who со снапшотом статусов комнаты при входе клиента. Проверьте локально с двумя вкладками одного пользователя: уход одной не гасит статус, уход обеих публикует offline один раз, а возврат до тридцати секунд не даёт ни одного кадра.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему away нельзя узнать от клиента?
Вкладку закрывают крышкой ноутбука, телефон уходит в сон, и ни один клиент не обязан успеть сообщить об уходе — сеть уже может лежать. Серверное измерение тишины работает всегда, потому что опирается на то, что уже есть: heartbeat и входящие кадры. Клиентская команда остаётся для явных уровней вроде dnd.
Что делать со статусами при нескольких инстансах?
Одна карта presence живёт внутри одного процесса, и её события не пересекают границу. Связку строят через шину событий: каждый инстанс публикует свои смены статусов, слушает чужие и держит копию карты. Детали — в двадцать третьем уроке про Redis Pub/Sub; до масштабирования карту можно не трогать.
Как часто можно опрашивать статусы через who?
Снапшот рассчитан на вход и на восстановление после потери событий: раз при подключении и после заметного зазора. Постоянный поллинг who каждую секунду возвращает архитектуру к тому, от чего websocket уходил, — к вопросу «есть ли новое». Дельты и гистерезис существуют именно чтобы поллинг не понадобился.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами