Реалізуємо 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>...
Пишемо власний сервер
Наш сервер повинен працювати по такому принципу:
- Отримати запити
- Розпарсити HTTP-запит
- Обробити запит
- Повернути відповідь
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.
Обробка підключення
Для кожного підключення ми повинні:
- Прочитати дані
- Розпарсити запит
- Обробити запит
- Відправити відповідь
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.