20 KiB
Семинар 3. HTTP на практике
На семинаре мы проследили путь HTTP-запроса: от curl, браузера или Python-клиента через Nginx до Flask-приложения и обратно. Ниже — основные понятия и способы повторить демонстрации на стенде.
HTTP: запрос и ответ
Запрос содержит метод, адрес ресурса (URI), версию HTTP, заголовки и, при необходимости, тело. Ответ содержит версию HTTP, код состояния, заголовки и тело. Пустая строка отделяет заголовки от тела.
На запущенном стенде выполните:
curl -v http://localhost:8080/const
В подробном выводе curl строки с > относятся к запросу, с < — к ответу, а с * описывают работу самого клиента. /const возвращает заранее заданный текст непосредственно из Nginx.
Например, при curl -4sv http://localhost:8080/ на запущенном стенде получили такой фрагмент вывода:
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.31.6
< Content-Type: text/html; charset=utf-8
< Content-Length: 17
< Connection: keep-alive
< X-Upstream: 172.18.0.2:5002
<
Hello from app v2
Здесь Nginx передал запрос одному из Flask-серверов и вернул его ответ клиенту. X-Upstream показывает адрес выбранного сервера внутри сети Docker; при другом запуске адрес, версия приложения и версия Nginx могут отличаться. Флаги -4 и -s в примере убирают попытку соединиться по IPv6 и индикатор прогресса, а -v показывает обмен запросом и ответом.
Заголовки, которые разбирали на занятии:
| Заголовок | Где смотреть | Что показывает |
|---|---|---|
Host |
Запрос | К какому сайту или приложению обращается клиент, в том числе когда один адрес и порт обслуживают несколько сайтов. |
User-Agent |
Запрос | Сведения о клиенте, например curl. |
Accept |
Запрос | Какие форматы ответа клиент готов принять. |
Accept-Encoding |
Запрос браузера | Какие способы сжатия ответа клиент поддерживает. |
Server |
Ответ | Какой сервер отправил ответ; в демонстрации через proxy виден Nginx. |
Content-Type |
Ответ | Формат тела ответа, например обычный текст, HTML или JSON. |
Connection: keep-alive |
Ответ в примере | TCP-соединение можно использовать повторно, не устанавливая его заново перед каждым запросом. |
Браузер тоже HTTP-клиент. Во вкладке Network в DevTools найдите запрос к /const и сравните его заголовки и ответ с выводом curl. Браузер может посылать дополнительные заголовки: два клиента не обязаны формировать запрос одинаково.
HTTP запросы из кода
На примере Python-библиотеки requests разобрали GET-запрос, query-параметры, timeout и проверку статуса.
- Query-параметры — пары «ключ — значение» после
?в URI. Передавайте их через аргументparams, а не собирайте URI вручную: символ&внутри значения иначе можно принять за разделитель параметров. timeout=(1, 3)в показанном примере задаёт 1 секунду на установление соединения и 3 секунды на ожидание данных при чтении ответа. Второе число — не общий предел времени на получение всего ответа: пока сервер регулярно присылает данные, запрос может длиться дольше. Без timeout вызов зависимого сервиса может ждать слишком долго.raise_for_status()помогает обнаружить ответ с ошибочным HTTP-статусом и обработать его в коде.- Если после timeout или ошибки зависимого сервиса нужных данных нет, приложение может вернуть заранее предусмотренный fallback. На семинаре обсуждали fallback-текст и запасную картинку кота.
Что происходит в Flask-приложении
В приложении стенда Flask связывает пути / и /kittens с функциями-обработчиками через @app.route(...).
/возвращает текст с версией приложения:v1илиv2./kittensзапрашивает JSON у внешнего Cat API, извлекает URL изображения и подставляет его в HTML-шаблон черезrender_template.- Получив HTML, браузер делает ещё один HTTP-запрос за самой картинкой. Оба обращения можно увидеть в DevTools Network.
Этот пример не рассматривали как готовое production-приложение. Исходящий запрос к Cat API сделан без явного timeout и проверки HTTP-статуса. Если внешний сервис зависнет, ответит ошибкой или пришлёт неожиданные данные, нужно обработать сбой. В качестве возможного ответа на семинаре предложили заранее сохранённую картинку.
Как устроен демонстрационный стенд
Docker Compose запускает два экземпляра Flask-приложения и Nginx. Запись порт_хоста:порт_контейнера означает, что слева указан порт для обращения с вашей машины, справа — порт внутри контейнера.
| Сервис | Внутри контейнера | С вашей машины | Назначение |
|---|---|---|---|
server1 |
5001 |
9001 |
Flask-приложение с APP_VERSION=v1. |
server2 |
5002 |
9002 |
То же приложение с APP_VERSION=v2. |
proxy |
80 |
8080 |
Nginx: proxy, redirect и /const. |
proxy |
10000 |
8081 |
Nginx: раздача файлов из static. |
Compose подключает конфигурацию Nginx и каталог static внутрь контейнера proxy. Суффикс :ro у файла конфигурации задаёт доступ только для чтения.
Чтобы поднять стенд, перейдите из корня репозитория в каталог с docker-compose.yaml. Понадобятся работающий Docker и команда docker compose:
cd materials/03-http/seminar/website
docker compose up --build -d
docker compose ps
up --build собирает образ Flask-приложения и запускает сервисы; -d оставляет контейнеры работать в фоне. docker compose ps показывает их состояние. Современный Compose может предупредить, что поле version в файле устарело; это предупреждение не мешает запуску. Закончив с примерами, остановите и удалите контейнеры стенда командой docker compose down из того же каталога.
Что такое Docker и как в стенде связаны порты
Docker запускает приложения в контейнерах — изолированных окружениях с нужными зависимостями. Образ Flask-приложения собирается по Dockerfile, а для Nginx Compose использует готовый образ nginx. Compose запускает три контейнера, задаёт их переменные окружения и публикует нужные порты на вашей машине.
Ваша машина Контейнеры
localhost:9001 ── 9001:5001 ──▶ server1:5001 (Flask v1)
localhost:9002 ── 9002:5002 ──▶ server2:5002 (Flask v2)
localhost:8080 ── 8080:80 ──▶ proxy:80 (Nginx)
└─▶ server1:5001 или server2:5002
localhost:8081 ── 8081:10000 ─▶ proxy:10000 (файлы из static)
Слева от : в Compose указан порт вашей машины, справа — порт контейнера. Когда Nginx обращается к server1:5001 или server2:5002, он использует внутренние адреса сервисов, а не порты 9001 и 9002 вашей машины.
Возможности Nginx и файл его конфигурации
В этом стенде Nginx принимает запросы как единая точка входа, распределяет их между копиями приложения, возвращает redirect и сам отдаёт текст или файлы. Какую из этих задач выполнять, определяет его конфигурация.
Reverse proxy принимает запрос клиента и сам обращается к backend. В группе upstream стенда перечислены server1:5001 и server2:5002. Для / и /kittens Nginx выбирает один из них и добавляет в ответ X-Upstream с адресом выбранного backend.
Если один backend перестаёт отвечать, Nginx временно исключает его из балансировки и направляет новые запросы к оставшимся живым репликам. При ошибке соединения он может попробовать другую реплику и для текущего запроса, поэтому отказ одного сервера не обязательно приводит к ошибке у клиента.
Когда мы несколько раз отправили запрос к / через Nginx, он по очереди направил запросы к двум Flask-серверам: ответы v1 и v2 чередовались. Так мы увидели round-robin — простой алгоритм выбора backend по очереди. Для трёх серверов порядок мог бы выглядеть так: 3 → 1 → 2 → 3 → 1 → 2. Алгоритм не оценивает, сколько работы потребует конкретный запрос. Если серверы различаются по мощности или сетевой задержке, равное число запросов может дать им разную нагрузку; в обсуждении упоминали веса backend-серверов. При этом клиент обращается к одной точке входа — Nginx — и не выбирает реплику сам.
При redirect Nginx возвращает клиенту код 3xx и заголовок Location с новым URI. Следующий запрос по этому URI делает уже клиент; при reverse proxy Nginx обращается к backend сам. Правило /search/ на стенде перенаправляет запрос в поиск Google: браузер следует переходу автоматически, а curl можно передать флаг -L. Как прикладной случай обсудили перенаправление со старого URI /promotions на новый /discounts.
Кроме проксирования и redirect, Nginx сам отдаёт текст по /const и статические файлы через порт 8081. Для каталога static включён autoindex, поэтому в браузере можно переходить по его содержимому.
Как повторить примеры
Команды ниже рассчитаны на запущенный стенд. localhost означает машину, на которой опубликованы порты контейнеров. Флаг -i показывает заголовки ответа.
# Посмотреть запрос, ответ и заголовки.
curl -v http://localhost:8080/const
# Обратиться напрямую к двум экземплярам Flask-приложения.
curl -i http://localhost:9001/
curl -i http://localhost:9002/
# Обратиться через Nginx; повторите команду и сравните ответ и X-Upstream.
curl -i http://localhost:8080/
# Посмотреть redirect на поиск Google, затем пройти по нему.
curl -i http://localhost:8080/search/cats
curl -L http://localhost:8080/search/cats
# Получить файл, который отдаёт сам Nginx.
curl -i http://localhost:8081/greetings.txt
Ещё два опыта удобно провести в браузере: откройте http://localhost:8080/const и сравните его запрос с curl во вкладке Network; затем откройте http://localhost:9001/kittens и найдите отдельную загрузку изображения. /kittens зависит от внешнего Cat API, а переход на Google — от доступности Google.
API заказов: набросок контракта
В конце занятия мы наметили операции интернет-магазина: создать заказ, получить его данные, изменить адрес или другую часть заказа. Для создания предложили POST, для чтения — GET, для частичного изменения — PATCH; в зависимости от контракта приложение может использовать PUT. Упомянули и системы, где для упрощения чтение реализуют через POST с телом запроса, но не представляли это как универсальное правило.
В результате можно предложить следующий контракт:
| Операция | Запрос | Успешный ответ | Почему так |
|---|---|---|---|
| Создать заказ | POST /api/orders с данными заказа в JSON |
201 Created, заголовок Location с URI заказа |
Запрос отправляют коллекции заказов; сервер обрабатывает данные и создаёт новый заказ с собственным ID. |
| Получить заказ | GET /api/orders/{id} |
200 OK и данные заказа в JSON |
Клиент обращается к уже известному заказу по его ID и читает данные, не создавая новую сущность. |
| Частично изменить заказ | PATCH /api/orders/{id} с JSON, например с новым адресом |
200 OK и обновлённый заказ в JSON |
Клиент указывает существующий заказ и передаёт только те данные, которые нужно изменить. |
| Удалить заказ | DELETE /api/orders/{id} |
204 No Content |
Клиент указывает конкретный заказ, который нужно удалить; тело успешного ответа не требуется. |
Если заказ не найден, для чтения, изменения или удаления предусмотрен 404 Not Found; если данные для создания неверны — ошибка клиента, например 400 Bad Request. Для долгой обработки допускается отдать 202 Accepted и отдельный ресурс операции.
Повторы запросов и идемпотентность
GET только читает заказ, поэтому его повтор не создаёт новую сущность. С POST /api/orders иначе: если клиент не получил ответ и повторил запрос, он может случайно создать ещё один заказ. Сервису нужно понять, относится ли повтор к той же логической операции. Для этого можно использовать токен идемпотентности: повтор с тем же токеном сервис распознаёт и не создаёт второй заказ. На семинаре обсудили два способа получить такой идентификатор.
- Ключ создаёт клиент. Например, фронтенд генерирует токен и передаёт его в заголовке или в JSON-теле запроса. Сервис сохраняет токен, чтобы определить возможные повторы запросов. Если ключ нужен лишь на время нескольких попыток и позже клиент его не воспроизводит, хранить его бессрочно незачем: подойдёт кэш на стороне сервиса с ограниченным сроком жизни, например несколько минут. Важно, чтобы на протяжении этого срока повторные попытки запроса приходили с тем же ключом.
- Ключ создаёт сервис. Если предметная область такова, что у сущности имеется некоторый уникальный идентификатор (например,
order.idв случае создания заказа), то сервис может использовать этот ID как основу ключа идемпотентности. Посколькуorder.idскорее всего хранится в базе вместе с заказом, такой ключ идемпотентности может жить столько же, сколько запись о заказе; отдельное короткое время жизни, как у временного ключа в кэше, ему не обязательно.
Таким образом, выбор способа и срока хранения ключа идемпотентности зависит от контракта API и предметной области.