173 lines
20 KiB
Markdown
173 lines
20 KiB
Markdown
# Семинар 3. HTTP на практике
|
||||
|
|
|
|||
|
|
На семинаре мы проследили путь HTTP-запроса: от `curl`, браузера или Python-клиента через Nginx до Flask-приложения и обратно. Ниже — основные понятия и способы повторить демонстрации на [стенде](website/docker-compose.yaml).
|
|||
|
|
|
|||
|
|
## HTTP: запрос и ответ
|
|||
|
|
|
|||
|
|
**Запрос** содержит метод, адрес ресурса (URI), версию HTTP, заголовки и, при необходимости, тело. **Ответ** содержит версию HTTP, код состояния, заголовки и тело. Пустая строка отделяет заголовки от тела.
|
|||
|
|
|
|||
|
|
На запущенном стенде выполните:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -v http://localhost:8080/const
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
В подробном выводе `curl` строки с `>` относятся к запросу, с `<` — к ответу, а с `*` описывают работу самого клиента. `/const` возвращает заранее заданный текст непосредственно из Nginx.
|
|||
|
|
|
|||
|
|
Например, при `curl -4sv http://localhost:8080/` на запущенном стенде получили такой фрагмент вывода:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
> 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-приложении
|
|||
|
|
|
|||
|
|
В [приложении стенда](website/kittens/app.py) Flask связывает пути `/` и `/kittens` с функциями-обработчиками через `@app.route(...)`.
|
|||
|
|
|
|||
|
|
- `/` возвращает текст с версией приложения: `v1` или `v2`.
|
|||
|
|
- `/kittens` запрашивает JSON у внешнего Cat API, извлекает URL изображения и подставляет его в [HTML-шаблон](website/kittens/templates/index.html) через `render_template`.
|
|||
|
|
- Получив HTML, браузер делает **ещё один HTTP-запрос** за самой картинкой. Оба обращения можно увидеть в DevTools Network.
|
|||
|
|
|
|||
|
|
Этот пример не рассматривали как готовое production-приложение. Исходящий запрос к Cat API сделан без явного timeout и проверки HTTP-статуса. Если внешний сервис зависнет, ответит ошибкой или пришлёт неожиданные данные, нужно обработать сбой. В качестве возможного ответа на семинаре предложили заранее сохранённую картинку.
|
|||
|
|
|
|||
|
|
## Как устроен демонстрационный стенд
|
|||
|
|
|
|||
|
|
[Docker Compose](website/docker-compose.yaml) запускает два экземпляра 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](website/nginx.conf) и каталог `static` внутрь контейнера `proxy`. Суффикс `:ro` у файла конфигурации задаёт доступ только для чтения.
|
|||
|
|
|
|||
|
|
Чтобы поднять стенд, перейдите из корня репозитория в каталог с `docker-compose.yaml`. Понадобятся работающий Docker и команда `docker compose`:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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` из того же каталога.
|
|||
|
|
|
|||
|
|
<details>
|
|||
|
|
<summary>Что такое Docker и как в стенде связаны порты</summary>
|
|||
|
|
|
|||
|
|
Docker запускает приложения в контейнерах — изолированных окружениях с нужными зависимостями. Образ Flask-приложения собирается по [Dockerfile](website/kittens/Dockerfile), а для Nginx Compose использует готовый образ `nginx`. Compose запускает три контейнера, задаёт их переменные окружения и публикует нужные порты на вашей машине.
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
Ваша машина Контейнеры
|
|||
|
|
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` вашей машины.
|
|||
|
|
|
|||
|
|
</details>
|
|||
|
|
|
|||
|
|
## Возможности Nginx и файл его конфигурации
|
|||
|
|
|
|||
|
|
В этом стенде Nginx принимает запросы как единая точка входа, распределяет их между копиями приложения, возвращает redirect и сам отдаёт текст или файлы. Какую из этих задач выполнять, определяет его [конфигурация](website/nginx.conf).
|
|||
|
|
|
|||
|
|
**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`, поэтому в браузере можно переходить по его содержимому.
|
|||
|
|
|
|||
|
|
## Как повторить примеры
|
|||
|
|
|
|||
|
|
Команды ниже рассчитаны на запущенный [стенд](website/docker-compose.yaml). `localhost` означает машину, на которой опубликованы порты контейнеров. Флаг `-i` показывает заголовки ответа.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Посмотреть запрос, ответ и заголовки.
|
|||
|
|
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 и предметной области.
|