Протокол HTTP

Published by @squadette on 2016-06-27

Клиенты (то есть браузеры или роботы) общаются с веб-серверами именно по протоколу HTTP. Тонкие детали работы HTTP и его взаимодействия с нижними и верхними слоями технологии — один из ключей к высокой производительности.

HTTP расшифровывается как «Hyper Text Transport Protocol». Конечно же, он используется не только для передачи с сервера на клиент HTML-файлов, но и для любых других объектов: картинок, скриптов, CSS-файлов, файлов данных. Также он работает и в обратную сторону — для заливки на сервер файлов, отправки форм и т. п. AJAX-приложения также, очевидно, общаются с сервером по HTTP. Иногда HTTP используется и для более специфических вещей, например, для управления содержимым сервера по протоколу WebDAV и т. п.

Коммуникация по протоколу HTTP состоит из череды перемежающихся HTTP-запросов и HTTP-ответов между клиентом и серверами.

HTTP-запросы

HTTP-запрос состоит из трёх частей: строки запроса, заголовков и тела сообщения. Вот пример HTTP-запроса, который забирает файл, находящийся по адресу http://www.habrahabr.ru/robots.txt:

GET /robots.txt HTTP/1.1
Host: www.habrahabr.ru
User-Agent: Wget 1.8.3
 

Подавляющее большинство HTTP-запросов в современном вебе — это GET-запросы, у которых тело запроса пустое (нулевой длины), что мы и наблюдаем в данном случае. У POST-запросов, например, тело практически всегда непустое.

Разберем пример запроса детально. Первое слово в запросе — GET. Это так называемый «метод» HTTP-запроса. Самым распространенным методом как раз является GET. Метод GET означает, что мы попросту запрашиваем содержимое (страницу или файл), доступное по указанному URL’у. Важным обстоятельством является то, что GET-запросы могут кэшироваться.

Другим часто встречающимся запросом является POST. POST-запросы практически всегда содержат тело запроса, закодированное в одном из специальных форматов. POST-запросы обычно отправляются в результате нажатия пользователем на кнопку «Submit» или аналогичных действий.

Используется также метод HEAD (сосед метода GET), и ещё целый ряд более редких методов, таких как PUT, DELETE, а также большой набор методов, специфичных для протокола WebDAV. Подробнее о них — в других главах.

Второе слово в нашем запросе: /robots.txt — это URL той страницы или файла, которые мы хотим получить.

Третье слово в нашем запросе: HTTP/1.1. Это собственно наименование протокола и номер версии протокола. HTTP 1.1 используется практически повсеместно, но для некоторых достаточно специфических целей иногда может пригодиться снижение версии до HTTP 1.0. Основные отличия HTTP 1.1 от HTTP 1.0 состоят в обязательной поддержке виртуальных хостов, улучшенной поддержки кэширования, обязательной поддержке HTTP keep-alive, и ещё ряда важных возможностей.

Строка запроса завершается комбинацией символов CR-LF (\r\n). Далее идут заголовки запроса, отделенные друг от друга также комбинацией CR-LF. Единственный обязательный (в HTTP 1.1) заголовок — это Host, содержащий имя домена, на котором находится страница или файл. В стандарте HTTP описано множество различных заголовков запроса, многие из которых играют ключевую роль в вопросе обеспечения высокой производительности. В примере указан ещё один заголовок, User-Agent, который содержит строчку-идентификатор клиента.

Список заголовков заканчивается ещё одной комбинацией символов CR-LF (то есть, пустой строкой). После заголовков идёт тело запроса. В нашем примере тело запроса пустое (нулевой длины).

HTTP-ответы

Отправив HTTP-запрос серверу, клиент ожидает ответа. HTTP-ответ выглядит в целом аналогично запросу: статусная строка, список заголовков и тело ответа.

HTTP/1.1 200 OK
Server: nginx/0.5.35
Date: Tue, 22 Apr 2008 10:18:08 GMT
Content-Type: text/plain; charset=windows-1251
Connection: close
Last-Modified: Fri, 30 Nov 2007 12:46:53 GMT
ETag: ``27e74f-43-4750063d''
Accept-Ranges: bytes
Content-Length: 34

User-agent: *
Disallow: /people

Первое слово в ответе — HTTP/1.1, версия протокола. Второе слово — 200. Это самый распространенный код ответа, означающий, что запрошенный файл или страница обнаружены и отданы клиенту. Существует множество различных кодов ответа, часть из которых играет ключевую роль в вопросе обеспечения высокой производительности. Оставшуюся часть строки занимает человеко-читабельное описание кода ответа (в данном случае — OK).

После строки статуса следует список заголовков, содержащих дополнительную, иногда чрезвычайно важную информацию о файле или странице. В данном случае интерес представляет MIME-тип содержимого, дата последнего изменения файла (Last-Modified), его длина (Content-Length), а также ETag (это специальный маркер, используемый для кэширования). Также в ответе находится некоторая информация о сервере (Server и Date) и о соединении (Connection).

После списка заголовков мы видим ещё одну пустую строку и, наконец, собственно содержимое ответа (в данном случае это две текстовые строчки).

Можно сделать вывод, что HTTP — простой, гибкий за счет текстового синтаксиса протокол. Аккуратное использование множества заголовков запроса и ответа позволит достичь существенных улучшений в области высокой производительности веб-системы. Детальным разбором соответствующих технологий мы займёмся в следующих главах.

Оптимизация с помощью HTTP keep-alive

Протокол HTTP работает поверх протокола TCP («Transmission Control Protocol»). TCP — это надёжный протокол двусторонней передачи потока данных. TCP работает, пересылая пакеты данных от клиента к серверу и обратно. TCP-пакет состоит из заголовка и данных. В заголовке указаны, помимо прочего, IP-адреса клиента и сервера, номера TCP-портов, используемых на клиенте и сервере, и набор флагов. Для HTTP на сервере обычно используется стандартный TCP-порт номер 80.

TCP-соединение между клиентом и сервером устанавливается с помощью классического «TCP three-way handshake». Сначала клиент посылает серверу пакет с флагом SYN. В ответ сервер посылает пакет с флагами SYN+ACK. Наконец, клиент посылает ещё один пакет, с флагом ACK и с этой минуты соединение считается установленным, и клиент может посылать свои данные, в нашем случае — HTTP-запрос.

Видно, что эта часть протокольного взаимодействия требует один раз подождать ответного пакета от сервера, прежде чем можно было бы послать HTTP-запрос. То есть, операция установления TCP-соединения может быть относительно недешевой, а на некоторых каналах с высокой задержкой (latency) и довольно дорогой. Иными словами, мы заинтересованы в том, чтобы снизить количество TCP-соединений.

В HTTP 1.1 эта оптимизация включена по умолчанию. Один раз подключившись к серверу, клиент обменивается с ним запросами и ответами, не разрывая соединение. Это называется HTTP keep-alive.

Понятно, что если десяток тысяч браузеров установит с сервером keep-alive соединение, то они достаточно быстро исчерпают его ресурсы. Поэтому во всех серверах есть конфигурируемый тайм-аут, по истечении которого keep-alive соединение разрывается, если на нём не было никакой активности.

Клиент может запросить разрыв соединения после ответа, передав в запросе заголовок Connection: close. Аналогично, сервер может сообщить в ответе, что не желает поддерживать keep-alive соединение, передав точно такой же заголовок: Connection: close. Вообще говоря, все эти расшаркивания с взаимным уведомлением, строго говоря, не налагают никаких обязанностей. И сервер, и клиент должны быть полностью готовы к тому, что соединение прервётся в любой момент времени по инициативе другой стороны без каких-либо уведомлений.

Для того, чтобы соблюсти целостность keep-alive соединения, сервер должен знать длину ответа. Самый простой способ — указать её в заголовке Content-Length. Если длина ответа не указана обработчиком, то сервер вынужден перед отправкой ответа установить заголовок Connection: close и закрыть соединение со своей стороны после отправки ответа.

Проследите, что ваше веб-приложение правильно выставляет заголовок Content-Length, а иначе keep-alive соединения не будут работать.

Иногда размер ответа сложно определить на стадии создания. В этом случае можно либо пожертвовать keep-alive соединением, либо использовать специальную chunked-кодировку ответа (она задаётся с помощью заголовка Transfer-Encoding: chunked). Подробности можно прочитать в стандарте HTTP.

Оптимизация с помощью HTTP-pipelining

Когда мы делаем серию запросов и ответов в рамках одного keep-alive соединения, важную роль в производительности играет время задержки (latency) между запросом и ответом. Задержка может быть вызвана как высокой задержкой на канале, так и большим временем обработки запросов на сервере. Перед посылкой очередного запроса мы должны дождаться завершения обработки следующего. Чтобы справиться с этой проблемой, может использоваться технология HTTP-pipelining.

Смысл HTTP-pipelining состоит попросту в том, что клиент посылает подряд несколько запросов, а потом начинает постепенно разгребать приходящие ответы. Поддержка со стороны серверов в целом хорошая, но к сожалению, в настоящий момент клиенты, поддерживающие HTTP-pipelining, составляют очень небольшой процент рынка.

Оптимизация с помощью HTTP-кэширования

Вокруг кэширования в HTTP за многие годы скопилось множество мифов и предрассудков. Тринадцатая глава RFC2616 (Caching in HTTP) довольно запутанна. Основная причина этого состоит в том, что авторы попытались объяснить всё всем сразу: и разработчикам серверов, и разработчикам клиентов, и разработчикам кэширующих проксей. Кроме того, неразберихи добавляет обсуждение борьбы с устаревшими (HTTP 1.0) клиентами, серверами и прокси.

В этой главе мы постараемся аккуратно рассказать всю историю с точки зрения разработчика современного (то есть, поддерживающего HTTP 1.1) сервера. Сначала мы обсудим простейшую ситуацию: как работать с современным клиентом (без кэширующих проксей по дороге). Затем — как работать с современным клиентом через одну или несколько современных кэширующих проксей. Наконец, мы обсудим дополнительно, какие (устаревшие) заголовки надо добавить в ответ, чтобы добиться приемлемого результата от старых клиентов и устаревших проксей по дороге (заметим, впрочем, что существование этих двух видов зверей доказать чрезвычайно затруднительно).

Кэширование в современных клиентах

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

Чтобы выставить срок годности содержимого, используется заголовок ответа Cache-Control: max-age=300. Здесь 300 — это срок годности содержимого в секундах (то есть, пять минут). Это означает, что в течение пяти минут клиент может свободно использовать полученный объект, вообще не запрашивая повторно сервер.

Если объект требуется после истечения срока годности, его необходимо ревалидировать. Ревалидация — это попросту повторный запрос того же самого объекта, но с дополнительными заголовками. Ревалидация может привести либо к повторной передаче объекта, либо к посылке специального кода ответа 304 Not Modified. Очевидно, что второй вариант предпочтителен. Есть три основных метода ревалидации:

  1. по времени последнего изменения (Last-Modified);

  2. по идентификатору объекта (ETag);

  3. безусловная (просто отдать содержимое объекта).

Для того, чтобы работала ревалидация по времени последнего изменения, среди заголовков ответа должен быть специальный заголовок Last-Modified, содержащий, натурально, время последнего изменения объекта. При ревалидации это значение передаётся клиентом в специальном заголовке запроса: If-Modified-Since. Обработчик запроса может проверить, изменился ли объект, и если нет — вернуть ответ с пустым телом и кодом ответа 304 Not Modified. Само содержимое не передаётся, и клиент будет использовать то содержимое, которое хранится у него в кэше.

Использовать ревалидацию по времени последнего изменения проще всего для статических файлов, и все сервера по умолчанию автоматически включают эту возможность. Для случая динамически генерируемых страниц вопрос подбора правильной архитектуры обработки запроса и вообще возможность и целесообразность такой ревалидации обсуждается ниже.

Для того, чтобы работала ревалидация по идентификатору объекта, среди заголовков ответа должен быть специальный заголовок ETag. В нём возвращается некоторый неспецифицированный идентификатор версии объекта (например, для статических файлов Apache по умолчанию использует комбинацию из даты последнего изменения файла, его размера и номера inode в файловой системе). При ревалидации клиент посылает в запросе специальный заголовок If-None-Match, содержащий тот же самый идентификатор. Сервер сравнивает текущее значение идентификатора версии, и если оно совпадает, возвращает ответ с пустым телом и кодом ответа 304 Not Modified.

Как и в случае с ревалидацией по времени последнего изменения, эту ревалидацию проще всего использовать для статических файлов. Для случая динамически генерируемых страниц вопрос подбора правильной архитектуры контента обсуждается ниже. Оба способа ревалидации могут использоваться совместно.

Для совместимости с мифическими браузерами и прокси, не поддерживающими HTTP 1.1, в ответ следует добавлять заголовок Expires, в котором указан требуемый момент устаревания файла или страницы. Формат значения этого заголовка таков: Tue, 15 Nov 1994 08:12:31 GMT.

Стратегии ревалидации

Как уже упоминалось, проще всего и понятнее всего кэширование статических файлов, таких как картинки от дизайна, JavaScript-файлы и CSS-файлы. Для статических файлов очень легко сгенерировать и проверить значение обоих условий: как Last-Modified, так и ETag.

Самый, наверное, сложный вопрос в случае со статическими файлами — подобрать удобный срок годности. Разработчики Yahoo.com советуют простую стратегию: а) выставлять на статические файлы срок годности в один год (читай: «навсегда») и б) внедрить систему изменения урлов при выкладывании новой версии проекта на production-сервера.

Например, в Ruby on Rails можно настроить проект так, что URL CSS-файлов будет выглядеть так: /stylesheets/main.css?31415, где 31415 — это номер последней ревизии в системе контроля версий. Аналогичный механизм должно быть несложно внедрить в любом движке.

Другая, консервативная стратегия состоит в том, чтобы кэшировать статические файлы на небольшое время, порядка получаса. Таким образом, мы ускоряем типичную сессию пользователя. Когда же он зайдёт на сайт в следующий раз, при первом обращении просто произойдёт массовая ревалидация всей статики. При этой стратегии не требуется вносить изменений в системы выноса кода на production (что в принципе можно считать недостатком этой стратегии — зачастую это может означать, что такой системы нет вообще).

Вообще для того, чтобы добиться какого-то существенного прироста производительности, необходима счастливая комбинация условий. Во-первых, для страницы должно быть относительно легко вычислить валидатор (время последнего изменения или ETag). Во-вторых, инфо-дизайн страницы должен позволять загрузку клиентом достаточно устаревших копий. По опыту, достаточно редкие классы страниц обеспечивают эту комбинацию, не говоря уж о каком-то измеримом эффекте от внедрения ревалидации. Вполне возможно, что для современных интерактивных проектов вообще уже не имеет смысла думать в этом направлении, а сконцентрироваться на предельно скоростной генерации этих самых страниц.

Повторим, однако, что вышеописанное кэширование статических файлов играет важнейшую роль в user experience каждого конкретного пользователя, и такому кэшированию необходимо уделить внимание (благо это нетрудно).

Ещё один аспект данной проблемы состоит в том, что поисковые системы очень рекомендуют выставлять более-менее правильное время последнего изменения (Last-Modified) на всех страницах, которые вы собираетесь скормить поисковому роботу. Возможно, что в процессе внесения исправлений в движок на эту тему у вас получится без особого труда внедрить также и собственно стратегию кэширования.

Отключение кэширования и интерактивные страницы

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

Cache-Control: no-cache
Pragma: no-cache
Expires: now

Мы не будем останавливаться на подробном разборе заблуждений, собранных в этих трёх строчках. Скажем лишь, что первая строчка предназначена не вполне для данной цели, вторая строчка вообще никогда не имела смысла, а третья катастрофически устарела (хотя и выполняет поставленную задачу).

Правильный современный способ — использовать единственный заголовок Cache-Control: max-age=0. Он означает, что содержимое страницы устаревает немедленно после её получения, и браузер обязан ревалидировать её. Сервер, получив запрос на ревалидацию, попросту возвращает пользователю новую версию страницы.

Для совместимости с мифологическими старыми версиями браузеров и прокси, не поддерживающими HTTP 1.1, в ответ также следует добавлять заголовок Expires: now. Он имеет такой же смысл, что и Cache-Control: max-age=0.

Оптимизация с помощью компрессии

Прекрасным способом существенно снизить трафик и ускорить отклик сервера является компрессия отдаваемых файлов и страниц. Практически все современные веб-сервера так или иначе поддерживают эту функциональность. Коэффициент сжатия варьируется в зависимости от каждой конкретной страницы и может достигать значений порядка 10. Для разработчика веб-приложения сжатие полностью прозрачно и не требует никаких усилий.

Способность принимать компрессированное содержимое клиент анонсирует серверу с помощью заголовка Accept-Encoding: gzip. Если сервер настроен на сжатие соответствующего контента, то он может добавить заголовок ответа Content-Encoding: gzip (не путать с Transfer-Encoding) и отправить клиенту сжатое содержимое.

Исторически различные браузеры имели множество различных ошибок в обработке сжатого содержимого. Сейчас (конец 2010 года), кажется, процент реально используемых клиентов с ошибками снизился до пренебрежимо малого. Обычно в документации на сервер изложен примерный набор комбинаций из видов контента и браузеров, которые работают, а также рекомендации по обходу ошибок в старых клиентах.

Специфическая оптимизация отдаваемого содержимого

Попросту говоря, это удаление лишних пробелов и переводов строк из HTML-файлов и CSS-файлов, минификация JavaScript, а также оптимизация размеров файлов с картинками. Помимо влияния на размер передаваемых данных, эта оптимизация также может привести к увеличению производительности на стороне клиента: браузеру придётся меньше парсить и занимать меньше памяти под исходник страницы и DOM-узлы обработанной страницы.

Минификация JavaScript может также проводиться одновременно с obfuscation (затемнением смысла кода), могущем иметь некоторый смысл в плане политики защиты интеллектуальной собственности. Библиотеки широкого назначения, такие как prototype.js, содержат встроенную поддержку минификации, а также возможность удаления неиспользуемого в проекте подмножества компонентов.

Комбинирование контента

Для того, чтобы снизить количество HTTP-запросов, можно просто скомбинировать несколько файлов в один. Зачастую вместе с комбинированием можно также минифицировать результирующий файл.

CSS-файлы можно просто конкатенировать в один. JavaScript-файлы требуют осторожности в этом отношении, но вообще можно считать, что если ваш JavaScript не конкатенируется — у вас какая-то проблема с архитектурой JS-кода.

Иконки, навигационную графику и т. п. можно комбинировать в один файл с помощью т. н. CSS-spriting. См. многочисленные руководства на эту тему в Интернете.


2015-2016 Mokum.place