Содержание урока · 10 разделов
- Websocket json go: конверт с полем type
- WriteJSON и ReadJSON: соединение говорит структурами
- Клиент: JSON.parse и различение событий
- Протокол сообщений ws: правила, которые экономят месяцы
- Минимальный набор событий чата
- Байтовый канал против конверта
- Заглавные ключи и структурные теги
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
До сих пор наши чаты возили сырые байты: хаб из восьмого урока раздавал []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 при этом остаётся обычной структурой — никаких интерфейсов и рефлексии, только поля.
Структуры протокола и новый тип канала хаба
GOtype 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 и дедлайны записи остаются на местах из девятого урока.
WriteJSON шлёт TextMessage всегда. Если же вручную отправить json-байты кадром BinaryMessage, серверный ReadJSON их честно развернёт — тип кадра он игнорирует, — но браузер получит в event.data Blob вместо строки, и JSON.parse упадёт с SyntaxError, по которой не видно причины. Правило: JSON — текстовые кадры, бинарные бронируйте для будущих бинарных нагрузок.
Клиент: JSON.parse и различение событий#
На клиенте появляется второй слой: строка из event.data превращается в объект вызовом JSON.parse, и только потом попадает в интерфейс. Разбор ведётся по полю type — обычный if или switch; ветка для chat рисует сообщение автора, ветка system — служебную строку, ветка typing поднимает индикатор «печатает». Две привычки стоит закрепить с первого клиента. Первая: неизвестный type не роняет страницу — просто игнорируется; старый клиент, открывший сайт после релиза с новыми событиями, продолжит работать, а новые события молча пропустит. Вторая: отсутствующее поле читается как undefined, а не как ошибка — конверт без Text не взорвёт разбор, но и на экран выведет пустоту; клиент вправе фильтровать такое, но падать не обязан. Отправка симметрична: JSON.stringify собирает объект в строку, и socket.send уносит её текстовым кадром. Клиентский код умещается в один обработчик onmessage — в этом вся прелесть протокола на JSON: интерфейсная часть читается как расписка.
Обработчик клиента: три ветки по типу
GOsocket.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 []byte | chan 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-версию насосов
Заполните три пропуска: чтение конверта, передача в хаб и запись конверта.
Соберите разбор на клиенте
Расставьте строки обработчика в правильном порядке: от события до добавления сообщения в чат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSON-протокол
Протокол встал на транспорт: хаб из восьмого урока теперь раздаёт конверты, пара 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 не падает, а просто молчит — это и есть прямая совместимость, которую вы только что проверили руками.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему JSON, а не protobuf?
JSON читается в DevTools без инструментов, нативен для JavaScript и не требует генерации кода — для чатов и уведомлений этого достаточно. Protobuf выигрывает на размере и скорости при бинарных нагрузках и высокой частоте кадров; если ваш протокол туда перерастёт, конверт с type переедет на новое кодирование без смены правил.
Что делать, если клиент прислал невалидный JSON?
ReadJSON вернёт ошибку разбора, и по общему правилу соединение закрывается — играться с мусорными кадрами сервер не обязан. Для дружелюбных клиентов серверы часто сначала отвечают событием error с кодом причины, но технически после ошибки разбора соединение считается скомпрометированным: проще отключить и дать клиенту переподключиться.
Можно ли слать структуры без тегов и жить с заглавными ключами?
Можно: типы совпадают, и разбор на клиенте пишет msg.Type. Вопрос вкуса и договорённости с фронтендом. Но если API увидят внешние потребители, сразу добавьте теги и lowercase — переименование после релиза превращается в ломающее изменение со всеми последствиями для старых вкладок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами