3. net/http сервер с нуля

В Go стандартная библиотека net/http предоставляет мощный и гибкий HTTP-сервер. Понимание его внутреннего устройства — ключ к написанию производительных и надежных сервисов. Давайте построим HTTP-сервер с нуля, чтобы понять, как всё работает «под капотом».

Большинство высокоуровневых веб-фреймворков в других экосистемах прячут сетевой ввод-вывод за сложными абстракциями сервлетов, пулов потоков и тяжеловесных ASGI/WSGI адаптеров. В Go всё иначе: авторы языка Роб Пайк и Кен Томпсон заложили в net/http прямую связь с системными вызовами ядра Linux и планировщиком рантайма. Здесь нет «черных ящиков». Поняв однажды, как сырые байты из TCP-сокета превращаются в структуры *http.Request и http.ResponseWriter, вы навсегда избавитесь от страха перед высоконагруженными серверами.


Основы: минимальный сервер

Начнем с самого простого примера, который часто приводят в первых главах учебников:

Go
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, происходят следующие шаги:

  1. Создание listener: Go вызывает системный вызов socket() и bind() для открытия порта
  2. Принятие соединений: через accept() сервер получает новые TCP-соединения
  3. Создание горутины: для каждого соединения Go запускает новую горутину
  4. Парсинг HTTP: Go парсит HTTP-запрос и вызывает соответствующий обработчик
  5. Отправка ответа: результат обработки отправляется обратно клиенту
DIAGRAM
Сетевой стек Linux           Рантайм Go netpoller                 Пул горутин приложения
┌─────────────────┐         ┌────────────────────────┐         ┌─────────────────────────┐
│ TCP SYN / ACK   │ ──────> │ epoll_wait(2) пробудил │ ──────> │ go c.serve(connCtx)     │
│ Очередь accept  │         │ net.Listener сокет     │         │ Стек 2 КБ, свой контекст│
└─────────────────┘         └────────────────────────┘         └─────────────────────────┘

Создание сервера с настройками

В боевом продакшене вы никогда не должны вызывать дефолтный http.ListenAndServe без явного указания таймаутов. По умолчанию сервер Go не ограничивает время чтения заголовков или записи ответа. Медленный клиент может открыть TCP-соединение, присылать по одному байту в минуту и удерживать дескриптор сокета бесконечно долго:

Go
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:

Go
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-сервер, но с правильной архитектурой, инкапсулирующей управление жизненным циклом и маршрутизацию:

Go
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):

Go
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 использует следующие механизмы для производительности:

  1. Goroutines: каждое соединение обрабатывается в отдельной горутине
  2. netpoll: эффективное управление тысячами соединений
  3. Connection pooling: повторное использование соединений
  4. Buffering: буферизация чтения и записи

Заключение

Понимание внутреннего устройства HTTP-сервера в Go позволяет:

  • Правильно настраивать таймауты и ограничения
  • Оптимизировать производительность
  • Диагностировать проблемы с соединениями
  • Писать более надежный код

Следующая статья: 4. Роутинг. ServeMux, gin, chi, gorilla mux