3. net/http сервер с нуля
В Go стандартная библиотека net/http предоставляет мощный и гибкий HTTP-сервер. Понимание его внутреннего устройства — ключ к написанию производительных и надежных сервисов. Давайте построим HTTP-сервер с нуля, чтобы понять, как всё работает «под капотом».
Большинство высокоуровневых веб-фреймворков в других экосистемах прячут сетевой ввод-вывод за сложными абстракциями сервлетов, пулов потоков и тяжеловесных ASGI/WSGI адаптеров. В Go всё иначе: авторы языка Роб Пайк и Кен Томпсон заложили в net/http прямую связь с системными вызовами ядра Linux и планировщиком рантайма. Здесь нет «черных ящиков». Поняв однажды, как сырые байты из TCP-сокета превращаются в структуры *http.Request и http.ResponseWriter, вы навсегда избавитесь от страха перед высоконагруженными серверами.
Основы: минимальный сервер
Начнем с самого простого примера, который часто приводят в первых главах учебников:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
http.ListenAndServe(":8080", nil)
}
Этот код создает HTTP-сервер, который слушает порт 8080 и отвечает "Hello, World!" на все запросы. Но за этой простотой скрывается сложная архитектура.
Обратите внимание на второй аргумент nil в вызове http.ListenAndServe(":8080", nil). Это указание использовать http.DefaultServeMux — глобальный мультиплексор стандартной библиотеки. В учебных программах это удобно, однако в промышленных сервисах использование глобального мукса считается антипаттерном: сторонний пакет, импортированный в проект, может зарегистрировать в нем скрытые отладочные ручки (например, профилировщик net/http/pprof), открыв несанкционированный доступ к внутренностям вашей системы из публичного интернета.
Под капотом: как работает ListenAndServe
Когда вы вызываете http.ListenAndServe, происходят следующие шаги:
- Создание listener: Go вызывает системный вызов
socket()иbind()для открытия порта - Принятие соединений: через
accept()сервер получает новые TCP-соединения - Создание горутины: для каждого соединения Go запускает новую горутину
- Парсинг HTTP: Go парсит HTTP-запрос и вызывает соответствующий обработчик
- Отправка ответа: результат обработки отправляется обратно клиенту
Сетевой стек Linux Рантайм Go netpoller Пул горутин приложения
┌─────────────────┐ ┌────────────────────────┐ ┌─────────────────────────┐
│ TCP SYN / ACK │ ──────> │ epoll_wait(2) пробудил │ ──────> │ go c.serve(connCtx) │
│ Очередь accept │ │ net.Listener сокет │ │ Стек 2 КБ, свой контекст│
└─────────────────┘ └────────────────────────┘ └─────────────────────────┘
Создание сервера с настройками
В боевом продакшене вы никогда не должны вызывать дефолтный http.ListenAndServe без явного указания таймаутов. По умолчанию сервер Go не ограничивает время чтения заголовков или записи ответа. Медленный клиент может открыть TCP-соединение, присылать по одному байту в минуту и удерживать дескриптор сокета бесконечно долго:
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
// Маршрутизатор приложения
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
// Создаем сервер с явными таймаутами
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // Защита от Slowloris-атак
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
}
// Запуск сервера
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Server failed to start: %v\n", err)
}
}
Собственный HTTP-сервер: разбор по частям
Чтобы до конца развеять «магию» веб-серверов, снимем все абстракции и напишем минимальный HTTP/1.1 сервер с нуля, используя только сокеты пакета net и буферизованный ввод-вывод bufio:
package main
import (
"bufio"
"fmt"
"net"
"strings"
)
func main() {
// Создаем TCP-слушатель
listener, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Server listening on :8080")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Printf("Accept error: %v\n", err)
continue
}
// Обрабатываем соединение в отдельной горутине
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
request, _ := reader.ReadString('\n')
// Парсим первый заголовок HTTP-запроса
parts := strings.Split(request, " ")
if len(parts) >= 2 {
method := parts[0]
path := parts[1]
response := buildResponse(method, path)
conn.Write(response)
}
}
func buildResponse(method, path string) []byte {
var body string
switch path {
case "/":
body = "<h1>Welcome to our server!</h1>"
case "/health":
body = "OK"
default:
body = "Not Found"
}
response := fmt.Sprintf(
"HTTP/1.1 200 OK\r\n"+
"Content-Type: text/html\r\n"+
"Content-Length: %d\r\n"+
"\r\n%s",
len(body), body,
)
return []byte(response)
}
Правильная реализация с использованием net/http
Теперь давайте создадим полноценный HTTP-сервер, но с правильной архитектурой, инкапсулирующей управление жизненным циклом и маршрутизацию:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type Server struct {
httpServer *http.Server
}
func NewServer(port string, handler http.Handler) *Server {
return &Server{
httpServer: &http.Server{
Addr: ":" + port,
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
},
}
}
func (s *Server) Start() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Stop(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
func main() {
// Создаем маршруты
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/health", healthHandler)
// Создаем сервер
server := NewServer("8080", mux)
// Запускаем сервер в отдельной горутине
go func() {
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
}()
fmt.Println("Server started on :8080")
// Ждем сигнал остановки
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("Shutting down server...")
// Корректно завершаем сервер
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Stop(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
fmt.Println("Server exited")
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<h1>Welcome to our server!</h1>")
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
Как работает HTTP-сервер под капотом
Внутри net/http сервер использует следующую архитектуру:
graph TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Client Request"]:::entry --> B["TCP Connection"]
B --> C["accept() syscall"]
C --> D["New Goroutine per Connection"]
D --> E["HTTP Request Parsing"]
E --> F["Route Matching"]
F --> G["Handler Execution"]
G --> H["Response Writing"]
H --> I["Connection Close"]
Тонкие настройки сервера
В высоконагруженных промышленных системах критически важно конфигурировать не только общие таймауты, но и специфические параметры защиты от атак типа Denial of Service (DoS):
func configureServer() *http.Server {
return &http.Server{
Addr: ":8080",
Handler: setupRoutes(),
// Таймауты
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
// Ограничения
MaxHeaderBytes: 1 << 20, // 1MB
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
// Кастомный ConnState callback
ConnState: func(conn net.Conn, state http.ConnState) {
log.Printf("Connection state changed: %v for %v", state, conn.RemoteAddr())
},
// Кастомный ErrorLog
ErrorLog: log.New(os.Stderr, "http: ", log.LstdFlags),
}
}
Параметр ReadHeaderTimeout заслуживает особого внимания: он задает максимальное время, отведенное клиенту на отправку заголовков запроса. Это ключевая защита от атаки Slowloris, при которой злоумышленник открывает тысячи соединений и шлет заголовки со скоростью 1 байт каждые несколько секунд. Если ReadHeaderTimeout не задан, такие соединения забивают таблицу дескрипторов ядра и исчерпывают лимиты сервера.
Производительность и ограничения
HTTP-сервер в Go использует следующие механизмы для производительности:
- Goroutines: каждое соединение обрабатывается в отдельной горутине
- netpoll: эффективное управление тысячами соединений
- Connection pooling: повторное использование соединений
- Buffering: буферизация чтения и записи
Заключение
Понимание внутреннего устройства HTTP-сервера в Go позволяет:
- Правильно настраивать таймауты и ограничения
- Оптимизировать производительность
- Диагностировать проблемы с соединениями
- Писать более надежный код
Следующая статья: 4. Роутинг. ServeMux, gin, chi, gorilla mux