---
title: "Первый эхо-сервер WebSocket"
description: "Первый websocket эхо-сервер на Go: ReadMessage и WriteMessage в цикле, типы кадров, журнал сообщений в [][]byte и честные ошибки чтения. Полный main.go."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/04-pervyj-server/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 4 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/04-pervyj-server/*

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

Сервер из прошлого урока умеет апгрейдить и молчит: соединение открылось — и сразу умерло, потому что хендлер вышел сразу после 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?

- ✅ mt равно 1, msg — []byte с байтами слова, err nil
- mt равно 0, msg — готовая строка
- mt равно 2, потому что байты бинарные
- ничего: сразу err, текстовые кадры читает браузер

Текстовый кадр — TextMessage, число 1; бинарный — BinaryMessage, число 2. Нагрузка приходит как []byte, и string(msg) превращает её в читаемый вид, когда вы уверены в тексте. Ноль messageType не существует: типы нумеруются с единицы.

### Типичная ошибка: принудительный 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. Что произойдёт?

- Кадр уйдёт и долежит до перезапуска сервера
- ✅ WriteMessage вернёт ошибку: соединение мертво, писать нельзя
- Паника горутины и падение сервера
- Кадр поставится в очередь и уйдёт при переподключении клиента

После ошибки чтения соединение закрыто: библиотека вернёт ошибку записи, а кадр никуда не пойдёт. Паники не будет — gorilla выражает все проблемы соединения ошибками, поэтому обе ошибки в эхо-цикле проверяются и обе заканчивают хендлер через return.

### Почему 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-событиями. Общий журнал на несколько соединений потребует мьютекс — тема седьмого урока.

### Сколько сообщений в журнале

```go
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))
        }
}
```

**Что выведет программа?**

- ✅ 3
- 2
- 1
- 4

Три итерации — три добавления, лимит из десяти не сработал ни разу, поэтому на третьем сообщении в журнале три элемента, включая само слово history: оно тоже append-ится, и только потом проверяется. Отправка команды клиенту при этом не эхо-копируется из-за continue в настоящем цикле сервера.

> **Одна горутина-писатель — правило gorilla.** Писать в conn из двух горутин одновременно нельзя: кадры перемешаются на середине, соединение умрёт протокольной ошибкой. В эхо-сервере пишет ровно одна горутина — читатель, и это безопасно. Как только появится второй источник записей, например рассылка от другого клиента, записи пойдут через канал одной горутине-писателю: этот механизм зовётся writePump и станет уроком девятым.

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

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

- Одна на всех: соединения делят общий цикл
- ✅ Ровно пятьдесят — по одной на каждое живое соединение
- Ни одной: циклы выполняются в браузерах
- Зависит от размера сообщений

Модель net/http — горутина на соединение, и после апгрейда она превращается в цикл чтения. Пятьдесят живых соединений — пятьдесят горутин в ожидании кадров; память под них измеряется килобайтами, поэтому тысячи клиентов для Go — не событие.

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

Разберём, что именно прилетает в 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-сервер не поднимет; полный разбор браузерного клиента — в следующем уроке.

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

```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.

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

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

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

Подсказка: Чтение блокирует горутину до кадра; при ошибке соединения горутина покидает хендлер; запись повторяет кадр тем же типом сообщения.

Правильные ответы в пропусках: `ReadMessage`, `return`, `WriteMessage`

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

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

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

1. `for {`
2. `mt, msg, err := conn.ReadMessage()`
3. `if err != nil { return }`
4. `err = conn.WriteMessage(mt, msg)`
5. `if err != nil { return } // запись тоже рвётся`
6. `}`

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

- **ReadMessage** — Блокирующее чтение следующего сообщения: тип кадра (1 или 2), []byte и ошибка. Ошибка означает конец соединения — читать дальше нельзя.
- **WriteMessage** — Отправка одного сообщения: тип и байты уходят одним кадром. Из двух горутин одновременно писать нельзя — понадобится канал и writePump.
- **TextMessage** — Тип кадра 1: текст в UTF-8. Браузер отдаёт его готовой строкой в event.data.
- **BinaryMessage** — Тип кадра 2: байты как есть — файлы, protobuf, медиа. Перекодировок нет и быть не должно.
- **[][]byte** — Срез срезов байтов: удобный журнал, где каждый элемент — одно цельное сообщение с сохранёнными границами.
- **1006** — Код аномального закрытия, который не передаётся по сети: локальная метка обрыва без close-рукопожатия, например выдернутый кабель.

У нас есть обе половины real-time: сервер, который шлёт и слушает, и способ дотянуться до него из консоли. Дальше — [пятый урок](/courses/websocket/lessons/05-js-klient/), где соберём полноценного браузерного клиента: new WebSocket, события, readyState и то, почему send без onopen роняет код. Если установку или поля Upgrader нужно освежить, вернитесь к [третьему уроку](/courses/websocket/lessons/03-ustanovka-gorilla/); арифметика горутин из этого урока опирается на [продвинутый курс Go](/courses/go-advanced/). Напоминание о честности запуска: весь этот урок проверяется только локально — go run плюс консоль браузера, песочница TCP не поднимет. Общая карта всех двадцати пяти уроков, от heartbeat до Redis-моста, лежит на [странице курса](/courses/websocket/).

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

- Эхо-сервер: апгрейд, 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 посвящён четырнадцатый урок курса.
