HTTP-сервер на Go без фреймворков: пошаговая инструкция
Стандартная библиотека Go содержит всё, чтобы поднять полноценный HTTP-сервер за полчаса. Разбираем каждый шаг — от маршрутов до graceful shutdown — и получаем рабочий API без единой внешней зависимости.

Что понадобится до старта
Прежде чем писать первую строку, убедитесь, что у вас стоит Go версии 1.22 или новее. Именно в 1.22 пакет net/http получил улучшенный маршрутизатор, который умеет различать HTTP-методы прямо в паттерне. Всё, что ниже, написано для Go 1.22+.
- Go 1.22+ — проверьте командой
go version. - Терминал и любой текстовый редактор. VS Code с расширением Go или GoLand — удобно, но необязательно.
- Свободный порт 8080 — на нём будем слушать запросы.
В конце у вас будет сервер с тремя эндпоинтами: приветствие, JSON-ответ и проверка здоровья. Его можно расширять, не подключая ни Gin, ни Chi, ни Echo.
Шаг 1. Инициализируем модуль
Создайте папку проекта и выполните в ней команду:
mkdir myserver && cd myserver
go mod init myserver
Проверка: в папке появился файл go.mod с первой строкой module myserver.
Шаг 2. Минимальный сервер — одна функция
Создайте файл main.go:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Привет, мир!")
})
fmt.Println("Сервер слушает :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Ошибка запуска:", err)
}
}
Запустите:
go run main.go
Проверка: откройте в браузере http://localhost:8080/hello — увидите «Привет, мир!». POST-запрос на тот же адрес вернёт 405 Method Not Allowed, потому что мы явно указали GET в паттерне.
Обратите внимание: мы передаём nil вторым аргументом в ListenAndServe. В этом случае используется http.DefaultServeMux — глобальный маршрутизатор. Для учебного проекта это нормально, но ниже мы заведём собственный мультиплексор — так безопаснее и удобнее.
Шаг 3. Собственный мультиплексор и JSON-ответ
Глобальный DefaultServeMux — частый источник проблем: любая импортируемая библиотека может зарегистрировать в нём свой хендлер. Свой ServeMux изолирует маршруты.
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// statusResponse — структура для JSON-ответа проверки здоровья.
type statusResponse struct {
Status string `json:"status"`
Time string `json:"time"`
}
func main() {
mux := http.NewServeMux()
// Приветствие
mux.HandleFunc("GET /hello", handleHello)
// JSON-ответ
mux.HandleFunc("GET /health", handleHealth)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
fmt.Println("Сервер слушает :8080")
if err := server.ListenAndServe(); err != nil {
fmt.Println("Ошибка:", err)
}
}
func handleHello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Привет, мир!")
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := statusResponse{
Status: "ok",
Time: time.Now().Format(time.RFC3339),
}
// Ошибку кодирования логируем, но клиенту уже ушёл 200 —
// в реальном проекте стоит буферизовать ответ.
if err := json.NewEncoder(w).Encode(resp); err != nil {
fmt.Println("Ошибка кодирования JSON:", err)
}
}
Проверка: curl -s http://localhost:8080/health | python3 -m json.tool — вернётся валидный JSON с полем status: "ok".
Зачем таймауты
Без ReadTimeout и WriteTimeout медленный клиент, который не закрывает соединение, займёт горутину навсегда. Тысяча таких клиентов — и сервер перестаёт отвечать. Таймауты — минимальная защита, которую стоит ставить с первого дня.
| Таймаут | Что ограничивает | Разумный старт |
|---|---|---|
| ReadTimeout | Время на чтение заголовков и тела запроса | 5 с |
| WriteTimeout | Время на запись ответа | 10 с |
| IdleTimeout | Время keep-alive соединения без запросов | 60–120 с |
Шаг 4. Middleware — логирование запросов
Middleware в стандартной библиотеке — это функция, которая принимает http.Handler и возвращает http.Handler. Никакой магии.
// loggingMiddleware оборачивает хендлер и печатает метод, путь и время ответа.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("%s %s — %v\n", r.Method, r.URL.Path, time.Since(start))
})
}
Подключается в одну строку — оберните mux перед передачей в Server:
server := &http.Server{
Addr: ":8080",
Handler: loggingMiddleware(mux),
// ... таймауты
}
Проверка: при каждом запросе в терминале появится строка вида GET /health — 42.1µs.
Стандартная библиотека Go — один из редких случаев, когда «без фреймворка» не означает «без батареек». Маршрутизация, JSON, TLS, HTTP/2 — всё уже внутри.
Шаг 5. Graceful shutdown
Если сервер обслуживает запрос, а процесс убивают по Ctrl+C, клиент получит обрыв соединения. Shutdown позволяет дождаться завершения активных запросов.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type statusResponse struct {
Status string `json:"status"`
Time string `json:"time"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", handleHello)
mux.HandleFunc("GET /health", handleHealth)
server := &http.Server{
Addr: ":8080",
Handler: loggingMiddleware(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Запускаем сервер в отдельной горутине
go func() {
fmt.Println("Сервер слушает :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("Ошибка:", err)
os.Exit(1)
}
}()
// Ждём сигнал завершения
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
fmt.Println("Завершаем...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
fmt.Println("Ошибка при завершении:", err)
}
fmt.Println("Сервер остановлен")
}
func handleHello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Привет, мир!")
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := statusResponse{
Status: "ok",
Time: time.Now().Format(time.RFC3339),
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
fmt.Println("Ошибка кодирования JSON:", err)
}
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("%s %s — %v\n", r.Method, r.URL.Path, time.Since(start))
})
}
Проверка: запустите сервер, отправьте запрос, нажмите Ctrl+C. В терминале появятся строки «Завершаем...» и «Сервер остановлен». Если в момент сигнала шёл запрос — он успеет отработать в пределах десятисекундного контекста.
Если не получилось
bind: address already in use— порт 8080 занят. Найдите процесс:lsof -i :8080(macOS/Linux) илиnetstat -ano | findstr 8080(Windows). Либо смените порт вAddr.undefined: http.ErrServerClosed— версия Go ниже 1.16, где эта переменная ещё не экспортирована. Обновитесь до 1.22+.- 405 Method Not Allowed на
/hello— вы отправили POST, а хендлер зарегистрирован только для GET. Проверьте метод:curl -X GET http://localhost:8080/hello. - 404 на
/hello/(с trailing slash) — паттернGET /helloбез завершающего слеша не совпадает с/hello/. Если хотите принимать оба варианта, зарегистрируйте два паттерна или используйте суффикс{$}— поведение описано в документацииnet/httpдля Go 1.22. - Пустой JSON в
/health— убедитесь, что поля структурыstatusResponseначинаются с заглавной буквы. Неэкспортируемые поляencoding/jsonигнорирует.
Сноска: ServeMux (service multiplexer) — маршрутизатор HTTP-запросов в стандартной библиотеке Go. Он сопоставляет URL входящего запроса с зарегистрированными паттернами и вызывает нужный обработчик. Начиная с Go 1.22 паттерны поддерживают HTTP-методы и именованные параметры пути.
Что дальше
Сервер работает, но до продакшена ему далеко. Вот три вещи, которые стоит сделать следующими:
- Structured logging. Пакет
log/slog, включённый в стандартную библиотеку с Go 1.21, позволяет писать структурированные логи в JSON — заменитеfmt.Printlnна него. - Конфигурация через переменные окружения. Порт, таймауты и другие параметры не должны быть захардкожены. Минимум —
os.Getenv. - Тесты хендлеров. Пакет
net/http/httptestпозволяет вызвать хендлер без запуска сервера и проверить статус-код и тело ответа — это следующая статья.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Официальная документация пакета net/http — Go, Go 1.22 Release Notes — Routing Enhancements
Частые вопросы
Зачем создавать свой ServeMux, если есть DefaultServeMux?
Синтаксис «GET /hello» в паттерне не компилируется. Что делать?
Можно ли обойтись без graceful shutdown?
Как добавить параметры пути, например /users/{id}?
Достаточно ли стандартной библиотеки для реального API?
Как запустить сервер на HTTPS без Nginx перед ним?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.