Урок курса

HTTP-сервер на net/http: регистрация обработчиков и формирование ответа

Golang с нуля

В прошлом уроке мы передавали ctx первым аргументом и следили за его отменой через select. Здесь та же дисциплина появится в другом месте: каждый HTTP-обработчик получает запрос r, у которого есть r.Context() — и паттерн, уже знакомый по WithCancel/WithTimeout, работает там без изменений. Но сначала нужно разобраться, как вообще устроен сервер: как зарегистрировать обработчики, запустить сервер и правильно сформировать ответ.

http.NewServeMux() и mux.HandleFunc: изолированный маршрутизатор с зарегистрированными путями

Пакет net/http содержит глобальный маршрутизатор по умолчанию — http.DefaultServeMux. Если вызывать http.HandleFunc("/path", ...) без явного mux, обработчик попадает именно туда. Проблема в том, что DefaultServeMux — глобальная переменная пакета: любой импортированный пакет может зарегистрировать в ней свои пути, и это может привести к неожиданным конфликтам. Поэтому практически всегда лучше создавать собственный маршрутизатор:

mux := http.NewServeMux()

Эта строка возвращает новый, пустой *http.ServeMux, никак не связанный с глобальным. Всё, что регистрируется на mux, существует только в нём.

Регистрация обработчика выглядит так:

mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello!")
})

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "root")
})

Сигнатура функции-обработчика фиксирована: func(w http.ResponseWriter, r *http.Request). http.ResponseWriter — это интерфейс, через который мы пишем ответ; *http.Request — структура с данными входящего запроса.

Теперь про разницу между "/hello" и "/". Путь без завершающего слеша — точное совпадение: "/hello" сработает только для запроса ровно на /hello. Путь с завершающим слешем — префиксное совпадение: "/" поймает всё, что не совпало с более конкретным паттерном. Если зарегистрировать только "/hello", то запрос на /about вернёт 404. Если добавить "/", то /about попадёт туда.

Один mux может содержать сколько угодно маршрутов — просто вызываете HandleFunc несколько раз.

Ещё один момент: если позже понадобится тестировать обработчики изолированно (без реального порта), удобно оформить создание маршрутизатора в виде функции-конструктора:

func newRouter() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/hello", helloHandler)
    mux.HandleFunc("/", rootHandler)
    return mux
}

Каждый вызов newRouter() возвращает свежий независимый *http.ServeMux. Это позволяет в тестах создавать отдельный экземпляр маршрутизатора для каждого теста, не мешая другим.

http.ListenAndServe: блокирующий запуск, обработка ошибки и рабочий цикл с двумя терминалами

Когда маршрутизатор готов, его нужно передать серверу:

if err := http.ListenAndServe(":8080", mux); err != nil {
    log.Fatal(err)
}

http.ListenAndServe принимает адрес для прослушивания и http.Handler — интерфейс, который реализует *http.ServeMux. Функция открывает TCP-сокет на порту 8080 и входит в бесконечный цикл приёма соединений. Она блокирует текущую горутину — то есть строки после неё в нормальном режиме работы никогда не выполнятся.

Именно поэтому нельзя просто написать http.ListenAndServe(...) и продолжать код ниже: сервер занял поток и ждёт запросов.

Что происходит на практике:

  • Запускаете программу в первом терминале — он «зависает» (это нормально, сервер работает).
  • Открываете второй терминал и отправляете запрос:
# Linux / macOS
curl http://localhost:8080/hello

# Windows PowerShell
Invoke-WebRequest http://localhost:8080/hello
  • Сервер обрабатывает запрос, первый терминал может показать логи, второй — ответ.
  • Чтобы остановить сервер, нажимаете Ctrl+C в первом терминале.

Почему log.Fatal? ListenAndServe возвращает ошибку только в одном случае: что-то пошло не так при запуске или во время работы — например, порт уже занят другим процессом. Игнорировать эту ошибку нельзя: без неё вы не узнаете, что сервер вообще не стартовал. log.Fatal выводит сообщение и завершает программу с ненулевым кодом.

Типичная ошибка при занятом порте выглядит так:

2024/01/15 10:23:45 listen tcp :8080: bind: address already in use

Если написать http.ListenAndServe(":8080", mux) без проверки — программа завершится молча или продолжит выполнение с незапущенным сервером, и отлаживать это будет неудобно.

r.Method, r.URL.Path и порядок Header → WriteHeader → Write при формировании ответа

Внутри обработчика r *http.Request содержит всё о входящем запросе. Два самых частых поля:

  • r.Method — строка с HTTP-методом: "GET", "POST", "DELETE" и т.д.
  • r.URL.Path — строка с путём запроса, например "/hello".

Пример использования:

func helloHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        w.Header().Set("Content-Type", "text/plain")
        w.WriteHeader(http.StatusMethodNotAllowed)
        fmt.Fprintln(w, "only GET allowed")
        return
    }
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "Hello from %s\n", r.URL.Path)
}

Порядок вызовов строго обязателен: сначала w.Header().Set(...), затем w.WriteHeader(code), затем запись тела через w.Write(...) или fmt.Fprintf(w, ...).

Почему порядок нельзя нарушать? HTTP-ответ устроен так: сначала идёт статусная строка и заголовки, потом тело. Как только вы начинаете писать тело, заголовки уже уходят клиенту — изменить их невозможно. net/http реализует это жёстко: первый вызов Write или Fprintf автоматически отправляет статус 200 и все текущие заголовки, если WriteHeader ещё не был вызван.

Что будет, если нарушить порядок:

// Неправильно!
func badHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "oops")         // ← здесь неявно уходит статус 200
    w.WriteHeader(http.StatusTeapot) // ← этот вызов игнорируется, уже поздно
}

Клиент получит статус 200, а в логах Go напишет что-то вроде:

http: superfluous response.WriteHeader call from ...

Это предупреждение в stderr, но ответ уже отправлен с неверным статусом.

Одно исключение: если вы отвечаете с кодом 200 и не устанавливаете специальных заголовков, WriteHeader(200) можно не вызывать — первый Write сделает это сам. Но как только нужен другой статус или любой заголовок — следуйте порядку явно.

Попробуйте решить

Обработчик пишет код:

fmt.Fprintf(w, "hello")
w.WriteHeader(201)

Какой HTTP-статус получит клиент и почему?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку