Проголосуйте за следующую запись или укажите, что вы хотите видеть дальше:



HTTP везде! Каждый посещаемый нами веб-сайт запускается на HTTP-сервере. Вы можете подумать, что тогда насчет серверов HTTPS. Технически HTTPS - это то же самое, что и HTTP, но с большей безопасностью.

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

И я один из многих программистов.

Недавно я начал использовать node.js в одном из своих проектов. Во время его использования мне стало любопытно: «Как строятся HTTP-серверы?» и «Как работают HTTP-серверы?» И следующий вопрос, который я задал: «Как я могу построить HTTP-серверы с нуля?». «Может ли новичок построить такой?».

Ответ:

Первый вопрос, который мы задаем:

С чего мы начнем?

Во-первых, нам нужно узнать, что такое OSI.

OSI:

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

Для реализации HTTP нас интересует только 4-й уровень: транспортный уровень.

Транспортный уровень:

Транспортный уровень в первую очередь отвечает за обеспечение надежной и безошибочной передачи данных из одной точки в другую. Например, транспортный уровень обеспечивает отправку и получение данных в правильной последовательности.

Транспортный уровень обеспечивает управление потоком и обработку ошибок, а также участвует в решении проблем, связанных с передачей и приемом пакетов. Распространенными примерами протоколов транспортного уровня являются протокол управления передачей (TCP), протокол дейтаграмм пользователя (UDP) и последовательный обмен пакетами (SPX).

На транспортном уровне мы в основном используем протокол управления передачей (TCP) для реализации HTTP-сервера. Мы также можем использовать протокол пользовательских датаграмм (UDP) для реализации HTTP-сервера, но многие его не используют. Причины этого могут отличаться от нашей основной темы построения HTTP-сервера.

Вкратце, из RFC 2616:

Связь HTTP обычно осуществляется через соединения TCP / IP. Порт по умолчанию - TCP 80, но можно использовать и другие порты. Это не препятствует реализации HTTP поверх любого другого протокола в Интернете или других сетях. HTTP предполагает только надежный транспорт; может использоваться любой протокол, который предоставляет такие гарантии; отображение структур запроса и ответа HTTP / 1.1 на транспортные блоки данных рассматриваемого протокола выходит за рамки данной спецификации.

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

Все известные HTTP-серверы, такие как Apache Tomcat, NginX и т. Д., Реализованы поверх TCP. Итак, в этом посте мы просто остановимся на HTTP-сервере, основанном на TCP.

Теперь вы можете подумать: «Что за чертовщина RFC!»

RFC:

Запрос комментариев (RFC) в контексте управления Интернетом - это тип публикации от Инженерной группы Интернета (IETF) и Internet Society (ISOC), главный технический орган по разработке и стандартизации Интернета .

RFC создается инженерами и компьютерщиками в форме меморандума, описывающего методы, поведение, исследования или инновации, применимые к работе Интернета и систем, подключенных к Интернету. Он подается либо на экспертную оценку, либо для передачи новых концепций, информации или (иногда) инженерного юмора. IETF принимает некоторые из предложений, опубликованных в виде RFC, как Интернет-стандарты. Документы Запрос на комментарии были изобретены Стивом Крокером в 1969 году для записи неофициальных заметок о развитии ARPANET. RFC с тех пор стали официальными документами Интернет-спецификаций, коммуникационных протоколов, процедур и событий.

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

На август 2017 года более 8200 RFC.

Официальным источником RFC во всемирной паутине является RFC Editor.

Вот некоторые из стандартизованных RFC:

HTTP / 1.1 → Изначально это RFC 2616, но позже он был заменен на RFC 7230, RFC 7231, RFC 7232, RFC 7233, RFC 7234, RFC 7235. Итак, нам нужно прочитать от RFC 7230 до RFC 7235, чтобы реализовать базовую работу HTTP.

HTTP / 2 → RFC 7540 и RFC 7541

FTP → RFC959

Итак, если мы хотим реализовать HTTP-сервер, мы должны прочитать их конкретные RFC, а именно RFC 7230, RFC 7231, RFC 7232, RFC 7233, RFC 7234, RFC 7235.

Просто расслабьтесь на мгновение, прежде чем мы погрузимся в кодирование😄

Теперь реализуем то, что мы узнали:

Реализация TCP:

Сначала нам нужно реализовать транспортный уровень HTTP, которым является TCP.

ПРИМЕЧАНИЕ. Для кодирования будет использоваться язык C. Причина использования языка C заключается в том, что его можно использовать с любым языком программирования, таким как Python, Java, Swift и т.д. -уровневые современные языки. Вы можете интегрировать свой код C с любым языком высокого уровня.

Код, который мы будем реализовывать, предназначен для систем на основе UNIX, таких как macOS и Linux. Только код реализации TCP отличается для Windows от UNIX. Но реализация HTTP-сервера такая же, потому что мы должны следовать некоторым конкретным рекомендациям из HTTP RFC, который не зависит от языка!

Чтобы реализовать TCP, мы должны изучить программирование сокетов TCP.

Что такое сокет?

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

Механизм сокетов был создан, чтобы быть независимым от любого конкретного типа сети. Однако IP, безусловно, является наиболее доминирующей сетью и наиболее популярным применением сокетов.

Программирование с сокетами TCP / IP

Использование сокетов состоит из нескольких шагов:

  1. Создайте сокет
  2. Определите розетку
  3. На сервере ждем входящего подключения
  4. Отправлять и получать сообщения
  5. Закройте розетку

Шаг 1. Создайте сокет

Сокет server_fd создается с помощью системного вызова socket:

int server_fd = socket(domain, type, protocol);

Все параметры, а также возвращаемое значение являются целыми числами:

домен или семейство адресов -

коммуникационный домен, в котором должен быть создан сокет. Некоторые из семейств адресов: AF_INET (IP), AF_INET6 (IPv6), AF_UNIX (local channel, similar to pipes), AF_ISO (ISO protocols) и AF_NS (Xerox Network Systems protocols).

тип -

тип сервиса. Он выбирается в соответствии со свойствами, требуемыми приложением: SOCK_STREAM (virtual circuit service), SOCK_DGRAM (datagram service), SOCK_RAW (direct IP service). Узнайте у своей семьи адресов, доступна ли конкретная услуга.

протокол -

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

Для сокетов TCP / IP мы хотим указать семейство IP-адресов (AF_INET) и службу виртуальных каналов (SOCK_STREAM). Поскольку существует только одна форма службы виртуальных каналов, вариантов протокола нет, поэтому последний аргумент, протокол, равен нулю. Наш код для создания TCP-сокета выглядит так:

#include <sys/socket.h>
...
...
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) 
{
    perror(“cannot create socket”); 
    return 0; 
}

Шаг 2. Идентифицируйте (имя) сокета

Когда мы говорим о именовании сокета, мы говорим о назначении транспортного адреса сокету (номер порта в IP-сети). В сокетах эта операция называется привязкой адреса, и для этого используется системный вызов bind.

Аналогия - это присвоение телефонного номера линии, которую вы запросили у телефонной компании на шаге 1, или присвоение адреса почтовому ящику.

Транспортный адрес определяется в структуре адреса сокета. Поскольку сокеты были разработаны для работы с различными типами коммуникационных интерфейсов, интерфейс является очень общим. Вместо того, чтобы принимать, скажем, номер порта в качестве параметра, он принимает структуру sockaddr, фактический формат которой определяется в семействе адресов (типе сети), которое вы используете. Например, если вы используете сокеты домена UNIX, bind фактически создает файл в файловой системе.

Системный вызов для bind:

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

Первый параметр socket - это сокет, созданный с помощью системного вызова socket.

Для второго параметра структура sockaddr представляет собой общий контейнер, который просто позволяет ОС читать первую пару байтов, идентифицирующих семейство адресов. Семейство адресов определяет, какой вариант структуры sockaddr использовать, которая содержит элементы, которые имеют смысл для этого конкретного типа связи. Для IP-сетей мы используем struct sockaddr_in, который определен в заголовке netinet/in.h. Эта структура определяет:

struct sockaddr_in 
{ 
    __uint8_t         sin_len; 
    sa_family_t       sin_family; 
    in_port_t         sin_port; 
    struct in_addr    sin_addr; 
    char              sin_zero[8]; 
};

Перед вызовом bind нам нужно заполнить эту структуру. Нам нужно установить следующие три ключевые части:

sin_family

Семейство адресов, которое мы использовали при настройке сокета. В нашем случае это AF_INET.

sin_port

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

sin_addr

Адрес этого сокета. Это просто IP-адрес вашего компьютера. С IP у вашей машины будет один IP-адрес для каждого сетевого интерфейса. Например, если ваша машина имеет и Wi-Fi, и Ethernet-соединения, у этой машины будет два адреса, по одному для каждого интерфейса. В большинстве случаев мы не указываем конкретный интерфейс и можем позволить операционной системе использовать все, что она хочет. Специальным адресом для этого является 0.0.0.0, определяемый символьной константой INADDR_ANY.

Поскольку структура адреса может отличаться в зависимости от типа используемого транспорта, третий параметр определяет длину этой структуры. Это просто sizeof(struct sockaddr_in).

Код для привязки сокета выглядит так:

#include <sys/socket.h> 
… 
struct sockaddr_in address;
const int PORT = 8080; //Where the clients can reach at
/* htonl converts a long integer (e.g. address) to a network representation */ 
/* htons converts a short integer (e.g. port) to a network representation */ 
memset((char *)&address, 0, sizeof(address)); 
address.sin_family = AF_INET; 
address.sin_addr.s_addr = htonl(INADDR_ANY); 
address.sin_port = htons(PORT); 
if (bind(server_fd,(struct sockaddr *)&address,sizeof(address)) < 0) 
{ 
    perror(“bind failed”); 
    return 0; 
}

Шаг 3. На сервере ждем входящего подключения

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

#include <sys/socket.h> 
int listen(int socket, int backlog);

Второй параметр, backlog, определяет максимальное количество ожидающих соединений, которые могут быть поставлены в очередь до того, как соединения будут отклонены.

Системный вызов accept захватывает первый запрос на соединение в очереди ожидающих соединений (установлен в listen) и создает новый сокет для этого соединения.

Исходный сокет, который был настроен для прослушивания, используется только для приема соединений, а не для обмена данными. По умолчанию операции с сокетами являются синхронными или блокирующими, и accept будет блокироваться до тех пор, пока соединение не будет присутствовать в очереди.

Синтаксис accept:

#include <sys/socket.h> 
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);

Первый параметр, socket, - это сокет, который был установлен для приема соединений с listen.

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

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

Код для прослушивания и приема выглядит так:

if (listen(server_fd, 3) < 0) 
{ 
    perror(“In listen”); 
    exit(EXIT_FAILURE); 
}
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0)
{
    perror("In accept");            
    exit(EXIT_FAILURE);        
}

Шаг 4. Отправляйте и получайте сообщения.

Мы наконец подключили сокеты между клиентом (когда вы посещаете IP-адрес своего сервера из веб-браузера) и сервером!

Общение - самая легкая часть. Те же системные вызовы read и write, которые работают с файлами, также работают с сокетами.

char buffer[1024] = {0};
int valread = read( new_socket , buffer, 1024); 
printf(“%s\n”,buffer );
if(valread < 0)
{ 
    printf("No bytes are there to read");
}
char *hello = "Hello from the server";//IMPORTANT! WE WILL GET TO IT
write(new_socket , hello , strlen(hello));

ПРИМЕЧАНИЕ. Реальная работа HTTP-сервера зависит от содержимого переменной char *hello. Мы вернемся к этому позже.

Шаг 5. Закройте розетку.

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

close(new_socket);

Мы успешно создали TCP-сокет на сервере!

Серверный код сокета TCP:

Чтобы протестировать код TCP-сервера, я написал код TCP-клиента: (Не беспокойтесь об этом коде. Этот код написан, чтобы показать разницу между простым TCP-соединением и HTTP-соединением. Вы помните, что я говорил о переменной char *hello в Шаге 4. Отправлять и получать сообщения?).

Код TCP-сокета на стороне клиента:

Теперь запустите код TCP-сокета на стороне сервера в одном Терминале и клиентский код TCP-сокета в другом Терминале .

ПРИМЕЧАНИЕ. Здесь важен порядок. Сначала код на стороне сервера, затем код на стороне клиента.

В выводе на стороне сервера:

+++++++ Waiting for new connection ++++++++
Hello from client
------------------Hello message sent-------------------
+++++++ Waiting for new connection ++++++++

В выводе на стороне клиента:

Hello message sent
Hello from server

Ура! Наш код запущен, и мы можем обмениваться данными между приложениями. Это означает, что наша реализация TCP работает нормально.

Мы в основном закончили с кодированием.

Теперь перейдем к реализации HTTP-сервера.

HTTP

Сначала мы рассмотрим взаимодействие между сервером и веб-браузером.

Это основная схема взаимодействия.

Если мы приблизимся к HTTP-части:

  1. Первоначально HTTP-клиент (то есть веб-браузер) отправляет HTTP-запрос на HTTP-сервер.
  2. Сервер обрабатывает полученный запрос и отправляет HTTP-ответ HTTP-клиенту.

Теперь давайте посмотрим на клиент-сервер, на то, что они отправляют и что получают.

HTTP-клиент (веб-браузер):

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

Таким образом, клиент обязан инициировать соединение.

Что мы обычно будем делать, когда хотим подключиться к серверу?

Набираем какой-нибудь URL / адрес сайта в браузере

Для отображения страницы браузер извлекает файл index.html с веб-сервера.

То же, что и www.example.com (по умолчанию: порт 80, файл index.html, протокол http).

Итак, если вы наберете www.example.com в веб-браузере, веб-браузер преобразует URL / адрес в виде:

http://www.example.com:80

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

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

Эта веб-страница определяется именем файла. Некоторые серверы имеют public.html, а некоторые - index.html.

В этом примере мы рассматриваем index.html как страницу по умолчанию.

Не могу поверить?

Мы сделаем одно дело.

  1. Запустите серверный код TCP (сверху) в Терминале.
  2. Откройте свой веб-браузер и введите localhost:8080/index.html в адресной строке.
  3. Теперь посмотрим, что выводится в Терминале.

Вывод в терминале:

+++++++ Waiting for new connection ++++++++
GET /index.html HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10bind3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
DNT: 1
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
------------------Hello message sent-------------------
+++++++ Waiting for new connection ++++++++

Получаем аналогичный результат, как показано на картинке.

Но подождите секунду. Вы смотрели в веб-браузере?

Это то, что вы видите.

В чем проблема? Почему мы не видим данные, отправленные с сервера?

Вы помните, что я говорил о переменной char *hello в Шаге 4. Отправлять и получать сообщения? Если вы забыли об этом. Вернитесь и проверьте, что я там сказал.

Мы вернемся к этой переменной char* hello через минуту. Не волнуйся.

HTTP-методы (глаголы):

GET - это метод по умолчанию, используемый HTTP.

Есть 9 HTTP-методов.

Некоторые из них:

  1. GET - получить URL
  2. HEAD - получить информацию об URL-адресе
  3. PUT - сохранить по URL-адресу
  4. POST - отправить данные формы по URL-адресу и получить ответ.
  5. УДАЛИТЬ - Удалить URL-адрес. Обычно используются команды GET и POST (формы).

API REST используют GET, PUT, POST и DELETE.

HTTP-сервер:

Теперь пришло время ответить клиенту и отправить ему то, что он хочет!

Клиент прислал нам несколько заголовков и ожидает от нас того же взамен.

Но вместо этого мы отправляем просто приветственное сообщение:

char* hello = "Hello from server";

Браузер ожидает ответа в том же формате, в котором он отправил нам запрос.

HTTP - это не что иное, как следование некоторым правилам, указанным в документах RFC. Вот почему я сказал, что реализация HTTP не зависит от языка в начале раздела Реализация TCP.

Это формат HTTP-ответа, который веб-браузер ожидает от нас:

Если мы хотим отправить Hello from server, сначала нам нужно создать заголовок. Затем вставьте пустую строку, после чего мы сможем отправить наше сообщение / данные.

Заголовки, показанные выше, являются лишь примером. На самом деле в HTTP присутствует множество заголовков. Вы можете ознакомиться с HTTP RFC → RFC 7230, RFC 7231, RFC 7232, RFC 7233, RFC 7234, RFC 7235.

Теперь мы создадим минимальный заголовок HTTP, чтобы наш сервер работал.

char *hello = "HTTP/1.1 200 OK\nContent-Type: text/plain\nContent-Length: 12\n\nHello world!";

Эти 3 заголовка необходимы как минимум.

  1. HTTP/1.1 200 OK → Здесь указано, какую версию HTTP мы используем, код состояния и сообщение о состоянии.
  2. Content-Type: text/plain → Это говорит о том, что я (сервер) отправляю простой текст. Есть много Content-Types. Например, для изображений мы используем это.
  3. Content-Length: 12 → Указывает, сколько байтов сервер отправляет клиенту. Веб-браузер читает только то, что мы здесь упоминаем.

Следующая часть - это Body часть. Здесь мы отправляем наши данные.

Сначала нам нужно подсчитать, сколько байтов мы отправляем в Body. Затем мы упоминаем это в Content-Length. Кроме того, мы устанавливаем Content-Type в соответствии с отправляемыми данными.

Код состояния и сообщения о состоянии:

Коды состояния выдаются сервером в ответ на запрос клиента к серверу. Он включает коды из Запроса комментариев (RFC) IETF, другие спецификации и некоторые дополнительные коды, используемые в некоторых распространенных приложениях Протокола передачи гипертекста (HTTP).

Первая цифра кода состояния указывает один из пяти стандартных классов ответов. Показанные фразы сообщений являются типичными, но может быть предоставлена ​​любая удобочитаемая альтернатива. Если не указано иное, код состояния является частью стандарта HTTP / 1.1 (RFC 7231).

Итак, если вы не можете найти файл, который запрашивает клиент, отправьте соответствующий код состояния.

Если у клиента нет разрешения на просмотр файла, вы отправляете соответствующий код состояния.

Это список кодов статуса, который мы можем использовать.

Теперь запустите приведенный ниже код в Терминале и перейдите к localhost:8080 в своем браузере.

Теперь в вашем браузере отображается Hello world!.

Единственное, что я изменил, это char* hello переменную.

Наконец-то наш HTTP-сервер заработал!

Как отправить клиенту запрошенную веб-страницу?

До сих пор мы научились отправлять строку.

Теперь посмотрим, как отправить файл, изображение и т. Д.

Предположим, вы ввели localhost:8080/info.html в адресную строку.

В серверном Терминале мы получаем следующие Заголовки запросов:

GET /info.html HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10bind3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
DNT: 1
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9

Для простоты мы рассматриваем только 1-ю строку в заголовках запросов.

GET /info.html HTTP/1.1

Итак, нам просто нужно найти файл info.html в текущем каталоге (поскольку / указывает, что он ищет в корневом каталоге сервера. Если это похоже на /messages/info.html, то мы должны заглянуть в папку messages для info.html файла).

Здесь необходимо рассмотреть множество случаев:

Некоторые из них:

  1. Файл (веб-страница) присутствует
  2. Файл (веб-страница) отсутствует
  3. У клиента нет разрешений на доступ к файлу (веб-странице).

И многое другое… ..

Сначала выберите соответствующий код статуса здесь.

Если файл присутствует и у клиента есть права доступа к нему, выберите соответствующий Content-Type из здесь.

Затем откройте файл, считайте данные в переменную. Подсчитайте количество байтов, прочитанных из файла. Когда вы читаете простой текстовый файл, мы можем считать при чтении файла или по возвращаемому значению функции read() или strlen(variable). Установите Content-Length.

Затем создайте Заголовок ответа.

Теперь добавьте newline в конец Заголовка ответа и добавьте к нему данные, которые мы прочитали из файла (если и только если файл присутствует и у клиента есть разрешения на доступ к нему).

ОТПРАВИТЬ ЗАГОЛОВКУ ОТВЕТА КЛИЕНТУ!

Вот и все!

Мы успешно создали HTTP-сервер с нуля!

Есть сомнения / вопросы / предложения? Комментарий внизу.