В этой статье я постараюсь описать работу с HTTP-запросами в языке программирования PHP.

Очень часто на собеседованиях приходится слышать вопрос:

Что происходит, когда пользователь в адресной строке браузера, вводит адрес сайта и нажимает enter?

Ответ должен звучать примерно так:

Отправляется HTTP-запрос по протоколу TCP/IP на сервер, на котором расположен сайт. Далее программа web-сервер (обычно Apache, nginx или lighttpd) принимает этот запрос и в случае, если вызываемый файл — это обычный HTML, то посылает в ответ браузеру свой HTTP-ответ, в котором содержится этот HTML.

Если вызываемый файл — это скрипт, например PHP, то сначала передается управление этому скрипту, который после всех своих операций на выход подает HTML, который web-сервер отсылает HTTP-ответом обратно браузеру.

Получив от сервера HTML, браузер его преобразовывает в удобочитаемый вид согласно стандарту W3C, отправляет дополнительные запросы для отображения изображений или flash и пользователь видит содержимое сайта — текст, картинки, flash и т.д.

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

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

Кстати, очень полезными бывают утилиты, отображающие HTTP-запросы, которые отправляет и получает браузер. Я пользуюсь для этого браузером Mozilla Firefox и утилитой HttpFox.

Итак, давайте рассмотрим пример.

Введем в адресную строку браузера http://example.com и нажмем enter.

В результате браузер отправит HTTP-запрос вида:

GET / HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (X11; U; Linux i686; ru; rv:1.9.0.8) Gecko/2009032600 SUSE/3.0.8-1.1.1 Firefox/3.0.8
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru,en-us;q=0.7,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: windows-1251,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive

В приведенном примере браузер хочет получить индексную страницу сайта example.com.

Получив запрос от браузера, web-сервер проверяет в своих настройках что является индексным файлом для сайта example.com.

Если это обычный HTML, например файл index.html, то просто возвращается его содержимое.

При запросе индексной страницы сайта http://example.com браузер получает от сервера следующий ответ:

HTTP/1.x 200 OK
Date: Tue, 21 Jul 2009 17:09:54 GMT
Server: Apache/2.2.3 (CentOS)
Last-Modified: Tue, 15 Nov 2005 13:24:10 GMT
Etag: "b80f4-1b6-80bfd280"
Accept-Ranges: bytes
Content-Length: 438
Connection: close
Content-Type: text/html; charset=UTF-8 

<HTML>
<HEAD>
  <TITLE>Example Web Page</TITLE>
</HEAD>
<body>
<p>You have reached this web page by typing "example.com",
"example.net",
  or "example.org" into your web browser.</p>
<p>These domain names are reserved for use in documentation and are not available
  for registration. See <a href="http://www.rfc-editor.org/rfc/rfc2606.txt">RFC
  2606</a>, Section 3.</p>
</BODY>
</HTML>

Обратите внимание на 2 перевода строки в ответе сервера. Они являются разделением между параметрами ответа и телом сообщения, содержащего html, который и отображается в окне браузера.

В случае какого-либо скрипта, сначала происходит выполнение этого скрипта, который возвращает web-серверу HTML. Web-сервер в свою очередь отправляет этот HTML браузеру.

Представим себе, что индексной страницей сайта example.com является файл index.php. Web-сервер в этом случае, получив запрос от браузера, сначала передает управление интерпретатору PHP, который выполняет код, содержащийся в этом файле.

Например, в файле index.php содержался бы следующий код:

1
2
3
4
5
6
7
8
9
10
<?php
	if($GET['category'] == 1)
	{
		echo "<p>This is a category of example.com</p>";
	}
	else
	{
		echo "<p>This is a main page of example.com</p>";
	}
?>

Если ввести в адресной строке браузера http://example.com?category=1 и нажать enter, то браузер отправит примерно следующий запрос:

GET /index.php?category=1 HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (X11; U; Linux i686; ru; rv:1.9.0.8) Gecko/2009032600 SUSE/3.0.8-1.1.1 Firefox/3.0.8
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru,en-us;q=0.7,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: windows-1251,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive

От сервера придет в этом случае следующий ответ:

HTTP/1.x 200 OK
Date: Tue, 21 Jul 2009 17:09:54 GMT
Server: Apache/2.2.3 (CentOS)
Last-Modified: Tue, 15 Nov 2005 13:24:10 GMT
Etag: "b80f4-1b6-80bfd280"
Accept-Ranges: bytes
Content-Length: 39
Connection: close
Content-Type: text/html; charset=UTF-8 

<p>This is a category of example.com<p>

Однако, если в адресной строке браузера ввести http://example.com/index.php, то ответ сервера будет таким:

HTTP/1.x 200 OK
Date: Tue, 21 Jul 2009 17:09:54 GMT
Server: Apache/2.2.3 (CentOS)
Last-Modified: Tue, 15 Nov 2005 13:24:10 GMT
Etag: "b80f4-1b6-80bfd280"
Accept-Ranges: bytes
Content-Length: 40
Connection: close
Content-Type: text/html; charset=UTF-8 

<p>This is a main page of example.com</p>

С телом запроса думаю все понятно — в нем содержится html, который и отражается в окне браузера. Давайте теперь разберем что же хранится в параметрах запроса браузера и ответа сервера.

Начнем с HTTP-запроса, который отправляет браузер.

Первая строка - это строка запроса. Она содержит следующие поля, разделенные через пробел:

  • Метод запроса. Может принимать значения: OPTIONS, GET, HEAD, POST, DELETE, TRACE. Целью настоящей статьи не является рассмотрение всех методов. Нам будет достаточно метода GET. Если вы хотите получить описание каждого из методов, то советую обратится к документу RFC 2068
  • Запрашиваемый URI — путь до запрашиваемого ресурса на сервере. Если запрашивается главная страница сайта, то указывается путь до корневого каталога сервера - «/».
  • Версия HTTP-протокола. В этом поле содержится строка вида: «HTTP/1.1». О различных версиях протокола HTTP также можно прочитать в документе RFC 2086.

Далее после строки запроса следуют HTTP-заголовки. Описание каждого заголовка начинается с новой строки и представлено в виде:

ЗАГОЛОВОК: ЗНАЧЕНИЕ.

Рассмотрим заголовки, которые отсылает браузер в нашем примере.

  • Host. Значением для этого заголовка должно быть доменное имя и порт запрашиваемого ресурса. Значение представляется в виде:

    ДОМЕННОЕ_ИМЯ:ПОРТ

    Порт можно не указывать. В этом случае предполагается, что используется порт по умолчанию — в нашем случае это порт 80.

    В нашем последнем примере для этого заголовка устанавливается значение example.com, т.е. браузер отправляет запрос на сервер example.com и хочет обратиться к файлу /index.php с переданным GET-параметром category=1.

  • User-Agent. Содержит информацию о клиенте, который инициировал запрос к серверу, а также его программное обеспечение. Значение этого поля описывает ряд компонентов клиента. Оно может быть множественным, где все значения отделены друг от друга через пробел.

    В нашем последнем примере значением этого заголовка является:

    Mozilla/5.0 (X11; U; Linux i686; ru; rv:1.9.0.8) Gecko/2009032600 SUSE/3.0.8-1.1.1 Firefox/3.0.8

    Здесь описываются параметры браузера и операционной системы — браузер Mozilla Firefox 3.0.8, основанный на движке Gecko, операционная система Linux Suse 11.1 оконной системой X11, процессор с архитектурой i686.

    Этот заголовок используется для статистических целей.

  • Accept. В этом заголовке клиент передает список форматов, которые допустимы для ответа. Значение этого поля хранит список допустимых форматов, разделенных через запятую. Кроме того, для каждого формата можно указать уровень предпочтения от 0 до 1. Если уровень предпочтения не указан, то он считается равным 1. Значение в этом поле должно соответствовать виду:

    ФОРМАТ1[;q=УРОВЕНЬ_ПРЕДПОЧТЕНИЯ1],ФОРМАТ2[;q=УРОВЕНЬ_ПРЕДПОЧТЕНИЯ2]...

    В нашем примере браузер в этом заголовке посылает значение:

    text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

    Его можно прочитать следующим образом: браузер предпочитает ответы в форматах text/html, application/xhtml+xml. Если сервер не может послать ответ в этих форматах, то браузер будет ожидать ответ в формате application/xml. Если же и в этом формате сервер не может послать ответ, то браузер примет ответ в любом формате.

  • Accept-Language. Этот заголовок аналогичен заголовку ACCEPT, только здесь браузер сообщает серверу о своих предпочтениях о естественных языках, в которых должен приходить ответ.

    В нашем примере значением заголовка является:

    ru,en-us;q=0.7,en;q=0.3

    Браузер сообщает серверу, что ждет ответ на русском языке, но также примет ответ на «американском английском» и английском языках.

  • Accept-Encoding. Подобен ACCEPT. В этом заголовке браузер указывает какое кодирование к содержимому допустимо в ответе. Кодирование содержимого необходимо для сжатия или других его преобразований.

    В нашем примере в ответе для браузера допустимо сжатие программой gzip и механизмом deflate:

    gzip,deflate

  • Accept-Charset. Этот заголовок браузер посылает, чтобы сообщить серверу о своих предпочтениях кодировки ответа.

    В нашем примере этот заголовок принимает значение:

    windows-1251,utf-8;q=0.7,*;q=0.7

    Браузер ждет ответ от сервера в первую очередь в кодировке windows-1251, но также примет ответ в любой кодировке.

  • Keep-Alive. Заголовок Keep-Alive содержит значение, которое означает в течение какого времени в секундах будет удерживаться соединение. Этот заголовок следует отправлять только в том случае, если заголовок Connection содержит значение keep-alive.

    Поддерживается только для протокола HTTP версии 1.1.

    В нашем примере этот заголовок содержит значение 300, т.е. браузер сообщает серверу, что намерен удерживать постоянное соединение с сервером в течение 300 секунд.

  • Connection. Информация о проводимом соединении. В нашем примере этот заголовок принимает значение keep-alive, которое говорит серверу о том, что браузер хочет установить постоянное TCP соединение.

    До появления постоянных соединений для запроса каждой страницы сайта устанавливалось отдельное соединение. Открытие каждый раз отдельного соединения требует дополнительной нагрузки на сервер.

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

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

    Заголовок Connection поддерживается только для протокола HTTP версии 1.1.

    Этот заголовок также может принимать значение close. В этом случае клиент сообщит серверу, что после принятия HTTP-запроса соединение должно быть разорвано.

А теперь давайте рассмотрим параметры HTTP-ответа, который отправляет сервер браузеру.

Первая строка — это строка состояния, которая состоит из следующих полей, разделенных пробелом:

  • Версия протокола HTTP. В нашем ответе от сервера в этом поле содержится HTTP/1.x. Сервер поддерживает протоколы HTTP 1.0 и 1.1.
  • Код состояния ответа. С возможными кодами состояния можно ознакомиться в документе RFC 2068. В нашем случае от сервера нам необходим код состояния ответа, равный 200, который означат, что запрос браузера был выполнен удачно.
  • Комментарий. Комментарий для кода состояния. Если код состояния 200, то комментарий будет «OK».

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

  • Date. В этом заголовке хранится дата в формате RFC 822, описывающее когда отправляемый ответ был сгенерирован. Но это теория, на практике значением этого заголовка может быть любое время.

    В нашем примере в этом заголовке содержится:

    Tue, 21 Jul 2009 17:09:54 GMT.

  • Server. В этом заголовке содержится информация о программном обеспечении, которое используется на сервере, а также его компонентах. Если ответ от сервера направляется через proxy, то используется заголовок «Via».

    В ответе от сервере на наш запрос содержится следующее:

    Apache/2.2.3 (CentOS)

    Это означает, что на сервере используется web-сервер Apache версии 2.2.3. В качестве операционной системы используется дистрибутив Linux CentOS.

  • Last-Modified. В этом заголовке сервер сообщает дату последнего изменения какой-либо сущности — файла, базы данных и т.п. В нашем случае сервер отправляет дату последнего изменения файла index.php.

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

    В ответе сервера из нашего примера в этом заголовке хранится:

    Tue, 15 Nov 2005 13:24:10 GMT

  • Etag. В этом заголовке содержится метка объекта ресурса. В нашем случае в качестве объекта выступает файл index.php.

    Наряду с заголовком Last-Modified значение этого заголовка используется при кешировании браузером. При первом запросе страницы браузер сохраняет значение Etag для страницы. При повторном запросе страницы значение Etag, которое пришло от сервера, сравнивается с тем, которое хранится в кеше браузера. Если они равны, то браузеру нет смысла считывать еще раз данные страницы, он просто возьмет их из кеша.

    В нашем примере в ответе сервера Etag имеет следующее значение:

    "b80f4-1b6-80bfd280"

  • Accept-Ranges. Этот заголовок сообщает клиенту о том, что он может запрашивать данные с сервера фрагментами, указывая их смещение в байтах.

    Используется для реализации скачивания файлов с докачкой.

    В нашем примере сервер поддерживает эту возможность, о чем он и сообщает, передав в заголовке Accept-Ranges значение bytes.

    Зная эту возможность, браузер может передать серверу смещение в байтах, с которого необходимо начать передачу файла. Для этого браузер посылает заголовок Range с параметром bytes, значение которого и является смещением в байтах. Например:

    Range: bytes=2048

    Этот заголовок означает, что браузер запросил у сервера содержимое файла, начиная с 2-го мегабайта.

  • Content-Length. Этот заголовок содержит размер тела HTTP-ответа сервера.
    В нашем примере размер тела сообщения 40.

  • Connection. Как и в случае для клиента, этот заголовок предоставляет информацию о проводимом соединении. В нашем примере этот заголовок принимает значение close, которое говорит клиенту о том, что сервер либо не поддерживает постоянное TCP соединение, либо он просто отказывает браузеру в постоянном соединении.

  • Content-Type. В этом заголовке содержится формат тела HTTP-ответа сервера.

    В нашем примере в этом заголовке содержится:

    text/html; charset=UTF-8

    Это означает, что сервер отвечает браузеру данными в формате text/html и кодировке UTF-8.

    В этом заголовке сервера также может храниться, например, «text/css; charset=utf-8» при ответе браузеру на запрос файла CSS. Или, например, может храниться «image/gif» при ответе браузеру на запрос GIF-изображения.

    О других форматах тела сообщения в ответах сервера читайте в RFC 822.

После HTTP—заголовков в ответе сервера содержится 2 перевода строки, за которыми следует само тело сообщения. 2 перевода строки служат разделением между параметрами ответа сервера и телом сообщения, в котором содержится html, отображаемый браузером.

В нашем примере в теле сообщения ответа от сервера содержится:

<p>This is a main page of example.com</p>

Ну а теперь напишем простой прокси-сервер на PHP, который покажет практическое использование упомянутых HTTP-заголовков.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php
$body = "";
// доменное имя передаем в GET-параметре $_GET['host']
$host = $_GET['host'];
// открываем сокет на стандартном для HTTP порту 80
$hSocket = fsockopen($host, 80, $errno, $errstr, 30);
if ($hSocket)
{
    // первой строкой идет строка запроса
    $request = "GET / HTTP/1.1\r\n";
    // указываем доменное имя
    $request .= "Host: $host\r\n";
     // пусть мы будем для сервера браузером Mozilla Firefox
    $request .= "User-Agent: Mozilla/5.0 (X11; U; Linux i686; ru; rv:1.9.0.8) Gecko/2009032600 SUSE/3.0.8-1.1.1 Firefox/3.0.\r\n";
    // указываем форматы, в которых будем ожидать ответ от сервера
    $request .= "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n";
    // языки, на которых ожидаем ответ
    $request .= "Accept-Language: ru,en-us;q=0.7,en;q=0.3\r\n";
    // указываем какое кодирование к содержимому допустимо в ответе. 
    //Только в этом случае нужно будет раскодировать содержимое.
    //$request .= "Accept-Encoding: gzip,deflate\r\n"; 
    // выставляем предпочтения для кодировок ответа
    $request .= "Accept-Charset: windows-1251,utf-8;q=0.7,*;q=0.7\r\n";
    // будем удерживать постоянное соединение в течение 300 секунд
    $request .= "Keep-Alive: 300\r\n";
    // открываем постоянное соединение
    // здесь мы завершаем передачу HTTP-заголовков, 
    // поэтому в конце обязательно указываем 2 перевода строки
    $request .= "Connection: keep-alive\r\n\r\n";
    // посылаем HTTP-заголовки через открытый сокет
    fwrite($hSocket, $request);
    // флаг, указывающий на то, что считывается тело запроса
    $bIsData = false;
    while (!feof($hSocket)) // считываем ответ от сервера
    {
        $str = fgets($hSocket, 128); // построчно
        if($bIsData)
        {
            $body .= $str; // тело запроса записываем в переменную $body
        }
        // встретилась строка, содержащая только перевод строки, это означает, 
        //что дальше пойдет тело запроса
        elseif($str == "\r\n")
        {
            $bIsData = true;
        }
    }
    fclose($hSocket); // закрываем сокет
 
    // указываем на то, чтобы web-сервер отправил браузеру заголовки 
    // Last-Modified, Connection и Keep-alive
    header("Last-Modified: Tue, 21 Jul 2009 17:09:54 GMT", true);
    header("Connection: Keep-Alive", true); //
    header("Keep-Alive: timeout=30, max=100", true);
    // передаем тело запроса
    echo $body;
}
 
// работа php-скрипта закончена, теперь web-сервер отправляет ответ браузеру
?>

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

В нашем случае один процесс — это процесс браузера, второй — это процесс web-сервера, запущенный на удаленном компьютере.

В PHP сокет открывается функцией fsockopen. Далее в переменную $request мы записываем все нужные нам заголовки и отправляем их серверу функцией fwrite. После этого мы построчно считываем ответ сервера пока не будет достигнут конец данных (feof). После получения ответа мы отправляем этот ответ браузеру вместе с заголовками, которые в PHP выставляются функцией header.

Этот скрипт принимает через GET-параметр доменное имя сайта и отправляет к нему HTTP-запрос, считывает ответ и отдает этот ответ web-серверу, который отправляет его браузеру.

При помощи подобного скрипта вы можете сами поиграться с отправляемыми HTTP-запросами.

Нам этом все. Спасибо за внимание.

Для тех, кто заинтересовался и хочет подробнее изучить HTTP могу посоветовать следующие ссылки:

http://ru.wikipedia.org/wiki/HTTP - статья, подробно описывающая HTTP.

http://www.faqs.org/rfcs/rfc2068.html - документ RFC 2068.