Как работает веб: клиент, сервер, HTTP
Как работает веб: клиент, сервер, HTTP
Веб - это не магия. Это сообщения.
Браузер (клиент) говорит серверу: «Дай страницу». Сервер отвечает: «Держи HTML, CSS, JS». Иногда ещё: «А ты кто вообще?» - и просит авторизацию.
Клиент и сервер
Клиент - программа, которая отправляет запрос. Чаще всего это браузер, но может быть мобильное приложение, curl, Postman или другой сервер.
Сервер - программа, которая слушает запросы и отвечает на них. Это твой Go/PHP/Node код, запущенный на удалённой машине.
Браузер → (HTTP запрос) → Сервер → (HTTP ответ) → Браузер
Одна важная деталь: сервер не звонит первым. Он всегда ждёт, пока клиент попросит. Это модель «запрос-ответ».
Stateless: сервер тебя не помнит
HTTP не хранит состояние между запросами. Каждый запрос приходит как первый: сервер не знает, что минуту назад ты уже логинился, если ты сам об этом не напомнишь.
Запрос 1: POST /login → сервер: «ок, ты Дмитрий»
Запрос 2: GET /profile → сервер: «а ты кто?»
Поэтому идентичность приходится везти с собой в каждом запросе - в cookie или в заголовке Authorization. Это не костыль, а осознанный дизайн, и он даёт бэкендеру две очень практичные вещи:
- Горизонтальное масштабирование. Запрос может обработать любой из десяти серверов, потому что никто из них ничего не помнит. Если бы сервер держал состояние в памяти процесса, пользователя пришлось бы «прибивать» к конкретной машине.
- Перезапуск без потерь. Задеплоил новую версию, процесс перезапустился - пользователи ничего не заметили, потому что в памяти процесса не было ничего ценного.
Что происходит при открытии страницы
Разберём подробно, что происходит между нажатием 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 записи
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-методы
Метод говорит серверу, что клиент хочет сделать:
GET - получить данные (страницу, список, файл)
POST - создать что-то новое (отправить форму, создать запись)
PUT - заменить ресурс целиком
PATCH - частично обновить ресурс
DELETE - удалить ресурс
OPTIONS - узнать, какие методы поддерживает сервер (CORS preflight)
GET не должен менять данные на сервере. Если обновишь страницу - запрос повторится, и ничего не сломается. POST может менять - поэтому браузер спрашивает «Отправить форму повторно?».
Идемпотентность методов
Идемпотентный метод - тот, который можно вызвать несколько раз подряд с одинаковым результатом. Повторный вызов не меняет состояние сервера:
GET - идемпотентный (просто читает данные)
PUT - идемпотентный (перезаписывает ресурс одним и тем же значением)
DELETE - идемпотентный (удалить уже удалённое = ничего не происходит)
POST - НЕ идемпотентный (каждый вызов создаёт новый ресурс)
PATCH - НЕ идемпотентный (зависит от реализации)
Статус-коды
Сервер отвечает числом, которое говорит «что произошло». Первая цифра задаёт класс ответа:
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, пока схема адресов не устоялась.
Заголовки (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 - адрес ресурса
Порт 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, приложение.
Мини-задание
- Открой 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