WEB-сервер на Delphi



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

Как работает WEB-сервер

Перед тем как начать размахивать молотком, давай потратим немного времени и разберемся с теоретической частью, иначе есть сильно большой шанс отбить себе пальцы. Итак, Web-сервер – прежде всего обычная программа. Такие программы висят на 80 порту и ждут подключения клиентов – web-браузеров. Как только клиент устанавливает соединение (используется протокол HTTP), начинается обмен данными. Клиент формирует запрос на получения документа/файла и посылает его серверу (пример такого запроса мы рассмотрим чуть позже). Полученную информацию сервер тщательно анализирует и если все тип-топ, то возвращает положительный ответ (коды ответов сервера смотри в таблице № 1) и собственно сам документ. Закончив передачу документа, web-сервер разрывает соединение с клиентом (если не было иной договоренности) и ждет следующего запроса.

Тонкости HTTP

Протоколом HTTP (HyperText Transfer Protocol) каждый из нас пользуется ежедневно. Мы уже привыкли, что для получения нужного web-документа от нас лишь требуется ввести адрес сервера в своей бродилке и ожидать результата. Само собой, о том, какие данные в этом время браузер передает серверу, мало кто задумывается. Но поскольку сегодня нам предстоит выступать в роли сервера, мы просто обязаны уделить несколько минут на рассмотрение внутренностей HTTP. HTTP – протокол прикладного уровня и как и многие другие работает поверх TCP/IP. В основе протокола лежит до боли известная технология клиент-сервер. Ты уже должен знать, кто выступает клиентом, а кто сервером, поэтому двадцать раз одно и тоже я повторять не стану. Для получения информации по HTTP, клиенту необходимо подготовить запрос, которой должен содержать идентификатор запрашиваемого ресурса (Uniform Resource Identifier). По URI сервер и будет искать нужный документ. По правилам хорошего тона, каждый HTTP запрос должен состоять из трех неотъемлемых частей:

1. Стартовая строка. Она определяет тип запроса.

2. Заголовок. В заголовке содержаться дополнительные сведения (название программы, поддерживаемые кодировки и т.д.).

3. Тело. Для наглядности, ниже я привел реальный текст запроса, который я отснифал у FireFox при попытке зайти на локальный сайт. Для более удобного восприятия и комментирования, каждую строку запроса я снабдил порядковым номером.


//Стартовая строка
1. GET / HTTP/1.1
//Заголовки
2. Host: localhost:8080
3. User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.9) Gecko/2008052906 Firefox/3.0
4. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
5. Accept-Language: ru,en-us;q=0.7,en;q=0.3
6. Accept-Encoding: gzip,deflate
7. Accept-Charset: windows-1251,utf-8;q=0.7,*;q=0.7
8. Keep-Alive: 300
9. Connection: keep-alive

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

2. Со второй строки начинается описание заголовков. В первую очередь определяется адрес и порт сервера, у которого будут запрошены данные.

3. Строка носит чисто информативный характер и предназначена для идентификации программы, которая отправила запрос.

4. Эта строка определяет тип содержимого, который может быть принят клиентом.

5. Смысл этой строки такой же, как и у предыдущей, но с одним отличием – она определяет национальные языки.

6. В этой строке задается тип кодирования страницы.

7. В Accept-Charset устанавливается предпочтительная кодировка.

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


HTTP/1.0 200 OK
Server: Apache
Content-Length: 1341
Content-Type: text/html
Connection: close

Формат ответа от сервера идентичен формату запроса клиента. Главное отличие в используемых директивах. Как видно из примера, в качестве стартовой строки указывается лишь версия протокола и код состояния. В приведенном примере записан код 200. Далее идут заголовки. Подробней мы их рассмотрим на практике.

Выбираем гвозди

С введением в основы HTTP покончено и этих знаний тебе вполне хватит для дальнейшего понимания сегодняшнего урока. Теперь самое время определится с набором необходимого инструментария, которым сегодня нам предстоит воспользоваться. Сегодняшний пример, мы будем целиком писать на Delphi и с использованием одних лишь WinAPI функций. Такой путь более труден, но зато позволяет забыть о неудобстве и тормознутости многих компонентов (особенно тех, которые «помогают» создавать сетевые сервисы) и разработать более гибкое и функциональное приложение. Все функции для работы с сетью, определены в наборе WinSock API. Эта билиотека содержит множество функций на любой случай жизни. Про функции входящие в эту библиотеку я уже не раз рассказывал, поэтому сегодня я особо останавливаться на них не буду. Всю дополнительную информацию, ты можешь найти в предыдущих выпусках нашего журнала.

Куем индейца

Запускай Delphi и создавай новый проект. Для сегодняшнего примера нам не потребуется форма, поэтому сразу ее удали: «Project -> Remove Form Project», а затем открой исходник проекта – «View -> Source». Во избежание курьезных ситуаций, вроде внезапного отключение света, потрать пару секунд и сохрани проект.


Рисунок 1 (Никаких форм, только код и еще раз код)

Создаем настройки

У любого WEB-сервера как минимум должны существовать три настраиваемые опции:

1. Путь к корневой директории. В ней располагаются файлы и папки, доступные для клиенту.

2. Порт. По умолчанию, все WEB-сервера для своей работы используют 80 порт, но к сожалению, этот порт может быть занят другим приложением, поэтому в своей программе это нужно учесть и предоставлять возможность выбирать произвольный порт.

3. Имя индексной страницы. Когда клиент в своем запросе указывает / или просто имя папки, то это означает, что он желает получить основную страницу каталога. Обычно индексную страницу именуют как index.html (расширение может быть любым), но многие любят отступать от правил и выдумывать другие имена, поэтому желательно иметь возможность изменять наименование индексного файла.

В своем примере я не стал париться и реализовал эти опции с помощью трех констант (DOCUMENT_ROOT, PORT и DIRECTORY_INDEX). Для реального приложения такой способ не удобен (т.к. тебе придется поставлять свое творение в исходниках, а бедному пользователю мучиться с их компиляцией), но для демонстрационного он вполне подойдет. С константами разобрались, двигаемся дальше. Если ты читаешь наш журнал постоянно, то уже должен знать, что перед тем как начинать использовать WinSock API, нужно проинициализировать сетевую библиотеку с помощью функции WSAStartup(). Ей нужно передать всего лишь два параметра:

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

Проинициализировав сетевую библиотеку, у нас появляется возможность, использовать абсолютно любые WinSock API функции, а значит, мы можем открыть порт и запустить цикл ожидания новых клиентов. Для удобства использования и эстетичности, код «прослушки» я вынес в отдельный поток и обозвал его TListenThread. Если ты вдруг решишь написать WEB-сервер с графическим интерфейсом, то тебе обязательно придется выполнять прослушивание в отдельном потоке, иначе твое приложение заснет непробудным сном. Содержимое моего потока приведено в листинге №1.

Листинг 1 (Прослушивание порта)

var
_listenSocket, _clientSocket:TSocket;
_listenAddr, _clientAddr: sockaddr_in;
_clientThread:TClientThread;
_size:integer;
begin
 
_listenSocket := socket (AF_INET, SOCK_STREAM, 0);
 
if (_listenSocket = INVALID_SOCKET) then
begin
 MessageBox (0, 'Socket create Error',
 'warning!', 0);
 Exit;
end;
 
_listenAddr.sin_family := AF_INET;
_listenAddr.sin_port := htons(ListenPort);
_listenAddr.sin_addr.S_addr := htonl(INADDR_ANY);
 
if (Bind(_listenSocket, _listenAddr, 
sizeof(_listenAddr)))=SOCKET_ERROR then
begin
 MessageBox (0, 'BIND Erorr', 'warning!', 0);
 Exit;
end;
 
if Listen(_listenSocket, 4) = SOCKET_ERROR then
begin
 MessageBox (0, 'Listen Error', 'warning', 0); 
 Exit;
end;
 
while true do
begin
_size := sizeof(_clientAddr);
 
_clientSocket := accept(_listenSocket, 
@_clientAddr, @_size);
 
if (_clientSocket = INVALID_SOCKET) then
  Continue;
 
_clientThread := TClientThread.Create(true);
_clientThread._Client := _ClientSocket;
_clientThread.DocumentRoot := DocumentRoot;
_clientThread.DirectoryIndex := DirectoryIndex;
_clientThread.Resume;
end;

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

1. Семейство протоколов. Мы собираемся использовать Интернет протокол, поэтому указываем AF_INET.

2. Тип сокета. В WinSock API выделяется два типа сокета: SOCK_STREAM (для протокола TCP/IP) и SOCK_DGRAM (для протоколов, не требующих установки соединения, например UDP). Мы работаем с TCP/IP и поэтому указываем SOCK_STREAM.

3. Протокол. Для TCP нужно указать IPPROTO_TCP. Выполнив функцию, нужно обязательно проверить ее результат. Для проверки достаточно сравнить возвращенное значение с константой INVALID_SOCKET. Если они равны, то 100% произошла ошибка, и нет смысла продолжать работу. Если бог миловал и сокет таки создался, нужно заполнить структуры типа sockaddr_in. У этой структуры 3 поля для заполнения:

- sin_family - семейство протоколов. Указываем тоже значение, как и для первого параметра функции socket.

- sin_port – используемый порт. Номер порта, который мы будем «слушать» содержится в переменной ListenPort, поэтому в это свойство присваиваем значение этой переменной.

- sin_addr.S_addr – интерфейсы, с которых мы будем принимать соединения. Чтобы не заморачиваться, нужно просто указать константу INADDR_ANY, которая говорит, что нас интересуют абсолютно все интерфейсы.

Теперь у нас есть заполненная структура и созданный сокет. Для дальнейших действий их нужно связать. Связь устанавливается с помощью функции Bind(), которая принимает всего лишь три параметра:

- созданный сокет
- заполненная структура sockaddr_in
- размер структуры.

Если Bind() не вернула ошибок, то значит все готово для вызова функции Listen(). После этого, наш сокет перейдет в состояние ожидания новых подключений. В качестве параметров, функция Listen() принимает: - сокет. - длина очереди соединений. Поскольку мы создаем WEB-сервер, то изначально подразумевается, что клиентов может быть несколько, причем несколько одновременно. Поэтому чтобы никто не стоял в очереди я запускаю бесконечный цикл, в котором вызывается функция Accept(). Если после выполнения данной функции не возникло ошибок, то значит, новый клиент успешно подключился и для него нужно создать отдельный поток – TClientThread. В этом потоке и будет происходить обработка запросов пользователя и отправка соответствующих документов. Исходный код потока TClientThread приведен в листинге 2, поэтому не засиживайся и начинай его переписывать, а я постараюсь, как можно тщательней его прокомментировать.

Листинг 2 (Взаимодействие с клиентом)

var
 _buff: array [0..1024] of char;
 _request:string;
 _temp: string;
 _path: string;
 _FileStream : TFileStream;
begin
 Recv(_client, _buff, 1024, 0);
 
 _request:=string(_buff);
 
 if _request='' then
 begin
  CloseSocket(_client);
  exit;
 end;
 
 AddToLog(_request);
 _path := GetFilePath (Copy(_request, 1, 
pos(#13, _request)));
 
 _path := ReplaceSlash(_path);
 
 if ((_path = '') or (_path = '\')) Then
  _path := DocumentRoot +'\' + DirectoryIndex;
 
 if (FileExists(_Path)) Then
 begin
  _FileStream := TFileStream.Create(_Path,
fmOpenRead);
 
  SendStr(_Client, 'HTTP/1.0 200 OK');
  SendStr(_Client, 'Server: xSrV');
  SendStr(_Client, 'Content-Length:' + IntToStr(_FileStream.Size));
  SendStr(_Client, 'Content-Type: ' + GetTypeContent(_Path));
  SendStr(_Client, 'Connection: close');
  SendStr(_Client, '');
  SendFile(_Client, _FileStream);
 
  _FileStream.Free;
 end
 else
 begin
_path := ExtractFilePath(ParamStr(0)) + '404.html';
_FileStream := TFileStream.Create(_Path, fmOpenRead);
 
SendStr(_Client, 'HTTP/1.0 404 Not Found');
 
SendStr(_Client, 'Server: xSrV');
 
SendStr(_Client, 'Content-Length:' + IntToStr(_FileStream.Size));
 
SendStr(_Client, 'Content-Type: ' + GetTypeContent(_Path));
 
SendStr(_Client, 'Connection: close');
 
SendStr(_Client, '');
 
SendFile(_client, _FileStream);
end;
 
Terminate();

Сразу после старта потока TClientThread, я вызываю функцию Recv(), которая позволяет читать данные, прилетевшие на сокет. Из параметров я передаю:

- Сокет, с которого нужно читать. - Буфер, в который нужно читать.
- Размер буфера.

После чтения, все полученные данные я копирую в переменную _request и сразу же проверяю ее значение. Если она пуста, то значит, клиент не отправил запрос и ничего не остается, как закрыть сокет и прервать выполнения процедуры. Ну а если данные все-таки есть, то их нужно добавить в лог (надо же знать, кто к нам подсоединялся), и приступить к анализу запроса.

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

Получив эту строку, функция начнет ее проверять на предмет наличия метода GET. Если он будет найден, то указанный в нем URI будет вырезан и возвращен в качестве результата. Я не стал приводить в статье код этой функции, так он очень простой и в нем лишь использованы известные каждому Delphi-программисту функция для работы со строками. Полученный путь к документу, я скармливаю еще одной самописной функции -ReplaceSlash(), которая заменит все обратные слэши (/), используемые в nix системах, на их win братьев – (\).

Путь запрашиваемого документа у нас есть, теперь остается только получить полный путь, относительно директории определенной в DocumentRoot. Для этого я проверяю переменную _path. Если ее значение пустое, либо равно “\”, то пользователю нужно отправить индексную страницу или индексную страницу определенной папки.

Теперь, когда мы знаем полный путь к документу, нам не составит труда отправить его клиенту. Но перед отправкой обязательно нужно убедиться в существовании требуемого файла. Ведь вполне возможно, что пользователь просто ошибся, набирая адрес в своей бродилке, и такого документа у нас может и вовсе не быть. Что в этом случае делать? Молчать? Нет, отмалчиваться не стоит, иначе клиент подумает, что сайт просто недоступен. Чтобы этого не случилось, достаточно просто оправить страницу, содержащую код ошибки. В данной ситуации это будет ошибка 404. Для определения наличия файла, я использую стандартную функцию FileExists(). Если она вернет, тру, то значит все Ok и следует начать отправку запрошенного файла, а если нет, то передать заранее подготовленную страницу с ошибкой 404.

Давай взглянем на случай, при котором файл найден, а действия при отсутствия файла аналогичны. Для передачи файла клиенту, необходимо загрузить этот файл в поток (объект типа TFileStream). Объект инициализируется стандартным способом, т.е. через метод Create(). Из параметров этот метод просит:

1. Путь к файлу.
2. Тип доступа. Нам нужен файл только для чтения, поэтому я указываю fmOpenRead.

После загрузки файла, нужно приступать к формированию и отправлению ответа для клиента. Вся текстовая часть заголовка отправляется с помощью самописный функции SendStr(). В качестве параметров ей нужно передать: - Сокет, которому нужно послать данные. - Отправляемая строка. В принципе не обязательно объявлять свою функцию для отправки данных. Я это сделал лишь для удобства. Моя реализация функции избавляет от постоянного добавления к отправляемой строке символов завершения строки (#13) и перевода каретки (#10). Т.е. проще говоря, выполнять отправку данных ты можешь с помощью примерно такой конструкции:

var
_buff: array [0..255] of char;
_temp: AnsiString;
begin
_temp :=str+#13+#10;
CopyMemory(@_buff, PChar(_temp), Length(_temp));
send(s, _buff, Length(_temp), 0);

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

В заголовке я указываю:

- Server. Определяет имя и версию сервера, отправившего документ.
- Content-Length. Размер отправляемого документа
- Content-Type. Тип документа. - Connection.

Есть ли необходимость после передачи закрывать соединение. Передав заголовки, можно отправлять сам файл. Для отправки файла используется процедура SendFile(), код который приведен в третьем листинге. Перед тем как начать рассматривать содержимого последнего листинга, я хочу, чтобы ты обратил внимание на строчку, в которой происходит отправка типа содержимого передаваемого файла. Эта строчка играет огромную роль и от нее зависит, как поведет себя браузер клиента. Например, если пользователь запросил архив, а мы в content-type указали text/html, то наверняка браузер просто-напросто начнет загружать архив как обычную страницу и пользователь увидит всякие непонятные символы. Чтобы этого не произошло, я написал функцию, GetTypeContent(), которая по расширению отправляемого файла будет определять его тип. Например, если передать этой функции в качестве параметра file.zip, то она вернет: application/x-zip-compressed. Получив такую строку, браузер не будет пытаться загрузить и отобразить этот файл, а просто запросит у пользователя путь для сохранения и спокойно начнет его скачивать. Думаю, с этим моментом мы разобрались и теперь готовы приступить к рассмотрению последнего листинга.

Функция SendFile() написана для более удобной отправки файлов клиенту. В качестве параметров ей необходимо передать сокет, которому будем отправлять файл и поток, содержащий файл. Передавать файл мы будем маленькими частями. Для этого я определяю буфер размером 5000 байт и запускаю бесконечный цикл. В теле цикла я читаю кусок отправляемого файла в наш буфер и выполняю передачу клиенту с помощью уже знакомой тебе функции send(). Чтобы узнать момент отправки файла и выйти из цикла, после очередного чтения, я проверяю переменную _ret на равенство 0. Если ее значение равно нулю, то значит, файл уже полностью отправлен клиенту и следует прервать цикл.

Листинг 3 (Процедура SendFile())

var
_buff:array [0..5000] of char;
_ret:integer;
begin
 while true do
 begin
  _ret := FileStream.Read(_buff, sizeOf(_buff));
 
  if (_ret = 0) Then
   Break;
 
  Send(s, _buff, _ret, 0);
end;

Протестим

Наш пример готов и чтобы убедиться, что все работает как надо, его нужно хорошенечко протестировать. Для теста я подготовил две самодельные WEB-страницы и один zip архив и поместил их в папку, путь которой определен в константе DocumentRoot. Скомпилировав и запустив пример, я попробовал запросить эти страницы с помощью моего любимого FireFox. Не успел я, и моргнуть, как огненный лист успешно соединился с моим web-сервером и получил индексную страницу. Мне этого показалось мало, и я решил запросить созданный мной архив. К счастью и этот запрос был правильно выполнен. Результаты моего тестирования ты можешь увидеть на рисунках 2-4.


Рисунок 2 (Индексная страница успешно загружена!)


Рисунок 3 (И по ссылкам ходит правильно)


Рисунок 4 (FireFox успешно определил, что передается архив)

Coding complete

На этом лекцию по созданию нового конкурента для Apache считаю оконченной. Наш простенький WEB-сервер готов и уже сейчас может выполнять простейшие операции. Тем не менее, перед использованием сервера в боевых условиях, я рекомендую тебе немного его доработать. Лично я бы первым делом снабдил код проверками на ошибки, добавил бы красивое ведение лога и реализовал поддержку остальных HTTP методов и т.д. В общем, плацдарм для творчества немалый. Мне теперь остается лишь пожелать тебе удачи и попрощаться.

Written by Игорь «Spider_NET» Антонов
E-mail:
WWW: ]]>http://vr-online.ru]]>

ВложениеРазмер
source.7z7.15 КБ

Комментарии

3 комментария(ев)
аватар: Va-Bank
Va-Bank
Дата: Пнд, 12/12/2011 - 15:28
Звание: Гуру
Сообщений: 8234

Код на Delphi7 ?

аватар: jimmyjonezz
jimmyjonezz
Дата: Пнд, 12/12/2011 - 17:58
Звание: Мастер
Сообщений: 2469

Зуб даю, что 7. Laughing out loud

аватар: Postscripter
Postscripter
Дата: ПТ, 30/11/2012 - 08:37
Звание: Наблюдатель
Сообщений: 7

Recv(_client, _buff, 1024, 0);

А если в запросе будет больше 1024 байт? Или, скажем, медленный интернет. Бывает же такое? Запрос разбился на два пакета. Первый мы получим, а остальные?