NET·VI Сети Глава 43 из 65
Анатомия этой страницы
Вы набрали адрес, и через полсекунды перед вами страница. Глава вскрывает её саму: имя в DNS, заголовки ответа сервера, HTML, из которого браузер вырастил дерево, и тайминги загрузки, которые ваш браузер записал минуту назад. По дороге — записка Тима Бернерса-Ли 1989 года, на которой начальник написал «расплывчато, но увлекательно», и свой веб-сервер на сокетах.
Сети
- 41 Сети
- 42 TCP/IP
- 43 Веб вы здесь
- 44 Распределённые
Опирается на: 42 · Изобрести протокол 07 · Собеседник из строк
Что вы унесёте из главы
- прочитать путь от адреса в строке браузера до готовой страницы: DNS, соединение, запрос, ответ, разбор и отрисовка — и найти, где теряется время
- писать и читать HTTP руками: запрос, коды ответа, заголовки, куки, кэш — и поднять свой сервер и JSON API на Python
- понимать, как браузер превращает HTML в дерево и почему он прощает ошибки, которых не прощает Python
Прошлая глава кончилась вопросом: вы набираете в браузере legost.in и жмёте Enter — что происходит? Отвечать на него можно по учебнику, а можно по уликам. Улики у нас есть: вы читаете эту страницу, значит, всё, о чём спрашивалось, уже произошло — несколько секунд или минут назад, в вашем браузере. И браузер вёл протокол. Он записал, когда начал искать адрес сервера, когда установил соединение, когда получил первый байт ответа и когда закончил рисовать. Вот этот протокол.
Строк в протоколе с десяток, и каждая — отдельное событие. Найти, где живёт сервер. Договориться с ним о соединении и о шифре. Попросить страницу. Дождаться, пока сервер её соберёт. Получить, разобрать, раскрасить, нарисовать. У кого-то из читателей первая строка займёт сотни миллисекунд, у кого-то — ноль, и почему так бывает, станет понятно к середине главы. Мы проведём вскрытие по всем правилам: снаружи внутрь, от того, что видно в адресной строке, до того, что видно на экране, — и в конце вернёмся к этой шкале, когда каждая полоска на ней будет понятна.
Расплывчато, но увлекательно
Чтобы паутина заработала, Бернерс-Ли понадобились три изобретения, и все три вы использовали, открывая эту страницу. Первое — адрес, который однозначно называет документ в любой точке сети. Второе — протокол, по которому браузер просит документ у сервера, а сервер его отдаёт. Третье — язык разметки, на котором документ написан: где заголовок, где абзац, где ссылка. Сеть, по которой всё это ездит, уже была: пакеты из главы 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 — произвольный текст, например подтверждение для поисковика, что сайт принадлежит владельцу. Пройдитесь по дереву сами.
Того, кто ходит по дереву за вас, называют резолвером. Обычно это сервер вашего провайдера или публичный сервис вроде 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.
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. В виджете ниже работает собственный разборщик вашего браузера, и на нём это легко проверить.
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 году и рассказали притчей о парламенте греческого острова, который заседает, хотя законодатели то и дело уходят на рынок. Эта притча — следующая глава.