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

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

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

Урок 19 из 25 ~20 мин +50 XP

Проксирование и стриминг ответов

Пора пропустить чужие ответы через себя: фронтенду нужен единый адрес с одним TLS-сертификатом, бэкендам — независимые релизы, а вам — место, где [звенья из урока про middleware](/courses/gin/lessons/06-middleware/) стоят перед чужим трафиком. Обратный прокси на gin — это обычный хендлер в [группе маршрутов из урока 10](/courses/gin/lessons/10-gruppy/), только вместо JSON он перекладывает байты: запрос уходит на бэкенд, ответ течёт обратно без единого лишнего буфера в памяти. Разберём gin прокси по-честному: httputil.ReverseProxy с современным Rewrite и легаси-Director, балансировку round-robin на атомарном счётчике, хуки ModifyResponse и ErrorHandler. Затем стриминг gin: io.Copy без накопления в памяти, c.Stream с шаговой функцией и честный SSE — text/event-stream, data:, event:, переподключение по Last-Event-ID. И грабли: буферизация на промежуточных слоях, WriteTimeout, режущий длинные потоки, и 502 без конверта, если забыть ErrorHandler. Заодно вспомним, как [лимиты из урока 18](/courses/gin/lessons/18-rate-limit/) встают перед прокси и защищают бэкенды одним звеном.

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

gin прокси: зачем фреймворку чужие ответы#

Обратный прокси — сервер, который принимает запрос, сам ходит за ответом к бэкенду и возвращает его клиенту как свой. Зачем это нужно, когда есть nginx? Во-первых, единая дверь: одно доменное имя, один TLS, а за ним хоть десять сервисов — фронтенд не хочет знать их адреса и CORS-танцы исчезают сами собой, как мы обсуждали в уроке про CORS. Во-вторых, место для логики: у nginx язык конфигов, а у вас — Go. Выбор бэкенда по клейму из JWT, тарифу пользователя или весовой доле — это код, а не config. В-третьих, агрегация: типичный BFF (backend for frontend) собирает данные с нескольких сервисов, и проксирование — его базовый приём. gin прокси здесь удобен тем, что это обычный хендлер: вокруг него работают те же группы, звенья, конверт ошибок и логирование, что и вокруг остального приложения. Важное уточнение по ресурсам: прокси не читает ответы целиком — он копирует поток из сети в сеть, поэтому бэкенд может отдать гигабайтный файл, а память прокси останется на уровне буфера в десятки килобайт. Как это устроено — тема второй половины урока.

httputil.ReverseProxy: Director и Rewrite#

Основа — httputil.ReverseProxy из стандартной библиотеки: структура с несколькими точками вмешательства. Transport — чем ходить на бэкенд (по умолчанию http.DefaultTransport); ModifyResponse — правка ответа до отправки клиенту; ErrorHandler — что ответить, когда бэкенд недоступен; FlushInterval — как часто выталкивать буферы клиенту; ErrorLog — куда жаловаться. Главный выбор — функция-маршрутизатор запроса, и тут два поколения API. Легаси-путь — Director func(http.Request): вам дают исходящий запрос, вы правите URL.Scheme, URL.Host, URL.Path — как хотите. Современный путь, с Go 1.20, — Rewrite func(httputil.ProxyRequest): в структуре лежат In (запрос клиента, только чтение) и Out (запрос к бэкенду), плюс два готовых метода: SetURL(target) проставляет схему, хост и путь цели с сохранением query, а SetXForwardedFor() заполняет заголовки X-Forwarded-For, X-Forwarded-Host и X-Forwarded-Proto. Разница глубже синтаксиса: перед Rewrite прокси стирает клиентские X-Forwarded-* — защита от подделки, а перед Director не стирает; и после Director прокси сам дописывает IP клиента к существующему X-Forwarded-For, а в режиме Rewrite ничего не ставит без вашего ведома. Шорткат NewSingleHostReverseProxy(target) собирает Director за вас: константа цели, склейка путей, сохранение query — идеально для первого знакомства.

Минимальный прокси: NewSingleHostReverseProxy

GO
package main

import (
        "net/http"
        "net/http/httputil"
        "net/url"

        "github.com/gin-gonic/gin"
)

func main() {
        target, _ := url.Parse("http://127.0.0.1:9000")

        proxy := httputil.NewSingleHostReverseProxy(target)

        r := gin.New()
        r.Use(gin.Logger(), gin.Recovery())

        // Всё с /api уходит на бэкенд, префикс отрезаем.
        r.Any("/api/*path", func(c *gin.Context) {
                c.Request.URL.Path = c.Param("path")
                proxy.ServeHTTP(c.Writer, c.Request)
        })

        r.Run(":8080")
}

NewSingleHostReverseProxy вернул структуру с готовым Director-ом: цель константа, пути склеиваются. В хендлере мы подменяем путь запроса: wildcard ловит /api/todos, а бэкенд ждёт /todos — c.Param("path") отдаёт хвост без префикса. Метод Any прокидывает все HTTP-глаголы сразу. ServeHTTP принимает стандартные ResponseWriter и Request — gin-писатель реализует первый, поэтому прокси встаёт в хендлер без обёрток.

Проверка: судьба X-Forwarded-For

Клиент шлёт X-Forwarded-For: 203.0.113.7 и попадает на NewSingleHostReverseProxy. Что окажется в этом заголовке на бэкенде?

Прокси на группе: round-robin и выбор цели#

Один бэкенд — разминочный круг. Реальный шлюз выбирает цель: реплики крутятся по кругу (round-robin), тарифный план решает, кому достанется тяжёлый запрос, апи-версия в заголовке развозит по версиям сервиса. Механика та же, но прокси теперь не одна, а массив: создаём пул один раз при старте — структуры дешёвые, а Director/Rewrite-замыкание хранит свою цель. Счётчик — atomic.Uint64: Add(1) атомарен, горутины не дерутся, а index по модулю длины пула даёт честный круг. Хендлер на группе из урока 10 принимает /v1/*path, отрезает префикс и передаёт выбранному прокси. Если нужен префикс на бэкенде — не отрезайте, SetURL склеит путь цели с хвостом. Для sticky-сессий (клиент всегда к той же реплике) ключом берут cookie из урока про сессии или hash идентификатора — round-robin ломает липкость, но для stateless-API это неважно. И помните: у каждого прокси из пула свои ErrorHandler и ModifyResponse — собирайте их фабрикой, чтобы не копипастить.

Пул прокси с Rewrite: round-robin по репликам

GO
targets := []*url.URL{
        {Scheme: "http", Host: "127.0.0.1:9001"},
        {Scheme: "http", Host: "127.0.0.1:9002"},
        {Scheme: "http", Host: "127.0.0.1:9003"},
}

var next atomic.Uint64
proxies := make([]*httputil.ReverseProxy, len(targets))
for i, t := range targets {
        t := t // захват переменной цикла — своя цель на прокси
        proxies[i] = &httputil.ReverseProxy{
                Rewrite: func(pr *httputil.ProxyRequest) {
                        pr.SetURL(t)          // схема, хост, склейка пути
                        pr.SetXForwardedFor() // XFF/XFH/XFP заново
                },
                ModifyResponse: func(resp *http.Response) error {
                        resp.Header.Set("X-Upstream", t.Host)
                        return nil
                },
                ErrorHandler: func(w http.ResponseWriter,
                        r *http.Request, err error) {
                        w.WriteHeader(http.StatusBadGateway)
                },
        }
}

r.Any("/v1/*path", func(c *gin.Context) {
        idx := int(next.Add(1)-1) % len(proxies)
        c.Request.URL.Path = c.Param("path")
        proxies[idx].ServeHTTP(c.Writer, c.Request)
})

Пул собирается на старте: три структуры, у каждой своя цель в замыкании. Rewrite — современный режим: SetURL ставит цель и склеивает путь с сохранением query, SetXForwardedFor затирает клиентские X-Forwarded-* и заполняет честными. ModifyResponse ставит отладочный X-Upstream, ErrorHandler отдаёт 502 — в бою тут будет JSON-конверт из урока про ошибки. Хендлер — три строки: выбрал реплику атомарным счётчиком, отрезал префикс, отдал прокси.

Проверка: куда уйдут пять запросов

Пул из двух реплик: 9001 и 9002. next — atomic.Uint64 с нуля; хендлер берёт idx = int(next.Add(1)-1) % len(proxies). Пять запросов подряд — куда они уйдут?

Поток без памяти: io.Copy и FlushInterval#

Почему прокси не боится гигабайтных ответов? Потому что он не читает их в память. ReverseProxy копирует тело через io.Copy: буфер 32 КиБ, прочитали — записали клиенту, забыли. Ручной хендлер повторяет ту же логику в три строки: http.Get (или клиент с таймаутами), скопировать заголовки, io.Copy из resp.Body в c.Writer. Буферизация в памяти нужна только если вы реально хотите править тело — тогда вы платите RAM-ом за размер ответа и осознанно, а не случайно. Вторая сторона — своевременность. Байты, записанные в ResponseWriter, по умолчанию не обязаны покидать процесс немедленно: сетевой слой собирает их в пакеты. Для стриминга нужен Flush — явное «выталкивай сейчас». gin-писатель реализует http.Flusher, поэтому c.Writer.Flush() доступен всегда. У ReverseProxy за это отвечает FlushInterval: с Go 1.16 он сам ставит мгновенный flush для ответов с Content-Type text/event-stream и без известной Content-Length — стриминг работает из коробки; для обычных ответов прокси не торопится, и если бэкенд шлёт данные кусками, задайте FlushInterval: 100 * time.Millisecond. Отдача файлов с диска — та же идея без сети: c.File из урока про загрузку файлов льёт их стандартным FileServer-ом.

Ручной потоковый хендлер: строка за строкой

GO
r.GET("/logs", func(c *gin.Context) {
        upstream, err := http.Get("http://127.0.0.1:9000/logs.txt")
        if err != nil {
                c.JSON(http.StatusBadGateway, gin.H{
                        "error": "upstream недоступен"})
                return
        }
        defer upstream.Body.Close()

        c.Writer.Header().Set("Content-Type",
                "text/plain; charset=utf-8")
        c.Writer.WriteHeader(upstream.StatusCode)

        sc := bufio.NewScanner(upstream.Body)
        for sc.Scan() {
                fmt.Fprintln(c.Writer, sc.Text())
                c.Writer.Flush() // строка уходит немедленно
        }
})

Статус и Content-Type берём с бэкенда — клиент узнаёт правду об апстриме. Scanner читает строку, Fprintln пишет её клиенту, Flush выталкивает — хвост лога течёт в браузер в реальном времени, память прокси при этом нулевая. Помните потолок Scanner: строка длиннее 64 КиБ уронит скан — для произвольных данных берите io.Copy с FlushInterval, для строк — bufio.Reader.ReadString.

c.Stream: шаговая функция gin#

Для кусочных ответов у gin есть готовый метод: c.Stream(step func(w io.Writer) bool) bool. Контракт простой: gin крутит цикл, на каждом обороте зовёт вашу step, а после каждого вызова сам делает Flush — выталкивание встроено, забывать не надо. step пишет в writer очередной кусок и возвращает решение: true — продолжаем, false — поток закончен. Выход из цикла происходит и без вашего участия: gin слушает CloseNotify соединения и прекращает цикл, когда клиент ушёл — сочинять свой детектор отключения не нужно. Внутри step обычно стоит select: данные из канала, тикер времени, или Context.Done() запроса — контекст отменяется при разрыве, и это второй способ узнать об уходе клиента. Шаговая модель закрывает три кейса: деградированный long-polling, генерация данных на лету (курс валют раз в секунду) и — главный кейс — SSE, до которого дойдём через блок. Против бесконечного цикла с ручным Flush плюс минус очевидны: c.Stream короче, не забывает Flush и корректно закрывается, а вы остаётесь с одной строчкой логики на шаг.

Курс валют шагом: c.Stream и тикер

GO
r.GET("/rate", func(c *gin.Context) {
        c.Writer.Header().Set("Content-Type", "text/plain")

        c.Stream(func(w io.Writer) bool {
                select {
                case <-time.After(5 * time.Second):
                        rate := fetchRate() // ход на бэкенд
                        fmt.Fprintf(w, "курс: %s\n", rate)
                        return true // следующий шаг через 5 секунд
                case <-c.Request.Context().Done():
                        return false // клиент отключился
                }
        })
})

Каждый шаг ждёт тикера, пишет строку и возвращает true — c.Stream сама делает Flush после шага, клиент получает курс каждые пять секунд. Context.Done закрывает поток при разрыве соединения. time.After внутри цикла аллоцирует таймер на шаг; для долгоживущих потоков экономнее time.NewTicker с defer ticker.Stop.

Что увидит клиент при первом false

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

c.Stream(func(w io.Writer) bool {
        fmt.Fprint(w, "раз ")
        return false
})

SSE: протокол событий текстом#

Server-Sent Events — стандарт односторонних потоков от сервера к браузеру поверх обычного HTTP. Контент-тип text/event-stream, формат — текст: строка data: несёт полезную нагрузку, event: задаёт имя события, id: — идентификатор для восстановления, retry: — интервал переподключения в миллисекундах; пустая строка означает «событие собрано, отправляй». Два data: подряд склеиваются переносом — многострочные сообщения соберутся сами. На клиенте EventSource: конструктор с URL, обработчики onmessage и addEventListener по имени события — браузер сам переподключается при обрыве, сам шлёт Last-Event-ID с последним полученным id, и сервер может дозировать пропущенное. Всё это живёт на обычном HTTP: прокси, балансировщики и gzip-уровни видят стандартный ответ, а не экзотический протокол. В gin SSE собирается из уже знакомых деталей: заголовки text/event-stream и Cache-Control: no-cache, канал с событиями, c.Stream с шагом, а c.SSEvent(event, obj) пишет форматированное событие целиком — имя, data и перевод строк по стандарту. Двусторонний обмен и бинарные данные — территория WebSocket из урока 20-websocket: там апгрейд соединения и gorilla/websocket, здесь — чистый HTTP, который проходит через любой прокси.

SSE-хаб: каналы и broadcast

GO
type hub struct {
        mu      sync.Mutex
        clients map[chan string]struct{}
}

func (h *hub) add() chan string {
        ch := make(chan string, 16)
        h.mu.Lock()
        h.clients[ch] = struct{}{}
        h.mu.Unlock()
        return ch
}

func (h *hub) remove(ch chan string) {
        h.mu.Lock()
        delete(h.clients, ch)
        h.mu.Unlock()
}

func (h *hub) broadcast(msg string) {
        h.mu.Lock()
        defer h.mu.Unlock()
        for ch := range h.clients {
                select {
                case ch <- msg:
                default: // буфер полон — медленный клиент пропущен
                }
        }
}

r.GET("/events", func(c *gin.Context) {
        ch := hub.add()
        defer hub.remove(ch)

        c.Writer.Header().Set("Content-Type", "text/event-stream")
        c.Writer.Header().Set("Cache-Control", "no-cache")

        c.Stream(func(w io.Writer) bool {
                select {
                case msg := <-ch:
                        c.SSEvent("message", msg)
                        return true
                case <-c.Request.Context().Done():
                        return false
                }
        })
})

r.POST("/push", func(c *gin.Context) {
        var body struct {
                Text string `json:"text"`
        }
        if err := c.ShouldBindJSON(&body); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }
        hub.broadcast(body.Text)
        c.Status(http.StatusAccepted)
})

Хаб — карта каналов под мьютексом: add при подключении, remove через defer при любом выходе из хендлера, broadcast раскладывает сообщение всем. default в broadcast — защита от медленного подписчика: переполненный буфер лучше пропустить, чем заморозить всех. Эндпоинт /events ставит заголовки SSE и живёт в c.Stream до разрыва. POST /push публикует событие из любого клиента — bind и конверт ошибок стандартные.

Событие на проводе

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

c.SSEvent("price", "100")

Заголовки по пути: hop-by-hop и X-Forwarded-*#

Прокси не пересылает всё подряд. Стандарт делит заголовки на end-to-end и hop-by-hop: вторые описывают связь между соседними узлами и на следующем хопе бессмысленны — ReverseProxy вырезает Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade и Proxy- до похода на бэкенд. Именно поэтому заголовок Upgrade из SSE не ломает проксирование, а WebSocket через ReverseProxy без специальных манипуляций не пройдёт — для апгрейда нужен туннель, о нём поговорим в уроке про WebSocket. Вторая группа — навигационные X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto: бэкенду нужно знать настоящий адрес клиента, оригинальный хост и схему. Здесь действует правило из урока про лимиты: верить им можно только от доверенных прокси — иначе первый пришедший напишет себе любой X-Forwarded-For. Режим Rewrite решает это радикально: стирает клиентские X-Forwarded- перед Rewrite-функцией, и SetXForwardedFor заполняет их заново от реального RemoteAddr. Если ваш gin-шлюз сам стоит за nginx, не забудьте про SetTrustedProxies — иначе ClientIP на шлюзе покажет nginx, и per-IP лимиты из урока 18 слипнутся в одно ведро. Host бэкенду проставляет SetURL; если нужно сохранить оригинальный — ведро в Out.Host остаётся пустым, а X-Forwarded-Host унесёт правду.

Точки вмешательства ReverseProxy#

ТочкаКогда вызываетсяЧто там делать
Rewrite (современный)до похода на бэкенд, после вырезания hop-by-hop и X-Forwarded-*SetURL к цели, SetXForwardedFor, правка пути и заголовков запроса
Director (легаси)до похода на бэкенд, X-Forwarded-* не стираютсяправка URL.Request цели; для нового кода предпочитайте Rewrite
ModifyResponseполучен ответ от бэкенда, до отправки клиентудобавить/убрать заголовки, подменить статус, посчитать метрику
ErrorHandlerошибка похода на бэкенд: таймаут, refusal, сбой транспортаконверт ошибок клиенту, метрика сбоя, аккуратный 502
FlushIntervalво время копирования телапериодический flush для стриминга; text/event-stream и ответы без длины флешатся сами

Проверка: бэкенд умер

Реплика за прокси перезапускается, соединение отваливается по ошибке транспорта. ErrorHandler не задан. Что получит клиент?

Грабли: буферизация, таймауты и медленные клиенты#

Первый грабль — невидимая буферизация на пути. Ваш SSE-эндпоинт честно шлёт данные раз в секунду, а браузер молчит: виноват не gin, а промежуточный слой. Собственный gzip-звено на группе собирает тело в буфер — стриминговым маршрутам нужен обход; nginx по умолчанию буферизует проксированные ответы — лечится X-Accel-Buffering: no в ответе бэкенда или proxy_buffering off в конфиге. Диагностика проста: curl видит поток, браузер — нет, значит буферизует кто-то посередине. Второй грабль — таймауты http.Server: WriteTimeout, поставленный на весь сервер, обрежет любой поток длиннее его значения — соединение молча закроется через N секунд. Для серверов со стримингом WriteTimeout обнуляют и контролируют дедлайны точечно через контекст запроса. Третий — таймауты бэкенда: дефолтный Transport ждёт вечно, зависшая реплика повесит и хендлер прокси; настраивайте ResponseHeaderTimeout и общий дедлайн на клиенте. Четвёртый — медленные подписчики SSE: broadcast с default-веткой теряет события для переполненного канала, но сохраняет остальных; очереди без границ в памяти — путь к OOM. Пятый — дублирование лимитов: лимит запросов gin на шлюзе защищает бэкенды бесплатно, не забудьте поставить звено из урока 18 перед прокси-группой, а конверт ошибок взять из урока 17-oshibki.

Шесть правил прокси и стриминга#

  • Пул прокси собирается один раз на старте: цель живёт в замыкании Rewrite, хендлер только выбирает индекс и отрезает префикс.
  • Rewrite вместо Director для нового кода: SetURL и SetXForwardedFor, стирание клиентских X-Forwarded-* — встроенная защита от подделки.
  • Прокси не копит тело: io.Copy кусками по 32 КиБ; буферизация в памяти — осознанное решение за RAM, а не дефолт.
  • c.Stream сама делает Flush после каждого шага и ловит уход клиента через CloseNotify; step вернул false — поток закончен штатно.
  • SSE — text/event-stream, no-cache, c.SSEvent на канал; переподключение и Last-Event-ID — забота EventSource, а не вашего кода.
  • Буферизация посередине и WriteTimeout режут потоки тихо: обходите gzip на стриминговых группах, X-Accel-Buffering: no, дедлайны через контекст, ErrorHandler — для честного 502-конверта.

Соберите SSE-эндпоинт

Допишите поток событий: контент-тип, вход в c.Stream, публикацию события и реакцию на разрыв.

r.GET("/events", func(c *gin.Context) { ch := hub.add() defer hub.remove(ch) c.Writer.Header().Set("Content-Type", "text/") c.Writer.Header().Set("Cache-Control", "no-cache") c.(func(w io.Writer) bool { select { case msg := <-ch: c.("message", msg) return true // поток продолжается case <-c.Request.Context().(): return false // клиент ушёл } }) })

Соберите жизнь одного SSE-сообщения

Клиент смотрит ленту событий. Разложите путь сообщения: от подписки до тихого разрыва.

Строки

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

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

Карточки: прокси и стриминг

Термин

httputil.ReverseProxy

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

Структура-прокси из стандартной библиотеки: копирует запрос на бэкенд и тело ответа обратно через io.Copy кусками 32 КиБ — память не зависит от размера ответа. Точки вмешательства: Rewrite/Director, ModifyResponse, ErrorHandler, FlushInterval, ErrorLog.

1 / 6

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

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

Вывод

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

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

  • Обратный прокси на gin — обычный хендлер: httputil.ReverseProxy перекладывает запрос и ответ, память не растёт от размера тела, потому что копирование идёт потоками по 32 КиБ.
  • Rewrite — современная точка маршрутизации: SetURL к цели, SetXForwardedFor с автоматическим стиранием клиентских X-Forwarded-*; Director — легаси с дописыванием IP к существующему списку.
  • Пул реплик — массив прокси, собранный на старте, и атомарный счётчик с модулем: round-robin в три строки хендлера, ModifyResponse и ErrorHandler — свои у каждого прокси.
  • Стриминг gin держится на Flush: gin-писатель реализует http.Flusher, c.Stream делает Flush после каждого шага и ловит уход клиента, FlushInterval заведует своевременностью у ReverseProxy.
  • SSE — обычный HTTP с text/event-stream: data:, event:, id:, retry:, пустая строка-терминатор; EventSource сам переподключается и шлёт Last-Event-ID, серверу остаётся хаб каналов и c.SSEvent.
  • Тихие убийцы потоков — буферизация посередине и WriteTimeout: обход gzip, X-Accel-Buffering: no, дедлайны через контекст, ErrorHandler с конвертом вместо пустого 502.

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

Соберите шлюз из трёх реплик на :9001-:9003. Пул httputil.ReverseProxy с Rewrite: SetURL, SetXForwardedFor, X-Upstream в ModifyResponse и ErrorHandler с конвертом ошибок UPSTREAM_UNAVAILABLE и 502. Группа /v1 с round-robin на atomic.Uint64 и отрезанием префикса. Рядом SSE-эндпоинт /events: хаб с каналами под мьютексом, буфер 16, broadcast с default-веткой, заголовки text/event-stream и no-cache, c.Stream с c.SSEvent и Context.Done. POST /push публикует через bind. Проверьте: пять запросов уходят строго по кругу 9001-9002-9003; curl -N /events показывает событие мгновенно после /push; Ctrl-C в curl убирает канал из хаба; убитая реплика отдаёт ваш JSON-конверт, а не пустой 502. Убедитесь, что gzip-звено, если появилось, обходит группу /events, и что WriteTimeout у сервера либо нулевой, либо больше максимального времени жизни потока.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Проксирование и стриминг ответов)

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

Rewrite или Director для нового кода?

Rewrite, если минимальная версия Go позволяет: он получает ProxyRequest с разделением In/Out, SetURL и SetXForwardedFor делают типовые вещи, а клиентские X-Forwarded-* стираются автоматически — спуфинг заголовков от клиента отсечён на уровне библиотеки. Director остаётся для совместимости и тонких случаев, где нужна правка именно *http.Request, но для него придётся вручную думать о доверии к XFF.

Чем c.Stream лучше ручного цикла с Flush?

Тремя вещами: Flush после каждого шага встроен и о нём нельзя забыть; выход по уходу клиента уже реализован через CloseNotify; контракт step true/false делает код читаемым. Ручной цикл нужен, когда шаг и выталкивание должны разойтись — например, сканирование большого тела с Flush раз в мегабайт.

Почему браузер не видит SSE-поток, а curl видит?

Классика буферизации посередине: gzip-звено на группе собирает тело, nginx буферизует проксированный ответ, антивирус на клиенте любит перемешивать потоки. Снимите gzip со стриминговой группы, поставьте X-Accel-Buffering: no или proxy_buffering off — и проверьте заголовок Content-Type: браузер требует точного text/event-stream, с charset-суффиксом EventSource может не стартовать.

SSE или WebSocket для чата?

Односторонний поток сервера — SSE: HTTP, автопереподключение, Last-Event-ID, ноль зависимостей. Двусторонний обмен, бинарные кадры, частые сообщения от клиента — WebSocket: апгрейд соединения, gorilla/websocket и отдельный разговор про прокси, потому что Upgrade-заголовок hop-by-hop и через ReverseProxy не пролезает без настройки. Лента уведомлений — SSE; чат, игра, совместное редактирование — WebSocket из следующего урока.

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

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

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

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

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

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

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию