Кроссплатформенный https сервер с неблокирующими сокетами

  • Tutorial
Эта статья является продолжением моей статьи Простейший кросcплатформенный сервер с поддержкой ssl.
Поэтому для того, чтобы читать дальше очень желательно прочитать хотя бы часть предыдущей статьи. Но если не хочется, то вот краткое содержание: я взял из исходников OpenSSL файл-пример «serv.cpp» и сделал из него простейший кроссплатформенный сервер, который умеет принимать от клиента один символ.
Теперь я хочу пойти дальше и заставить сервер:
1. Принять от браузера весь http заголовок.
2. Отправить браузеру html страницу на которую будет выведен http заголовок.
3. Кроме этого, я хочу чтобы сокеты не блокировали процесс сервера и для этого я переведу их в так называемый «неблокирующий режим».

Для начала мне понадобится модифицированный в предыдущей статье файл serv.cpp.
Первое, что нужно сделать — написать кроссплатформенные макросы для перевода сокетов в неблокирующий режим:

для этого строки кода
#ifndef WIN32
#define closesocket  close
#endif


меняем на следующие:
#ifdef WIN32
#define SET_NONBLOCK(socket)	\
	if (true)					\
	{							\
		DWORD dw = true;			\
		ioctlsocket(socket, FIONBIO, &dw);	\
	}
#else
#include <fcntl.h>
#define SET_NONBLOCK(socket)	\
	if (fcntl( socket, F_SETFL, fcntl( socket, F_GETFL, 0 ) | O_NONBLOCK ) < 0)	\
		printf("error in fcntl errno=%i\n", errno);
#define closesocket(socket)  close(socket)
#endif


Готово! Теперь, чтобы перевести «слушающий» сокет в неблокирующий режим, достаточно сразу после строки
listen_sd = socket (AF_INET, SOCK_STREAM, 0);	  CHK_ERR(listen_sd, "socket");


вставить строку:
SET_NONBLOCK(listen_sd);


Tеперь «слушающий» сокет неблокирующий и функция accept вернет управление программе сразу же после вызова.
Вместо дескриптора сокета accept теперь вернет значение (-1).
Таким образом, в неблокирующем режиме нам нужно вызывать функцию accept в бесконечном цикле, пока она не вернет дескриптор сокета

  int sd = -1;
  while(sd  == -1)
  {
	  Sleep(1);
#ifdef WIN32
	sd = accept (listen_sd, (struct sockaddr*) &sa_cli, (int *)&client_len);
#else
	sd = accept (listen_sd, (struct sockaddr*) &sa_cli, &client_len);
#endif  
  }

Чтобы программа не грузила на 100% процессор, я добавил в цикле Sleep(1). В Windows это означает перерыв на 1 миллисекунду. Чтобы это работало в Linux, добавьте в начале файла:

#ifndef WIN32
#define Sleep(a) usleep(a*1000)
#endif


Теоретически, вместо бесконечного цикла, можно с помощью функции select и ее более мощных аналогов, ждать пока сокет listen_sd станет доступен для чтения, а лишь потом один раз вызвать accept. Но лично я не вижу в моем способе с циклом никаких особых недостатков.

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


Теперь, когда сокет для общения с клиентом неблокирующий, функция
err = SSL_accept (ssl);

не будет подвешивать процесс, а вернется сразу после вызова с значением err = SSL_ERROR_WANT_READ или SSL_ERROR_WANT_WRITE
чтобы принять зашифрованное сообщение, нам понадобится еще один бесконечный цикл:

  while(1)
  {
	Sleep(1);
	err = SSL_accept (ssl); 

	const int nCode = SSL_get_error(ssl, err);
	if ((nCode != SSL_ERROR_WANT_READ) && (nCode != SSL_ERROR_WANT_WRITE))
	    break;
  }
  CHK_SSL(err);


Лишь когда программа выйдет из этого цикла, можно быть уверенными, что зашифрованное соединение установлено и можно начинать прием и отправку сообщений.
Мы будем подключаться к серверу с помощью браузера, поэтому сообщения клиента состоят из http заголовка и тела запроса.
При этом http заголовок должен заканчиваться строкой "\r\n\r\n".
Исправим наш код так, чтобы сервер читал весь http заголовок, а не только его первую букву.

Для того, чтобы сократить код, я предлагаю воспользоваться замечательной библиотекой STL:
1. Добавим три заголовочных файла:
#include <vector>
#include <string>
#include <sstream>


2. Заменим строки

  err = SSL_read (ssl, buf, sizeof(buf) - 1);                   CHK_SSL(err);
  buf[err] = '\0';
  printf ("Got %d chars:'%s'\n", err, buf);


на следующий код:

  std::vector<unsigned char> vBuffer(4096); //выделяем буфер для входных данных
  memset(&vBuffer[0], 0, vBuffer.size()); //заполняем буфер нулями

  size_t nCurrentPos = 0;
  while (nCurrentPos < vBuffer.size()-1)
  {
	  err = SSL_read (ssl, &vBuffer[nCurrentPos], vBuffer.size() - nCurrentPos - 1); //читаем в цикле данные от клиента в буфер
	  if (err > 0)
	  {
		  nCurrentPos += err;
		 
		  const std::string strInputString((const char *)&vBuffer[0]);
		  if (strInputString.find("\r\n\r\n") != -1) //Если найден конец http заголовка, то выходим из цикла
			  break;

		  continue;
	  }
	 
	  const int nCode = SSL_get_error(ssl, err);
	  if ((nCode != SSL_ERROR_WANT_READ) && (nCode != SSL_ERROR_WANT_WRITE))
		  break;
  }

В этом цикле сервер читает данные от клиента до тех пор, пока не получит символы конца http заголовка "\r\n\r\n", либо пока место в буфере не кончится.
Буфер мне удобно выделять как std::vector хотя бы потому, что не нужно отдельной переменной для запоминания его длины.
После выхода из цикла в буфере должен храниться весь http заголовок и, возможно, часть тела запроса.

3. Отправим браузеру html страницу, в которую напишем http заголовок его запроса.
Заменим строку
err = SSL_write (ssl, "I hear you.", strlen("I hear you."));  CHK_SSL(err);


на следующий код:
  //Преобразуем буфер в строку для удобства
  const std::string strInputString((const char *)&vBuffer[0]);

  //Формируем html страницу с ответом сервера
  const std::string strHTML = 
	  "<html><body><h2>Hello! Your HTTP headers is:</h2><br><pre>" + 
	  strInputString.substr(0, strInputString.find("\r\n\r\n")) + 
	  "</pre></body></html>";

	//Добавляем в начало ответа http заголовок
 	std::ostringstream strStream;
	strStream << 
		"HTTP/1.1 200 OK\r\n"
		<< "Content-Type: text/html; charset=utf-8\r\n"
		<< "Content-Length: " << strHTML.length() << "\r\n" <<
		"\r\n" <<
		strHTML.c_str();

	//Цикл для отправки ответа клиенту.
	nCurrentPos = 0;
	while(nCurrentPos < strStream.str().length())
	{
		err = SSL_write (ssl, strStream.str().c_str(), strStream.str().length());
		if (err > 0)
		{
			nCurrentPos += err;
			continue;
		}
	 
		const int nCode = SSL_get_error(ssl, err);
		if ((nCode != SSL_ERROR_WANT_READ) && (nCode != SSL_ERROR_WANT_WRITE))
			break;
	}



Поскольку сокеты у нас неблокирующие то нет гарантии, что ответ отправится полностью с первого раза. Поэтому нужно вызывать SSL_write в цикле.
Вот и все. Теперь можно запустить наш сервер, а в браузере набрать localhost:1111
В ответ браузер покажет страницу со своим http запросом.

Проект для Visual Studio 2012 в архиве 3_.3s3s.org.
Чтобы скомпилировать под Linux, скопируйте из архива файлы «ca-cert.pem» и «serv.cpp» в один каталог и запустите компилятор: «g++ -L/usr/lib -lssl -lcrypto serv.cpp»

ПС: написал продолжение этой статьи
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 37
    0
    В чем смысл делать сокеты неблокирующими — а потом вызывать методы в цикле?
      –4
      В чем смысл делать сокеты неблокирующими — а потом вызывать методы в цикле?

      Это простой пример, демонстрирующий принцип работы.
      В реальных задачах, в каждом цикле вызывается callback функция в которой процессу можно заниматься какимито другими задачами.
      В случае блокирующих сокетов процесс тупо виснет на каждой блокирующей функции и ничего с ним сделать невозможно.
        +4
        В реальных задачах используется хотя бы select, не говоря уже о более продвинутых способах.

        Активное ожидание, пусть и с вызовом sleep — суть костыль.
          –4
          В реальных задачах используется хотя бы select

          Приведите аргумент в пользу select и
          более продвинутых способов
          для слушающего сокета?

          Для «рабочих» клиентских сокетов смысл есть, согласен. Только в приведенном примере этого смысла нет, потому как тут обрабатывается одно единственное подключение.
            0
            Есди сервер обрабатывает одно-единственное подключение, то зачем вообще неблокирующие сокеты?
              –2
              Это туториал!
              Если перестанете кидать минусы — напишу, как из этого примера сделать реально работающий сервер для любого количества подключений ))
                0
                Даже если закидают, пост всё равно нужен, чтобы его закидали плюсами.

                PS: На самом деле магии в использовании select,epoll,kqueue довольно много, простые примеры они простые, но если речь идёт о работе на разных платформах под большими нагрузками, то начинают всплывать различны мелкие проблемки.
                Самый простой вариант использовать готовые билиотеки для обработки событий, просто использовать и работают стабильно.
                Можно использовать код из моей Астры: bitbucket.org/cesbo/astra/src/astra-4/core/event.c (поддерживает select,poll,epoll,queue) гоняет гигабиты ТВ.
                  +4
                  Пишите. Потому что этот туториал ничему не учит…
                    0
                    Гы, с отрицательной кармой это теперь будет проблематично )))
        +1
        Я бы посоветовал вам смотреть на что-нибудь вроде www.boost.org/doc/libs/1_47_0/doc/html/boost_asio/examples.html#boost_asio.examples.ssl.

        Это и кроссплатформенно, и на C++, и не подвержено проблеме c10k и вообще самый правильный способ.
          –2
          Спасибо за совет. Я знаю про boost::asio.
          Из вашего поста следует, что мой код подвержен
          проблеме c10k
          и является «неправильным способом», я правильно понимаю?
          Было бы любопытно почитать ваши доводы на эту тему.
            +3
            Сколько соединений сможет одновременно «держать» ваш сервер? Я не вижу полного кода, поэтому тут либо 1, либо 1000. Это и есть проблема.

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

            Неблокирующие сокеты — это способ повысить лимит числа соединений дальше. Классическая схема с select поддерживает до 10 000 соединений, дальше начинаются проблемы в силу квадратичной природы алгоритма. Однако, и большее число соединений держится, пусть и с лагами.

            Дальше идут такие вещи, как poll, epoll, /dev/poll, kevent, асинхронные сокеты и куча других страшных слов, с которыми лично я никогда не работал. Там можно обрабатывать одновременно столько соединений, сколько способен держать сетевой стек ОС — своих накладных расходов эти способы накладывают крайне мало.
              –4
              Сколько соединений сможет одновременно «держать» ваш сервер?

              Хоть сколько.
              Сервер в моей статье не использует ни select ни epoll ничего!
              Да, это не правильно, но так тоже можно.

              В принципе, если меня не закидают минусами, я могу показать, как пишется кроссплатформенный сервер с select и epoll. Он будет поддерживать тоже любое количество соединений в Linux, а в Windows — сколько позволит select.
              Ничего там сверхъестественного нет.
                +3
                Хоть сколько.
                Неверно. Тот код, который я вижу, вообще не сможет обработать более одного соединения — это еще хуже, чем многопоточный сервер.

                Но даже если в ваш сервер добавить многопоточность — то он все равно будет хуже, чем такой же сервер на блокирующих сокетах.
                  –4
                  Дальше идут такие вещи, как poll, epoll, /dev/poll, kevent, асинхронные сокеты и куча других страшных слов, с которыми лично я никогда не работал.

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

                  Ну вам то оно конечно виднее, но по моему вы меня тролите.
          0
          Может кто опровергнет или подтвердит. Помоему при установке соединения (handshake) openssl блокирует всё и вся прелесть неблокирующих сокетов теряется.
            +4
            Опровергаю: nginx использует openssl — но в использовании блокирующих сокетов никогда замечен не был.
              0
              Кастую VBart в тред.
                +1
                Тут и я не нужен, достаточно на код автора посмотреть, он хоть очень странный, написан просто ужасно, да и выедает процессор в бесконечном цикле так, что лучше бы уж блокировался, но тем не менее, из него видно, что SSL_do_handshake/SSL_read/SSL_write возвращают управление в функцию, сигнализируя SSL_ERROR_WANT_READ и SSL_ERROR_WANT_WRITE, после чего остается только взвести таймер и положить дескриптор на соответствующее событие в epoll/kqueue/eventports/по_вкусу.
                If the underlying BIO is non-blocking, SSL_do_handshake() will also return when the underlying BIO could not satisfy the needs of SSL_do_handshake() to continue the handshake. In this case a call to SSL_get_error() with the return value of SSL_do_handshake() will yield SSL_ERROR_WANT_READ or SSL_ERROR_WANT_WRITE. The calling process then must repeat the call after taking appropriate action to satisfy the needs of SSL_do_handshake(). The action depends on the underlying BIO. When using a non-blocking socket, nothing is to be done, but select() can be used to check for the required condition. When using a buffering BIO, like a BIO pair, data must be written into or retrieved out of the BIO before being able to continue.
                www.openssl.org/docs/ssl/SSL_do_handshake.html
                  +1
                  Поправочка. Автор и accept'ит соединение с помощью OpenSSL, поэтому не пользуется SSL_do_handshake(), но сути дела это не меняет, она работает также: www.openssl.org/docs/ssl/SSL_accept.html
              +3
              Вместо дескриптора сокета accept теперь вернет значение (-1).

              Вы же кроссплатформенный код пишите, нельзя так делать. accept возвращает -1 в никсах, а в винде она возращает INVALID_SOCKET который ещё надо проверить и убедиться что там WSAEWOULDBLOCK, а не ошибка.

              И немного не понял смысл замены блокирующего вызова на зацикленный неблокирующий.
              Неблокирующий сокет нужен для обработки циклом клиентов и приёма новых конектов в одной ните, вместо разнесения этих операций по разным нитям.
                –2
                accept возвращает -1 в никсах, а в винде она возращает INVALID_SOCKET

                INVALID_SOCKET в винде равен -1.
                Хотя согласен, можно добавить этот макрос для линукса. Про WSAEWOULDBLOCK — не вижу смысла его проверять для сервера: ну ошибка и ошибка, все равно будем в цикле слушать.

                И немного не понял смысл замены блокирующего вызова на зацикленный неблокирующий.

                Вы же сами ответили:
                Неблокирующий сокет нужен для обработки циклом клиентов и приёма новых конектов в одной ните, вместо разнесения этих операций по разным нитям.
                  +3
                  Тип SOCKET, в винде беззнаковый, а в никсах сокеты имеют знаковый тип int, а у Вас все присваивается типу int.
                    +2
                    Уже ответили что в винде не -1. Товарищи, разрабатывающие pvs studio пишут про эту ошибку чуть ли не в каждой статье.
                    К тому же в MSDN вполне чёрным по белом что ошибка функции accept это именно INVALID_SOCKET, а не что-то другое. И именно на него надо проверять если нет желания когда-то потом мучаться с совместимостью и переносимостью кода.
                    Стоило хотя бы упомянуть что код ошибки стоит проверять что это действительно всё ок для неблокирующего вызова. Это всего пара лишних макросов чтобы свести к единому виду, можно даже было бы привести в статье.

                    Вы же сами ответили:

                    Я в примерах вижу в цикле вызывает accept без каких либо других действий внутри цикла. А обработка коннекта идёт после выхода из него.
                    Чем это отличается блокирующего вызова кроме ухудшения производительности из-за слипа?
                    Должно быть примерно так всё же:
                    while(1) {
                      accept
                      ...
                      // делаем что-то там нам нужное со старыми открытыми коннектами или с новым
                    }
                    ...
                    // закрваем сокеты
                    
                      –2
                      Да я вообще-то планировал сделать несколько статей, в каждой из которых постепенно добавлять куски кода, чтобы в конце концов выложить тут полноценный однопоточный кроссплатворменный сервер для высоконагруженных проектов.
                      Сомневаюсь, что кому-то была бы интересна взявшаяся из воздуха портянка кода из 100500 классов по 9000 строк. Любители таких развлечений ковыряют исходники nginx, а не на форумах сидят.
                      А вот сейчас, когда мне слили тут всю карму, как то не особенно и хочется уже продолжать.

                      ЗЫ. Не знаю как в винде, а в Visual Studio 2012 INVALID_SOCKET равен -1. Можете проверить, ссылка на готовый проект есть в статье.
                        +1
                        WinSock.h
                        typedef UINT_PTR        SOCKET;
                        
                        #define INVALID_SOCKET  (SOCKET)(~0)
                        

                        Разницу между -1 и ~0 надеюсь объяснять не надо?
                          0
                          Надо, объясните если не затруднит
                            0
                            Значение ~0 зависит от типа. Двоичное представление у него в виде всех единиц: для знакового это будет соответствовать -1, для беззнакового максимальное допустимое значение.
                              –3
                              ОК, продолжите свою мысль:
                              в Windows код
                              int sd = accept (listen_sd, (struct sockaddr*) &sa_cli, (int *)&client_len);
                              в случае ошибки вернет какое значение для переменной sd?

                              Или что то же самое:
                              в Windows код
                              int sd = INVALID_SOCKET
                              вернет какое значение для переменной sd?
                                +4
                                Вообще не вижу смысла о чём-то говорить если в винде пишите int sd а не SOCKET sd
                                За этим заканчиваю обсуждение.
                          0
                          Не знаю как в винде, а в Visual Studio 2012 INVALID_SOCKET равен -1. Можете проверить, ссылка на готовый проект есть в статье.


                          Нельзя использовать магические константы! Разработчки, ну зачем вы сами себе собираете атомную бомбу, с таймером произвольного запуска.
                    0
                    а где вообще можно глянуть код целиком?
                      0
                      а где вообще можно глянуть код целиком?
                      Дочитайте статью до конца — может найдете ответ на свой вопрос.
                        0
                        2014 год, select. Где-то тут делят на 0. Есть же iocp, epoll, libevent.
                          0
                          Если бы проблема была в этом :)
                            0
                            Простите, но где вы тут select нашли?
                            0
                            Теоретически, вместо бесконечного цикла, можно с помощью функции select и ее более мощных аналогов, ждать пока сокет listen_sd станет доступен для чтения, а лишь потом один раз вызвать accept. Но лично я не вижу в моем способе с циклом никаких особых недостатков.


                            Как-то тоже был интерес к написанию http-сервера в качестве тестового задания. А получился пост :) В нем как раз можно и более «мощные средства» найти. В них бывает польза… Правда приведенный материал без ssl.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое