#
LD31D
Реалізуємо HTTP сервер поверх TCP

Реалізуємо HTTP сервер поверх TCP

Кожного дня ми використовуємо протокол HTTP, навіть не замислюючись, як саме він працює. Сьогодні ми розберемося з тим, як він передає дані, та напишемо власний мінімальний сервер на Golang поверх TCP.

Ми не будемо заглиблюватися у всі аспекти його роботи. Наша мета - реалізувати базовий парсинг HTTP-запитів та формування відповідей.

Ця стаття є початковою в циклі вивчення протоколу HTTP. У наступних матеріалах ми розглянемо інші цікаві можливості цього протоколу.

HTTP

HTTP (HyperText Transfer Protocol) - протокол передачі гіпертексту. Одним з прикладів його використання є браузер, коли ми відкриваємо якусь інтернет сторінку браузер надсилає HTTP-запит на сервер та отримує відповідь з HTML яку потім відмальовує для користувача.

sequenceDiagram
    Browser->>Server: HTTP request
    Server->>Browser: HTTP response

Запит

Запит має такий вигляд:

Method Path Version
Headers

Body

Де:

  • Method - HTTP метод
  • Path - шлях до endpoint
  • Version - версія HTTP
  • Headers - заголовки
  • Body - опціональне тіло запиту

Приклад

Коли ми відкриваємо google.com наш браузер відправляє такий запит на сервери google:

GET / HTTP/1.1
Accept: */*
Accept-Encoding: deflate, gzip
User-Agent: ...
Host: google.com

Відповідь

Відповідь має такий вигляд:

Version Status
Headers

Body

Приклад

В якості відповіді на запит до google.com ми отримаємо щось схоже:

HTTP/1.1 200 OK
Date: Fri, 23 Jan 2026 10:15:59 GMT
Content-Type: text/html; charset=UTF-8

<!doctype html>...

Пишемо власний сервер

Наш сервер повинен працювати по такому принципу:

  1. Отримати запити
  2. Розпарсити HTTP-запит
  3. Обробити запит
  4. Повернути відповідь

TCP сервер і прийом з’єднань

HTTP працює поверх протоколу TCP, тому нам потрібно написати простий TCP сервер, у Golang для цього є вбудований пакет net.

func main() {
	const host = "0.0.0.0:8080"

	listener, err := net.Listen("tcp", host)

	if err != nil {
		fmt.Println("Error starting server:", err)
		return
	}

	defer listener.Close()

	fmt.Println("Server is listening on:", host)

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}

		go handleConnection(conn)
	}
}

В даному коді ми запускаємо TCP сервер, приймаємо зʼєднання та передаємо його для обробки в функцію handleConnection.

Обробка підключення

Для кожного підключення ми повинні:

  1. Прочитати дані
  2. Розпарсити запит
  3. Обробити запит
  4. Відправити відповідь
func readHTTPRequest(conn net.Conn) (*HTTPRequest, error) {
	buf := make([]byte, 4096)

	n, err := conn.Read(buf)
	if err != nil {
		return nil, err
	}

	requestString := string(buf[:n])
	httpRequest, err := parseHTTPRequest(requestString)
	if err != nil {
		return nil, err
	}

	return httpRequest, nil
}

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

	httpRequest, err := readHTTPRequest(conn)
	if err != nil {
		fmt.Println("Error reading HTTP request: ", err.Error())
		return
	}

	httpResponse := handleRequest(httpRequest)
	responseBytes := httpResponse.toBytes()

	_, err = conn.Write(responseBytes)
	if err != nil {
		fmt.Println("Error writing response to connection:", err)
		return
	}
}

Для спрощення реалізації сервер читає лише 4096 байт за один виклик Read.
У реальній реалізації читання повинно відбуватися в циклі, поки не буде знайдено \r\n\r\n і, за потреби, повністю прочитано тіло запиту.

У нашому прикладі, якщо запит буде більшим за 4096 байт, частина даних буде втрачена.

Парсинг запиту

Коли ми отримали запит нам треба дістати з нього всю корисну для нас інформацію:

  • метод
  • шлях
  • заголовки
  • тіло

Запит має такий формат:

Method Path Version \r\n
Headers \r\n
\r\n
Body \r\n

Кожен рядок закінчується на \r\n.

Код:

type HTTPRequest struct {
	Method  string
	Path    string
	Headers map[string]string
	Body    []byte
}

func parseHTTPRequest(request string) (*HTTPRequest, error) {
	lines := strings.Split(request, "\r\n")
	if len(lines) < 1 {
		return nil, fmt.Errorf("invalid HTTP request")
	}

	requestLine := strings.Split(lines[0], " ")
	if len(requestLine) < 3 {
		return nil, fmt.Errorf("invalid request line")
	}

	method := requestLine[0]
	path := requestLine[1]
	headers := make(map[string]string)

	for _, line := range lines[1:] {
		if line == "" {
			break
		}
		headerParts := strings.SplitN(line, ": ", 2)
		if len(headerParts) == 2 {
			headers[headerParts[0]] = headerParts[1]
		}
	}

	bodyIndex := strings.Index(request, "\r\n\r\n")
	body := ""
	if bodyIndex != -1 {
		body = request[bodyIndex+4:]
	}

	return &HTTPRequest{
		Method:  method,
		Path:    path,
		Headers: headers,
		Body:    []byte(body),
	}, nil
}

Відповіді

Відповідь повинна мати таку структуру:

Version Status \r\n
Headers \r\n
\r\n
Body \r\n

Нам треба створити структуру відповіді та метод, який буде переводити її у байти.

Код:

type HTTPStatus string

const (
	HTTPStatusOK       HTTPStatus = "200 OK"
	HTTPStatusNotFound HTTPStatus = "404 Not Found"
)

type HTTPResponse struct {
	StatusCode HTTPStatus
	Headers    map[string]string
	Body       []byte
}

func (response *HTTPResponse) toBytes() []byte {
	statusLine := fmt.Sprintf("HTTP/1.1 %s\r\n", response.StatusCode)

	headers := ""
	for key, value := range response.Headers {
		headers += fmt.Sprintf("%s: %s\r\n", key, value)
	}

	if response.Body != nil {
		headers += fmt.Sprintf("Content-Length: %d\r\n", len(response.Body))
	}

	data := []byte(statusLine + headers + "\r\n")
	data = append(data, response.Body...)

	return data
}

Обробка запитів

Це найцікавіший етап, все що відбувається на цьому етапі фреймворки виносять в handlers.

Ми можемо як завгодно обробляти наші запити, але на даному етапі додамо лише 2 обробники. Якщо користувач буде надсилати GET-запит на / - він отримає HTML з заголовком Hello, World!. На будь-які інші запити сервер буде повертати 404.

func handleRequest(request *HTTPRequest) *HTTPResponse {
	if request.Method == "GET" && request.Path == "/" {
		return &HTTPResponse{
			StatusCode: HTTPStatusOK,
			Headers: map[string]string{
				"Content-Type": "text/html",
				"Connection":   "close",
			},
			Body: []byte("<h1>Hello, World!</h1>"),
		}
	}

	return &HTTPResponse{
		StatusCode: HTTPStatusNotFound,
		Headers: map[string]string{
			"Content-Type": "text/plain",
			"Connection":   "close",
		},
		Body: []byte("404 Not Found"),
	}
}

Повний код

package main

import (
	"fmt"
	"net"
	"strings"
)

type HTTPRequest struct {
	Method  string
	Path    string
	Headers map[string]string
	Body    []byte
}

func parseHTTPRequest(request string) (*HTTPRequest, error) {
	lines := strings.Split(request, "\r\n")
	if len(lines) < 1 {
		return nil, fmt.Errorf("invalid HTTP request")
	}

	requestLine := strings.Split(lines[0], " ")
	if len(requestLine) < 3 {
		return nil, fmt.Errorf("invalid request line")
	}

	method := requestLine[0]
	path := requestLine[1]
	headers := make(map[string]string)

	for _, line := range lines[1:] {
		if line == "" {
			break
		}
		headerParts := strings.SplitN(line, ": ", 2)
		if len(headerParts) == 2 {
			headers[headerParts[0]] = headerParts[1]
		}
	}

	bodyIndex := strings.Index(request, "\r\n\r\n")
	body := ""
	if bodyIndex != -1 {
		body = request[bodyIndex+4:]
	}

	return &HTTPRequest{
		Method:  method,
		Path:    path,
		Headers: headers,
		Body:    []byte(body),
	}, nil
}

type HTTPStatus string

const (
	HTTPStatusOK       HTTPStatus = "200 OK"
	HTTPStatusNotFound HTTPStatus = "404 Not Found"
)

type HTTPResponse struct {
	StatusCode HTTPStatus
	Headers    map[string]string
	Body       []byte
}

func (response *HTTPResponse) toBytes() []byte {
	statusLine := fmt.Sprintf("HTTP/1.1 %s\r\n", response.StatusCode)

	headers := ""
	for key, value := range response.Headers {
		headers += fmt.Sprintf("%s: %s\r\n", key, value)
	}

	if response.Body != nil {
		headers += fmt.Sprintf("Content-Length: %d\r\n", len(response.Body))
	}

	data := []byte(statusLine + headers + "\r\n")
	data = append(data, response.Body...)

	return data
}

func handleRequest(request *HTTPRequest) *HTTPResponse {
	if request.Method == "GET" && request.Path == "/" {
		return &HTTPResponse{
			StatusCode: HTTPStatusOK,
			Headers: map[string]string{
				"Content-Type": "text/html",
				"Connection":   "close",
			},
			Body: []byte("<h1>Hello, World!</h1>"),
		}
	}

	return &HTTPResponse{
		StatusCode: HTTPStatusNotFound,
		Headers: map[string]string{
			"Content-Type": "text/plain",
			"Connection":   "close",
		},
		Body: []byte("404 Not Found"),
	}
}

func readHTTPRequest(conn net.Conn) (*HTTPRequest, error) {
	buf := make([]byte, 4096)

	n, err := conn.Read(buf)
	if err != nil {
		return nil, err
	}

	requestString := string(buf[:n])
	httpRequest, err := parseHTTPRequest(requestString)
	if err != nil {
		return nil, err
	}

	return httpRequest, nil
}

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

	httpRequest, err := readHTTPRequest(conn)
	if err != nil {
		fmt.Println("Error reading HTTP request: ", err.Error())
		return
	}

	httpResponse := handleRequest(httpRequest)
	responseBytes := httpResponse.toBytes()

	_, err = conn.Write(responseBytes)
	if err != nil {
		fmt.Println("Error writing response to connection:", err)
		return
	}
}

func main() {
	const host = "0.0.0.0:8080"

	listener, err := net.Listen("tcp", host)

	if err != nil {
		fmt.Println("Error starting server:", err)
		return
	}

	defer listener.Close()

	fmt.Println("Server is listening on:", host)

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}

		go handleConnection(conn)
	}
}

Резюме

Сьогодні ми розібралися, як працює HTTP на базовому рівні та реалізували мінімальний HTTP-сервер поверх TCP.

Наша реалізація є навчальною і має обмеження. Проте саме ці обмеження дозволяють побачити базову механіку HTTP без складності, зосереджуючись на основних ідеях.

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