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

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

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

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

Первый эхо-сервер WebSocket

Websocket эхо сервер — это hello world real-time: клиент шлёт кадр, сервер возвращает его тем же типом, и на двух вызовах — ReadMessage и WriteMessage — держится вся механика живого соединения. В этом уроке соберём полный main.go с нуля, разберём сигнатуры обеих функций построчно, добавим журнал последних сообщений на срезе [][]byte и посмотрим, как соединение умирает по-настоящему: через ошибку чтения, а не через HTTP-статус.

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

Сервер из прошлого урока умеет апгрейдить и молчит: соединение открылось — и сразу умерло, потому что хендлер вышел сразу после defer. Договорим до конца: после Upgrade хендлер превращается в цикл. Это главное отличие от привычного HTTP-кода, где обработка заканчивается вместе с ответом. Пока цикл жив, живо и соединение: горутина застревает на ReadMessage, просыпается на каждом кадре, что-то делает с байтами и возвращает ответ. Эхо — идеальная первая программа на этом механизме: она проверяет и чтение, и запись, и закрытие соединения, и при этом не требует ни протокола, ни состояния, ни базы. Пятьдесят строк кода — и у вас на :8080 работает настоящее двустороннее соединение, к которому в следующем уроке подключится настоящий браузер. Соберём его целиком, чтобы потом не возвращаться: от package main до log.Fatal.

Полный main.go: эхо-сервер WebSocket

GO
package 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

GO
var 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))
        }
}

Проверка: арифметика горутин

Пятьдесят клиентов открыли вкладки и держат WS-соединения. Сколько горутин крутят циклы чтения?

Ошибки чтения: как соединение умирает по-настоящему#

Разберём, что именно прилетает в err. Клиент закрыл вкладку — придёт ошибка закрытия с кодом 1000 или 1001; горутина закончится штатно. Обрыв сети — неожиданный конец файла, а код закрытия станет 1006, локальной меткой обрыва. Протокольные нарушения — неверный кадр — тоже приходят ошибкой чтения. Различать варианты помогает пара функций пакета: websocket.IsCloseError проверяет конкретные коды, IsUnexpectedCloseError отделяет их от неожиданных обрывов; полноценная классификация понадобится в уроке про отвалы и чистку мёртвых соединений. Правило после любой из них одно: соединение мертво, читать и писать в него нельзя, а горутине — пора возвращаться. Логируйте err с кодом: половина отладки real-time состоит в правильной классификации этих ошибок. И тонкость, которая кусает новички чаще всего: если клиент молчит, обрыв сети не проявится вообще — чтение продолжит висеть до ping-механизма с дедлайнами из шестого урока.

Четыре строки проверки из консоли

GO
const 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.

Соберите ядро эхо-цикла

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

for { mt, msg, err := conn.() if err != nil { } if err := conn.(mt, msg); err != nil { return } }

Соберите тело read-цикла

Расставьте строки эхо-цикла в порядке выполнения: от чтения до конца итерации.

Строки

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

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

Карточки: первый сервер

Термин

ReadMessage

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

Блокирующее чтение следующего сообщения: тип кадра (1 или 2), []byte и ошибка. Ошибка означает конец соединения — читать дальше нельзя.

1 / 6

У нас есть обе половины 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-код.

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

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

Почему ReadMessage возвращает []byte, а не string?

Кадры бывают двух типов: текст и байты, и сервер заранее не знает, что приедет. Сигнатура одна на оба случая: messageType различает типы, []byte держит нагрузку как есть. Для текстовых протоколов string(msg) — обычное дело; для бинарных преобразование только испортит данные.

Эхо-сервер отвечает всем или только отправителю?

Только отправителю: WriteMessage уходит в тот conn, из которого прочитали кадр. Чтобы ответить всем участникам, нужен общий список соединений и цикл по нему — рассылке посвящён седьмой урок, а правильной архитектуре рассылки — hub-паттерн в восьмом.

Нужно ли ограничивать размер входящих сообщений?

В эхо-уроке не нужно, в проде обязательно: ReadMessage соберёт сообщение практически любого размера, и один гигабайтный кадр съест память. Верхнюю границу ставит SetReadLimit — ему и защите от DoS посвящён четырнадцатый урок курса.

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

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

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

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

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

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

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