Как работает веб: клиент, сервер, HTTP

Как работает веб: клиент, сервер, HTTP

Веб - это не магия. Это сообщения.

Браузер (клиент) говорит серверу: «Дай страницу». Сервер отвечает: «Держи HTML, CSS, JS». Иногда ещё: «А ты кто вообще?» - и просит авторизацию.

Клиент и сервер

Клиент - программа, которая отправляет запрос. Чаще всего это браузер, но может быть мобильное приложение, curl, Postman или другой сервер.

Сервер - программа, которая слушает запросы и отвечает на них. Это твой Go/PHP/Node код, запущенный на удалённой машине.

Браузер → (HTTP запрос) → Сервер → (HTTP ответ) → Браузер

Одна важная деталь: сервер не звонит первым. Он всегда ждёт, пока клиент попросит. Это модель «запрос-ответ».

Stateless: сервер тебя не помнит

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

Запрос 1: POST /login    → сервер: «ок, ты Дмитрий»
Запрос 2: GET /profile   → сервер: «а ты кто?»

Поэтому идентичность приходится везти с собой в каждом запросе - в cookie или в заголовке Authorization. Это не костыль, а осознанный дизайн, и он даёт бэкендеру две очень практичные вещи:

  • Горизонтальное масштабирование. Запрос может обработать любой из десяти серверов, потому что никто из них ничего не помнит. Если бы сервер держал состояние в памяти процесса, пользователя пришлось бы «прибивать» к конкретной машине.
  • Перезапуск без потерь. Задеплоил новую версию, процесс перезапустился - пользователи ничего не заметили, потому что в памяти процесса не было ничего ценного.
Сложил данные корзины в глобальную переменную процесса. Локально в один процесс всё работает. В проде за балансировщиком стоят три инстанса, запросы летят по кругу, и корзина «то есть, то нет». Состояние живёт в БД, Redis или подписанной cookie - но не в памяти обработчика. Где именно хранить - разбирается в уроке про [cookie, session и localStorage](./14-storage.md).

Что происходит при открытии страницы

Разберём подробно, что происходит между нажатием Enter и появлением картинки на экране. Каждый шаг стоит времени, и знать порядок полезно: половина «сайт не открывается» диагностируется именно по номеру шага, на котором всё встало.

1. URL          браузер разбирает адрес, проверяет свой кэш и список HSTS
2. DNS          имя → IP (кэш браузера → кэш ОС → DNS-резолвер провайдера)
3. TCP          три пакета рукопожатия: SYN → SYN-ACK → ACK
4. TLS          сертификат, проверка подписи, согласование ключей (только HTTPS)
5. HTTP-запрос  GET / HTTP/1.1 плюс заголовки
6. Работа       роутинг, запрос в БД, сборка HTML или JSON
   сервера
7. Ответ        статус, заголовки, тело
8. Парсинг      HTML → DOM, CSS → CSSOM, JS исполняется
9. Догрузка     картинки, шрифты, стили, скрипты - отдельными запросами
10. Отрисовка   layout, paint, composite

Шаги 1-5 - это сеть, и они происходят до того, как твой код вообще узнал о запросе. Шаг 6 - твоя зона ответственности как бэкендера. Шаги 8-10 - работа браузера.

DNS: имя превращается в адрес

DNS (Domain Name System) - телефонная книга интернета. Ты помнишь example.com, а для соединения нужен числовой адрес. Ответ ищется по цепочке: кэш браузера, кэш операционной системы, файл /etc/hosts, потом DNS-резолвер провайдера, который при необходимости идёт к корневым и авторитетным серверам домена.

dig +short example.com          # какой IP отдаёт DNS
dig example.com                 # полный ответ, видно TTL записи
У каждой DNS-записи есть TTL - время, на которое её можно кэшировать. Переехал на новый сервер, поменял A-запись, а часть пользователей ещё идёт на старый IP, пока не истечёт TTL в их кэше. Поэтому перед переездом TTL заранее снижают до 60-300 секунд. Это же объясняет, почему при отладке помогает `/etc/hosts`: он проверяется раньше любого DNS.

TCP и TLS: два рукопожатия до первого байта

TCP-соединение устанавливается за один круг (round trip) между клиентом и сервером, TLS-рукопожатие добавляет ещё один-два. Отсюда неприятный вывод: на канале с задержкой 100 мс первый байт ответа физически не может прийти раньше, чем через 300 мс, даже если сервер отвечает мгновенно.

curl -w "dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" \
  -o /dev/null -s https://example.com
# dns:0.021 connect:0.045 tls:0.112 ttfb:0.160 total:0.161

Эта команда - первый инструмент, когда «сайт тормозит». Большой time_namelookup означает проблему с DNS, большой time_appconnect - с TLS или сертификатом, большой time_starttransfer при быстром подключении - виноват уже бэкенд: медленный запрос в БД или блокирующая обработка.

Одна страница - десятки запросов

HTML в ответе на шаге 7 - это только каркас. Браузер разбирает его и обнаруживает ссылки на CSS, шрифты, скрипты, картинки, каждая - отдельный HTTP-запрос. Типичная страница делает от 20 до 100 запросов, поэтому соединение переиспользуется (Connection: keep-alive), а HTTP/2 умеет вести все эти запросы через одно соединение параллельно. Детали версий протокола - в уроке Основы HTTP.

HTTP - протокол общения

HTTP (HyperText Transfer Protocol) - набор правил: как формировать запрос и ответ.

Запрос

GET /course/sql/08-isolation HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
Accept-Language: ru,en

Состоит из: метод (GET), путь (/course/sql/08-isolation), версия протокола, заголовки (метаданные запроса).

Ответ

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 4521

<!doctype html>
<html>...</html>

Состоит из: статус-код (200 OK), заголовки ответа, тело (HTML, JSON, файл).

HTTP request и response: одинаковая структура - стартовая строка, заголовки, тело

HTTP-методы

Метод говорит серверу, что клиент хочет сделать:

GET - получить данные (страницу, список, файл)
POST - создать что-то новое (отправить форму, создать запись)
PUT - заменить ресурс целиком
PATCH - частично обновить ресурс
DELETE - удалить ресурс
OPTIONS - узнать, какие методы поддерживает сервер (CORS preflight)

GET не должен менять данные на сервере. Если обновишь страницу - запрос повторится, и ничего не сломается. POST может менять - поэтому браузер спрашивает «Отправить форму повторно?».

Идемпотентность методов

Идемпотентный метод - тот, который можно вызвать несколько раз подряд с одинаковым результатом. Повторный вызов не меняет состояние сервера:

GET - идемпотентный (просто читает данные)
PUT - идемпотентный (перезаписывает ресурс одним и тем же значением)
DELETE - идемпотентный (удалить уже удалённое = ничего не происходит)
POST - НЕ идемпотентный (каждый вызов создаёт новый ресурс)
PATCH - НЕ идемпотентный (зависит от реализации)
Сеть ненадёжна: запрос может потеряться, и клиент повторит его. Если метод идемпотентный - повторный запрос безопасен. Если нет (POST) - сервер может создать дубликат. Поэтому оплату и заказы защищают ключом идемпотентности (idempotency key). Подробнее - в уроке [Идемпотентность и дедупликация](/course/event-driven/idempotency).

Статус-коды

Сервер отвечает числом, которое говорит «что произошло». Первая цифра задаёт класс ответа:

Семейства HTTP-статусов: 1xx/2xx/3xx/4xx/5xx с примерами

2xx  получилось
     200 OK              обычный успешный ответ с телом
     201 Created         создали ресурс, в Location - адрес нового
     204 No Content      получилось, но тела нет (удаление, PUT без ответа)

3xx  ищи в другом месте
     301 Moved Permanently  переехали навсегда, браузер закэширует
     302 Found              временно, кэшировать нельзя
     304 Not Modified       у тебя актуальная копия, тело не отправляю

4xx  виноват клиент
     400 Bad Request        сломанный JSON, не тот тип поля
     401 Unauthorized       не представился (нет или истёк токен)
     403 Forbidden          представился, но прав не хватает
     404 Not Found          такого ресурса нет
     409 Conflict           конфликт состояния (email уже занят)
     422 Unprocessable      синтаксис верный, значения не проходят валидацию
     429 Too Many Requests  сработал rate limit

5xx  виноват сервер
     500 Internal Server Error  необработанная паника или исключение
     502 Bad Gateway            nginx достучался до приложения и не получил ответ
     503 Service Unavailable    приложение живо, но не готово (перегрузка, деплой)
     504 Gateway Timeout        приложение не ответило за отведённое время

Для бэкендера в этой таблице зашита диагностика:

  • 401 против 403. Первое лечится новым токеном, второе - изменением прав. Отдавать 403 вместо 401 значит заставить фронтенд гадать, нужно ли отправлять пользователя на логин.
  • 400 против 422. Если тело вообще не разобралось - 400. Если разобралось, но age = -5 - 422. Это разные ветки обработки на клиенте: показать «ошибка приложения» или подсветить конкретное поле формы.
  • 502 и 504 почти всегда твои. Пользователь их видит от nginx, а не от приложения: процесс упал, не слушает порт или не успел ответить за таймаут. В логах приложения при этом может не быть ничего - смотреть надо логи nginx и статус процесса.
  • 301 опасен необратимостью. Браузер кэширует постоянный редирект надолго, и «откатить» его у уже посетивших пользователей нельзя. При сомнениях ставь 302, пока схема адресов не устоялась.
404 - «ты попросил то, чего нет». 500 - «я (сервер) сломался, извини». Если пользователь видит 500 - это баг в твоём коде. 404 - нормальная ситуация. Встречается API, который на любую проблему отвечает `200 OK` и телом `{"error": "not found"}`. Клиент не может отличить успех от ошибки без разбора тела, ретраи и мониторинг слепнут, графики «доля 5xx» показывают идеальную картину при сломанном сервисе. Статус - часть контракта, а не украшение. Как это выглядит с точки зрения клиента - в уроке про [fetch](./11-js-fetch.md).

Заголовки (Headers)

Заголовки - метаданные запроса/ответа. Самые частые:

Content-Type: application/json - тип содержимого
Authorization: Bearer eyJhbG... - токен авторизации
Cache-Control: max-age=3600 - кеширование
Set-Cookie: session=abc123 - установить cookie
Accept: text/html, application/json - что клиент принимает

HTTPS - безопасный HTTP

HTTPS = HTTP + TLS-шифрование. Данные между браузером и сервером зашифрованы - никто посередине (Wi-Fi роутер, провайдер) не может их прочитать или подменить.

Без HTTPS: пароли, куки, данные форм передаются открытым текстом. Сегодня HTTPS - стандарт, браузеры помечают HTTP-сайты как «небезопасные».

URL - адрес ресурса

Анатомия URL: схема, хост, порт, путь, query, фрагмент

Порт 443 (HTTPS) и 80 (HTTP) - стандартные, обычно не пишутся. Query-строка ?page=2 - параметры запроса. Фрагмент #intro - якорь на странице (не отправляется серверу).

Где кончается фронтенд и начинается бэкенд

Граница проходит по HTTP-ответу. Всё, что до отправки ответа - бэкенд. Всё, что после получения - браузер. Но сама граница выглядит по-разному в двух распространённых схемах:

Схема 1: сервер отдаёт готовый HTML (SSR, классический подход)
браузер → GET /profile → сервер: БД + шаблон → HTML → браузер рисует

Схема 2: сервер отдаёт JSON, страницу собирает JS (SPA)
браузер → GET /            → сервер: пустой HTML + bundle.js
браузер → GET /api/profile → сервер: {"name":"Дмитрий"} → JS рисует

В первой схеме бэкенд знает про вёрстку и рендерит шаблоны. Во второй он ничего не знает про кнопки и отдаёт только данные, зато появляются новые заботы: CORS, формат ошибок в JSON и версионирование API. Обе схемы живут поверх одного и того же HTTP, поэтому этот урок одинаково полезен в любой из них.

Минимальный сервер помогает увидеть, что запрос - это просто разобранный текст, к полям которого у тебя есть доступ:

package main

import (
	"fmt"
	"log/slog"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		// Всё это пришло текстом в HTTP-запросе
		slog.Info("request",
			slog.String("method", r.Method),
			slog.String("path", r.URL.Path),
			slog.String("ua", r.UserAgent()),
		)

		name := r.URL.Query().Get("name") // из query-строки ?name=Дмитрий
		if name == "" {
			w.WriteHeader(http.StatusBadRequest) // осознанно выбираем статус
			fmt.Fprintln(w, "name is required")
			return
		}

		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		fmt.Fprintf(w, "Привет, %s\n", name)
	})

	http.ListenAndServe(":8080", nil)
}
<?php
declare(strict_types=1);

// Запуск: php -S localhost:8080 index.php
// Те же части HTTP-запроса, только разложены в суперглобальные массивы
error_log(sprintf(
    'request method=%s path=%s ua=%s',
    $_SERVER['REQUEST_METHOD'],
    parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
    $_SERVER['HTTP_USER_AGENT'] ?? '',
));

$name = $_GET['name'] ?? '';

if ($name === '') {
    http_response_code(400); // осознанно выбираем статус
    header('Content-Type: text/plain; charset=utf-8');
    echo "name is required\n";
    exit;
}

header('Content-Type: text/plain; charset=utf-8');
echo 'Привет, '.$name."\n";

Метод, путь, query, заголовки, статус, тело - в этом коде видны ровно те же сущности, что и в текстовых примерах выше. Дальше подробности расходятся по трекам: HTTP-сервер в Go и формы и запросы в PHP.

Грабли, которые ломают первый день в вебе

  • Порт при разработке и его отсутствие в проде. Локально ты открываешь localhost:8080, потому что порт нестандартный. В проде порт 443 подразумевается по умолчанию, а перед приложением стоит nginx. Из-за этого ссылки, собранные вручную из хоста и порта, ломаются при деплое - генерируй их относительными.
  • Смешанный контент. Страница по HTTPS, а картинка или запрос по http:// - браузер заблокирует запрос и напишет об этом в консоль. Симптом: «в проде не грузится, локально всё хорошо».
  • GET с телом. Технически можно, практически тело потеряется по дороге: прокси и кэши имеют право его выбросить. Параметры чтения живут в query-строке.
  • Кэшированный GET вместо свежих данных. Браузер и прокси могут отдать старый ответ, не спросив сервер. Если данные обязаны быть свежими, это решается заголовками, а не добавлением случайного параметра в URL. Подробнее - в уроке про HTTP-кэширование.
  • Ошибка «на сервере», которой нет в логах приложения. Значит, ответ сформировал не твой код: nginx отдал 502, или запрос вообще не дошёл. Проверять надо по цепочке снаружи внутрь: DNS, TLS, nginx, приложение.
Твой [Go](../go/21-http.md)/[PHP](../php/07-forms.md) сервер говорит с браузером через HTTP. Если понимаешь запрос/ответ, статус-коды и заголовки - половина багов становится очевидной. А вторую половину найдёшь в [DevTools](./07-devtools.md).

Мини-задание

  • Открой DevTools → Network и посмотри любой запрос на example.com
  • Найди метод, статус-код и заголовок Content-Type в ответе
  • Попробуй curl -v https://example.com в терминале и прочитай заголовки
  • Найди запрос, который возвращает JSON (подсказка: API-запросы)
  • Измерь этапы запроса: curl -w "dns:%{time_namelookup} tls:%{time_appconnect} ttfb:%{time_starttransfer}\n" -o /dev/null -s https://example.com - найди самый долгий
  • Посмотри, во что резолвится домен: dig +short example.com, и сравни с IP в DevTools (колонка Remote Address)
  • Найди на любом сайте ответ 301 или 302 и посмотри заголовок Location
  • Посчитай в Network, сколько всего запросов делает одна страница, и сколько из них не HTML
  • Запусти минимальный сервер из урока и открой localhost:8080/hello без параметра name - убедись, что пришёл именно 400

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.