сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Веб и тесты на Go

HTTP-сервер на net/http

Сервер на стандартной библиотеке

Поднял сервер и постучал в него пятьюдесятью одновременными запросами. Горутин в процессе стало 252 против одной до нагрузки - примерно пять на каждый запрос в работе. Спрашивают ровно про это устройство: сигнатура обработчика, маршрутизация, конкурентность и мягкая остановка. С Go 1.22 тема обновилась: роутер научился методам и переменным пути.

Стержень: обработчик - обычная функция, каждое соединение обслуживает своя горутина, а прод требует таймаутов и мягкой остановки.

// Формулировки: «как выглядит обработчик?», «сколько горутин обслуживает запросы?», «что такое graceful shutdown?»

Роутер 1.22: проверил все четыре случая

Завёл в http.ServeMux три шаблона и постучал. GET /users/42 при шаблоне «GET /users/{id}» вернул 200 и id=42 через r.PathValue("id"). POST по тому же адресу вернул 405 с заголовком Allow: GET, HEAD - роутер сам разбирается с методами, руками это писать больше не надо.

Шаблон «GET /files/{path...}» с многоточием ловит остаток пути целиком: запрос /files/a/b/c.txt отдал path=a/b/c.txt. А обычная переменная {id} захватывает ровно один сегмент - /users/42/extra ушёл в 404.

// Сам обработчик - функция с сигнатурой func(w http.ResponseWriter, r *http.Request). Интерфейс http.Handler требует метод ServeHTTP, а http.HandlerFunc - адаптер, превращающий обычную функцию в такой интерфейс.

PathValue
значение переменной пути из шаблона, с 1.22
{path...}
многосегментная переменная: ловит остаток пути

Заголовки после первого Write уже никуда не поедут

Опыт на настоящем сервере: обработчик пишет тело, потом ставит заголовок и зовёт WriteHeader(418). Клиент получил статус 200 и пустой заголовок. А сервер написал в лог: http: superfluous response.WriteHeader call from main.go:31.

Причина в том, что первая запись в тело отправляет строку статуса и все заголовки, какие есть на тот момент. Дальше менять уже нечего - байты ушли в сокет.

// Отсюда порядок в любом обработчике: сперва заголовки, потом WriteHeader, потом тело. Особенно это касается ошибок - развилку «а вдруг ошибка» нужно проходить до первой записи.

порядок записи
заголовки, статус, тело - и только в таком порядке
superfluous WriteHeader
сообщение сервера о вызове после первой записи

Прод: таймауты и остановка

Напечатал поля пустого http.Server{}: ReadTimeout 0s, WriteTimeout 0s, IdleTimeout 0s. Ноль означает «без ограничения», то есть по умолчанию медленный или вредный клиент может держать соединение сколько угодно - это и есть атака slowloris. Поэтому все три задают руками, а размер тела ограничивают через http.MaxBytesReader.

Мягкую остановку тоже проверил. Клиент ушёл в запрос на 200 мс, через 30 мс я позвал srv.Shutdown(ctx): вызов вернулся через 280 мс, а клиент получил полный ответ. Сервер перестал принимать новые соединения и дал текущему доработать - без этого каждая выкатка рвёт запросы на середине.

// Таймауты сервера не ограничивают время работы самого обработчика: он может считать что-то вечно. Для этого нужен контекст запроса с дедлайном, и его же протаскивают дальше в базу и соседние сервисы.

graceful shutdown
отказ от новых соединений плюс ожидание текущих
slowloris
удержание соединений медленной отправкой байтов

Как отвечать: «Как устроен HTTP-сервер в Go и что нужно добавить для прода?»

Обработчик это обычная функция с сигнатурой func(w http.ResponseWriter, r *http.Request), интерфейс http.Handler требует метод ServeHTTP, а HandlerFunc - адаптер между ними. Маршрутизацию с версии 1.22 закрывает стандартный ServeMux: в шаблоне пишется метод и переменные пути, например GET /users/{id}, значение достаётся через r.PathValue, и роутер сам отдаёт 405 с заголовком Allow, если метод не совпал. Многоточие в шаблоне ловит остаток пути целиком. На каждое соединение сервер поднимает горутину, поэтому обработчик обязан быть безопасен для конкурентного вызова, а общее состояние - под мьютексом; я замерял, пятьдесят одновременных запросов дали больше двухсот пятидесяти горутин. Для прода добавляю три вещи. Первое - таймауты: у пустого http.Server все три поля нулевые, то есть ограничений нет вообще, и медленный клиент удерживает соединения бесконечно, это классический slowloris. Плюс ограничение размера тела через MaxBytesReader. Второе - мягкую остановку через Shutdown с контекстом: я проверял, вызов ждёт текущий запрос и клиент получает полный ответ, а без этого выкатка рвёт запросы. Третье - проброс r.Context дальше в базу и соседние сервисы, чтобы при отвале клиента не доделывать ненужную работу.

Сильный ответ: база подана коротко и с актуальным роутингом 1.22 вплоть до автоматического 405, а дальше идёт эксплуатационный слой - нулевые таймауты с названной атакой, мягкая остановка с проверенным поведением и проброс контекста. Видно человека, который выкатывал сервис.

На чём валят

  • Не задают таймауты серверу. У пустого http.Server все три нулевые, то есть без ограничений.
  • Пишут заголовки после первого Write. Статус останется 200, а в лог уедет superfluous WriteHeader.
  • Считают обработчик однопоточным и держат общее состояние без мьютекса.
  • Выкатывают без мягкой остановки и рвут запросы на каждом деплое.
  • Не пробрасывают r.Context дальше: клиент ушёл, а работа продолжается.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.

  1. #go_net_http1 / 5
    Можно ли задать HTTP-заголовок после первого w.Write(...) в обработчике?
    A)Да, все заголовки уходят вместе с телом ответа уже при завершении обработчика
    B)Да, если использовать w.Header().Set с флагом override
    C)Нет: первый Write фиксирует заголовки, поздние правки игнорируются
    D)Нет, но можно откатить уже записанное через w.Reset()
    показать ответ и разбор
    +C)Нет: первый Write фиксирует заголовки, поздние правки игнорируются

    // разбор: Заголовки и статус уходят клиенту при ПЕРВОМ w.Write (или явном w.WriteHeader). После этого w.Header().Set уже не влияет — заголовки отправлены, статус зафиксирован (по умолчанию 200, если не звали WriteHeader). Поэтому все заголовки и нужный код статуса выставляют ДО первого Write. Отката записанного нет.

  2. #go_net_http2 / 5
    Что такое http.Handler и как он связан с http.HandlerFunc?
    A)Интерфейс с ServeHTTP; HandlerFunc делает Handler из обычной функции
    B)Структура роутера, куда добавляют пути методом Add
    C)Глобальный список всех зарегистрированных обработчиков сервера
    D)Функция-конструктор, создающая новый экземпляр http.Server на каждый входящий запрос
    показать ответ и разбор
    +A)Интерфейс с ServeHTTP; HandlerFunc делает Handler из обычной функции

    // разбор: http.Handler — интерфейс с единственным методом ServeHTTP(w, r). Всё в net/http работает с ним: сервер, мультиплексор. http.HandlerFunc — тип-адаптер: обычная функция func(w,r) приводится к нему и получает метод ServeHTTP, вызывающий саму функцию. Так функция становится Handler'ом без объявления структуры. Мелкий интерфейс — вся мощь композиции middleware.

  3. #go_net_http3 / 5
    Что нового появилось в маршрутизаторе http.ServeMux в Go 1.22?
    A)Поддержка метода и переменных пути прямо в шаблоне маршрута
    B)Встроенное ограничение частоты запросов на каждый маршрут
    C)Автоматическая сериализация ответа в JSON по типу возвращаемого значения
    D)Группировка маршрутов с общим префиксом и общими middleware
    показать ответ и разбор
    +A)Поддержка метода и переменных пути прямо в шаблоне маршрута

    // разбор: Теперь шаблон пишется как «GET /users/{id}»: метод проверяется маршрутизатором, а значение достаётся через r.PathValue("id"). Раньше ради этого тащили сторонний роутер или руками резали путь. Middleware и группы по-прежнему собираются обёртками — стандартная библиотека их не добавляет, но базовый роутинг закрывает большинству сервисов.

  4. #go_net_http4 / 5
    Сколько горутин обслуживают входящие HTTP-запросы в сервере net/http?
    A)Одна: сервер обрабатывает запросы последовательно в цикле приёма
    B)По одной на процессор, между ними запросы распределяет планировщик
    C)Фиксированный пул, размер которого задаётся полем структуры Server
    D)По одной на каждое соединение — обработчики выполняются конкурентно
    показать ответ и разбор
    +D)По одной на каждое соединение — обработчики выполняются конкурентно

    // разбор: На каждое принятое соединение сервер поднимает горутину — они дёшевы, поэтому пул не нужен. Отсюда два следствия: обработчик обязан быть безопасным для конкурентного вызова (общее состояние — под мьютексом), а ограничивать нагрузку нужно самому: без лимитов всплеск соединений упрётся в память и число дескрипторов.

  5. #go_net_http5 / 5
    Что даёт вызов srv.Shutdown(ctx) в отличие от простого выхода из программы?
    A)Мгновенно рвёт все соединения и освобождает порт для перезапуска
    B)Перестаёт принимать новые соединения и ждёт завершения текущих запросов
    C)Сохраняет незавершённые запросы на диск и повторяет их после старта
    D)Переводит сервер в режим только для чтения до окончания работы
    показать ответ и разбор
    +B)Перестаёт принимать новые соединения и ждёт завершения текущих запросов

    // разбор: Мягкая остановка: слушатель закрывается, а уже принятые запросы дорабатывают. Контекст задаёт предел ожидания — вышел срок, и оставшиеся соединения рвутся. Без этого выкатка обрывает запросы на середине, и пользователи ловят ошибки на каждом деплое. Обычная связка: сигнал от системы → Shutdown с таймаутом → выход по завершении.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.