HTTP и сетевое программирование в Go
Сеть кажется магией только до тех пор, пока вы не начнете думать о ней как об обычном обмене сообщениями. Один процесс пишет байты, другой процесс их читает. HTTP, TCP, заголовки, статусы, CORS и JSON - это договоренности о том, как эти байты должны выглядеть и что они означают.
Go хорошо подходит для сетевых программ, потому что в стандартной библиотеке уже есть сильные пакеты net, net/http, net/url, net/http/httputil, context, io и time. На практике это значит, что вы можете написать HTTP API, CLI-клиент, reverse proxy, простой TCP-сервер или интеграцию с внешним сервисом без тяжелого фреймворка.
Главная мысль: сеть ненадежна. Запрос может зависнуть, соединение может оборваться, сервер может вернуть HTML вместо JSON, DNS может временно не работать, а клиент может читать ответ слишком медленно. Хороший сетевой код не предполагает идеальный мир.
HTTP как разговор
HTTP-запрос похож на письмо с несколькими обязательными частями:
- метод: что вы хотите сделать, например
GET,POST,PUT,DELETE; - путь: к какому ресурсу вы обращаетесь, например
/users/42; - заголовки: метаданные, например
Content-TypeилиAuthorization; - тело: данные запроса, если они есть;
- статус ответа: результат обработки, например
200,404,500; - тело ответа: данные, которые вернул сервер.
Когда вы пишете backend, вы обычно работаете с двумя сторонами HTTP:
- HTTP-клиент: ваш код отправляет запрос в чужой сервис;
- HTTP-сервер: чужой код или браузер отправляет запрос в ваш сервис.
Эти роли важно не смешивать. У клиента свои проблемы: таймауты, ретраи, парсинг ответа. У сервера свои: маршрутизация, валидация, статус-коды, graceful shutdown.
HTTP-клиент
Для учебного примера можно увидеть http.Get, но в рабочем коде лучше создать http.Client с таймаутом:
client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get("https://api.example.com/data") if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status: %s", resp.Status) }
Без таймаута запрос может ждать бесконечно долго. Для CLI это означает зависшую команду. Для backend-сервиса это означает занятую goroutine, открытое соединение и постепенно растущую нагрузку.
Query parameters
URL-параметры нельзя безопасно собирать простым склеиванием строк. В значениях могут быть пробелы, &, ?, кириллица и другие символы, которые должны быть закодированы.
baseURL := "https://api.example.com/search" params := url.Values{} params.Set("q", "golang http") params.Set("limit", "10") requestURL := baseURL + "?" + params.Encode()
url.Values берет на себя экранирование символов. Это особенно важно, когда значение приходит от пользователя.
JSON-запрос
Большинство современных API обмениваются JSON. В Go обычный путь такой: подготовить структуру, закодировать ее в JSON, создать запрос, выставить заголовки, отправить запрос, проверить статус, прочитать ответ.
type CreateUserRequest struct { Name string `json:"name"` Email string `json:"email"` } payload := CreateUserRequest{ Name: "Alice", Email: "alice@example.com", } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("encode request: %w", err) } req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close()
Обратите внимание: Content-Type говорит серверу, какой формат лежит в теле запроса. Authorization передает credential. Эти заголовки не являются украшением, сервер реально использует их для принятия решений.
Чтение ответа с лимитом
Никогда не стоит бездумно читать огромный ответ в память:
limited := io.LimitReader(resp.Body, 1<<20) // 1 MB data, err := io.ReadAll(limited) if err != nil { return fmt.Errorf("read response: %w", err) }
Если внешний сервис ошибся и вернул гигантский ответ, лимит защитит вашу программу от лишнего потребления памяти.
HTTP-сервер
Минимальный сервер в Go выглядит просто:
mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) server := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err }
ServeMux выбирает обработчик по пути. ResponseWriter нужен, чтобы записать статус, заголовки и тело ответа. Request содержит метод, путь, query parameters, headers, body и контекст запроса.
Таймауты сервера важны по той же причине, что и у клиента. Медленный клиент не должен бесконечно занимать ресурсы сервера.
Статус-коды
Статус-код - это короткий итог обработки:
200 OK: запрос успешно выполнен;201 Created: ресурс создан;204 No Content: успешно, но тела ответа нет;400 Bad Request: клиент отправил неправильные данные;401 Unauthorized: не передана или неверна авторизация;403 Forbidden: пользователь известен, но доступа нет;404 Not Found: ресурс не найден;409 Conflict: конфликт состояния, например email уже занят;500 Internal Server Error: ошибка на стороне сервера.
Новички часто возвращают 500 на любую проблему. Это плохо: frontend, тесты и другие сервисы не понимают, что произошло. Если пользователь отправил невалидный JSON, это не авария сервера, а 400.
Middleware
Middleware - это обработчик, который оборачивает другой обработчик. Он удобен для логирования, проверки авторизации, CORS, recovery после panic, метрик и request id.
func logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) slog.Info("request handled", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start), ) }) }
Использование:
server.Handler = logging(mux)
Мысленно представляйте middleware как слои на входе в здание. Сначала охрана проверяет пропуск, потом администратор записывает время входа, потом человек попадает в нужный кабинет.
Маршрутизация и chi
Стандартный http.ServeMux подходит для многих задач. Когда нужны параметры в пути, группы маршрутов и удобные middleware, часто берут chi.
r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") fmt.Fprintf(w, "user id=%s", id) })
Важно понимать, что роутер не заменяет net/http. Он просто помогает выбрать обработчик. Внутри все равно остаются http.Handler, http.ResponseWriter и *http.Request.
CORS
CORS - это правило браузера. Если frontend открыт на одном origin, а API находится на другом, браузер проверяет, разрешает ли backend такой запрос.
w.Header().Set("Access-Control-Allow-Origin", "https://example.com") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
Для приватных API не стоит автоматически ставить *. Особенно если используются cookie или токены. CORS не является заменой авторизации: он только говорит браузеру, какие cross-origin запросы разрешены.
TCP и net.Conn
HTTP работает поверх TCP. Иногда нужно опуститься ниже HTTP: написать простой TCP-сервер, протокол обмена сообщениями, echo-сервер или сетевой инструмент.
listener, err := net.Listen("tcp", ":9000") if err != nil { return err } defer listener.Close() for { conn, err := listener.Accept() if err != nil { return err } go handleConn(conn) }
Каждое соединение лучше обрабатывать отдельно:
func handleConn(conn net.Conn) { defer conn.Close() if err := conn.SetDeadline(time.Now().Add(5 * time.Minute)); err != nil { return } scanner := bufio.NewScanner(conn) for scanner.Scan() { fmt.Fprintln(conn, "echo:", scanner.Text()) } }
net.Conn похож на файл: из него можно читать и в него можно писать. Разница в том, что на другой стороне сидит процесс, сеть между вами может тормозить, а соединение может оборваться в любой момент.
IP, порты и localhost
Адрес 127.0.0.1 или localhost означает текущую машину. Порт - это номер "двери" внутри машины. Один процесс может слушать :8080, другой :5432, третий :6379.
Когда вы запускаете backend на localhost:8080, вы говорите операционной системе: "пусть мой процесс принимает TCP-соединения на порту 8080". Когда браузер открывает http://localhost:8080, он подключается к этому порту и отправляет HTTP-запрос.
IPv4 и IPv6 - это разные форматы IP-адресов. Для большинства учебных backend-задач достаточно понимать, что 127.0.0.1 - локальная IPv4-петля, а ::1 - локальная IPv6-петля.
Сетевые ошибки
Сетевой код должен спокойно относиться к ошибкам:
- таймаут запроса;
- отказ соединения;
- DNS-ошибка;
- неожиданный статус-код;
- разрыв соединения;
- некорректный JSON;
- слишком большой ответ;
io.EOFпри чтении потока.
Не каждая ошибка означает баг в вашем коде. Но каждая ошибка должна быть обработана так, чтобы программа не зависла, не потеряла ресурс и оставила понятный след в логах.
Чеклист
- У HTTP-клиента есть
Timeout. resp.Bodyзакрывается черезdefer.- Статус-код проверяется до чтения успешного результата.
- URL-параметры собираются через
url.Values. - Большие ответы читаются с лимитом.
- Сервер имеет
ReadTimeout,WriteTimeout,IdleTimeout. - Middleware не прячет бизнес-логику обработчика.
- CORS не используется как замена авторизации.
- TCP-соединения закрываются.
- Ошибки получают контекст через
fmt.Errorf.