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

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

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

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

JSON-протокол сообщений

Websocket json go — это протокол поверх транспорта: конверт с полем type, по которому клиент различает чат, системные события и что-нибудь ещё. В этом уроке определим структуры протокола, переведём хаб и насосы с байтов на структуры через WriteJSON и ReadJSON, напишем разбор на клиенте и сформулируем правила, по которым протокол сообщений ws переживёт новые события без сломанных старых клиентов.

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

До сих пор наши чаты возили сырые байты: хаб из восьмого урока раздавал []byte, насосы из девятого передавали их без разбора, и эхо-сервер из четвёртого вообще возвращал кадр тому, кто прислал. Такая простота дороже, чем кажется, и цена становится видна на первом же системном событии. В чате появляется «Аня вошла в комнату» — и вот клиент должен решить: рисовать это как сообщение Ани или как служебную строку? С байтами он не может решить ничего: соединение не несёт смысла, смысл должен приносить протокол. Это классическая задача слоёв: TCP и WebSocket гарантируют доставку и границы кадров, но не понимают содержимого; слой протокола приложения добавляет структуру — и в мире браузеров де-факто стандартом стал JSON. Он читаемый в отладке, нативный для JavaScript и не требует библиотек ни на сервере, ни на клиенте. В этом уроке соберём протокол минимального чата: конверт с типом события, серверные структуры, разбор на клиенте и правила расширения, которые спасут от поломок при первой же новой фиче.

Websocket json go: конверт с полем type#

Форма протокола называется конвертом, или envelope: у каждого сообщения есть обязательное поле type и необязательная нагрузка. Это тот же паттерн, что type в JSON-RPC или event в Server-Sent Events, и он работает по одной причине: получатель всегда знает, где искать смысл. Для минимального чата хватает трёх полей: Type — вид события, Author — кто говорит, Text — содержимое. Пока мы не используем структурные теги, encoding/json сериализует поля с заглавными именами: Type, Author, Text — и клиентская сторона будет читать именно их; переименование в строчные ключи тегами разберём ниже, отдельно, потому что делать его лучше осознанно и один раз. Важно, что конверт один на оба направления: клиент шлёт type со значением chat, когда говорит, сервер рассылает system, когда кто-то подключился. Единая форма экономит ветки в разборе и позволяет одному обработчику принимать всё. Структура в Go при этом остаётся обычной структурой — никаких интерфейсов и рефлексии, только поля.

Структуры протокола и новый тип канала хаба

GO
type Envelope struct {
        Type   string // "chat", "system", "typing"
        Author string
        Text   string
}

// хаб из восьмого урока меняет тип канала:
type Hub struct {
        clients    map[*Client]bool
        broadcast  chan Envelope
        register   chan *Client
        unregister chan *Client
}

Envelope — вся серверная сторона протокола: три строковых поля, сериализация стандартным пакетом. Канал broadcast меняет тип с []byte на chan Envelope — ровно то расширение, которое восьмой урок оставил на будущее: цикл run не меняется, меняется только содержимое посылок. send в Client станет chan Envelope тем же ходом.

WriteJSON и ReadJSON: соединение говорит структурами#

Gorilla приносит JSON-хелперы, и они точно повторяют семантику привычных вызовов. WriteJSON(v) сериализует значение и отправляет его одним текстовым кадром — строго эквивалентно паре json.Marshal плюс WriteMessage с типом TextMessage, только короче и без ручных байтов. ReadJSON(v) читает следующий кадр и разворачивает его в переданную структуру по указателю; ошибка — либо проблемы соединения, либо невалидный JSON, и в обоих случаях соединение считается мёртвым: правило из четвёртого урока работает без изменений. Обратите внимание на нюанс: ReadJSON игнорирует тип кадра — ему всё равно, был ли кадр текстовым или бинарным, он unmarshal-ит байты как получится. Это удобно, но дисциплину не отменяет: JSON должен ехать текстом, и причин две — браузерная сторона и отладка, о них ниже. Замена байтов на структуры трогает ровно два места: вызовы ReadMessage и WriteMessage в насосах и типы каналов — сам каркас хаба и пара readPump writePump остаётся тем же, что в девятом уроке.

Сервер: чтение конверта и рассылка структур

GO
// readPump вместо ReadMessage:
for {
        var env Envelope
        if err := c.conn.ReadJSON(&env); err != nil {
                break
        }
        c.hub.broadcast <- env
}

// ветка writePump вместо WriteMessage:
case env := <-c.send:
        c.conn.SetWriteDeadline(time.Now().Add(writeWait))
        if err := c.conn.WriteJSON(env); err != nil {
                return
        }

Обе точки меняются минимально: чтение разворачивает кадр в структуру по указателю и передаёт конверт в хаб, запись сериализует конверт из send. Ошибки обрабатываются как раньше — любое отклонение означает конец соединения. Ветвь ok false при закрытом send и дедлайны записи остаются на местах из девятого урока.

Клиент: JSON.parse и различение событий#

На клиенте появляется второй слой: строка из event.data превращается в объект вызовом JSON.parse, и только потом попадает в интерфейс. Разбор ведётся по полю type — обычный if или switch; ветка для chat рисует сообщение автора, ветка system — служебную строку, ветка typing поднимает индикатор «печатает». Две привычки стоит закрепить с первого клиента. Первая: неизвестный type не роняет страницу — просто игнорируется; старый клиент, открывший сайт после релиза с новыми событиями, продолжит работать, а новые события молча пропустит. Вторая: отсутствующее поле читается как undefined, а не как ошибка — конверт без Text не взорвёт разбор, но и на экран выведет пустоту; клиент вправе фильтровать такое, но падать не обязан. Отправка симметрична: JSON.stringify собирает объект в строку, и socket.send уносит её текстовым кадром. Клиентский код умещается в один обработчик onmessage — в этом вся прелесть протокола на JSON: интерфейсная часть читается как расписка.

Обработчик клиента: три ветки по типу

GO
socket.onmessage = (e) => {
  const msg = JSON.parse(e.data);

  if (msg.type === "chat") {
    addItem(msg.author + ": " + msg.text);
  } else if (msg.type === "system") {
    addItem("-- " + msg.text);
  } else if (msg.type === "typing") {
    showTyping(msg.author);
  }
};

// отправка от клиента — тот же конверт:
socket.send(JSON.stringify({
  type: "chat",
  author: name,
  text: input.value
}));

Вход — JSON.parse, выход — JSON.stringify: одна пара вызовов покрывает весь клиентский слой протокола. Веток ровно столько, сколько событий понимает интерфейс, и незнакомый type проходит мимо без ошибки — это и есть прямая совместимость в действии. Имя автора здесь берётся из формы для простоты; настоящую идентификацию даст авторизация соединения из тринадцатого урока.

Протокол сообщений ws: правила, которые экономят месяцы#

Протокол сообщений ws вырастает из пяти правил, и лучше занять их в первый день, чем изобретать после первого инцидента. Первое: у каждого сообщения есть type — без исключений, включая служебные и приватные события; сообщение без типа не разбирается. Второе: неизвестный тип игнорируется, а не роняет клиента — это делает деплой сервера вперёд клиента безопасным и наоборот. Третье: направления не пересекаются — события, которые шлёт клиент, и события, которые шлёт сервер, имеют разные имена; когда появятся подписки, набор имён не превратится в свалку. Четвёртое: публичному API нужна версия — поле v в конверте или субпротокол в заголовке рукопожатия; внутреннему протоколу хватает дисциплины игнорирования. Пятое: сервер не доверяет конверту — поля проверяются, длины ограничены, SetReadLimit из девятого урока стоит до разбора JSON. Эти правила — не бюрократия: каждое из них оплачивало себе дорогу в реальных чатах, где старые вкладки живут часами и прилетают в новый сервер с протоколом прошлой недели.

Минимальный набор событий чата#

  • chat — сообщение для всех: Author и Text; шлёт клиент, разносит сервер.
  • system — служебные события сервера: вход и выход участников, смены статуса.
  • typing — индикатор печати: короткое событие без Text, живёт секунды и не хранится.
  • error — реакция сервера на невалидный конверт: код и человекочитаемая причина.
  • Всё остальное — будущие расширения: подписки, presence, истории; их добавляют новыми значениями type.

Байтовый канал против конверта#

Критерийchan []bytechan Envelope
Что едет по сетибайты без смыслаконверт с type и нагрузкой
Кто различает событияникто: клиент угадываетклиент по полю type
Новое событиедоговорённость на байтахновое значение type, ветка на клиенте
Отладкачитать байты рукамиJSON читается в DevTools
Ценаноль преобразованийmarshal и unmarshal на каждое сообщение

Заглавные ключи и структурные теги#

Пока у Envelope нет тегов, JSON выглядит как Type, Author, Text — с заглавных букв, и это честная плата за минимум кода. В продакшене ключи переименовывают структурными тегами пакета encoding/json: поле Type с тегом json со значением type уедет ключом type, и конверт станет соответствовать привычному camelCase фронтенда. Делать это лучше в первый день протокола и сразу для всех полей: переименование после релиза означает договорённость с клиентами, а клиенты у WebSocket живут долго — старые вкладки будут приходить со старыми ключами. Смена тегов — это ломающее изменение протокола, и если вы пишете публичный API, вспомните четвёртое правило: версия в конверте. Клиентская сторона при аккуратном переименовании меняется одной строкой — ключи в разборе; если же переименование синхронизировали с релизом, клиенту не меняется вовсе.

Проверка: тип кадра

Какой тип кадра использует conn.WriteJSON(env)?

Проверка: бинарный кадр с JSON

Кто-то отправил JSON вручную: WriteMessage(websocket.BinaryMessage, jsonBytes). Что получит браузер в onmessage?

Что напечатает разбор

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

// в e.data пришла строка:
// {"type":"chat","author":"Борис"}
const msg = JSON.parse(e.data);
console.log(msg.type, msg.text);

Соберите JSON-версию насосов

Заполните три пропуска: чтение конверта, передача в хаб и запись конверта.

for { var env Envelope if err := c.conn.(&env); err != nil { break } c.hub.broadcast <- } // writePump: if err := c.conn.(&env); err != nil { return }

Соберите разбор на клиенте

Расставьте строки обработчика в правильном порядке: от события до добавления сообщения в чат.

Строки

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

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

Карточки: JSON-протокол

Термин

Envelope

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

Конверт протокола: Type плюс нагрузка. Единственный обязательный элемент — type; остальное зависит от события.

1 / 6

Протокол встал на транспорт: хаб из восьмого урока теперь раздаёт конверты, пара readPump writePump несёт структуры без изменений каркаса, а наивная версия из седьмого урока осталась в истории как карта с мьютексом. Проверьте всё живьём: сервер с ReadJSON, две вкладки с пультом и обработчиком из этого урока — песочница сайта TCP не поднимет, нужен локальный go run. Дальше конверты оживут по-настоящему: в одиннадцатом уроке рассылка разветвится по комнатам, и поле type встанет рядом с адресом получателя. Если REST-часть вашего сервиса растёт рядом с WS-эндпоинтом, посмотрите курс по chi — роутер, рядом с которым WS-маршрут живёт без адаптеров, мы упоминали ещё в третьем уроке. А если типы и структуры вызывают вопросы, база по ним — в базовом курсе Go: конверт — обычная структура, никакой магии.

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

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

Вывод

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

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

  • Протокол начинается с конверта: обязательное поле type различает chat, system и будущие события.
  • WriteJSON шлёт структуру одним текстовым кадром; ReadJSON читает следующий кадр в структуру по указателю.
  • Переход с байтов на структуры трогает только типы каналов и два вызова в насосах — каркас хаба не меняется.
  • JSON едет только текстом: ReadJSON тип кадра игнорирует, но браузерный JSON.parse упадёт на Blob.
  • Неизвестный type игнорируется, отсутствующие поля читаются как undefined — так работает прямая совместимость.
  • Сервер не доверяет конверту: проверка полей и SetReadLimit до разбора.
  • Переименование ключей тегами — ломающее изменение: делайте его в первый день или с версией в конверте.

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

Возьмите чат с хабом и насосами и переведите его на конверты: структуры, ReadJSON, WriteJSON, разбор по type на клиенте. Затем добавьте событие typing: клиент шлёт его при вводе, сервер раздаёт, другие вкладки показывают «печатает...». Убедитесь, что старый обработчик без ветки typing не падает, а просто молчит — это и есть прямая совместимость, которую вы только что проверили руками.

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

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

Почему JSON, а не protobuf?

JSON читается в DevTools без инструментов, нативен для JavaScript и не требует генерации кода — для чатов и уведомлений этого достаточно. Protobuf выигрывает на размере и скорости при бинарных нагрузках и высокой частоте кадров; если ваш протокол туда перерастёт, конверт с type переедет на новое кодирование без смены правил.

Что делать, если клиент прислал невалидный JSON?

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

Можно ли слать структуры без тегов и жить с заглавными ключами?

Можно: типы совпадают, и разбор на клиенте пишет msg.Type. Вопрос вкуса и договорённости с фронтендом. Но если API увидят внешние потребители, сразу добавьте теги и lowercase — переименование после релиза превращается в ломающее изменение со всеми последствиями для старых вкладок.

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

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

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

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

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

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

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