Первый http сервер на С++, заметки для новичков
- воскресенье, 18 мая 2025 г. в 00:00:05
Решил написать простенькую статейку по следам реализации небольшой программки на С++ под Виндоус, которая содержит в себе TCP сервер. Мы получаем от клиента http запрос (соединение не защищенное).
На чем реализован клиент нам неизвестно: может на php (curl,socket,stream_contex_create,...), может на js (ajax), вообще может быть на чем угодно.
Данные, которые приходят от клиента это по сути просто поток байт. Причем приходить они могут, конечно же, частями, конечно не по порядку и конечно же с приличными задержками при низкой скорости сети. TCP сам собирает все байты по порядку, об этом нам беспокоится не надо.
Наша задача реализовать на сервере http парсинг запроса, выполнить задание (на каком-то подключенном к серверу оборудовании) и ответить клиенту о результате.
И вот тут у начинающих программистов (как и у меня в данной области) могут возникнуть разные трудности. Я просто хочу поделится своим скромным опытом, не судите строго...
Первое, что надо выяснить как отделяются заголовки запроса от тела. По сути в http есть тело, а перед ним некоторые данные (заголовки).
Отделяются заголовки от тела четырьмя байтами \r\n\r\n. Первое вхождение в принятом потоке байт \r\n\r\n это разделитель. Все что после разделителя - это тело, причем в теле конечно же также могут попадаться последовательности байт \r\n\r\n, в теле вообще может быть все что угодно.
Надо сразу понимать, что связь может быть и очень плохой. На что это повлияет? Сразу заметите, что байты начнут поступать меньшими порциями. Возникнет вопрос сколько вообще надо ждать прихода всех байт? И тут есть нюанс: в tcp передаче сначала устанавливается соединение. QTcpServer выпускает нам сигнал newConnection() и сразу tcp сервер начинает получать данные, которые мы должны считывать через вызов readAll() сокета.
Можно уже догадаться, что QTcpServer создает отдельный поток для каждого клиента, но речь не об этом.
Нам надо как-то ждать в нашем потоке программы, не блокируя желательно нашу программу. Для этого у сокета есть например waitForReadyRead(int timeout).
И вот мы подходим к вопросу какой timeout нам использовать. Но надо ответить на вопрос, что мы хотим за этот таймаут получить: весь запрос или только заголовки.
Так вот логично сначала получить полностью все заголовки, то есть начальные данные запроса, где найдется первое вхождение \r\n\r\n. Почему? Потому, что в заголовках мы должны получить значение Content-Length, то есть длину тела. И соответственно мы будем далее знать сколько байт точно нам должно прийти в теле.
Для реализации этого первого этапа, на наш взгляд, лучше использовать waitForReadyRead с небольшим таймаутом, например 2000мс, но вызывать его несколько раз, например 30 раз.
Почему так делаем? Если данные не придут вообще от клиента, мы будем ждать соответственно 2000*30 = 60000мс. Этого по-моему достаточно даже для самой плохой сети. Если заголовки, как это обычно происходит, придут в первые 2000мс, то мы распарсим найдем \r\n\r\n, определим Content-Length и перейдем к следующему этапу: получение тела (или остатка от тела, как получится).
Если в первые 2000мс мы не получим \r\n\r\n, то далее мы будем ждать еще 2000мс и если разделитель \r\n\r\n придет в этот раз, то общая продолжительность первого этапа получится от 2000мс до 4000мс. Вообще заголовки небольшие по объему и приходят обычно практически сразу после установки tcp соединения, но подстраховаться таким образом обязательно надо на случай плохой сети.
Да хотел отметить, что наш сервер это приложение под виндоус, которое пользователь устанавливает где-то себе на компьютере и какие там условия по скорости в сети нам не известно. И клиента пользователь реализует как его душе удобно. Поэтому мы вообще не знаем на каких скоростях там все будет работать (vpn,proxy,брандмауэры,роутеры...).
Если мы не получим Content-Length в заголовках, такое тоже может быть, когда например клиент реализован на сокетах и разработчик клиента просто не передал Content-Length , то мы очевидно должны ждать всех данных какое-то длительное неопределенное время, ну например 30000мс, чтобы предположить, что клиент послал все свои данные. Но лучше в своем АПИ для клиентов четко прописать, что Content-Length обязателен и если его нет возвращать например 400 Bad Request.
Кстати, если кто-то не знал, 400-тые возвраты тоже могут содержать тело, например с описанием ошибки, и лучше конечно информацию об ошибке передавать.
Далее рассматриваем только ситуацию, когда мы знаем размер тела. Это в байтах если что. Почему я это отмечаю, потому что если в теле вы увидите в логах например русские буквы, то знайте, что на русский символ (в UTF-8) приходится 2 байта.
Я уверен вам захочется посчитать длину пакета. Чтобы было вам проще можно открыть лог в Notepad++, выделить тело и посмотреть Summary (где-то в меню есть), там будет значение в байтах.
Также там же например в notepad++ можно включить просмотр всех символов и обратите внимание на переводы строк в запросе: вместо \r\n\r\n вы можете увидеть только \n\n. Я не знаю надо ли поддерживать в сервере вариант \n\n в качестве разделителя заголовков и тела, но чисто технически это не проблема сделать. Пока мы у себя поддерживаем только вариант \r\n\r\n.
Мы подошли к важному моменту в понимании проблем с кодированием данных в теле запроса. При чем тут лог сервера спросите вы?
По нашему скромному мнению, чтобы быстро, раз и навсегда, разобраться c пониманием как работает кодирование тела запроса через разные варианты типа urlencoded, json, base64 и даже hex в разных клиентах на php (или js) надо обязательно смотреть сырые логи сервера.
Потом вы никогда не будете более удивляться, почему у вас не работает запрос как надо .
Вот сразу пример из жизни: у пользователя нашей программы клиент был реализован на старом наборе php 5.6 curl 7.29.0, который менять ему было нельзя (кастомный сервер). И оказалось, что сервер отдает в теле json строку примерно такую "[ {...}, {...} ]", а curl клиента теряет где-то при приеме первую квадратную первую скобку и исправить это оказалось нельзя никак как вы понимаете. Но проблему решили просто - переходом на сокет в php, все заработало сразу нормально.
Теперь почему на нормальном сервере (например апаче) трудно посмотреть сырые http логи? Я не уверен, но наверное это прямая уязвимость в защите данных?...
В общем отрабатывать кодирование тела рекомендую на любых серверах с открытым http логом. Например как в нашем случае. Я бы мог здесь разместить ссылку на нашу коммерческую прогу, с бесплатным 2 недельным периодом, где это реализовано, но боюсь меня забанят (такое уже было раз).
Если вы знаете такие сервисы просьба в комментариях ознакомить хабровчан. Но вообще проще написать самому. Если кому-то понадобится могу открыто выложить на гитхабе пример такого тестового сервера (на С++ Qt).
Так вот теперь на данном этапе мы точно видим логи сервера и можем дальше изучать какие варианты кодирования можно реализовать в обмене нашего сервера и клиента.
А далее вдруг все становится просто до не приличия. Именно на этом этапе вы быстро разберетесь почему может не работать ваш запрос. Рассмотрим клиента на php.
Надо отметить, что на php много реализаций http клиентов curl не единственный, мне по крайней мере известен еще stream_context_create, socket_create.
В примере ниже мы реализуем 4 варианта кодирования на выбор.
Наш сервер по заголовку Content-Type определяет тип передаваемого контента, далее сервер знает что-делать. Если Content-Type не поддерживается сервер возвращает 4хх ответ.
<?php
$unic_id = mt_rand();
$postdata = array(
array(
'name'=>'2. Фискализируем чек',
'type'=>'kktReceiptFiscalization',
'data'=>array(
'1059'=>array(
array(
'productName_1030'=>'Отладка программы ',
'price_1079'=>0,
'qty_1023'=>1,
"amount_1043"=>0,
'unit_2108'=>0,
'paymentFormCode_1214'=>4,
'productTypeCode_1212'=>1,
'tax_1199'=>6
)
array(
'productName_1030'=>'Отладка программы ',
'price_1079'=>0,
'qty_1023'=>1,
"amount_1043"=>0,
'unit_2108'=>0,
'paymentFormCode_1214'=>4,
'productTypeCode_1212'=>1,
'tax_1199'=>6
)
),
'cashierName_1021'=>'Пупкин Иван Трофимович',
'cashierInn_1203'=>'',
'payments'=>[
'cash_1031'=>0,
'ecash_1081'=>0,
'prepayment_1215'=>0,
'credit_1216'=>0,
'barter_1217'=>0
],
'taxationType_1055'=>1,
'receiptType_1054'=>1,
'sendToEmail_1008'=>'kkmspb2008@yandex.ru',
'printDoc'=>true
)
)
);
$ch = curl_init( 'http://109.188.142.134:44736' );
$CntType = "json";
//$CntType = "urlencoded";
//$CntType = "base64";
//$CntType = "hex";
if( $CntType == "json" )
{
$ContentType = 'Content-Type: application/json; charset=UTF-8';
//$encoded = json_encode( $postdata , JSON_PRETTY_PRINT ); так теперь можно тоже
$encoded = json_encode( $postdata , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // так теперь можно
//$encoded = json_encode( $postdata ); // так ОК // все переносы, табуляции, русские символы экранируются , и все в одну строку получается
}
else if( $CntType == "urlencoded" )
{
$ContentType = 'Content-Type: application/x-www-form-urlencoded';
// сначала array в строку
$encoded = json_encode( $postdata , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // так теперь можно
// потом строку еще кодируем
$encoded = rawurlencode($encoded);
}
else if( $CntType == "base64" )
{
$ContentType = 'Content-Type: text/plain base64';
// сначала array в строку
$encoded = json_encode( $postdata , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // так теперь можно
// потом строку еще кодируем
$encoded = base64_encode($encoded);
}
else if( $CntType == "hex" )
{
$ContentType = 'Content-Type: text/plain hex';
// сначала array в строку
$encoded = json_encode( $postdata , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); // так теперь можно
// потом строку еще кодируем
$encoded = bin2hex($encoded);
}
curl_setopt( $ch, CURLOPT_POSTFIELDS, $encoded );
curl_setopt( $ch, CURLOPT_POST, 1);
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_HTTPHEADER, array(
$ContentType,
'Action: command_list',
'BIT_ENCODE_TYPE: PHP',
'BIT_ORDER_ID: 122',
'BIT_KKT_TOKEN: 435cb88c28fc49bd419d58d4b60680b5' // атол 1.05 435cb88c28fc49bd419d58d4b60680b5
) );
$result = curl_exec($ch);
curl_close($ch);
echo "
<h2>Ответ</h2>
<pre>$result</pre>";
echo "
<h2>\n послали:</h2>
<pre>" . $encoded."</pre>";
?>
В примере выше на php мы подготавливаем тело запроса изначально в виде объекта как массив array(..), но если быть точным у нас все-таки список, а в нем уже элементы это ассоциативные массивы. Потом мы кодируем этот объект в json строку. Но тут очень сильно влияют опции функции json_encode JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE:
Но если на сервере вы используете правильный парсинг json строки, то вам это безразлично. JSON_UNESCAPED_UNICODE будет менять русские символы на вариант \uxxxx, значит сервер должен это правильно декодировать обратно в русские символы.
JSON_PRETTY_PRINT добавляет переносы и табуляции, красиво разворачивая дерево json для более приятного отображения. Но если на вашем сервере декодер принимаемой json строки умеет парсить \n\t, то это тоже не проблема.
Тут важно понимать еще, что в CURLOPT_POSTFIELDS мы пишем просто строку или набор байт (другими словами). Это проще для понимания. Хотя можно в CURLOPT_POSTFIELDS и array(..) пихать, curl это понимает и чего-то там кодирует дополнительно. Но вот меня лично это больше путает.
Если мы кодируем передаваемый объект в строку так (без опций): $encoded = json_encode( $postdata ); ,то на сервере примем примерно так:
"POST / HTTP/1.1
Host: 109.16.14.2:44735
Accept: */*
Content-Length: 992
Content-Type: application/json; charset=UTF-8
[{"name":"2. \u0424\u0438\u0441........"}]
В примере выше вся содержание тела только в отображаемых символах ASCII, никаких управляющих байтов или русских символов.
Если мы кодируем в строку с JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
$encoded = json_encode( $postdata , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
то на сервере мы увидим и русские буквы и табуляцию и переносы , примерно так:
"POST / HTTP/1.1
Host: 10.168.14.2:44735
Accept: */*
Content-Type: application/json; charset=UTF-8
Content-Length: 1052
Expect: 100-continu
[
{
"name": "2. Фискализируем чек",
"type": "kktReceiptFiscalization",
"data": {
"cashierName_1021": "аааааааааа-ббббббббббб сссссссс ыыыыыыыыыы",
"cashierInn_1203": "007826152874",
"payments": {
"cash_1031": 0,
"ecash_1081": 0,
"prepayment_1215": 0,
"credit_1216": 0,
"barter_1217": 0
},
"taxationType_1055": 1,
"receiptType_1054": 1,
"printDoc": true,
"1059": [
{
"productName_1030": "Отладка программы код№2",
"price_1079": 0,
"qty_1023": 1,
"amount_1043": 0,
"unit_2108": 0,
"paymentFormCode_1214": 4,
"productTypeCode_1212": 1,
"tax_1199": 6
}
]
}
}
]"
И первый и второй варианты работать будут нормально, так как тело запроса это просто байты. Но проблема может быть на сервере, сервер должен уметь декодировать json строку правильно. То есть декодер сервера должен понимать все экранирование и кодирование символов в соответствиe со стандартом json. Но это отдельная тема.
rawurlencode
И вот далее мы можем нашу строку еще дополнительно как бы закодировать через rawurlencode или base64 и даже через hex. Эти варианты делаются, чтобы хоть какой-то вариант заработал у клиента.
Что касается base64 и hex то в Content-Type: мы указываем примерно так Content-Type:text/plain base64 или так Content-Type: text/plain hex. Я сразу скажу, что я не знаю как правильно указывать, но могу сказать что наш сервер просто ищет в значении Content-Type вхождение urlencoded, base64, json, hex и если находит, то воспринимает принятое тело соответственно.
Надо добавить, что если у вас все работает (на вашем макете): сервер на вашем ПК, клиент на каком-то вашем хостинге, то это не значит, что у пользователя все будет также прекрасно работать.
Вот пример заголовка, который заставил переписать алгоритм приема запроса сервером:
Вдруг оказалось, что у пользователя клиент передает какой-то неопознанный заголовок Expect: 100-continue. И далее, как я понял, клиент ждет от сервера разрешение дальнейшей передачи данных. Пришлось дополнительно анализировать приходящие заголовки и при получении Expect: 100-continue отдавать клиенту HTTP/1.1 100 Continue.
Если вы как и я начинаете только разрабатывать простые сервера для своих приложений, то я предполагаю, что неожиданных ситуаций с нерабочими клиентами будет еще не мало. Поэтому без http лога запросов на сервере никак не обойтись.
Наблюдательные читатели конечно заметили заголовок
Accept: */*
Это кстати полезный заголовок, если клиент хочет попросить сервер отдать ответ клиенту в определенном типе кодирования данных, который предпочитает клиент.
Accept: application/json
Например заголовок выше означает, что клиент хочет получить данные именно в формате json. И по хорошему сервер должен анализировать просьбу клиента и если это возможно отдавать данные в том типе кодирования, которое попросил клиент.
В случае если сервер не может отдать ответ в таком типе, надо возвращать соответствующую ошибку из набора 4хх вариантов. И очень желательно в теле ответа посылать описание ошибки и что должен попробовать исправить клиент.
По мере эксплуатации нашей программы пользователями мы будем собирать все новые неожиданные возникающие проблемы и пытаться их исправить.
Еще раз хочу отметить, что эта статейка не претендует на какую-то исчерпывающую информацию о HTTP протоколе и автору откровенно говоря некогда и лень изучать все нюансы протокола HTTP. Поэтому прошу еще раз не судить строго. Может описанные здесь некоторые наблюдения из практического опыта будут полезны начинающим программистам.
Если у кого есть конструктивные замечания по протоколу http буду признателен. В особенности по вопросу какие еще проблемы могут появится у меня на этом пути.
Примечание: автор реализует http сервер на устаревшем Qt4, используем QTcpServer и QTcpSocket (но для нашего http сервера это не принципиально).