Содержание урока · 8 разделов
- ReadMessage и WriteMessage: анатомия двух вызовов
- Типичная ошибка: принудительный TextMessage на любые байты
- Почему websocket эхо сервер — правильная первая программа
- Кейс: эхо-сервер как первый стенд при проблемах на проде
- Ошибки чтения: как соединение умирает по-настоящему
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервер из прошлого урока умеет апгрейдить и молчит: соединение открылось — и сразу умерло, потому что хендлер вышел сразу после defer. Договорим до конца: после Upgrade хендлер превращается в цикл. Это главное отличие от привычного HTTP-кода, где обработка заканчивается вместе с ответом. Пока цикл жив, живо и соединение: горутина застревает на ReadMessage, просыпается на каждом кадре, что-то делает с байтами и возвращает ответ. Эхо — идеальная первая программа на этом механизме: она проверяет и чтение, и запись, и закрытие соединения, и при этом не требует ни протокола, ни состояния, ни базы. Пятьдесят строк кода — и у вас на :8080 работает настоящее двустороннее соединение, к которому в следующем уроке подключится настоящий браузер. Соберём его целиком, чтобы потом не возвращаться: от package main до log.Fatal.
Полный main.go: эхо-сервер WebSocket
GOpackage main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func echo(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("рукопожатие не состоялось:", err)
return
}
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
log.Println("чтение кончилось:", err)
return
}
if err := conn.WriteMessage(mt, msg); err != nil {
log.Println("запись не прошла:", err)
return
}
}
}
func main() {
http.HandleFunc("/ws", echo)
log.Println("ws на :8080/ws")
log.Fatal(http.ListenAndServe(":8080", nil))
}Разберём по узлам. Upgrade — первая строка хендлера: после неё ResponseWriter больше не нужен, живёт только conn. defer conn.Close() гарантирует закрытие при любом выходе. ReadMessage блокирует горутину до прихода кадра; вернувшаяся ошибка — конец соединения, цикл выходит через return. WriteMessage возвращает кадр тем же типом и теми же байтами. main регистрирует маршрут обычным http.HandleFunc: WS-сервер остаётся обычным сервером net/http. Запуск — go run main.go.
ReadMessage и WriteMessage: анатомия двух вызовов#
Сигнатуры стоит выучить дословно, они встретятся в каждом уроке дальше. ReadMessage() возвращает три значения: messageType int, p []byte и err error. Чтение блокирующее: горутина спит, пока клиент не пришлёт кадр. Полезная нагрузка приходит свежим куском памяти под каждое сообщение — буфер не переиспользуется, поэтому ссылку на msg можно спокойно хранить дольше одной итерации. Тип сообщения — единица для текста, двойка для бинарных байт; границы сохранены, длинные сообщения библиотека собирает из фрагментов сама. Ошибка — единственный честный сигнал конца: и вежливое закрытие клиентом, и обрыв кабеля приходят как err из ReadMessage; различить их помогает разбор кода закрытия, к которому вернёмся в конце урока. WriteMessage(messageType int, data []byte) error уходит одним кадром и возвращает ошибку записи. Жёсткое правило пары: после ошибки чтения соединение считается мёртвым — и читать, и писать в него больше нельзя, любые попытки дадут ошибку.
Проверка: сигнатура чтения
Клиент прислал текстовый кадр со словом привет. Что вернёт ReadMessage?
Типичная ошибка: принудительный TextMessage на любые байты#
В эхо-сервере написан WriteMessage(mt, msg) — тем же типом, что пришёл, и это не стилистика, а протокольное требование. Типичная ошибка новичка — захардкодить TextMessage: кажется, что чат всё равно текстовый, и бинарных кадров не бывает. Пока клиент шлёт строки, разницы нет; но первый же кадр с байтами, не образующими корректный UTF-8, — скриншот, сжатый кусок, сырой protobuf — делает текстовый кадр невалидным. По спецификации получатель обязан закрыть соединение с кодом 1007: некорректные данные в текстовом кадре, и соединение умирает на ровном месте, а лог сообщает о протокольной ошибке вместо понятной причины. Правило простое: эхо повторяет mt; TextMessage шлём только когда сами собрали строку и гарантируем её UTF-8; бинарные данные — BinaryMessage без перекодировок. Проверить себя можно одной строкой: отправьте из консоли последовательность байт без валидной кодировки и посмотрите, какой код закрытия придёт в ReadMessage — это будет ваш личный 1007.
Отделим, что здесь делает net/http, а что делаете вы. Стандартная библиотека слушает порт, принимает TCP-соединения, создаёт горутину на каждое и вызывает ваш хендлер с готовым Request. Вы делаете ровно две вещи: проводите апгрейд и обеспечиваете жизнь соединения циклом чтения. Из этого следует знакомая по продвинутому курсу Go арифметика конкурентности: пятьдесят открытых вкладок — пятьдесят горутин, каждая со своим conn в собственном цикле; эхо-сервер обслуживает их все одновременно без единого мьютекса, потому что состояния у него нет. Проблемы начинаются, когда состояние появляется: общий список всех соединений для рассылки, счётчик сообщений, права доступа — любые данные, которых касается несколько горутин, требуют синхронизации или каналов. Это следующий большой шаг курса: в седьмом уроке появится карта клиентов, в восьмом — hub с каналами. Пока же запомните форму: один клиент — одна горутина — один цикл, и эта форма переживёт весь курс почти без изменений.
Проверка: запись после смерти
ReadMessage вернул ошибку закрытия, но хендлер проигнорировал её и вызвал conn.WriteMessage. Что произойдёт?
Почему websocket эхо сервер — правильная первая программа#
Эхо проверяет весь конвейер real-time за один запуск: рукопожатие, кадры в обе стороны, закрытие соединения. Каждый следующий урок курса — надстройка над этим циклом: ping-кадры вплетутся между чтениями, hub вырастет из карты соединений, JSON-протокол ляжет поверх байтов, комнаты превратят одну карту в несколько. Форма цикла при этом останется той же — продакшен-версия цикла носит имя readPump, и в девятом уроке она обрастёт дедлайнами, лимитами и обработкой pong, но узнаваемость сохранится полностью. Экономика отладки тоже честная: если эхо работает, транспорт жив, и все дальнейшие баги будут багами протокола или состояния, а не соединения. Поэтому первое, что проверяют при проблемах real-time на любом проекте, — эхо на голом цикле: оно отделяет вопросы сети и инфраструктуры от вопросов вашего кода. Держите этот файл под рукой как тестовый стенд для всех следующих уроков.
Эхо — частный случай более общей формы: кадр прочитан, обработан, ответ отправлен. Чтобы почувствовать, где подключается ваша логика, замените повтор на обработку: возьмите string(msg), прогоните через strings.ToUpper и отправьте уже её с явным TextMessage. Клиент получит ПРИВЕТ вместо привет — и это уже не эхо, а первый сервис на WS-транспорте. В этой точке всплывают решения, которые эхо прятало: тип ответа теперь ваш выбор, а не унаследованный; пустые строки и пробелы надо как-то решать — обрабатывать или отбивать ошибкой; и главное, обработка создаёт соблазн ответить не тому, кто спросил, а всем — именно здесь курс позже введёт общий список соединений и хаб. Смена регистра — игрушечная операция, но каркас вокруг неё настоящий: тот же цикл, те же ошибки чтения, то же правило одной горутины-писателя. Когда появится протокол с типами событий из десятого урока, обработка заменится на switch по полю type — и структура файла не изменится.
Кейс: эхо-сервер как первый стенд при проблемах на проде#
Собранный эхо-сервер — не только учебная программа, но и диагностический инструмент, и стоит заранее потренироваться им пользоваться. Сценарий: в бою чат теряет сообщения или отваливается, и непонятно, где искать — в коде, в прокси, в сети. Порядок проверки снизу вверх: сначала голое эхо из этого урока против того же хоста и порта, где живёт боевой сервис; если эхо работает часами без ошибок чтения — транспорт жив, и виноват уровень выше: протокол, состояние, логика рассылки. Если эхо умирает через минуту тишины — подозреваемый один: разрыв между клиентом и сервером, обычно таймаут прокси или балансировщика; добавление ping-механизма из шестого урока отличит первое от второго. Дальше поднимается уровень: тот же транспорт, но уже с JSON-сообщениями из десятого урока, затем хаб из восьмого. Каждый слой проверяется при фиксированном предыдущем, и болячка локализуется за минуты вместо вечера. Такой стенд я держу отдельным файлом рядом с проектом: занимает десять строк и окупается при каждом инциденте.
Журнал последних сообщений на [][]byte
GOvar history [][]byte // журнал этой сессии
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
return
}
history = append(history, msg)
if len(history) > 10 {
history = history[1:] // помним последние 10
}
if string(msg) == "history" {
for _, m := range history {
conn.WriteMessage(websocket.TextMessage, m)
}
continue
}
conn.WriteMessage(mt, msg) // обычное эхо
}history — срез срезов байтов: каждый элемент ровно одно сообщение, границы не размываются, и это прямое следствие сохранения границ в протоколе. Срез history[1:] держит окно из десяти последних кадров, старые уходят. Команда history возвращает клиенту накопленное отдельными кадрами — зачатки протокола поверх транспорта, который к десятому уроку станет JSON-событиями. Общий журнал на несколько соединений потребует мьютекс — тема седьмого урока.
Сколько сообщений в журнале
Прочитайте код и выберите, что он выведет:
history := [][]byte{}
for _, s := range []string{"a", "bb", "history"} {
msg := []byte(s)
history = append(history, msg)
if len(history) > 10 {
history = history[1:]
}
if string(msg) == "history" {
fmt.Println(len(history))
}
}Писать в conn из двух горутин одновременно нельзя: кадры перемешаются на середине, соединение умрёт протокольной ошибкой. В эхо-сервере пишет ровно одна горутина — читатель, и это безопасно. Как только появится второй источник записей, например рассылка от другого клиента, записи пойдут через канал одной горутине-писателю: этот механизм зовётся writePump и станет уроком девятым.
Проверка: арифметика горутин
Пятьдесят клиентов открыли вкладки и держат WS-соединения. Сколько горутин крутят циклы чтения?
Ошибки чтения: как соединение умирает по-настоящему#
Разберём, что именно прилетает в err. Клиент закрыл вкладку — придёт ошибка закрытия с кодом 1000 или 1001; горутина закончится штатно. Обрыв сети — неожиданный конец файла, а код закрытия станет 1006, локальной меткой обрыва. Протокольные нарушения — неверный кадр — тоже приходят ошибкой чтения. Различать варианты помогает пара функций пакета: websocket.IsCloseError проверяет конкретные коды, IsUnexpectedCloseError отделяет их от неожиданных обрывов; полноценная классификация понадобится в уроке про отвалы и чистку мёртвых соединений. Правило после любой из них одно: соединение мертво, читать и писать в него нельзя, а горутине — пора возвращаться. Логируйте err с кодом: половина отладки real-time состоит в правильной классификации этих ошибок. И тонкость, которая кусает новички чаще всего: если клиент молчит, обрыв сети не проявится вообще — чтение продолжит висеть до ping-механизма с дедлайнами из шестого урока.
Запустите go run main.go и откройте консоль браузера: new WebSocket("ws://localhost:8080/ws") и пара onopen/onmessage — и эхо видно. Из терминала удобнее wscat: npm i -g wscat, затем wscat -c ws://localhost:8080/ws. Оба способа требуют локального Go — песочница сайта TCP-сервер не поднимет; полный разбор браузерного клиента — в следующем уроке.
Четыре строки проверки из консоли
GOconst s = new WebSocket("ws://localhost:8080/ws");
s.onopen = () => s.send("привет из консоли");
s.onmessage = (e) => console.log("эхо:", e.data); // привет из консоли
s.onclose = (e) => console.log("закрыто:", e.code);Четыре строки — и сервер проверен: рукопожатие, отправка, эхо, закрытие с кодом. Это самый быстрый способ убедиться, что транспорт жив, прежде чем строить интерфейс. Провал рукопожатия тоже виден: если сервер не поднят, WebSocket упадёт с ошибкой и не дойдёт до onopen, а onclose получит код 1006.
Соберите ядро эхо-цикла
Заполните три пропуска: чтение, выход при ошибке и запись тем же типом.
Соберите тело read-цикла
Расставьте строки эхо-цикла в порядке выполнения: от чтения до конца итерации.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: первый сервер
У нас есть обе половины real-time: сервер, который шлёт и слушает, и способ дотянуться до него из консоли. Дальше — пятый урок, где соберём полноценного браузерного клиента: new WebSocket, события, readyState и то, почему send без onopen роняет код. Если установку или поля Upgrader нужно освежить, вернитесь к третьему уроку; арифметика горутин из этого урока опирается на продвинутый курс Go. Напоминание о честности запуска: весь этот урок проверяется только локально — go run плюс консоль браузера, песочница TCP не поднимет. Общая карта всех двадцати пяти уроков, от heartbeat до Redis-моста, лежит на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Эхо-сервер: апгрейд, defer conn.Close(), цикл ReadMessage-WriteMessage с выходом по первой ошибке.
- ReadMessage возвращает messageType, []byte и ошибку: 1 — текст, 2 — бинарные; границы сообщений сохранены.
- Ошибка чтения — единственный честный сигнал смерти соединения; после неё ни читать, ни писать нельзя.
- net/http сам создаёт горутину на каждое соединение: пятьдесят вкладок — пятьдесят циклов чтения.
- [][]byte — журнал сообщений: каждый элемент одно цельное сообщение, окно режется срезом.
- Писать в conn можно только из одной горутины: несколько источников записей — канал и writePump.
- Проверка без фронтенда: консоль браузера или wscat против ws://localhost:8080/ws.
Домашнее задание
Запустите эхо-сервер и допишите ему счётчик кадров с момента старта, печатая номер каждого принятого сообщения в лог. Откройте две вкладки и убедитесь, что журналы у соединений независимы — общее состояние появится позже. Затем закройте вкладку и посмотрите, какая ошибка пришла в ReadMessage: запомните её вид, это ваш первый близкий знакомый close-код.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему ReadMessage возвращает []byte, а не string?
Кадры бывают двух типов: текст и байты, и сервер заранее не знает, что приедет. Сигнатура одна на оба случая: messageType различает типы, []byte держит нагрузку как есть. Для текстовых протоколов string(msg) — обычное дело; для бинарных преобразование только испортит данные.
Эхо-сервер отвечает всем или только отправителю?
Только отправителю: WriteMessage уходит в тот conn, из которого прочитали кадр. Чтобы ответить всем участникам, нужен общий список соединений и цикл по нему — рассылке посвящён седьмой урок, а правильной архитектуре рассылки — hub-паттерн в восьмом.
Нужно ли ограничивать размер входящих сообщений?
В эхо-уроке не нужно, в проде обязательно: ReadMessage соберёт сообщение практически любого размера, и один гигабайтный кадр съест память. Верхнюю границу ставит SetReadLimit — ему и защите от DoS посвящён четырнадцатый урок курса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами