Содержание

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 при чтении потока.

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

Чеклист

  1. У HTTP-клиента есть Timeout.
  2. resp.Body закрывается через defer.
  3. Статус-код проверяется до чтения успешного результата.
  4. URL-параметры собираются через url.Values.
  5. Большие ответы читаются с лимитом.
  6. Сервер имеет ReadTimeout, WriteTimeout, IdleTimeout.
  7. Middleware не прячет бизнес-логику обработчика.
  8. CORS не используется как замена авторизации.
  9. TCP-соединения закрываются.
  10. Ошибки получают контекст через fmt.Errorf.

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи