1. Два способа запуска и роль http.Handler
Когда вы впервые видите HTTP‑сервер в Go, возникает ощущение: «Ну вот же — одна функция http.ListenAndServe, что тут ещё обсуждать?». И это честное ощущение: в маленьком примере одной строки действительно достаточно. Но как только вы начинаете писать не «примерчик на 8 строк», а приложение, появляется практический вопрос: где настраивать адрес, какой обработчик обслуживает запросы, и что делать, когда нам понадобится конфигурация посложнее, чем «включить сервер». Здесь и появляется развилка: быстрый старт через ListenAndServe или явная конфигурация через http.Server.
Представьте, что вы открываете мини‑кафе. Можно написать на двери «Работаем» и начать продавать кофе (это ListenAndServe). А можно повесить расписание, назначить ответственного, настроить кассу, правила закрытия смены и журнал инцидентов (это http.Server). Оба способа легальны. Разница — в управляемости.
Ментальная модель: сервер крутится вокруг http.Handler
Прежде чем сравнивать API, важно понять, вокруг чего вообще строится сервер в net/http. В Go HTTP‑сервер работает по простой модели: приходит запрос → сервер выбирает обработчик → обработчик пишет ответ. Центральное слово тут — обработчик.
В стандартной библиотеке обработчик описывается интерфейсом http.Handler, у которого есть ровно один метод ServeHTTP(w, r). Именно это «контрактное место», куда Go будет передавать каждый входящий запрос. И если вы поймёте, что «сервер = штука, которая вызывает handler», всё встанет на свои места: и ServeMux, и ListenAndServe, и http.Server.
Нарисуем поток жизни запроса (упрощённо):
flowchart LR
C[HTTP клиент] --> S[net/http сервер]
S --> H[http.Handler]
H --> W[ResponseWriter: статус/заголовки/тело]
Идея простая: сервер — это инфраструктура доставки запросов до handler’а.
2. http.ListenAndServe: быстрый старт
Если вы хотите поднять сервер «прямо сейчас» и не расписывать лишние структуры, http.ListenAndServe(addr, handler) — ваш выбор. Этот вызов делает две вещи: создаёт сервер «под капотом» и начинает слушать порт, принимая запросы.
Вот минимальный пример в стиле «есть маршрут "/health", который просто говорит “я жив”»:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) // 204
})
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Println("listen error:", err)
}
}
Здесь важно заметить одну вещь: мы передали mux как handler. То есть сервер не «сам ищет функции». Он просто будет звать mux.ServeHTTP(...), а mux уже внутри выберет нужный endpoint.
Адрес ":8080" — почему с двоеточием?
Новички очень часто пишут "8080" и получают ошибку. В Go адрес сервера обычно задаётся как host:port. Если вы пишете ":8080", это означает «слушай на всех интерфейсах на порту 8080». Если вы пишете "localhost:8080", вы привязываетесь только к localhost (удобно локально).
Что означает handler == nil?
Иногда вы увидите вот так:
package main
import "net/http"
func main() {
_ = http.ListenAndServe(":8080", nil)
}
nil здесь не «сервер без обработчиков». Это означает: «используй http.DefaultServeMux». То есть маршруты нужно регистрировать через http.Handle/http.HandleFunc (глобально). Это работает, но для учебного проекта (и почти для любого аккуратного проекта) чаще удобнее держать свой mux := http.NewServeMux(), чтобы не зависеть от глобального состояния.
Глобальное состояние в веб‑сервере — как общая доска на кухне офиса: сначала всем удобно, потом никто не понимает, кто там написал «Съесть йогурт нельзя, он мой».
3. http.Server: явная конфигурация
Теперь посмотрим на http.Server. Это структура, которая хранит настройки сервера. И главное практическое отличие — вы начинаете явно видеть, что именно настроено: адрес, handler и другие параметры.
Самый «честный» аналог предыдущего примера:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
if err := srv.ListenAndServe(); err != nil {
log.Println("server error:", err)
}
}
Сравните с http.ListenAndServe(":8080", mux): по смыслу это очень близко. Разница в том, что http.Server позволяет в одном месте держать конфигурацию сервера, а не «прятать» её в аргументах функции.
И ещё важный момент для мозга: метод называется тоже ListenAndServe(), но теперь он вызывается на srv. То есть «у сервера есть метод запуститься». Это интуитивно, и на больших приложениях так проще читать код.
Зачем нам это, если можно одной строкой?
Потому что как только у вас появляется хоть что‑то «настроечное» (например, особый логгер, ограничения по времени, особенности TLS, разные серверы на разных портах), структура http.Server превращается в удобную «точку сборки». Даже если сегодня вы используете только Addr и Handler, вы уже пишете код так, чтобы завтра не делать рефакторинг «по живому».
Мини‑рамка: почему http.Server лучше показывает архитектуру
Есть одна тонкая, но важная причина полюбить http.Server даже тогда, когда «пока достаточно ListenAndServe».
ListenAndServe(":8080", mux) выглядит как «какая‑то функция, которая где‑то что‑то делает». А srv := &http.Server {Addr: ":8080", Handler: mux} выглядит как «мы собрали объект сервера, и он запускается». Это усиливает архитектурное мышление: зависимости становятся полями, а не «переданными куда‑то аргументами».
И это напрямую сочетается с идеей «сервер крутится вокруг handler’а»: вы буквально видите Handler: mux в структуре.
4. Что где настраивается
Чтобы не превратить тему в философию, зафиксируем практический договор: что обычно настраивается через ListenAndServe, а что удобнее/правильнее настраивать через http.Server.
Ниже таблица — это не «единственно верный закон», а ориентир, который помогает не путаться.
| Что вы настраиваете | Где это чаще всего задают | Почему так удобнее |
|---|---|---|
| Адрес/порт (":8080", "localhost:8080") | В обоих вариантах | Это базовый параметр запуска, он в любом случае нужен |
| Главный обработчик (mux) | В обоих вариантах | Серверу нужен http.Handler, без него некуда доставлять запрос |
| Маршруты ("/health", "/tasks") | В ServeMux (ваш mux) | Роутинг — задача mux’а, а не самого сервера |
| Дополнительные настройки сервера (таймауты, кастомный ErrorLog и т.п.) | Через http.Server | Это именно «политика сервера», а не роутинга |
| Избегание глобального состояния | Свой mux + http.Server | Явная сборка легче тестируется и поддерживается |
Мы сознательно не углубляемся в «какие таймауты бывают» и «как правильно выключать сервер» — сейчас важно понять архитектурную границу: http.Server — это контейнер конфигурации сервера, а ListenAndServe — быстрый способ создать этот контейнер неявно.
5. Мини‑каркас приложения: buildMux() и запуск
С этого момента в примерах будем постепенно собирать один и тот же каркас: маленькое HTTP‑приложение для задач (условный «todo»). Пока без CRUD и без JSON‑декодирования: сегодня наша цель — правильно запустить сервер и понять, где что живёт.
Хороший стиль для новичка (и не только для новичка) — вынести сборку роутов в отдельную функцию. Тогда main читается как «собрали зависимости → запустили».
package main
import "net/http"
func buildMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) // 204
})
return mux
}
Теперь main становится очень коротким и приятным:
package main
import (
"log"
"net/http"
)
func main() {
mux := buildMux()
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
log.Println("listening on", srv.Addr) // listening on :8080
if err := srv.ListenAndServe(); err != nil {
log.Println("server error:", err)
}
}
Почему я добавил log.Println("listening on", srv.Addr)? Потому что это тот самый «человеческий след» в консоли, который спасает нервы, когда вы забыли, какой порт поставили, или вообще не уверены, что программа дошла до запуска.
6. Ошибка запуска сервера — нормальная ветка
Для начинающих очень характерна ошибка: «ну он же должен запускаться, зачем проверять ошибку?». Увы, сервер может не стартовать по куче причин, и самая популярная — порт занят. Или у вас нет прав слушать этот порт. Или адрес задан некорректно.
Поэтому базовая гигиена: ListenAndServe возвращает error, и его нельзя игнорировать.
В совсем простых учебных проектах иногда пишут log.Fatal(err). Это нормально, но важно понимать смысл: log.Fatal печатает и делает os.Exit(1). То есть программа завершится немедленно. Для кода «внутри библиотек» это плохо, но в main небольшого приложения — терпимо (хотя позже вы научитесь делать более контролируемую обработку ошибок). В любом случае, мысль одна: ошибка запуска — это часть нормального управления программой.
Аналогия: ServeMux — секретарь, Server — офис
Чтобы закрепить, держите аналогию (аккуратно, без превращения в цирк).
http.Server — это офис: адрес офиса (Addr), правила работы, инфраструктура.
http.ServeMux — секретарь на ресепшене: он смотрит на путь запроса и решает, к какому сотруднику (handler’у) отправить посетителя.
Если вы пытаетесь «в офисе настроить, кто отвечает за какие заявки» — вы начинаете путать слои. Поэтому роутинг мы держим в mux’е, а параметры «как работает офис» — в http.Server.
7. Типичные ошибки
Ошибка №1: перепутать DefaultServeMux и свой mux.
Это случается так: вы где‑то написали http.HandleFunc("/health", ...) (то есть зарегистрировали маршрут в глобальном DefaultServeMux), а запускаете сервер как http.ListenAndServe(":8080", myMux). В итоге ваш обработчик «как будто не работает», потому что вы зарегистрировали маршрут не там. Эта ошибка особенно коварна тем, что компилятор молчит: всё корректно, просто логика разъехалась.
Ошибка №2: написать "8080" вместо ":8080".
Потому что мозг помнит «порт 8080», но API ждёт «адрес:порт». В Go это строка формата host:port. Если host пустой, двоеточие всё равно нужно.
Ошибка №3: спрятать запуск сервера не в main, а «где‑то внутри».
Новички иногда запускают сервер внутри функции, которая ещё и «обрабатывает запросы», или внутри пакета, который вообще должен был заниматься бизнес‑логикой. Потом становится невозможно понять, кто отвечает за жизненный цикл приложения. Договор простой: запуск сервера — это ответственность main, а обработчики — ответственность HTTP‑слоя.
Ошибка №4: не логировать, где сервер слушает.
Это не «ошибка компиляции», это ошибка эксплуатации. Вы запускаете программу, видите пустую консоль и не понимаете: она зависла, упала или работает? Одна строка log.Println("listening on", addr) делает поведение прозрачным.
Ошибка №5: игнорировать ошибку ListenAndServe.
Если порт занят, программа мгновенно завершится (или будет в странном состоянии, если ошибку проигнорировали и вы продолжили делать вид, что всё хорошо). Проверка ошибки — это не бюрократия, а способ быстро получить причину сбоя.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ