Примечание: ниже перевод статьи "On Streaming, Chunking, and Finding the End", в которой авторы рассматривают процесс передачи информации по HTTP-соединению и возможности для ускорения этого процесса. Мои комментарии далее курсивом.
Как и в большинстве механизмов передачи данных, в HTTP существует два основных способа отправить сообщение: «все и сразу» или «по частям». Другими словами, в HTTP есть возможность отправлять данные до тех пор, пока еще есть хотя бы что-то, что можно отправить, либо отправить все данные как одну логическую единицу, указав предварительно ее размер.
Если вы занимаетесь веб-разработками достаточно продолжительное время, скорее всего, вы уже знаете, как работает сброс буфера (flush) на стороне сервера. Этот метод позволяет начать отправку части данных пользователю, в то время как скрипт может продолжать выполнять некоторые, достаточно медленные, действия (скажем, ресурсоемкий запрос к базе данных). Если вы уже применяли эту возможность, тогда вы, вероятно, использовали преимущества потокового (streaming) механизма, хотя могли и не знать всех деталей работы HTTP-протокола.
Однако, это не такая уж и старая история. Если вы являетесь современным веб-разработчиком или администратором, который заботится о работоспособности кода, написанного веб-программистами, то вам придется, скорее всего, столкнулся лицом к лицу с HTTP-потоками в своей профессиональной деятельности. И потоки эти станут ее неотъемлемой частью — хотите вы это осознавать или нет — и все это в связи с постоянно увеличивающимся значением AJAX в современных веб-приложениях. Обоснованность решения об использовании AJAX для увеличения производительности и ответственность за его принятие разработчиками частично зависит от того факта, что HTTP
(имея и некоторые другие интересные приемы) может отправлять и принимать данные в полностью поточном режиме.
Может быть, сейчас вы подумаете: «Круто, что протокол HTTP выполнит всю работу на меня, но мне, действительно, совершенно не нужно знать, как это конкретно там происходит. Почему я должен это знать? Я просто хочу это использовать эти проклятые потоки, но не быть в них гуру».
А сейчас мы можем повторить один из любимых «сполкиизмов» (Spolskyisms) — закон дырявых абстракций (Law of Leaky Abstractions) Spolsky — который утверждает следующее: Все абстракции протекают (All abstractions leak). Из этого закона существует важное следствие, которое мы можем сформулировать примерно следующим образом: Если у вас начнет протекать абстракция и вы не будете понимать, как она работает, то вы рискуете оказаться очень мокрым.
И это действительно так. Большую часть времени (как разработчиком, так и администратором) вам не придется беспокоиться о потоках. Все необходимые детали милостливо вынесены на отдельный уровень реализации HTTP-абстракции в ваших системных библиотеках, или в вашем клиентском браузере, или в вашем веб-сервере. Однако если бы реализация этого абстрактного уровня не была настолько хороша, что скрывала от нас всю внутреннюю кухню и не позволяла в большинстве случаев вообще о ней забыть, мы бы никогда не добились таких результатов. Вместо этого мы бы проводили все наше время в поиске материалов, например, этой статьи.
Но предположим, что сейчас вы администрируете веб-приложение с AJAX и вы что-то изменили в конфигурации сервера, а один-единственный виджет внезапно стал «подвисать». При этом все будет в абсолютном порядке как со стороны браузера, так и со стороны сервера, и данные так же передаются по сети в соответствии с настройками. Или, например, вы реализовываете веб-сервис, и он замечательно работает до тех пор, пока заказчик не просит своего провайдера использовать протокол HTTP 1.1
для передачи данных вместо HTTP 1.0
. В этот момент все ломается, и сообщения более правильно не парсятся. Случаи, подобные описанным, а также ряд других «сверхъестественных» явлений могут быть результатом того, что абстракция HTTP-потоков дала сбой.
В конце концов, это может оказаться весьма полезным — знать некоторые основополагающие концепции и наиболее важные выводы из них!
Например, до прочтения этой статьи вы знали уже, что в HTTP существует два разных способа для передачи потока данных? На самом же деле, всего можно насчитать целых три способа отправки HTTP-сообщения от провайдера к клиенту. А вы знаете, что каждый из этих способов построен на различной реализации более низкого уровня протокола, например, TCP? В частности, вы сможете ответить, как изменения на уровне отправки HTTP-сообщений отражаются на длительности более низкоуровневого TCP-соединения?
Уже запутались? Если так, не переживайте. Это наша миссия — объяснять вещи такого рода тем веб-профессионалам, которые ищут знаний. Хотя мы всегда немного удивляемся, насколько мало тех — даже весьма бывалых и подготовленных, — кто до конца понимает, о чем идет речь. К счастью, HTTP-потоки, на самом деле, не настолько сложная вещь для понимания. Все, что нужно, — это просто начать с конца.
Нет, это не опечатка. Мы, действительно, начнем с конца. Примерно так:
HTTP-сообщения могут нести в себе полезные данные (как бы это не парадоксально звучало :)): тело сообщения в HTTP-потоке (но не всегда, потому что есть еще и HEAD-запросы). Если это происходит, то HTTP-реализация, которая работает с этими данными, должна знать, когда данные эти заканчиваются. Это обязательное условие и для пользовательского клиента, например, браузера, и для поискового робота, и для поставщика веб-сервисов, и для сервера, который принимает данные от клиента, например, в случае POST-запросов. Ключевым моментом в понимании HTTP-потоков будет подробный анализ процесса, в результате которого становится известным конец HTTP-сообщения, чтобы в дальнейшем обработать полученные в сообщении данные.
Существует только три возможных способа для процесса узнать, когда закончилось сообщение, которое он принимает. Как вы могли предположить, эти три способа делятся на один непотоковый вариант отправки данных в HTTP и два потоковых:
Непотоковый случай. Наиболее топорным путем для уведомления принимающего процесса, что сообщение подошло к концу, будет использование HTTP-заголовка Content-Length
. Если этот заголовок присутствует, HTTP-клиент или сервер могут подсчитать размер сообщения и сравнить его с заявленным в заголовке значением. Отправка статических файлов, практически, всегда сопровождается таким заголовком, и его значение соответствует размеру самого файла (в байтах). Сам же файл становится, таким образом, телом HTTP-сообщения (данные за минусом размера заголовка). Аналогичным образом, POST-запросы от клиента также содержат заголовок Content-Length
.
Использование заголовка Content-Length
позволяет просто и надежно узнать, где находится конец данных. К несчастью, это не позволяет посылать кусок за куском разного (случайного) размера. Сервер, посылающий данные потоком, не может в начале потока знать, сколько кусков осталось или какого размера каждый из них. Если бы он знал, ему вообще не нужно было бы слать данные потоком. Налицо дилемма.
Я так понимаю, что без заголовка Content-Length
клиент никогда не узнает, смог ли он получить все сообщение от сервера или только его часть (например, из-за неожиданного разрыва соединения), что может существенно отразиться на целостности передаваемых данных.
Вводим потоки HTTP 1.0
. Если у HTTP-сообщения есть тело, но нет видимого конца содержания в самом его начале (в заголовке), тогда наиболее простым путем для сообщения HTTP-клиенту о том, что передача закончилась, будет закрытие нижележащего TCP-соединения. Нет ничего, что могло бы сказать: «Это все, ничего больше не будет» — лучше, чем закрытое соединение. Если вы используете любой анализатор протокола HTTP (например, HttpWatch или Live HTTP Headers) для отслеживания заголовков в HTTP-ответе, который передается по этому методу, то вы, скорее всего, увидите следующий заголовок:
Connection: close
указывающий на то, что сервер собирается закрыть нижележащее соединение, как только закончит передавать данные (технически, этот заголовок может и не присутствовать в контексте HTTP 1.0
, так как для HTTP 1.0
, по умолчанию, используется одноразовое (non-persistent) соединение, которое подразумевает, что в 1.0 реализации оно будет закрыто, если только сообщение не сопровождено заголовком Connection: Keep-Alive
). Если вы взгляните, что происходит с ответом на уровне TCP/IP (например, при помощи Wireshark или равноценного инструмента), вы увидите, что сразу после того, как сервер отправляет остатки данных в сообщении, для которого указан заголовок Connection: close
(или не указано никакого заголовка Connection
), сервер, фактически, инициирует двустороннее закрытие на уровне TCP, отправляя пакет с выставленными битами FIN
и ACK
. Это все замечательно отрабатывает и сообщает клиенту, что поток подошел к концу.
Хотя, очевидно, что в таком случае присутствует следующий неприятный момент, связанный с дополнительными издержками. Да, сервер сообщил клиенту, что данные закончились. Но ценой этого стал разрыв TCP-соединения, с помощью которого пересылались данные. И его придется восстанавливать через «тройное рукопожатие», если нужно передать еще одно или несколько сообщений. Это извращает саму идею того, что в HTTP 1.0
называется элементами поддержки установленного соединения (keep-alive) (или что мы называем в HTTP 1.1
постоянным соединением (persistent connections)).
Это так плохо? Ну, в общем, да, это может быть достаточно неприятно в зависимости от обстоятельств. Повторное использование существующих TCP-соединений, если есть такая возможность, позволяет существенно повысить производительность сервера. В результате таких соединений не только экономится время на установление новой пары TCP/IP-сокетов, они также позволяют использовать сетевые ресурсы более оптимальным образом. Это справедливо как для браузеров, которые могут отправлять множественные запросы через небольшое число установленных соединений при загрузке страницы, на которой присутствует много ссылок на внешние ресурсы. Это справедливо и для серверов, которые могут быстро собирать существующие соединения по истечению TIME_WAIT
состояния, если клиенты оказываются не в состоянии их поддерживать. Являясь опциональными в HTTP 1.0
(где они устанавливаются с помощью заголовка Connection: Keep-Alive
), в HTTP 1.1
постоянными соединения, по умолчанию, были включены — специально, чтобы предотвратить проблемы такого рода.
К несчастью, в HTTP 1.0
нет никакого другого способа, чтобы найти компромисс между передачей потока данных и сохранения соединения актуальным (alive). Вам придется выбрать что-то одно, потому что в данном случае клиенту никак нельзя сообщить о том, что данные закончились, кроме как оборвав сам поток...
Возможно, вы скажете, что хотели бы уйти от этого компромисса? Так и сделали те парни, что написали спецификацию HTTP 1.1
. Им очень понравился механизм Keep-Alive
, который, по-видимому, был добавлен с сильным запозданием в HTTP 1.0
, и не позволял использовать потоки на полную мощность. Итак, в HTTP 1.1
было сделано два значительных изменения. Во-первых, постоянное соединение устанавливается по умолчанию (в противном случае вам нужно специально сообщить программе на другом конце потока, что вы собираетесь это соединение закрыть); во-вторых, к этому было добавлено что-то, названное «кодированием передачи с помощью чанков» (chunked transfer encoding).
Это выражается в появлении в заголовках HTTP 1.1
ответа следующего:
Transfer-Encoding: Chunked
что означает, что сообщение передается при помощи этой кодировки. Если взглянуть на диаграмму ответа на TCP/IP уровне, то можно заметить, что соединение остается открытым даже после передачи всех данных, связанных с этим заголовком: сервер не отправляет FIN/ACK
после последнего чанка данных. Вместо этого уже открытый сокет обычно используется для следующего по очереди запроса.
Рисунок 1. Схема TCP/IP-соединения
Как это возможно? Или по-другому: откуда браузер узнает, что он получил конец данных в потоке? В конце концов, он уже не может это знать наверняка из того факта, что соединение было закрыто.
Если используются чанки, то это обеспечивает браузер информацией о том, в каком месте потока он находится в данный момент и когда этот поток заканчивается. Во-первых, перед каждым чанком данных передается поле, которое указывает его длину в байтах, используя шестнадцатеричное представление (через ASCII-символы). Каждый блок, состоящий из поля с длиной и соответствующего чанка данных, заканчивается символом CRLF
(Carriage Return Line Feed, возврат каретки). Затем следует следующий блок с длиной/данными и так далее, пока все данные не будут получены. Поле с длиной отсутствует только у последнего чанка, потому что оно всегда равно нулю. Если говорить более точно, оно всегда содержит значение 0
перед закрывающим CRLF
. таким образом клиент всегда может определить, что поток с данными правильно закончился
Надеюсь, этот краткий экскурс в потоки и чанки помогли понять эти основы HTTP-протокола, и он окажется полезным отладке веб-сайта и веб-приложений.
Какие выводы можно сделать из вышесказанного? Во-первых, для обслуживания веб-сайтов (для которых на одну страницу может приходиться несколько десятков запросов) стоит использовать протокол HTTP/1.1
либо HTTP 1.0
совместно с Keep-Alive
и Content-Length
— это позволит избежать части издержек на установление TCP-соединения.
Если ваш сервер настроен на отдачу только одного файла одному клиенту (например, оно обслуживает только HTML-файлы, а статические ресурсы отдаются с другого сервера, или же это файловый сервер, с которого загружают большие бинарные файлы), то стоит использовать HTTP 1.0
и Connection: close
(которое включено по умолчанию). Это позволит сэкономить ресурсы сервера на обслуживание соединений.