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

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

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

Урок 18 из 25 ~22 мин +50 XP

WebSocket в Echo: Upgrader, hub и hijack соединения

echo websocket — тема, которую семнадцатый урок про RESTful-практики оставил за кадром намеренно: REST живёт циклом «запрос — ответ — соединение закрыто», а сегодня мы это соединение впервые не закроем. Апгрейдим обычный GET до WebSocket прямо внутри echo-хендлера, и вся правая часть этого трюка — c.Response(), c.Request() — окажется на три строки длиннее, чем в мире fiber, где contrib-пакет спрятал апгрейд за фабрикой. Сверяемся с сырыми исходниками: response.go v4.15.0 покажет, почему *echo.Response подходит на роль http.ResponseWriter для Upgrader, и каким образом echo через http.NewResponseController реализует Hijack; серверный файл gorilla/websocket v1.5.3 — какие проверки переживает рукопожатие и что отвечает клиенту на каждую ошибку; док-комментарий http.Server.Shutdown — почему graceful shutdown обходит hijacked-соединения стороной, и что делать вам. Протокол целиком — кадры, маскирование, контрол-опкоды, heartbeat — мы уже разобрали в [уроке fiber про WebSocket](/courses/fiber/lessons/16-websocket/), здесь он понадобится ровно настолько, насколько echo его касается. Зато построим мы больше, чем эхо-сервер: hub с register/unregister/broadcast, прилетает в комнату каждое новое задание из POST /api/v1/tasks, токен едет в query-параметре, потому что браузерному WebSocket заголовок Authorization не установить. Приступаем — канон на :1323 обзаводится вторым протоколом.

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

echo websocket: почему REST-канону тесно#

Восемнадцать уроков канона жили по одной схеме: запрос пришёл, хендлер собрал ответ, echo записал его в Response и закрыл соединение. Для задач, форм, вложений и кук — идеально. Но класс задач на этом ломается: клиент должен узнавать об изменениях без опроса, а сервер — держать канал в обе стороны. Когда второй пользователь создаёт задачу через POST /api/v1/tasks, ваша вкладка со списком задач об этом не знает — пока не дёрнет listTasks снова. WebSocket закрывает ровно это: после специального HTTP-рукопожатия соединение не умирает, а превращается в постоянный двунаправленный канал с собственным форматом кадров поверх TCP.

Запрос «websocket go» выдаёт тысячи примеров на gorilla/websocket и net/http — и echo здесь честный середняк: собственного ws-слоя в фреймворке нет, cookbook предлагается брать gorilla и склеивать его с контекстом руками. Это не слабость, а последовательность философии из первого урока: echo — тонкая типизированная обёртка над net/http, и всё, что net/http умеет отдавать наружу, echo отдаёт как есть. В fiber мы собирали ту же конструкцию через contrib-фабрику websocket.New — урок fiber про WebSocket разобрал её от заголовков апгрейда до комнат; сегодня та же цель, но инструмент — голый Upgrader поверх c.Response() и c.Request(), и вы своими глазами увидите каждый шаг, который в fiber был спрятан.

План урока трёхслойный. Первый — транспорт: что *echo.Response умеет такого, чего не умеет обычный http.ResponseWriter-обёрточный слой, и что делает Hijack. Второй — протокол на стороне сервера: Upgrader из gorilla с его проверками, ошибками и 101 Switching Protocols, написанный в response-файле literally байтовый ответ. Третий — практика: эхо-эндпоинт на /api/v1/ws, аутентификация запросом, который не умеет заголовки, hub для broadcast задач и shutdown, который hijacked-соединения не трогает — проверено по доке net/http.

c.Response() как http.ResponseWriter: поле Writer и два интерфейса#

Upgrader.Upgrade из gorilla требует два аргумента: http.ResponseWriter и http.Request. В хендлере fiber их добывали через c.Context() и c.Request() — с оговорками, потому что fasthttp-обёртка подменяет стёк. В echo всё проще: c.Response() возвращает echo.Response, а c.Request() — обычный *http.Request, и первый вопрос урока — почему echo.Response можно передать в Upgrade напрямую, хотя сигнатура ждёт http.ResponseWriter.

Ответ читается в response.go v4.15.0: *echo.Response реализует интерфейс http.ResponseWriter — методы Header, Write, WriteHeader на месте — плюс два расширяющих интерфейса, http.Flusher и http.Hijacker. Второй и есть ключ к апгрейду: внутри gorilla идёт тип-ассершн h, ok := w.(http.Hijacker), и если обёртка не умеет Hijack — рукопожатие упадёт с ошибкой «websocket: response does not implement http.Hijacker». У echo.Response такой метод есть, и он целиком делегирован в стандартную библиотеку через http.NewResponseController — современный (Go 1.20+) способ добраться до Flush и Hijack сквозь любые слои обёрток.

Дальше интереснее. После апгрейда gorilla пишет 101 и все кадры МИНУЯ echo.Response: она делает h.Hijack(), забирает сырой net.Conn и общается с сокетом напрямую. echo об этом узнаёт только постфактум: поле Status остаётся нулевым, Committed — false, Size — нулевым, и любой хендлер ниже по цепочке, который попробует что-то записать через c.JSON, столкнётся с обёрткой, считающей ответ не начатым. Что при этом произойдёт в net/http — разберём в отдельном predict, потому что там спрятана самая коварная ловушка урока. Но сначала — сырые строки response.go.

response.go v4.15.0: Writer, WriteHeader, Hijack

GO
// response.go, v4.15.0 — *echo.Response
// Response wraps an http.ResponseWriter and implements its interface to be used
// by an HTTP handler to construct an HTTP response.
type Response struct {
    Writer      http.ResponseWriter
    echo        *Echo
    beforeFuncs []func()
    afterFuncs  []func()
    Status      int
    Size        int64
    Committed   bool
}

// WriteHeader sends an HTTP response header with status code. If WriteHeader is
// not called explicitly, the first call to Write will trigger an implicit
// WriteHeader(http.StatusOK). Thus explicit calls to WriteHeader are mainly
// used to send error codes.
func (r *Response) WriteHeader(code int) {
    if r.Committed {
        r.echo.Logger.Warn("response already committed")
        return
    }
    r.Status = code
    for _, fn := range r.beforeFuncs {
        fn()
    }
    r.Writer.WriteHeader(r.Status)
    r.Committed = true
}

// Flush implements the http.Flusher interface to allow an HTTP handler to flush
// buffered data to the client.
func (r *Response) Flush() {
    err := http.NewResponseController(r.Writer).Flush()
    if err != nil && errors.Is(err, http.ErrNotSupported) {
        panic(fmt.Errorf("echo: response writer %T does not support flushing (http.Flusher interface)", r.Writer))
    }
}

// Hijack implements the http.Hijacker interface to allow an HTTP handler to
// take over the connection.
func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    return http.NewResponseController(r.Writer).Hijack()
}

// Unwrap returns the original http.ResponseWriter.
// ResponseController can be used to access the original http.ResponseWriter.
func (r *Response) Unwrap() http.ResponseWriter {
    return r.Writer
}

Четыре факта, из которых складывается весь урок. Первый: Writer — экспортируемое поле http.ResponseWriter, и после апгрейда сырой сокет не прячется в приватные поля — echo просто больше не владеет им. Второй: WriteHeader охраняется флагом Committed — повторный вызов не паникует, а пишет Warn «response already committed» и выходит; это самая громкая строка файла, и она ещё выстрелит. Третий: Hijack реализует сигнатуру http.Hijacker — (net.Conn, *bufio.ReadWriter, error) — и делегирует в http.NewResponseController(r.Writer).Hijack(): ResponseController пробирается сквозь обёртки по цепочке Unwrap, и тот факт, что у echo.Response метод Unwrap есть, гарантирует, что оригинальный writer из net/http будет найден. Четвёртый: Flush через ResponseController паникует с текстом «echo: response writer %T does not support flushing», если нижний writer не умеет Flusher — для WebSocket это неважно, но ровно на том же механизме стоит следующий урок про SSE, запомните панику. Заметка про историю: в старых выпусках echo Response имел приватное поле writer и собственную логику Hijack; начиная с v4.13 механизм переведён на ResponseController, и v4.15.0 — та версия, по которой мы сверяемся.

Hijack: что именно забирает gorilla у net/http#

Hijack — это передача владения сокетом от http.Server вашему хендлеру. Сервер до этого момента ведёт соединение: парсит запрос, выставляет таймауты, управляет keep-alive, умеет записать ответ. Hijack возвращает net.Conn и *bufio.ReadWriter — буферизованную пару поверх сокета, в которой, возможно, ещё лежат непрочитанные байты, — и с этого момента сервер перестаёт что-либо делать с соединением: не закроет, не завершит по таймауту, не узнает о его смерти.

Gorilla проверяет пару вещей сразу после Hijack, и обе — про буфер, который сервер оставил после себя. Первая: brw.Reader.Buffered() > 0 — клиент умудрился отправить данные до завершения рукопожатия, и gorilla закрывает сокет с ошибкой «websocket: client sent data before handshake is complete». Вторая — оптимизация: если собственный буфер чтения сервера достаточно велик, gorilla переиспользует его как буфер соединения, чтобы не аллоцировать лишний. Всё это — детали, которых вы не увидите, пока не прочитаете server.go, а ошибка «client sent data before handshake» в проде выглядит загадочно ровно до этого момента.

И последнее свойство hijacked-соединения, которое формулирует сама net/http: сервер «does not even know about» такие соединения — док-комментарий Server.Close говорит это буквально: «Close does not attempt to close (and does not even know about) any hijacked connections, such as WebSockets». Не знает — значит не закроет ни при Close, ни при Shutdown. К чему это приводит при остановке приложения — отдельная секция урока, а пока перейдём к тому, кто и в каком порядке проверяет сам апгрейд.

Проверьте себя: Hijacker на *echo.Response

gorilla выполняет тип-ассершн h, ok := w.(http.Hijacker) на том объекте, который вы передали как первый аргумент Upgrade. Вы вызвали upgrader.Upgrade(c.Response(), c.Request(), nil). Что вернёт ассершн?

server.go v1.5.3: Upgrader целиком

GO
// server.go, gorilla/websocket v1.5.3
// Upgrader specifies parameters for upgrading an HTTP connection to a
// WebSocket connection.
//
// It is safe to call Upgrader's methods concurrently.
type Upgrader struct {
    // HandshakeTimeout specifies the duration for the handshake to complete.
    HandshakeTimeout time.Duration

    // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If
    // a buffer size is zero, then buffers allocated by the HTTP server are
    // used. The I/O buffer sizes do not limit the size of the messages that
    // can be sent or received.
    ReadBufferSize, WriteBufferSize int

    // WriteBufferPool is a pool of buffers for write operations. If the value
    // is not set, then write buffers are allocated to the connection for the
    // lifetime of the connection.
    WriteBufferPool BufferPool

    // Subprotocols specifies the server's supported protocols in order of
    // preference.
    Subprotocols []string

    // Error specifies the function for generating HTTP error responses. If
    // Error is nil, then http.Error is used to generate the HTTP response.
    Error func(w http.ResponseWriter, r *http.Request, status int, reason error)

    // CheckOrigin returns true if the request Origin header is acceptable. If
    // CheckOrigin is nil, then a safe default is used: return false if the
    // Origin request header is present and the origin host is not equal to
    // request Host header.
    //
    // A CheckOrigin function should carefully validate the request origin to
    // prevent cross-site request forgery.
    CheckOrigin func(r *http.Request) bool

    // EnableCompression specify if the server should attempt to negotiate per
    // message compression (RFC 7692).
    EnableCompression bool
}

Восемь полей — и каждое станет строчкой продакшн-конфига в конце урока, но три стоят разбора сразу. HandshakeTimeout ставит дедлайн на весь рукопожатный обмен: gorilla выставит write deadline на сокет и снимет его после записи 101. ReadBufferSize и WriteBufferSize управляют аллокациями, и док честно снимает главный миф: размеры буферов НЕ ограничивают размер сообщений — за это отвечает отдельный SetReadLimit, о котором fiber-урок уже говорил. CheckOrigin — ваш единственный барьер против cross-site апгрейда: если поле nil, работает дефолт «origin есть и не равен Host — отказ», а док-комментарий отдельно предупреждает: «A CheckOrigin function should carefully validate the request origin to prevent cross-site request forgery». Строка «It is safe to call Upgrader's methods concurrently» разрешает один общий Upgrader на всё приложение — заводите его пакетной переменной, а не в хендлере, это дешевле и безопаснее для буферов.

Проверки Upgrade: что и на что отвечает сервер#

ПроверкаСтатусОшибка в HandshakeError
Connection содержит токен upgrade400websocket: the client is not using the websocket protocol: 'upgrade' token not found in 'Connection' header
Upgrade содержит токен websocket400websocket: the client is not using the websocket protocol: 'websocket' token not found in 'Upgrade' header
Метод запроса GET405websocket: the client is not using the websocket protocol: request method is not GET
Sec-WebSocket-Version = 13400websocket: unsupported version: 13 not found in 'Sec-WebSocket-Version' header
Origin принят CheckOrigin403websocket: request origin not allowed by Upgrader.CheckOrigin
Sec-WebSocket-Key — base64 от 16 байт400websocket: not a websocket handshake: 'Sec-WebSocket-Key' header must be Base64 encoded value of 16-byte in length
writer реализует http.Hijacker500websocket: response does not implement http.Hijacker

101 Switching Protocols: ответ, написанный вручную#

Если все проверки пройдены, gorilla делает то, чего от HTTP-фреймворка ждать непривычно: собирает ответ как сырую строку байтов и пишет её в сокет, минуя обычный механизм ответа. В server.go это один append: префикс "HTTP/1.1 101 Switching Protocols", затем "Upgrade: websocket", "Connection: Upgrade", вычисленный Sec-WebSocket-Accept — хеш от клиентского ключа, знакомый вам по fiber-уроку, — затем субпротокол и расширения, затем всё из переданного responseHeader, затем пустая строка. Два согласования протокола echo в этой строке не участвуют вообще: к моменту записи сокет уже hijacked, echo.Response ничего не знает о 101.

Отсюда два правила, которые новичкам кажутся странными. Первое: после успешного Upgrade нельзя отвечать HTTP-методами — ни c.JSON, ни return echo.NewHTTPError, ни WriteHeader. Ответ на этот запрос уже целиком отправлен, и это не HTTP-ответ в смысле echo. Второе: строка netConn.SetDeadline(time.Time{}) сразу после сборки ответа — gorilla снимает таймауты, которые http.Server навесил на соединение, иначе keep-alive-дедлайны сервера убили бы долгоживущий ws-канал через минуты. Сокет начинает новую жизнь вне юрисдикции net/http.

И ещё одна граница — между ошибкой рукопожатия и успехом. Провал любой проверки из таблицы выше — это нормальный HTTP-ответ: gorilla сама пишет статус и текст через returnError, а хендлер получает (*Conn)(nil) и ошибку HandshakeError. То есть если клиент прислал не тот заголовок, вы вправе вернуть ошибку из хендлера — echo залогирует её как обычную 4xx. Всё, что после успешного апгрейда, — уже не HTTP, и правила игры меняются немедленно.

Прогноз: обычный GET на ws-маршрут

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

// маршрут: v1.GET("/ws", serveEcho)
// клиент: curl -H "Authorization: Bearer dev-token-42" localhost:1323/api/v1/ws

func serveEcho(c echo.Context) error {
    ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
    if err != nil {
        return err
    }
    defer ws.Close()
    // ...
}

Канон: ws-эндпоинт /api/v1/ws рядом с задачами

GO
// main.go — апгрейд через echo-контекст: эхо-сервер на /api/v1/ws
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return r.Host == "localhost:1323" // в проде — белый список origin-ов
    },
}

// браузерный WebSocket не умеет ставить заголовок Authorization,
// поэтому токен едет в query: ws://localhost:1323/api/v1/ws?token=dev-token-42
func wsAuth(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if c.QueryParam("token") != "dev-token-42" {
            return echo.NewHTTPError(http.StatusUnauthorized, "нужен ?token=")
        }
        c.Set("user", "alice") // как bearerAuth из восьмого урока
        return next(c)
    }
}

func serveEcho(c echo.Context) error {
    ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
    if err != nil {
        return err // HandshakeError: ответ 4xx уже отправлен returnError-ом
    }
    defer ws.Close()
    ws.SetReadLimit(4096) // потолок входящего сообщения

    for {
        mt, msg, err := ws.ReadMessage()
        if err != nil {
            return nil // close/ping/pong обрабатывает библиотека, цикл завершён
        }
        if mt == websocket.TextMessage {
            out := append([]byte("echo: "), msg...)
            if err := ws.WriteMessage(websocket.TextMessage, out); err != nil {
                return nil // писать в мёртвый сокет нечестно — выходим
            }
        }
    }
}

// регистрация рядом с задачами:
//   go hub.run() // скоро
//   v1.GET("/ws", serveEcho, wsAuth)
//   v1.POST("/tasks", createTask)
//
// wscat -c "ws://localhost:1323/api/v1/ws?token=dev-token-42"
// > привет
// < echo: привет

Три строки интеграции — upgrader.Upgrade(c.Response(), c.Request(), nil) — и вы в ws-мире; всё остальное — gorilla, знакомый по fiber-уроку: SetReadLimit против гигабайтных кадров, ReadMessage, возвращающий только сообщения данных, обработка ошибок чтения как сигнала к выходу. Обратите внимание на wsAuth: полноценный bearerAuth из восьмого урока здесь не годится не потому, что сломан, а потому, что браузерному WebSocket API нельзя указать произвольный заголовок Authorization — токен приходится класть в query-параметр, а это значит, что он попадёт в логи прокси и access-логи, и в проде токен для ws обязан быть краткоживущим. Middleware при этом остаётся обычным echo-слоем: wsAuth вписан третьим аргументом v1.GET и работает до хендлера, до апгрейда — отказ 401 уходит как обычный JSON-конверт, а не как ws-ошибка. CheckOrigin здесь сравнивает r.Host с localhost:1323: Host — заголовок запроса, Origin — то, откуда запущена страница; на локалке совпадают, в проде список origin-ов придётся вести явно. И да — переменная out, а не echo: затирать имя пакета внутри его же курса — плохая примета.

Ошибка после 101: что произойдёт, если хендлер не промолчал#

В коде выше после успешного апгрейда хендлер возвращает nil во всех ветках — и это не перестраховка, а точное следствие механики. Проследим, что случится, если вернуть echo.NewHTTPError(http.StatusInternalServerError, "упало") из хендлера, чьё соединение уже апгрейднуто. e.ServeHTTP получает ошибку и зовёт e.HTTPErrorHandler(err, c); тот формирует JSON-конверт и вызывает c.JSON(500, ...) — то есть WriteHeader(500) на *echo.Response.

А теперь самое интересное: Committed у echo.Response всё ещё false. gorilla писала 101 напрямую в сокет через Hijack — echo-обёртку она не касалась, поэтому WriteHeader(500) пройдёт свою внутреннюю проверку и вызовет r.Writer.WriteHeader(500) на исходном http.ResponseWriter из net/http. И вот тут в игру вступает защита стандартной библиотеки: в server.go net/http метод WriteHeader начинается с проверки — if w.conn.hijacked() { ... logf("http: response.WriteHeader on hijacked connection from %s (%s:%d)") ... return }. Запись отклоняется, в лог уходит предупреждение с местом вызова, а телу вообще некуда ехать: Write на hijacked-соединении возвращает 0 и ErrHijacked с той же записью в лог.

Итог спокойный, но шумный: ни падения, ни порчи ws-канала — соединение продолжает жить, и его кадры идут как ни в чём не бывало, зато каждое такое событие мусорит лог строчкой «response.WriteHeader on hijacked connection». В тестах и на локалке это безобидно, в проде — сигнал, что хендлер перепутал миры. Правило урока: после успешного Upgrade хендлер возвращает nil или ошибку только для лога, но никогда не рассчитывает, что кто-то отправит по ней HTTP-ответ.

Проверьте себя: возвращённая ошибка после апгрейда

Хендлер после успешного upgrader.Upgrade вернул echo.NewHTTPError(500, "oops"). Что вы увидите?

Hub: комната ws-клиентов для задач канона#

Эхо-сервер красив, но бесполезен: он отвечает только тому, кто спросил. Настоящая потребность канона — уведомлять всех открытых вкладки /tasks о том, что кто-то создал задачу. Классическая конструкция для этого называется hub, и эталонная версия лежит прямо в gorilla — examples/chat/hub.go, тот самый файл, который fiber-урок упоминал вскользь. Эго структура — четыре поля: клиенты картой, три канала: register, unregister, broadcast. Единый цикл run() в отдельной горутине select-ит их и исполняет единственные операции над картой — добавление, удаление, рассылку. Ни одно другое место программы не трогает clients напрямую: синхронизация достигается не мьютексом, а владением данными одной горутиной — тот же принцип, что мы проходили в базовом курсе про каналы, только здесь он защищает живые сокеты.

Рассылка в эталоне содержит гениальную деталь против медленных клиентов: send у клиента — БУФЕРИЗОВАННЫЙ канал, и в цикле broadcast стоит select с веткой default: если буфер переполнен — клиент слишком медленный, и его не ждут, а исключают (close(client.send) и delete). Прогноз ниже заставит вас проследить это до конца. Вторая деталь — удаление: unregister-ветка проверяет наличие клиента в карте перед close, потому что close закрытого канала — паника, и hub обязан быть идемпотентным к двойным unregister-ам.

Для канона мы адаптируем эталон без секретов: клиенты — *websocket.Conn, broadcast-канал типизируем Task — новые задачи из POST /api/v1/tasks будут уезжать всем подписчикам. Роли строго разделены: хендлер ws-маршрута ТОЛЬКО читает из сокета, hub ТОЛЬКО пишет — закон одного читателя и одного писателя из fiber-урока соблюдён архитектурно, без мьютексов.

Канон: hub задач на :1323

GO
// hub.go — register/unregister/broadcast по мотивам gorilla examples/chat
type Hub struct {
    clients    map[*websocket.Conn]bool
    broadcast  chan Task            // входящие события: новые задачи
    register   chan *websocket.Conn
    unregister chan *websocket.Conn
}

func newHub() *Hub {
    return &Hub{
        clients:    make(map[*websocket.Conn]bool),
        broadcast:  make(chan Task),
        register:   make(chan *websocket.Conn),
        unregister: make(chan *websocket.Conn),
    }
}

func (h *Hub) run() {
    for {
        select {
        case ws := <-h.register:
            h.clients[ws] = true
        case ws := <-h.unregister:
            if _, ok := h.clients[ws]; ok {
                delete(h.clients, ws)
            }
        case t := <-h.broadcast:
            data, _ := json.Marshal(t)
            for ws := range h.clients {
                // буфер send-канала из эталона; здесь пишем напрямую,
                // ошибка = клиент мёртв: исключаем, не ждём
                if err := ws.WriteMessage(websocket.TextMessage, data); err != nil {
                    delete(h.clients, ws)
                    ws.Close()
                }
            }
        }
    }
}

// serveEcho меняет одну строку: при подключении клиент встаёт в очередь
//   hub.register <- ws
//   defer func() { hub.unregister <- ws }()

func createTask(c echo.Context) error {
    var t Task
    if err := c.Bind(&t); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
    }
    t.ID = nextID()
    tasks = append(tasks, t)
    hub.broadcast <- t // событие уезжает всем открытым ws-клиентам
    return c.JSON(http.StatusCreated, t)
}

run() — дословный select из hub.go gorilla: register добавляет в карту, unregister удаляет с проверкой наличия (защита от двойного close — в эталоне ещё и close(client.send), который у нас не нужен, потому что send-канала нет), broadcast сериализует Task и рассылает. Разница с эталоном одна и принципиальна: gorilla пишет через буферизованный send-канал каждого клиента, а у нас WriteMessage зовётся из run() напрямую — это честный упрощённый вариант при условии, что больше НИКТО не пишет в эти сокеты: serveEcho после апгрейда только читает. Если обе роли нужны — возвращайте send-канал с writePump-ом, как в эталоне и fiber-уроке. createTask остаётся обычным REST-хендлером семнадцатого урока: 201 и JSON — плюс одна строка hub.broadcast <- t; блокирующий канал без буфера — осознанный выбор: пока рассылка не закончена, POST не ответит, и две createTask не перемешают рассылки. Запуск: hub := newHub(); go hub.run() до e.Start.

Прогноз: медленный клиент на broadcast

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

// вариант hub из эталона gorilla (examples/chat/hub.go):
case message := <-h.broadcast:
    for client := range h.clients {
        select {
        case client.send <- message:
            // буфер send-канала: send = make(chan []byte, 256)
        default:
            close(client.send)
            delete(h.clients, client)
        }
    }

// клиент A читает свои сообщения, клиент B завис (сеть умерла без close).
// в broadcast пришло 300 сообщений подряд. что произойдёт с B?

Соберите run() из эталона

Восстановите единый цикл hub: каждый канал — своя ветка, удаление — с проверкой наличия, рассылка — без ожидания медленных клиентов.

func (h *Hub) run() { for { select { case client := <-h.: h.clients[client] = true case client := <-h.: if _, ok := h.clients[client]; ok { delete(h.clients, client) close(client.send) } case message := <-h.: for client := range h.clients { select { case client.send <- message: : close(client.send) delete(h.clients, client) } } } } }

Соберите путь запроса до первого кадра

Расставьте события по порядку: от GET-запроса браузера до первого сообщения в канале — маршрутизация до апгрейда, токен до Upgrade, кадры после.

Строки

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

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

Graceful shutdown: hijacked-соединения никто не закроет#

Канон на :1323 останавливают через e.Shutdown(ctx) — обёртку над http.Server.Shutdown, как выстроено в fiber-курсе и будет в echo-уроке про деплой. Для обычных HTTP-соединений Shutdown идеален: перестать принимать новые, дождаться текущих, выйти. Но у нас теперь есть класс соединений, о которых док-комментарий Server.Shutdown говорит прямо: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets. The caller of Shutdown should separately notify such long-lived connections of shutdown and wait for them to close, if desired. See [Server.RegisterOnShutdown] for a way to register shutdown notification functions».

Переводим на наш случай: после e.Shutdown(ctx) HTTP-трафик уйдёт корректно, а ws-клиенты останутся подключенными к мёртвому процессу — и процесс не завершится, пока их кто-то не закроет, потому что горутины с ReadMessage всё ещё живы. Стандартная библиотека даёт ровно один крючок: Server.RegisterOnShutdown(fn) — функция будет вызвана при Shutdown. Echo хранит http.Server публично, e.Server, так что после e := echo.New() доступно e.Server.RegisterOnShutdown(...).

Для hub-канона схема получается трёхходовая: регистрируем коллбек, который закрывает канал done; в run() добавляем ветку case <-done: закрыть все conn-ы и опустошить карту; в serveEcho после закрытия сокета ReadMessage вернёт ошибку, хендлер выйдет, unregister-строка defer-ом почистит карту — и горутины остановятся. Теперь Shutdown дожидается всех: и HTTP-запросов, и ws-клиентов, получивших честное уведомление. Без этой ветки у вас будет классический симптом: e.Shutdown повис до контекстного дедлайна, хотя обычных запросов давно нет.

Канон: выключение с уведомлением ws-клиентов

GO
// main.go — shutdown, который умеет hijacked-соединения
func main() {
    e := echo.New()
    e.Use(middleware.RequestID(), middleware.Recover())

    hub := newHub()
    done := make(chan struct{})
    e.Server.RegisterOnShutdown(func() { close(done) }) // крючок из net/http
    go hub.run(done)

    // ... маршруты: v1.GET("/ws", serveEcho, wsAuth), v1.POST("/tasks", createTask)

    go func() {
        if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
            e.Logger.Fatal(err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := e.Shutdown(ctx); err != nil {
        e.Logger.Error(err)
    }
}

// в run() появляется ветка:
//   case <-done:
//       for ws := range h.clients {
//           ws.Close()
//           delete(h.clients, ws)
//       }

Порядок важен: RegisterOnShutdown вызывается до e.Start, иначе Shutdown пройдёт без коллбека — проверьте это в своих проектах, ошибка тихая. close(done) в коллбеке разбудит все select-ы run()-ов; закрытие сокетов ws.Close() даст ReadMessage в каждом хендлере ошибку — их горутины выйдут, defer-ы отправят unregister (получившему close(done) hub уже всё равно — карта пуста, а мёртвые register-каналы просто не прочитаются; для идеальной чистоты serveEcho перед unregister-ом проверяет select на done). Ветку err != http.ErrServerClosed при e.Start тоже держите: без неё штатная остановка падает в Logger.Fatal — ErrServerClosed сервер возвращает всегда, это сигнал нормального завершения Serve. Дедлайн 10 секунд — страховка: если какой-то ws-клиент игнорирует закрытие и горутина не выходит, процесс всё равно завершится по ctx.

Продакшн-минимум: таймауты, буферы, wss и прокси#

Соберём конфиг Upgrader, который переживёт не только локалку. HandshakeTimeout: 10 * time.Second — рукопожатие обязано завершаться, иначе полуоткрытые соединения копятся. ReadBufferSize/WriteBufferSize: дефолтные нули используют буферы сервера — норм для начала; WriteBufferPool типа BufferPool экономит аллокации, когда клиентов много, а записей в меру, и заводится один на приложение — док-комментарий прямо требует уникальный пул на каждое значение размера буфера. SetReadLimit — не поле Upgrader, а вызов на соединении, но без него endpoint не считается готовым: 4096 в каноне, дальше — close с кодом 1009, знакомым по fiber-уроку. EnableCompression — permessage-deflate с server_no_context_takeover; включайте осознанно: сжатие меж доменных ws-соединений в прошлом было источником атак BREACH-семейства.

Транспорт: wss:// — тот же ws поверх TLS. На локалке достаточно ws://localhost, в проде TLS терминирует прокси, и апгрейд должен доехать до echo нетронутым — заголовки Upgrade и Connection — hop-by-hop, их обязан переписать каждый прокси; nginx-конфигурация для этого разобрана в fiber-уроке про деплой и вернётся в двадцать четвёртом уроке этого курса, когда мы будем деплоить echo — там же проверим, что прокси не буферизует и не рвёт долгоживущие соединения.

И последний штрих, ради которого стоит перечитать Upgrader: Subprotocols — список поддерживаемых субпротоколов «in order of preference»; клиент в заголовке Sec-WebSocket-Protocol предлагает свои, сервер выбирает первый пересекающийся. Если ваш ws-канал несёт не JSON-чат, а конкретный протокол — скажем, graphql-ws — именно здесь место договорённости, а не в каждом сообщении.

Карточки: WebSocket в echo

Термин

Интеграция Upgrader с echo-контекстом

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

upgrader.Upgrade(c.Response(), c.Request(), nil): *echo.Response реализует http.ResponseWriter (Header/Write/WriteHeader) и http.Hijacker (Hijack() через http.NewResponseController(r.Writer).Hijack(), v4.13+, Unwrap отдаёт оригинальный writer). Провал рукопожатия — нормальный HTTP-ответ 400/403/405 от returnError + HandshakeError хендлеру.

1 / 6

Что дальше: поток в одну сторону#

Канон научился разговаривать в обе стороны: апгрейд через Upgrader поверх c.Response() с честным Hijack, handshake-таблица с семью проверками, hub, куда прилетают новые задачи из POST /api/v1/tasks, и shutdown, который hijacked-соединения уведомляет, а не бросает. Все строки сверены с response.go v4.15.0, server.go gorilla v1.5.3 и server.go net/http.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go, откройте две вкладки с клиентом и убедитесь, что задача, созданная в одной, уезжает в обе.

Следующий, девятнадцатый урок — Server-Sent Events: тот же результат «сервер знает раньше клиента», но без апгрейда, без hijack и только в одну сторону. c.Stream и c.Response() с Flush, heartbeat, именованные события task.created и reconnect с Last-Event-ID — и вы сможете осознанно выбирать между ws и SSE вместо случайного выбора. Навигация по курсу — на странице курса Echo; группы и версии, внутри которых живёт ws-маршрут, — в уроке про группы; слои, которые ws-запрос проходит как обычный GET, — в уроке про кастомные middleware.

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

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

Вывод

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

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

  • Echo не имеет своего ws-слоя: берём gorilla/websocket и зовём upgrader.Upgrade(c.Response(), c.Request(), nil). *echo.Response реализует http.ResponseWriter и http.Hijacker — Hijack() (net.Conn, *bufio.ReadWriter, error) делегирован в http.NewResponseController(r.Writer).Hijack() (response.go v4.15.0, механизм v4.13+ с Unwrap).
  • Провал рукопожатия — обычный HTTP-мир: returnError пишет 400/403/405 (таблица семи проверок server.go v1.5.3) и ставит Sec-Websocket-Version: 13, хендлер получает HandshakeError. badHandshake = «websocket: the client is not using the websocket protocol: ».
  • Успешный апгрейд: Hijack забирает сокет, gorilla пишет 101 сырыми байтами и снимает дедлайны сервера — echo об этом не знает: Committed=false. Ошибка из хендлера после 101 даст в лог «http: response.WriteHeader on hijacked connection», клиент не получит ничего; правило — после 101 возвращать nil.
  • Браузерный WebSocket не может ставить заголовок Authorization: токен уезжает в query (?token=...) и попадает в логи — для прода краткоживущий одноразовый токен. wsAuth — обычный echo-слой до апгрейда; ws-запрос проходит через весь middleware-стек как GET.
  • Hub (эталон gorilla examples/chat): клиенты картой, каналы register/unregister/broadcast, один run() select-ом; рассылка через буферизованный send с веткой default — медленный клиент исключается close+delete, рассылка не блокируется. В каноне: broadcast типизирован Task, createTask шлёт hub.broadcast <- t, хендлер ws только читает.
  • Shutdown: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets» — уведомление через e.Server.RegisterOnShutdown(func(){ close(done) }), ветка case <-done в run() закрывает conn, ReadMessage завершает хендлеры. RegisterOnShutdown — до e.Start; ErrServerClosed от e.Start — норма.
  • Продакшн-минимум: HandshakeTimeout, SetReadLimit (без него — память под гигантский кадр), CheckOrigin с белым списком origin-ов (дефолт — same-origin), WriteBufferPool один на приложение, wss через TLS-прокси с переписыванием hop-by-hop заголовков Upgrade/Connection; Subprotocols — «in order of preference».

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

Соберите канон урока: /api/v1/ws с wsAuth (?token=dev-token-42), эхо-цикл с SetReadLimit(4096), hub задач с broadcast из createTask. Первое: откройте два wscat-подключения и создайте задачу curl-ом — POST /api/v1/tasks с JSON {"title":"..."} и bearer-токеном; убедитесь, что оба ws-клиента получили Task JSON, а curl получил 201. Второе: отправьте в ws сообщение длиннее 4096 байт и поймайте close — проверьте код 1009 и текст ErrReadLimit в логе. Третье: замените CheckOrigin на функцию, разрешающую только Host localhost:1323, и попробуйте апгрейд с wscat без Origin — убедитесь, что он проходит (Origin пуст — checkSameOrigin вернёт true), затем подделайте Origin http://evil.example и получите 403. Четвёртое: добавьте RegisterOnShutdown с close(done) и ветку case <-done в run(); поднимите сервер, подключите два wscat, отправьте SIGTERM и убедитесь, что оба клиента получили close, а процесс завершился быстрее дедлайна ctx. Пятое: верните echo.NewHTTPError(500) из хендлера после апгрейда и найдите в логе строчку «http: response.WriteHeader on hijacked connection» — объясните по server.go net/http, почему клиент не получил ответ.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · WebSocket в Echo: Upgrader, hub и hijack соединения)

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

Почему в echo нет своего WebSocket-модуля, как у fiber — websocket.New?

Философия echo — минимальная обёртка над net/http, а апгрейд — это пару строк над c.Response()/c.Request(), которые и так доступны. Fiber технологически иначе: fasthttp не совместим с net/http, поэтому contrib-фабрика websocket.New с конфигом там — необходимый мост. На gorilla оба фреймворка дают одинаковый протокол и одинаковые сообщения об ошибках — отличается только точка входа: в echo сигнатуры net/http сохранены, в fiber за апгрейд отвечает стек.

Можно ли защитить ws-маршрут bearerAuth из восьмого урока?

Для не-браузерных клиентов — да: любой ws-клиент может поставить заголовок Authorization, и bearerAuth сработает как на обычном GET. Для браузера — нет: API WebSocket не позволяет задать заголовки, поэтому токен передают в query-параметре или в Sec-WebSocket-Protocol, и это должен быть отдельный краткоживущий токен (его увидят в access-логах прокси). Правильный гибрид: короткоживущий одноразовый токен в query на апгрейде, дальше сессия — внутри ws-кадров.

Почему процесс не завершается после e.Shutdown, хотя HTTP-запросов больше нет?

Потому что hijacked-соединения Shutdown не закрывает и не ждёт — но и горутины ваших ws-хендлеров никто не остановил, а процесс живёт, пока живы горутины. Закройте их сами: e.Server.RegisterOnShutdown с сигналом (close(done)), ветка case <-done в hub.run, ws.Close() на всех клиентах — ReadMessage вернёт ошибку, хендлеры выйдут, defer-ы почистят карту. После этого Shutdown вернётся, а не отработает по дедлайну контекста.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload