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

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

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

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

Сценарии: уведомления, тикер, кооп-редактор

Real-time сценарии на websocket различаются сильнее, чем кажется: уведомления живут на fan-out и бейджах, тикер цен — на снапшоте с дельтами, кооп-редактор — на версиях документа, и перенос протокола одного продукта в другой ломает его. Здесь собрана карта применения: три продукта, их конверты, их жертвы и готовые приёмы из курса.

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

Real-time сценарии: одно слово, три продукта#

«Нам нужен real-time» — самая растяжимая формулировка, которую слышит бэкендер. Под ней скрываются продукты с противоположными требованиями: сервис уведомлений готов терпеть задержку в полсекунды, но не теряет ни одного события; биржевой тикер готов терять промежуточные цены, но шлёт сотни обновлений в секунду; кооп-редактор не может терять ни правки, ни порядок правок, а его клиенты одновременно редактируют один документ. Инфраструктура — тот же hub, насосы и heartbeat из первых уроков — у всех трёх общая, и это заслоняет главное: протокол и правила деградации у каждого свои. Real-time сценарии стоит проговорить до первого коммита: что считается потерей, что можно коалесить, кто платит за reconnect. Дальше — три модели по очереди, с приёмами, которые курс уже дал. У каждого сценария есть и цена ошибки, и её форма: у уведомлений это молчание интерфейса при живой инфраструктуре, у тикера — растущий процессор, у редактора — расхождение документов, которое потом не склеить, и эта разница определяет, где первыми писать тесты.

Уведомления websocket: fan-out, badge и приоритеты#

Канал уведомлений — это fan-out на userID: событие адресовано человеку, где бы он ни был подключён, и маршрут через ws:dm из двенадцатого урока уже умеет такое. Отличие от чата — в дисциплине потерь: лайк можно потерять безнаказанно, а уведомление о входе в аккаунт с нового устройства — нет. Поэтому уведомления websocket делят на классы важности и каждому назначают свою гарантию: критичные дублируются хранением и повтором, обычные едут пачками. Вторая особенность — badge: счётчик непрочитанных, который интерфейс показывает на иконке колокольчика. Шлёть его после каждого события расточительно — счётчик коалесится как presence из девятнадцатого урока: за окно считается итог, и клиент получает одно число. Оффлайн-получателя спасает store-and-forward из двенадцатого урока: событие лежит в очереди, клиент забирает его при reconnect из семнадцатого.

notify: онлайн уезжает в send, оффлайн — в очередь

GO
func (h *Hub) notify(userID string, n *Notice) {
        env := &Envelope{Type: "notification", User: userID, Body: n}

        if c := h.clients[userID]; c != nil {
                select {
                case c.send <- env:
                        return // онлайн: конверт ушёл в очередь соединения
                default:
                        close(c.send) // клиент не читает: соединение мертво
                }
        }
        h.store.EnqueueOffline(userID, n) // оффлайн: очередь из двенадцатого урока
}

Select с default — тот же предохранитель медленного клиента, что и в рассылке хаба: конверт не переполняет очередь, а живое соединение встречает его мгновенно. Если получателя нет в карте или его очередь переполнена, событие не теряется, а оседает в хранилище: клиент заберёт его после reconnect. Приоритет события живёт в Notice и решает, повторять ли доставку.

Badge и приоритеты рассылки#

Классификация важности — единственное, что делает уведомления управляемыми. Критичный класс — разлогин, смена пароля, вход с нового устройства: доставка с повтором, через хранилище, и без батчинга: команда разлогинить соединение из двадцатого урока летит отдельным кадром мимо pending. Обычный класс — лайки, комментарии, реакции: их можно коалесить по ключу автора, десяток лайков за окно превращаются в одно событие с итогом. Маркетинговый класс живёт в отдельном канале и по отдельной подписке: клиент сам решает, слушать ли его, и сервер не смешивает рекламу с личным. Badge-счётчик обновляется последним событием окна: пятьдесят уведомлений и один кадр с числом вместо пятидесяти кадров — экономия видна на любом инстансе с тысячами активных пользователей. Повторная доставка критичного события означает, что клиент может увидеть его дважды, поэтому обработчики уведомлений строятся идемпотентно: конверт несёт идентификатор события, интерфейс хранит десяток последних обработанных, и повтор молча сворачивается. Без этого двойная доставка критичного уведомления превращается в двойное всплытие баннера на весь экран.

Классы уведомлений и их гарантии#

  • Критичные — разлогин, смена пароля, чужой вход: повтор доставки, хранение в очереди, мимо батчинга.
  • Безопасность — вход с нового устройства и новые сессии: хранение без повтора, отдельный тип конверта.
  • Обычные — лайки, комментарии, ответы: коалинг по ключу автора, доставка пачкой с badge.
  • Маркетинг — рассылки и акции: отдельный канал и подписка, клиент слушает добровольно.

Тикер цен: снапшот, дельты и троттлинг#

Котировки — обратный край спектра: событий много, каждое невелико, а клиенту важна последняя цена, не история. Прямая рассылка каждой сделки тонет: инструмент движется двести обновлений в секунду, и тысяча подписчиков превращает это в двести тысяч WriteJSON на инстанс. Тикер строится на трёх приёмах. Снапшот при входе: подписчик получает текущее состояние, потому что события потока не восстанавливают прошлое. Дельты: дальше летят только изменения цены, не весь документ котировки. Троттлинг на сервере: клиенту отдают не чаще N раз в секунду — обновление дисплея дважды в секунду глазу достаточно, и коалинг из двадцатого урока держит последнее значение по ключу инструмента. Цифры всегда с условием: для ленты новостей хватит одной секунды, для стакана — сотни миллисекунд, и это настройка канала, а не константа протокола.

Подписка на инструмент: снапшот и троттлинг

GO
case "subscribe":
        snap := book.Get(cmd.Symbol)
        c.conn.SetWriteDeadline(time.Now().Add(writeWait))
        c.conn.WriteJSON(&Envelope{
                Type:  "snapshot",
                Sym:   cmd.Symbol,
                Price: snap.Price,
                Seq:   snap.Seq,
        })
        c.instrument = cmd.Symbol

case <-c.quoteTick.C: // тикер подписчика, сто миллисекунд
        q := book.Get(c.instrument)
        c.conn.SetWriteDeadline(time.Now().Add(writeWait))
        c.conn.WriteJSON(&Envelope{Type: "quote", Sym: c.instrument, Price: q.Price, Seq: q.Seq})

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

Кооп-редактор: версии и presence#

Совместное редактирование — самый требовательный сценарий: правки не теряются и не меняют порядок, а два человека могут править одну строку одновременно. Ядро протокола — версии: каждая правка несёт baseVersion, версию документа, на которой она сделана, и сервер присваивает правкам монотонные номера. Клиент, увидев чужую версию после своей, понимает, что отстал, и заново синхронизируется. Разрешение конфликтов — отдельная наука: операционные преобразования, на которых вырос Google Docs, и CRDT, на которых работают библиотеки вроде Yjs и Automerge, дают сходимость без центрального арбитра; писать OT с нуля в продукте не стоит — берут готовую библиотеку и держат на сервере тонкий шлюз с нумерацией версий. Presence из девятнадцатого урока придаёт живости: курсоры и выделения летят отдельным lossy-каналом с коалингом по ключу пользователя — потерянная позиция курсора не страшна, следующий тик её исправит. Синхронизация при входе идёт снапшотом документа и текущим номером версии: правки старше снапшота клиент задним числом не получит, и то же поведение спасает при reconnect после минут офлайна — клиент отдаёт свою версию, сервер досылает хвост правок.

Три сценария и их жертвы#

СценарийЯдро протоколаЧем жертвуем
Уведомленияfan-out на userID, классы важности, badge-итогинизкой ценой потерь: критичные дублируются хранением
Тикер ценснапшот при входе, дельты, троттлинг, Seqпромежуточными ценами: важна последняя
Кооп-редакторbaseVersion у правок, номера версий, presenceлатентностью: сходимость дороже скорости
Чат этого курсакомнаты, presence, батчинг, store-and-forwardисторией при оффлайне без хранилища

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

Клиент тикера получил кадр с Seq 41, а предыдущий был 45. Что делать?

Проверка: уведомление спящему получателю

Пользователь оффлайн, серверу нужно доставить уведомление о входе с нового устройства. Что делает правильный обработчик?

Курсор в кооп-редакторе

Прочитайте код и выберите, что он выведет:

// writePump клиента с коалингом из двадцатого урока:
// буфер — карта по ключу, тикер flush раз в сто миллисекунд
// один пользователь шлёт позицию курсора 60 раз в секунду

for i := 0; i < 10; i = i + 1 {
        c.send <- env // десять конвертов cursor за окно
}
// сколько кадров уйдёт в сеть за окно?

Соберите подписку тикера

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

case "subscribe": snap := book.Get(cmd.Symbol) c.conn.WriteJSON(&Envelope{Type: "", Sym: cmd.Symbol, Price: snap.Price}) c.instrument = cmd.Symbol case <-c.quoteTick.: q := book.(c.instrument) c.conn.WriteJSON(&Envelope{Type: "quote", Sym: c.instrument, Price: q.Price, Seq: q.})

Соберите порядок подписки тикера

Расставьте строки обработчика: снапшот до дельт.

Строки

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

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

Карточки: сценарии

Термин

Fan-out уведомлений

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

Рассылка на userID через ws:dm: событие адресовано человеку, а не комнате. Оффлайн-получателя держит store-and-forward.

1 / 5

Карта применения заполнена: уведомлениям нужен fan-out с классами важности и хранилищем, тикеру — снапшот с дельтами и троттлинг, кооп-редактору — версии и lossy-курсоры. Всё это вариации деталей, которые курс уже собрал: конверты, насосы, presence, батчинг. Финальная точка — двадцать пятый урок, где всё складывается в проект чата с комнатами. Личные сообщения и store-and-forward описаны в двенадцатом уроке, коалинг и батчинг — в двадцатом, presence и курсоры — в девятнадцатом, reconnect — в семнадцатом уроке. Тикеры и точность интервалов разобраны в продвинутом курсе Go в теме про time. Прогоните один сценарий на локальном Go с двумя вкладками — карта из абстрактной станет вашей. План курса — на странице курса WebSocket.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Real-time сценарии различаются требованиями к потерям и порядку: протокол одного продукта не переносится в другой механически.
  • Уведомления делятся на классы важности: критичные с повтором и хранением, обычные с коалингом, маркетинг в отдельной подписке.
  • Badge-счётчик коалится за окно: пятьдесят уведомлений уезжают одним кадром с итогом.
  • Тикер строится на снапшоте при входе, дельтах и троттлинге: двести обновлений в секунду превращаются в десять кадров.
  • Seq в кадре показывает разрывы: клиент, увидевший пропуск, запрашивает снапшот заново.
  • Кооп-редактор держит baseVersion у правок и монотонные номера: конфликты решают готовые OT или CRDT-библиотеки.

Домашнее задание

Соберите notify с двумя ветками: онлайн-получателю конверт в send через select с default, оффлайн — в EnqueueOffline. Добавьте badge-счётчик с коалингом по userID и проверьте на двух вкладках, что десять событий дают один кадр с числом. Затем сделайте тикер: подписка со снапшотом и Seq, тикер на сто миллисекунд с чтением последней цены, и убедитесь, что при разрыве Seq клиент запрашивает снапшот повторно.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Сценарии: уведомления, тикер, кооп-редактор)

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

Почему для кооп-редактора не пишем OT сами?

Операционные преобразования требуют формальной модели и обширных тестов на сходимость: ошибка превращается в расхождение документов у двух пользователей, которое нельзя потом склеить. Библиотеки вроде Yjs или Automerge дают CRDT с проверенной сходимостью, а сервер остаётся тонким шлюзом: нумерация версий, рассылка правок, presence. Это тот случай, где готовое надёжнее самодельного.

Как выбрать частоту троттлинга тикера?

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

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

Транспорт и hub общие, но конверты и гарантии разные: у чата одна комната-адресат и store-and-forward по желанию, у уведомлений — userID-маршрут, классы важности и обязательное хранение критичных. Смешивать их в один поток стоит, только когда продукт маленький: позже маркетинговые рассылки в канале чата разъедут с личными событиями, и делить будет дороже.

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

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

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

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

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

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

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