Урок курса

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. Это позволяет в тестах создавать отдельный экземпляр маршрутизатора для каждого теста, не мешая другим.

Почему неизвестный путь внезапно отвечает как главная страница

В предыдущем примере зарегистрированы /hello и /. Второй шаблон в ServeMux охватывает не только главную страницу, но и другие пути, если для них нет более точного обработчика. Поэтому запрос на /about может получить текст root со статусом 200 вместо ожидаемой ошибки 404. Это легко пропустить, проверив только рабочий маршрут.

Если обработчик главной страницы должен принимать ровно /, проверьте путь внутри него. Ниже полный маленький пример: httptest создаёт тестовые запросы и записывает ответы без открытия сетевого порта.

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/" {
            http.NotFound(w, r)
            return
        }
        fmt.Fprint(w, "root")
    })
    for _, path := range []string{"/", "/about"} {
        w := httptest.NewRecorder()
        mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
        fmt.Println(path, w.Code)
    }
}

Вывод: / 200 и /about 404. Уберите проверку r.URL.Path и повторите: второй статус станет 200. Так вы проверяете не только запуск сервера, но и смысл маршрутов. В Go 1.22+ существует также шаблон /{$} только для корня, однако проверка пути внутри обработчика понятна и работает в более старом коде. Для остальных адресов регистрируйте отдельные обработчики по мере появления настоящих страниц. Следующий шаг после корректных статусов — ответы API в JSON.

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 сделает это сам. Но как только нужен другой статус или любой заголовок — следуйте порядку явно.


HTTP-сервер готов. Подключите к нему базу и другие сервисы

Дальше можно сохранять данные в PostgreSQL, вызывать соседний сервис по gRPC и передавать события через Kafka. Выберите, что хотите освоить:

Оба курса платные. Перед микросервисами закрепите HTTP, JSON и тестирование в оставшихся уроках. Для Docker нужны основы Git и работа с терминалом.

Выбрать продолжение по Go →

Выбрать другое направление в каталоге Latorn →

Задача 1

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

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

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Программа запускает HTTP-сервер вызовом http.ListenAndServe(":8080", mux) без проверки ошибки. Порт 8080 уже занят другим процессом. Что произойдёт?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Обработчик получил запрос GET /users/42?full=1. Какое выражение возвращает только путь /users/42, без query-параметров?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

HTTP-сервис приветствия

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

В центре задачи стоит функция newMux, которая собирает и возвращает свежий мультиплексор с двумя маршрутами. Первый отвечает за здоровье сервиса и позволяет внешним наблюдателям убедиться, что процесс жив. Второй — дружелюбно приветствует любого, кто обратится по нужному адресу. Важно понимать, что каждый обработчик обязан вести себя предсказуемо: правильный метод — правильный ответ, неправильный метод — явный отказ, а несуществующий путь должен честно сообщать о своём отсутствии. Порядок выставления заголовков тоже имеет значение: они должны быть готовы прежде, чем клиент увидит статус или тело ответа.

Что нужно сделать

Допишите service.go. Функция newMux должна при каждом вызове возвращать новый *http.ServeMux. На нём нужны два маршрута: GET /health отвечает строкой ok, а GET /hello — строкой Hello, Go!. Для другого метода на /hello верните 405 и текст method not allowed. Неизвестный путь должен остаться обычным 404 от ServeMux.

Перед записью статуса и тела установите Content-Type: text/plain; charset=utf-8. В main запустите сервер на :18080 и обработайте ошибку ListenAndServe. Здесь ещё нет JSON API: задача только про mux, обработчик и HTTP-ответ.

Открыть задание в уроке →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку
HTTP-сервер на Go: net/http и обработчики