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 передає кожен вхідний запит. І якщо ви зрозумієте, що «сервер — це штука, яка викликає обробник», усе стане на свої місця: і ServeMux, і ListenAndServe, і http.Server.
Погляньмо на шлях запиту у спрощеному вигляді:
flowchart LR
C[HTTP-клієнт] --> S[сервер net/http]
S --> H[http.Handler]
H --> W[ResponseWriter: статус/заголовки/тіло]
Ідея проста: сервер — це інфраструктура доставки запитів до обробника.
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("помилка запуску:", err)
}
}
Тут важливо помітити одне: ми передали mux як handler. Тобто сервер не «сам шукає функції». Він просто викликатиме mux.ServeHTTP(...), а mux уже всередині обере потрібний маршрут.
Адреса ":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. Це структура, у якій зберігаються налаштування сервера. І головна практична відмінність така: ви починаєте явно бачити, що саме налаштовано: адресу, обробник та інші параметри.
Найпростіший аналог попереднього прикладу:
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("помилка сервера:", 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: 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("слухаємо на", srv.Addr) // слухаємо на :8080
if err := srv.ListenAndServe(); err != nil {
log.Println("помилка сервера:", err)
}
}
Чому я додав log.Println("слухаємо на", srv.Addr)? Бо це той самий «людський слід» у консолі, який рятує нерви, коли ви забули, який порт задали, або взагалі не впевнені, що програма дійшла до запуску.
6. Помилка запуску сервера — нормальна гілка
Для початківців дуже типова помилка: «Він же має запускатися, навіщо перевіряти помилку?». На жаль, сервер може не стартувати з багатьох причин, і найпоширеніша з них — зайнятий порт. Або у вас немає прав слухати цей порт. Або адресу вказано некоректно.
Тому базова гігієна проста: ListenAndServe повертає error, і його не можна ігнорувати.
У зовсім простих навчальних проєктах інколи пишуть log.Fatal(err). Це нормально, але важливо розуміти зміст: log.Fatal друкує повідомлення і викликає os.Exit(1). Тобто програма завершиться негайно. Для коду всередині бібліотек це погано, але в main невеликого застосунку — терпимо, хоча згодом ви навчитеся робити більш контрольовану обробку помилок. У будь-якому разі думка одна: помилка запуску — це частина нормального керування програмою.
Аналогія: ServeMux — секретар, Server — офіс
Щоб закріпити, тримайте аналогію — обережно, без перетворення на цирк.
http.Server — це офіс: адреса офісу (Addr), правила роботи, інфраструктура.
http.ServeMux — секретар на ресепшені: він дивиться на шлях запиту й вирішує, до якого співробітника (обробника) відправити відвідувача.
Якщо ви намагаєтеся в офісі налаштувати, хто за що відповідає, ви починаєте плутати шари. Тому маршрутизацію ми тримаємо в 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.
Якщо порт зайнятий, програма миттєво завершиться. А якщо помилку проігнорувати й далі вдавати, що все добре, можна довго шукати причину збою. Перевірка помилки — це не бюрократія, а спосіб швидко зрозуміти, що саме пішло не так.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ