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, остальные разбираются в тренажёре.
- Можно ли задать HTTP-заголовок после первого w.Write(...) в обработчике?A)Да, все заголовки уходят вместе с телом ответа уже при завершении обработчикаB)Да, если использовать w.Header().Set с флагом overrideC)Нет: первый Write фиксирует заголовки, поздние правки игнорируютсяD)Нет, но можно откатить уже записанное через w.Reset()
показать ответ и разбор
+C)Нет: первый Write фиксирует заголовки, поздние правки игнорируются// разбор: Заголовки и статус уходят клиенту при ПЕРВОМ w.Write (или явном w.WriteHeader). После этого w.Header().Set уже не влияет — заголовки отправлены, статус зафиксирован (по умолчанию 200, если не звали WriteHeader). Поэтому все заголовки и нужный код статуса выставляют ДО первого Write. Отката записанного нет.
- Что такое http.Handler и как он связан с http.HandlerFunc?A)Интерфейс с ServeHTTP; HandlerFunc делает Handler из обычной функцииB)Структура роутера, куда добавляют пути методом AddC)Глобальный список всех зарегистрированных обработчиков сервераD)Функция-конструктор, создающая новый экземпляр http.Server на каждый входящий запрос
показать ответ и разбор
+A)Интерфейс с ServeHTTP; HandlerFunc делает Handler из обычной функции// разбор: http.Handler — интерфейс с единственным методом ServeHTTP(w, r). Всё в net/http работает с ним: сервер, мультиплексор. http.HandlerFunc — тип-адаптер: обычная функция func(w,r) приводится к нему и получает метод ServeHTTP, вызывающий саму функцию. Так функция становится Handler'ом без объявления структуры. Мелкий интерфейс — вся мощь композиции middleware.
- Что нового появилось в маршрутизаторе http.ServeMux в Go 1.22?A)Поддержка метода и переменных пути прямо в шаблоне маршрутаB)Встроенное ограничение частоты запросов на каждый маршрутC)Автоматическая сериализация ответа в JSON по типу возвращаемого значенияD)Группировка маршрутов с общим префиксом и общими middleware
показать ответ и разбор
+A)Поддержка метода и переменных пути прямо в шаблоне маршрута// разбор: Теперь шаблон пишется как «GET /users/{id}»: метод проверяется маршрутизатором, а значение достаётся через r.PathValue("id"). Раньше ради этого тащили сторонний роутер или руками резали путь. Middleware и группы по-прежнему собираются обёртками — стандартная библиотека их не добавляет, но базовый роутинг закрывает большинству сервисов.
- Сколько горутин обслуживают входящие HTTP-запросы в сервере net/http?A)Одна: сервер обрабатывает запросы последовательно в цикле приёмаB)По одной на процессор, между ними запросы распределяет планировщикC)Фиксированный пул, размер которого задаётся полем структуры ServerD)По одной на каждое соединение — обработчики выполняются конкурентно
показать ответ и разбор
+D)По одной на каждое соединение — обработчики выполняются конкурентно// разбор: На каждое принятое соединение сервер поднимает горутину — они дёшевы, поэтому пул не нужен. Отсюда два следствия: обработчик обязан быть безопасным для конкурентного вызова (общее состояние — под мьютексом), а ограничивать нагрузку нужно самому: без лимитов всплеск соединений упрётся в память и число дескрипторов.
- Что даёт вызов srv.Shutdown(ctx) в отличие от простого выхода из программы?A)Мгновенно рвёт все соединения и освобождает порт для перезапускаB)Перестаёт принимать новые соединения и ждёт завершения текущих запросовC)Сохраняет незавершённые запросы на диск и повторяет их после стартаD)Переводит сервер в режим только для чтения до окончания работы
показать ответ и разбор
+B)Перестаёт принимать новые соединения и ждёт завершения текущих запросов// разбор: Мягкая остановка: слушатель закрывается, а уже принятые запросы дорабатывают. Контекст задаёт предел ожидания — вышел срок, и оставшиеся соединения рвутся. Без этого выкатка обрывает запросы на середине, и пользователи ловят ошибки на каждом деплое. Обычная связка: сигнал от системы → Shutdown с таймаутом → выход по завершении.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.