NET·VI Сети Глава 43 из 65

Анатомия этой страницы

Вы набрали адрес, и через полсекунды перед вами страница. Глава вскрывает её саму: имя в DNS, заголовки ответа сервера, HTML, из которого браузер вырастил дерево, и тайминги загрузки, которые ваш браузер записал минуту назад. По дороге — записка Тима Бернерса-Ли 1989 года, на которой начальник написал «расплывчато, но увлекательно», и свой веб-сервер на сокетах.

Университет 60 минут Веб Сети История
NET·VI

Сети

  1. 41 Сети
  2. 42 TCP/IP
  3. 43 Веб вы здесь
  4. 44 Распределённые

Опирается на: 42 · Изобрести протокол 07 · Собеседник из строк

Что вы унесёте из главы

  • прочитать путь от адреса в строке браузера до готовой страницы: DNS, соединение, запрос, ответ, разбор и отрисовка — и найти, где теряется время
  • писать и читать HTTP руками: запрос, коды ответа, заголовки, куки, кэш — и поднять свой сервер и JSON API на Python
  • понимать, как браузер превращает HTML в дерево и почему он прощает ошибки, которых не прощает Python

Прошлая глава кончилась вопросом: вы набираете в браузере legost.in и жмёте Enter — что происходит? Отвечать на него можно по учебнику, а можно по уликам. Улики у нас есть: вы читаете эту страницу, значит, всё, о чём спрашивалось, уже произошло — несколько секунд или минут назад, в вашем браузере. И браузер вёл протокол. Он записал, когда начал искать адрес сервера, когда установил соединение, когда получил первый байт ответа и когда закончил рисовать. Вот этот протокол.

Загрузка этой страницы в вашем браузере, по его собственным записям (Navigation Timing). Нажмите на строку — появится, что происходило на этом шаге и в каком разделе главы он разобран. «Перезагрузить и сравнить» запомнит эти числа, перезагрузит страницу и положит новые рядом со старыми. На вкладке «Файлы» — всё, что страница скачала после HTML, на вкладке «Заголовки» — ответ сервера.

Строк в протоколе с десяток, и каждая — отдельное событие. Найти, где живёт сервер. Договориться с ним о соединении и о шифре. Попросить страницу. Дождаться, пока сервер её соберёт. Получить, разобрать, раскрасить, нарисовать. У кого-то из читателей первая строка займёт сотни миллисекунд, у кого-то — ноль, и почему так бывает, станет понятно к середине главы. Мы проведём вскрытие по всем правилам: снаружи внутрь, от того, что видно в адресной строке, до того, что видно на экране, — и в конце вернёмся к этой шкале, когда каждая полоска на ней будет понятна.

Расплывчато, но увлекательно

Чтобы паутина заработала, Бернерс-Ли понадобились три изобретения, и все три вы использовали, открывая эту страницу. Первое — адрес, который однозначно называет документ в любой точке сети. Второе — протокол, по которому браузер просит документ у сервера, а сервер его отдаёт. Третье — язык разметки, на котором документ написан: где заголовок, где абзац, где ссылка. Сеть, по которой всё это ездит, уже была: пакеты из главы 41 и надёжные соединения из главы 42. В книге Weaving the Web («Плетя паутину», 1999) Бернерс-Ли вспоминал, что не раз предлагал знатокам гипертекста и знатокам интернета поженить две технологии, а когда никто не взялся, сделал это сам. Эти три изобретения и станут нашими препаратами, а между ними вклинится четвёртый, который в 1989 году изобретать уже не пришлось, — имя сервера.

Препарат первый: адрес

Строка, которую вы видите над страницей, называется URL. У неё строгое устройство, и Python разбирает её одной функцией из модуля urllib.parse. Возьмём адрес этой главы, к которому кто-то приписал параметры и якорь, — так выглядят ссылки, которыми делятся в мессенджерах.

Схема https говорит, на каком языке разговаривать с сервером. Имя сервера — кого искать. Порт — к какой «двери» машины стучаться: порты мы встречали в главе 41, и у веба свои привычные номера, 80 для простого HTTP и 443 для шифрованного. Путь /cs/basics/web сервер толкует как хочет: когда-то это был путь к файлу на его диске, сегодня чаще — просто ключ, по которому программа сервера решает, что показать. Параметры после знака вопроса — пары «имя=значение» через &; parse_qs превращает их в словарь списков, потому что одно имя может повториться.

Последние две строки отвечают на вопрос, который рано или поздно задаёт себе каждый, кто копировал ссылку на русскую Википедию: откуда в ней берутся %D0%9F? В URL разрешены только латинские буквы, цифры и немного знаков. Всё остальное записывается байтами в кодировке UTF-8, и каждый байт — знаком процента и двумя шестнадцатеричными цифрами. Буква «П» в UTF-8 — два байта, D0 9F, отсюда %D0%9F. Браузер показывает вам красивое «Паутина», а по сети уходит процентная запись.

Вы открыли ссылку https://legost.in/cs/basics/web?from=tg#dns. Какая часть адреса до сервера не дойдёт?

Фрагмент после # браузер оставляет себе: это адрес места внутри уже полученной страницы, раздела с id="dns". Поэтому переход по оглавлению этой главы не требует ни одного запроса к серверу, а сервер никогда не узнает, к какому разделу вы прыгнули.

Препарат второй: имя

Имя legost.in удобно людям, но пакеты из главы 41 ездят по IP-адресам — по числам. Поэтому первым делом браузер превращает имя в число. Ищет он его сначала в собственной памяти, потом спрашивает операционную систему, а та заглядывает в маленький текстовый файл. Такой файл есть и в песочнице курса.

Файл /etc/hosts — пары «адрес, имя». Имя localhost система нашла в нём и вернула 127.0.0.1, «эту же машину». А legost.in в файле нет, и песочница сдаётся с той же ошибкой, что в конце прошлой главы: выхода в сеть у неё нет, спросить ей некого. Ваш компьютер в такой ситуации спросил бы сервер имён своего провайдера. Но сначала — почему вообще существует этот файл.

DNS устроена как дерево из главы 17. Читайте имя справа налево: legost.in — это узел legost внутри узла in, а тот — внутри безымянного корня. Строго говоря, в конце любого имени стоит невидимая точка, корень: legost.in. Узел дерева вместе со всем, что под ним, называют доменом. Мокапетрис предложил отдавать каждое поддерево в управление тому, кто им владеет. Корень знает только, кто отвечает за in, com, ru и ещё без малого полторы тысячи доменов верхнего уровня (в октябре 2026 года их 1437). Серверы зоны in, которыми управляет индийский реестр, знают, кто отвечает за legost.in. А серверы legost.in — у этого сайта это серверы имён хостинга DigitalOcean — знают сам адрес.

Утилита dig с ключом +trace проходит этот путь сама, начиная с корня. Ниже — её сокращённый вывод, снятый 2 октября 2026 года.

Каждая строка — запись: имя, срок жизни в секундах, тип и значение. Запись типа NS (name server) говорит «за это поддерево отвечают такие-то серверы», запись A — «у этого имени такой IPv4-адрес». Бывают и другие: AAAA — адрес IPv6, CNAME — «это имя — псевдоним другого», MX — куда доставлять почту, TXT — произвольный текст, например подтверждение для поисковика, что сайт принадлежит владельцу. Пройдитесь по дереву сами.

Дерево имён: настоящие записи этого сайта и его соседей, снятые 2 октября 2026 года. Выберите имя и нажмите «Найти»: резолвер пройдёт от корня вниз и запомнит всё, что узнал по дороге. Найдите второе имя из той же зоны — и увидите, как кэш срезает путь. «Промотать время» состарит кэш: записи с истёкшим сроком исчезнут. Задержки ответов условные.

Того, кто ходит по дереву за вас, называют резолвером. Обычно это сервер вашего провайдера или публичный сервис вроде 1.1.1.1 и 8.8.8.8. Браузер задаёт ему один вопрос — «какой адрес у legost.in?» — и ждёт готового ответа, а резолвер делает остальное: спрашивает корень, получает направление к in, спрашивает там, получает направление к DigitalOcean, спрашивает там. Три похода через полмира ради одного числа. Если бы так было каждый раз, интернет захлебнулся бы на одних именах.

Спасает кэш — та же идея, что в главе 34. Каждая запись приходит со сроком жизни, и до его конца резолвер отвечает из памяти. Направление к зоне in живёт двое суток (172 800 секунд), поэтому до корня резолвер добирается редко. Адрес legost.in живёт десять минут: владелец сайта выбрал короткий срок, чтобы при переезде на другой сервер мир узнал новый адрес быстро. Кэши стоят на каждом этаже: в браузере, в операционной системе, у резолвера. Вот почему в шкале загрузки строка «DNS» у многих читателей равна нулю: имя уже было в памяти. Обратная сторона та же, что у всякого кэша: сменили адрес — и ещё десять минут часть мира ходит по старому.

DNS — распределённая база данных, у которой нет хозяина. Корень, зоны верхнего уровня и зоны сайтов обслуживают разные организации на разных континентах, а скорость берётся из кэша со сроками жизни. Корневых имён всего 13, от a до m, но за ними стоит около двух тысяч серверов по всему миру: один адрес раздаётся из многих мест сразу, и пакет попадает к ближайшей копии.

Вопросы и ответы DNS — короткие пакеты UDP, без соединения: спросил, получил, а потерялось — спросил ещё раз. Устроены они несложно, и разобрать их можно руками. Ниже — два ответа, пойманных 2 октября 2026 года: от корневого сервера и от сервера зоны legost.in. Имя в пакете записано метками: байт длины, затем буквы; legost.in — это 06 l e g o s t 02 i n 00. А чтобы не повторять одно и то же имя десять раз, пакет может сослаться на имя, записанное выше: байт C0 и номер байта, с которого читать.

Корень на вопрос о legost.in не ответил — раздел «ответ» пуст. Вместо этого он сказал, куда идти: четыре сервера зоны in, и в разделе «подсказка» сразу приложил их адреса, чтобы резолверу не пришлось искать имя сервера имён тем же путём. Флаг «отвечаю сам» у корня сброшен, у сервера DigitalOcean поднят: он хозяин зоны, и его слово окончательное. Его ответ занимает 43 байта: 12 байт заголовка, вопрос и одна запись с адресом 9d e6 10 a2, то есть 157.230.16.162, и сроком 600 секунд. Номер вопроса 11111 придумал тот, кто спрашивал, а сервер вернул его, чтобы ответ можно было сопоставить с вопросом: UDP ничего не гарантирует, и ответы на разные вопросы могут прийти в любом порядке.

Соединение и замок

Адрес найден: 157.230.16.162. Дальше браузер открывает соединение TCP на порт 443 — рукопожатие из главы 42, один круг пакетов туда и обратно. Потом второй разговор, поверх первого: стороны договариваются о шифре. Это TLS, и вместе с ним HTTP превращается в HTTPS, замок у адресной строки. Как два компьютера, которые никогда не встречались, договариваются о секретном ключе, если каждое их слово слышит любой маршрутизатор по дороге, — большой вопрос, ему отведена глава 60. Здесь нам важна цена: в современной версии TLS 1.3 договор стоит ещё один круг туда-обратно, а в старой, 1.2, — два. В шкале загрузки это строки «TCP» и «TLS». Если сервер за океаном и круг занимает 150 миллисекунд, то до первого байта страницы уходит полсекунды, хотя ни одного байта самой страницы ещё не передано.

Препарат третий: разговор

Соединение открыто, и браузер говорит серверу первую фразу на языке HTTP. В версии 1.1, которая до сих пор работает повсюду, это обычный текст, и прочитать его можно глазами. Браузеры, говоря с крупными сайтами, давно перешли на HTTP/2 и HTTP/3: там те же сообщения упакованы в двоичные кадры, но смысл их тот же, и показывают их всегда в текстовом виде. Вот разговор с сервером этого сайта, снятый программой curl 2 октября 2026 года, — запрос и ответ на него, без тела страницы, которое шло следом.

Запрос начинается со строки из трёх слов: метод, путь и версия протокола. Метод HEAD значит «пришли только заголовки, без самой страницы» — так curl с ключом -I подглядывает за ответом, не скачивая его. Дальше идут заголовки, по одному на строку: Host — какой сайт нужен (на одном адресе живут сотни сайтов, и сервер различает их только по этому заголовку), User-Agent — кто спрашивает, Accept — что спрашивающий готов принять. Пустая строка означает «заголовки кончились».

Ответ устроен зеркально. Первая строка — версия и код состояния: 200, всё хорошо. Затем заголовки ответа. server выдаёт программу сервера: это nginx, он принимает соединения и передаёт запросы программе сайта на PHP. content-type говорит, что пришёл HTML в UTF-8: без этого заголовка браузер не знал бы, показывать ли полученные байты как страницу, как картинку или предложить сохранить их файлом. date — время на часах сервера. Три последних заголовка — защита: страницу нельзя встроить в чужой сайт, нельзя толковать её тип по-своему, а на этот адрес год вперёд разрешено ходить только по HTTPS. К cache-control и set-cookie мы вернёмся через пару разделов.

Раз HTTP — это текст, сервер для него можно написать самим, из сокетов, которыми мы уже пользовались в главе 42. Сервер ждёт соединения, читает байты до пустой строки, разбирает первую строку и заголовки, решает, что ответить, и пишет ответ в том же формате. Клиент в той же ячейке — тоже сокет, и запрос он пишет руками. Сервер работает в отдельном потоке из главы 39: иначе он ждал бы клиента, а клиент — его.

Меньше полусотни строк, и это работающий веб-сервер. Строки в протоколе разделяются парой символов \r\n, как у телетайпа, который сначала возвращал каретку, потом переводил строку. Длину тела сообщает заголовок Content-Length, и отсюда комментарий у body.encode(): длина нужна в байтах, а «Привет из песочницы» занимает в UTF-8 почти вдвое больше байтов, чем букв, потому что каждая русская буква — два байта. Ошибись здесь — и клиент оборвёт страницу на середине или будет вечно ждать недостающие байты. Заголовок Connection: close обещает, что после ответа сервер закроет соединение, поэтому клиент читает, пока recv не вернёт пустоту.

Заголовки запроса сервер складывает в словарь из главы 8, а ключи приводит к нижнему регистру: в HTTP имена заголовков не различают большие и маленькие буквы, Content-Length и content-length — одно и то же. Наш разбор ещё очень доверчив: строка без двоеточия уронит сервер, а тело запроса он вообще не читает. Аккуратный разбор с телом и ошибками — первая задача главы, «Разобрать запрос».

Методы и коды

GET и HEAD — не единственные слова, с которых начинается запрос. Метод говорит, чего клиент хочет. GET — получить документ. POST — отправить данные: форму, комментарий, программу на выполнение; данные идут в теле запроса после пустой строки. PUT — положить документ по адресу, DELETE — удалить. Различие не формальное. GET обещает ничего не менять на сервере, поэтому браузер смело повторяет такой запрос, поисковый робот ходит по таким ссылкам, а кэш хранит ответы. Повторить POST браузер без спросу не решится: вдруг это оплата заказа.

Коды состояния устроены по первой цифре, и по ней понятен смысл даже незнакомого кода. В Python они все собраны в одном перечислении.

Двухсотые — успех; 204 значит «сделано, а показать нечего». Трёхсотые — перенаправление: 301 «переехал навсегда, вот новый адрес» в заголовке Location, 304 «у вас уже есть свежая копия». Четырёхсотые — виноват клиент: 400 «не понял запроса», 401 «представьтесь», 403 «вам нельзя», 404 «такого нет», 405 «по этому адресу так нельзя», 429 «слишком часто». Пятисотые — виноват сервер: 500 программа сайта упала, 502 и 503 — посредник вроде nginx не дозвался программу или та перегружена. Код 418 «Я чайник» — первоапрельская шутка 1998 года (RFC 2324), но Python, как и многие другие, его знает. Различать четвёрки и пятёрки — первое, что делает человек, читающий журнал сервера: всплеск 404 — битые ссылки, всплеск 500 — пожар. Это третья задача главы, «Журнал сервера».

Теперь попробуйте говорить на HTTP сами. В конструкторе ниже два собеседника. Первый — сервер в песочнице курса, вроде нашего, только с несколькими адресами; ему можно послать любые байты, даже бессмыслицу, и посмотреть, что он ответит. Второй — сервер этого сайта, к которому браузер пустит только вежливые запросы GET и HEAD.

Конструктор запросов. «Песочница»: запрос уходит байт в байт серверу на Python, который запускается на сервере курса; внизу — сырой ответ и то, как сервер понял запрос. Попробуйте заготовки, потом испортите запрос: уберите Host, напишите метод строчными буквами, укажите неверную длину тела. «Этот сайт»: браузер отправляет запрос серверу legost.in и показывает код и заголовки ответа.

Забывчивый сервер и куки

У HTTP есть свойство, которое поначалу кажется недостатком: каждый запрос сам по себе. Сервер ответил и забыл. Следующий запрос с того же компьютера для него — новый незнакомец, и узнать «того же» ему не по чему. Это сделано нарочно: серверу, который ничего не помнит о клиентах, всё равно, какой из тысячи запросов обслуживать первым, а сам он может падать, перезапускаться и размножаться, не теряя ничьих разговоров. Но как тогда сайт помнит, что вы вошли под своим именем или что положили в корзину?

В июне 1994 года над этим думал Лу Монтулли из компании Netscape: компания делала интернет-магазин для заказчика, который не хотел хранить на своих серверах недособранные заказы. Монтулли взял приём, давно знакомый программистам Unix под именем magic cookie, «волшебное печенье»: кусочек данных, который программа получает и потом возвращает без изменений. Сервер кладёт в ответ заголовок Set-Cookie: имя=значение, браузер запоминает его и с этого момента прикладывает Cookie: имя=значение к каждому запросу на тот же сайт. Браузер Netscape научился куки в октябре того же года. Посмотрим на сервер, который считает визиты, и на двух клиентов: один ничего не запоминает, второй держит банку для куки, как браузер.

Этот сервер собран уже не из голых сокетов. Модуль http.server из стандартной библиотеки сам читает первую строку и заголовки и вызывает метод do_GET, а ThreadingHTTPServer заводит на каждого посетителя свой поток. Первый клиент трижды получил новый номер: сервер не знал, что это он. Второй вернул номер в заголовке Cookie, и сервер узнал его. Так работает вход на любой сайт: после правильного пароля сервер выдаёт куки с длинным случайным номером сессии, а у себя записывает, чей это номер. Кто знает номер — тот для сервера и есть вы, поэтому номер сессии берегут, как пароль.

Флаг HttpOnly в нашем сервере запрещает скриптам страницы читать это куки: их увидит только сервер. В заголовках legost.in, снятых выше, у куки legostin_session такой флаг есть, а у XSRF-TOKEN нет. Второе куки оставлено открытым для скриптов нарочно: скрипт страницы прикладывает его к своим запросам, и так сервер отличает запрос со своей страницы от подделанного чужим сайтом. Проверьте в своём браузере.

Свежий ответ сервера этой страницы: браузер повторил запрос и показывает заголовки, которые разрешено видеть скрипту. Ниже — куки, которые скрипт страницы видит в document.cookie. Куки сессии в списке нет, хотя браузер его хранит и отправляет: у него флаг HttpOnly.

Не спрашивать дважды

Заголовок cache-control: no-cache, private в ответе legost.in — указание браузеру и всем посредникам. private — страница личная, её нельзя хранить в общих кэшах между вами и сервером: в ней может оказаться ваше имя. no-cache вопреки названию разрешает хранить копию, но запрещает показывать её без проверки у сервера. Для картинок и программ, которые не меняются, сайты пишут обратное: max-age=31536000, «год не спрашивай». Это кэш браузера из главы 34: на вкладке «Файлы» в шкале загрузки многие файлы этой страницы, скорее всего, вообще не ходили в сеть.

Чтобы узнать, свежа ли копия, не скачивая её заново, браузер посылает условный запрос. Удобнее всего показать его на первом сайте в мире. Вот его ответ, снятый тем же 2 октября 2026 года.

Сайт ЦЕРН до сих пор отвечает по простому HTTP/1.1, без шифрования: HTTPS у него тоже есть, но туда он не перенаправляет. Его главная страница не менялась с февраля 2014 года — об этом говорит Last-Modified. ETag — ярлык версии, отпечаток содержимого. Браузер, у которого уже лежит копия, спрашивает: If-None-Match: "286-4f1aadb3105c0", «пришли, только если ярлык другой». Если не изменилось, сервер отвечает кодом 304 Not Modified без тела: двести байт вместо всей страницы. В конструкторе запросов есть заготовка с таким вопросом к серверу песочницы.

Тысяча посетителей сразу

Сервер из ячейки сервер.py обслуживает посетителей по одному: пока он читает запрос и пишет ответ, остальные ждут у accept. Пока ответ готовится за микросекунды, это незаметно. Но рабочий сайт, отвечая, ходит в базу данных, читает файлы, считает, и на один запрос у него уходят десятки миллисекунд, в которые процессор почти не занят. Сравним сервер-одиночку из http.server с многопоточным, когда шесть посетителей пришли одновременно, а каждый ответ «думает» 0,3 секунды.

Одиночка отвечает шестерым почти за две секунды: последний в очереди ждал пятерых. Многопоточный — за треть секунды, как одному. Это задача о планировании из главы 37: пока один запрос ждёт базу, процессор отдают другому. Серверы в интернете делают то же самое потоками, процессами или циклом событий: nginx, который отвечал нам выше, держит десятки тысяч соединений в нескольких процессах, переключаясь между ними, как asyncio. А как только посетители работают одновременно, возвращаются гонки из главы 39: словарь visits в ячейке с куки делят все потоки сервера, и под серьёзной нагрузкой счётчик визитов стоило бы прикрыть замком.

Препарат четвёртый: тело

После заголовков и пустой строки идёт то, ради чего всё затевалось: текст страницы на HTML. Это обычный текст с метками-тегами в угловых скобках: <h1> открывает заголовок, </h1> закрывает, <a href="…"> делает ссылку. Теги вкладываются друг в друга, как скобки, и значит, весь документ — дерево. Собрать его можно стопкой, которой в главе 15 проверяли скобки: открывающий тег кладём на стопку, закрывающий снимаем. Разбивать текст на теги и куски текста умеет модуль html.parser.

Первое дерево — то, что браузер строит из любой страницы и называет DOM, объектной моделью документа: корень html, в нём head и body, дальше заголовки, абзацы, списки, на листьях — текст. С этим деревом работает всё остальное: стили, скрипты, отрисовка.

Второе дерево вышло неправильным. В HTML абзац нельзя вложить в абзац: второй <p> молча закрывает первый, а теги <b> и <i> закрыты крест-накрест. Наш строитель послушно вложил абзацы друг в друга и пожаловался на скобки. Python на такую программу ответил бы SyntaxError, как в главе 1. Браузер не жалуется никогда: стандарт HTML расписывает, как чинить каждую ошибку, и все браузеры чинят одинаково. Поэтому страница 1992 года открывается в браузере 2026-го, хотя в ней есть теги, которых давно нет в языке. В той копии заголовок окна и служебный тег NEXTID обёрнуты в HEADER — по смыслу это то, что сегодня пишут как head. В нынешнем HTML слово header тоже есть, но значит шапку внутри страницы, и браузер кладёт её в body. В виджете ниже работает собственный разборщик вашего браузера, и на нём это легко проверить.

Слева — HTML, его можно править. Посередине — дерево DOM, которое построил разборщик вашего браузера; узлы, которых не было в тексте, а браузер дописал сам, отмечены. Справа — как это выглядит. Заготовка «Первая страница» — начало страницы info.cern.ch в копии 1992 года, «Эта страница» — дерево главы, которую вы читаете. Кнопка «Занять браузер» на полторы секунды загружает главный поток страницы работой.

Дерево — ещё не картинка. Внешний вид задаёт отдельный язык, CSS, каскадные таблицы стилей. Его предложил Хокон Виум Ли, работавший в ЦЕРН вместе с Бернерсом-Ли, 10 октября 1994 года, а первая версия стандарта вышла в декабре 1996-го. Правило CSS выбирает узлы дерева и назначает им свойства: h1 { color: navy } — все заголовки первого уровня тёмно-синие. Поведение задаёт третий язык, JavaScript. Брендан Айк, пришедший в компанию Netscape в апреле 1995 года, написал его первую версию за десять дней; язык успел побыть Mocha и LiveScript, прежде чем в декабре 1995-го получил имя, созвучное модному тогда Java. Скрипты читают и меняют дерево DOM: все виджеты этого курса — скрипты, которые достраивают дерево главы прямо у вас в браузере.

Дальше браузер работает как конвейер. Разбирает HTML в дерево, разбирает CSS в правила, вычисляет для каждого узла его стили. Потом раскладка: какой ширины каждый прямоугольник и где он стоит; ширина абзаца зависит от окна, высота — от того, сколько строк получилось, а положение следующего абзаца — от высоты предыдущего. Потом отрисовка: прямоугольники превращаются в пиксели. Меняется окно или дерево — конвейер повторяется для изменившейся части. В шкале загрузки этому соответствуют строки «Разбор HTML», «Скрипты» и метки «первый текст на экране» и «главное на экране».

Почти всё это делает один поток — главный поток страницы. Он устроен как цикл событий из главы 37: берёт из очереди следующее дело — нажатие, ответ сервера, кадр анимации, кусок скрипта — и выполняет его до конца. Пока скрипт считает, страница не отвечает ни на что: не прокручивается, не нажимается, анимация стоит. Кнопка «Занять браузер» в виджете выше показывает это на полторы секунды. Поэтому тяжёлые вычисления курса идут на сервере песочницы. А там, где браузеру всё же приходится много считать, работу режут на куски и между кусками отдают управление обратно циклу.

Страница, которую читает не человек

Сервер не обязан отвечать страницами. Нажмите «Запустить» под любой ячейкой этой главы, и браузер отправит на сервер курса запрос, в котором нет никакого HTML. Вот он, слегка сокращённый, — его видно в инструментах разработчика браузера, на вкладке «Сеть».

Тело запроса — JSON из главы 8: словарь с режимом, текстом программы и языком. Ответ — тоже JSON, но приходит он построчно, по мере того как программа печатает: формат NDJSON, по объекту на строку. Скрипт страницы читает строки и дописывает вывод под ячейкой. Набор адресов, на которые программы отправляют запросы и получают данные, называют API, программным интерфейсом. Так устроен почти весь сегодняшний веб: приложение в телефоне, карта, прогноз погоды — это скрипты и программы, которые говорят с серверами на HTTP и JSON. Сделаем свой API — каталог землетрясений из главы 6, который отвечает на вопросы по адресу.

С 2015 по 2025 год в каталоге восемь толчков магнитудой 8 и больше, сильнейший — у Камчатки в июле 2025-го. Сервер ответил на вопрос, заданный адресом, кодом 200 и JSON, а на неправильные вопросы — кодами 400 и 404 и тоже JSON с объяснением. Так выглядит хороший тон API: коды HTTP говорят программе, что случилось, а тело объясняет человеку, который будет её чинить. Тот, кто вызывает API, пишет три строки: запрос, проверка кода, json.loads. Из командной строки то же самое делает curl: curl 'https://…/api/quakes?min=8' — и программы, которые вы напишете, смогут говорить с любым сервисом, у которого есть API.

Сборка: снова шкала

Препараты разобраны, можно сложить их обратно и посмотреть на шкалу из начала главы другими глазами. Перенаправление — сервер ответил кодом 301 и отправил браузер по новому адресу, это целый лишний разговор. DNS — поход резолвера по дереву или мгновенный ответ из кэша. TCP и TLS — по кругу пакетов туда-обратно. «Ожидание ответа» — ещё круг плюс время, за которое программа сервера собрала страницу. Загрузка HTML — сколько байт и по какому каналу. Дальше работа браузера: дерево, стили, скрипты, раскладка, отрисовка. А за ними — вкладка «Файлы»: страница позвала ещё десятки файлов, и у каждого своя маленькая шкала.

Ту же раскладку по шагам умеет показывать curl: ключ -w печатает моменты, когда закончился каждый шаг, считая от начала. Вот главная страница сайта, загруженная с одного домашнего компьютера 2 октября 2026 года.

Имя нашлось за три миллисекунды: оно было в кэше. Рукопожатие TCP заняло 141 мс — это один круг до сервера во Франкфурте и обратно, то есть сеть здесь неблизкая. TLS — ещё 180 мс. От запроса до первого байта прошло 306 мс: круг плюс примерно 160 мс работы программы сайта. И последние 166 мс шли сами байты. Из восьмисот миллисекунд больше половины — чистые круги туда-обратно, в которых не передано ни одного байта страницы.

Страница из HTML и 30 файлов по 50 КБ, круг туда-обратно 80 мс, канал 50 Мбит/с, все файлы идут по одному соединению HTTP/1.1, по очереди. Что ускорит загрузку сильнее?

HTTP/2 — в несколько раз: при узком канале и одном соединении по очереди страница грузится около трёх секунд, с HTTP/2 — около 0,65 с, а широкий канал без HTTP/2 почти ничего не дал бы. Расчёт — в ячейке ниже.

Проверим прикидкой. Модель такая: на HTML уходят четыре круга (DNS, TCP, TLS, запрос) и время передачи; дальше файлы идут одним из четырёх способов, как веб ходил за свою историю. В HTTP/1.0 на каждый файл открывали новое соединение. В HTTP/1.1 соединение стали держать открытым и посылать по нему запросы один за другим. Браузеры открывают к одному серверу до шести таких соединений сразу. А HTTP/2 посылает все запросы разом по одному соединению.

Модель грубая: она не знает ни про медленный старт TCP, ни про то, что файлы зависят друг от друга. Но основной её вывод верен, и для каждого, кто будет делать сайты, это главный урок главы: время загрузки определяет не ширина канала, а число кругов туда-обратно, умноженное на длину круга. Ту же разницу между задержкой и пропускной способностью мы видели в главе 41. Длину круга ограничивает скорость света в оптоволокне, и её не купишь: от Москвы до Франкфурта и обратно — десятки миллисекунд при любом тарифе. Поэтому всё, чем ускоряют веб, — это способы ходить по кругу реже, ближе или налегке. Кэш со сроками жизни в DNS и в браузере. Одно соединение на много запросов. Сжатие: эта страница пришла сжатой (в заголовках ответа — content-encoding), и сколько байт сэкономлено, видно в шкале. Сеть доставки содержимого из главы 34, которая ставит копию файла в ближайшем городе. И меньше файлов, без которых страница может обойтись.

Когда сайт тормозит, сначала посмотрите на шкалу: вкладка «Сеть» в инструментах разработчика браузера или curl -w. Если основное время — «ожидание ответа», медленная программа сервера. Если длинные DNS, TCP и TLS — далеко или нет кэша. Если долгая загрузка — тяжёлые файлы. Если сеть давно закончила, а страница всё не готова — работа браузера: скрипты и раскладка.

Задачи

Все три задачи взяты из повседневной работы с вебом: разобрать чужой запрос, ответить на него и прочитать журнал сервера, когда что-то пошло не так. Тесты второй задачи поднимают ваш сервер в песочнице и говорят с ним по сокетам, как браузер.

Напишите parse_request(data): на входе — байты запроса HTTP/1.1, на выходе — словарь с ключами method, path, version (строки), headers (словарь) и body (байты). Имена заголовков — в нижнем регистре, значения — без пробелов по краям; если заголовок повторился, значения склеиваются через ", ". Тело — ровно столько байт, сколько сказано в Content-Length, а без этого заголовка тела нет: то, что пришло после пустой строки, принадлежит уже следующему запросу. Если запрос испорчен — нет пустой строки после заголовков, в первой строке не три части, у заголовка нет двоеточия, Content-Length не целое неотрицательное число или тело короче обещанного, — бросьте ValueError.

Например, parse_request(b"GET / HTTP/1.1\r\nHost: legost.in\r\n\r\n") возвращает {"method": "GET", "path": "/", "version": "HTTP/1.1", "headers": {"host": "legost.in"}, "body": b""}.

Заготовка — разбор из ячейки сервер.py, и он ломается на каждом шагу. split(b"\r\n\r\n") режет тело, если в нём тоже есть пустая строка; split(":") режет Host: localhost:8000 на три куска. Резать нужно только по первому вхождению: data.partition(b"\r\n\r\n") и line.partition(":") возвращают тройку «до, разделитель, после», а если разделителя нет, он пустой — по этому удобно ловить ошибки.

Длину тела берите из заголовка: int(headers.get("content-length", "0")), а тело — срезом rest[:length]. Перед int проверьте, что там только цифры: "10".isdigit() — да, "-1".isdigit() и "ten".isdigit() — нет. И сравните длину того, что осталось, с обещанной.

Тело не превращайте в строку: в нём может быть картинка, а Content-Length считает байты. Декодировать нужно только заголовки.

Первая мысль решения — partition: резать по первому разделителю, потому что всё, что после него, — чужие данные, в которых разделитель может встретиться сколько угодно раз. Вторая мысль — тело определяется не тем, что «пришло», а тем, что обещано: в одном соединении HTTP/1.1 запросы идут друг за другом, и только Content-Length говорит, где кончается этот и начинается следующий. Полноценные серверы понимают ещё и Transfer-Encoding: chunked — тело кусками с длиной перед каждым, когда длина заранее неизвестна, — но это уже за рамками задачи.

Напишите serve(listener) — веб-сервер на сокетах. Он получает уже открытый слушающий сокет и в бесконечном цикле принимает соединения: на каждое читает один запрос, отвечает и закрывает соединение. Адреса:

  • GET / — код 200, тело Главная;
  • GET /hello?name=Ада — 200, Привет, Ада!; без параметра — Привет, незнакомец! (параметр приходит в процентной записи, раскодируйте его);
  • POST /echo — 200, тело запроса без изменений, Content-Type — как в запросе, а если его нет, application/octet-stream;
  • любой другой путь — 404, тело Не найдено: путь (путь без параметров);
  • известный путь, но не тот метод — 405 и заголовок Allow с правильным методом (GET или POST);
  • HEAD там, где можно GET, — те же заголовки, что у GET, но без тела;
  • запрос, который не разобрать, — 400, и сервер продолжает работать.

У текстовых ответов Content-Type: text/plain; charset=utf-8, у всех — верный Content-Length и Connection: close. Тесты запускают ваш сервер в отдельном потоке и посылают ему запросы по сокету, как браузер. В заготовке уже есть чтение запроса и ответ — с одной ошибкой.

Ошибка заготовки — в respond: len(body) у строки «Главная» — 7 букв, а байтов в UTF-8 — 14. Клиент прочтёт 7 байт и оборвёт слово посередине. Превращайте тело в байты до того, как считать длину. Заодно пусть respond принимает и байты: эхо возвращает не текст.

Путь с параметрами разбирает urlsplit(target): .path — путь, .query — параметры. parse_qs(query) вернёт словарь списков и сам раскодирует процентную запись: parse_qs("name=%D0%90%D0%B4%D0%B0") — {'name': ['Ада']}.

Порядок проверок: сначала известен ли путь (иначе 404), потом подходит ли метод (иначе 405 с Allow), потом сам ответ. Для HEAD посчитайте тело, как для GET, поставьте его длину в заголовок, но не отправляйте. Для мусора оберните read_request в try: ValueError — значит, ответить 400. И закрывайте соединение в finally, что бы ни случилось.

Словарь ROUTES — таблица маршрутов, как в любом веб-фреймворке: путь → разрешённый метод. Фреймворки вроде Flask или Django делают то же самое, только вместо метода в таблице стоит функция, которая готовит ответ. Без кода 400 не обойтись: сервер в интернете получает мусор постоянно — сканеры, сломанные клиенты, рукопожатия TLS на нешифрованный порт, — и упасть от одного такого запроса значит упасть навсегда. Этот сервер обслуживает посетителей по одному; чтобы медленный клиент не держал остальных, handle запускают в отдельном потоке на каждое соединение, как ThreadingHTTPServer.

nginx записывает каждый запрос строкой журнала в таком формате:

Адрес клиента, два прочерка, время в квадратных скобках, запрос в кавычках, код ответа, размер ответа, откуда пришёл посетитель и его User-Agent — тоже в кавычках. Напишите status_report(lines): по списку строк журнала верните словарь с ключами "2xx", "3xx", "4xx", "5xx" — сколько ответов каждого класса (коды вне 200–599 не считаются), и "top404" — до трёх самых частых путей, на которые ответили ровно 404, списком кортежей (путь, сколько) по убыванию числа, при равенстве — по алфавиту. Путь — без параметров после ?. Строки, которые не похожи на запись журнала, пропускайте. В журнале бывает всякое: запрос "-", байты рукопожатия TLS вместо запроса, цифры и кавычки в User-Agent.

Заготовка берёт девятое слово строки. Это работает, пока запрос состоит ровно из трёх слов. Запрос "-" — одно слово, и код окажется не на своём месте. Ищите код сразу после закрывающей кавычки запроса: время в скобках, потом запрос в кавычках, потом код.

Удобнее всего регулярное выражение, привязанное к началу строки: re.compile(r'^\S+ \S+ \S+ \[[^\]]*\] "([^"]*)" (\d{3}) '). \S+ — слово без пробелов, [^\]]* — всё до закрывающей скобки, [^"]* — всё до кавычки. Не подошло — строка не из журнала. Подробно о регулярных выражениях — в главе 54, а здесь хватит этого шаблона.

Для 404 возьмите второе слово запроса и отрежьте всё после ?: path.split("?")[0]. Счётчики путей — словарь, а сортировка «по убыванию числа, потом по алфавиту» — ключ lambda kv: (-kv[1], kv[0]), как в главе 10.

Пробелы для разбора журнала не годятся: поля в кавычках сами могут их содержать, а сколько слов окажется в запросе, никто не обещает. Шаблон, привязанный к началу строки, находит код именно после поля запроса, и цифры в User-Agent его не обманут. На живом сайте такой отчёт — первое, что смотрят после выкатки новой версии: выросли ли пятисотые и не появились ли новые битые ссылки. А в списке 404 почти всегда окажутся /wp-login.php и /.env: роботы круглые сутки проверяют весь интернет на забытые пароли и уязвимые движки.

Куда дальше

Посчитайте, сколько машин работало, чтобы вы увидели эту страницу. Резолвер вашего провайдера. Корневой сервер — одна из примерно двух тысяч копий. Сервер зоны in. Сервер имён DigitalOcean. Сам сервер сайта. Серверы сетей доставки, откуда пришли шрифты и библиотеки, — загляните на вкладку «Файлы» в шкале, там видно, сколько разных имён у источников этой страницы. Ни одна из этих машин не главная. Корень не знает адреса сайта, сервер сайта не знает про корень, и если одна копия корня выключится, пакеты молча пойдут к соседней.

Но в этой цепочке есть слабое звено, и оно на виду: в DNS у legost.in записан один адрес. За ним одна машина во Франкфурте. Выключится она — и страницы не будет, сколько бы копий корня ни работало. Большие сервисы так не живут: у почты, банка, поисковика данные лежат на сотнях и тысячах машин, а машины ломаются каждый день — в большом дата-центре поломка где-нибудь случается постоянно. Пока копии одинаковы, это не страшно. Но копии надо менять, а сообщения между ними, как мы знаем с главы 41, теряются.

Три копии одного счёта, четыре операции, и каждое сообщение теряется с вероятностью в одну пятую. В итоге копии расходятся, и на вопрос «сколько денег на счёте» у системы три разных ответа. Повторять сообщения, как TCP, мало: копия может не потерять сообщение, а умереть с ним — или долго молчать, и никто не знает, умерла она или просто медленная. Серверов тысячи, и они падают. Как им договориться? Как сделать, чтобы тысяча ненадёжных машин вела себя как одна надёжная, если ни одна из них не главная? Ответ придумали в 1989 году и рассказали притчей о парламенте греческого острова, который заседает, хотя законодатели то и дело уходят на рынок. Эта притча — следующая глава.