Научиться создавать серверы TCP в блокирующем режиме работы сокетов.
Реализовать сервер протокола обмена файлами из ЛР № 4.
После запуска сервер требует ввода адреса и порта для привязки и приема входящих подключений. Затем сервер бесконечно принимает новое подключение и обслуживает запросы клиента до его отсоединения (то есть обслуживается один клиент за раз).
lab05-tcp-server
, подключите необходимые библиотеки для работы с API сокетов. Для Windows инициализируйте API.Сервер TCP работает по более сложной схеме, чем клиент (см. ту же презентацию, что в ЛР № 4):
socket()
. Изначально он ничем не отличается от сокета-передатчика, как в клиенте.bind()
.listen()
.accept()
. При этом создается сокет-передатчик для данных принятого соединения.send()
и recv()
.closesocket()
или close()
.closesocket()
или close()
.В смысле ресурсов сокет-слушатель соответствует одному порту, через который клиенты могут подключаться к приложению, а сокет-передатчик — одному соединению. В простейшей реализации этой ЛР используется только один сокет-передатчик за раз, но их может быть много и они могут работать параллельно (ЛР № 6—7).
Создайте сокет-слушатель:
auto listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
При помощи функции ask_endpoint()
из предыдущих ЛР запросите адрес и порт для привязки и привяжите к ним сокет функцией bind()
.
Переведите сокет в режим слушателя:
::listen(listener, 3)
Второй параметр listen()
— размер очереди входящих соединений. Он важен, если в то время, пока сервер обслуживает одного клиента (то есть пока не вызвана accept()
) попытаются присоединиться новые. До трех первых из них станут в очередь на подключение (ОС проведет само подключение, но не позволит обмениваться данными), прочие сразу получат ошибку подключения. Максимально длинная очередь обозначается константой SOMAXCONN
.
Вызов accept()
для принятия нового подключения — блокирующий, то есть выполнение программы останавливается на нем, пока извне не попытается подключиться клиент. Помимо сокета-слушателя accept()
принимает указатель на адрес и на размер адреса подключившегося клиента, полностью аналогично функции recvfrom()
с ее адресом отправителя. При ошибке accept()
возвращает INVALID_SOCKET
(Windows) или -1
(*nix).
В бесконечном цикле ведите прием подключений:
while (true) {
auto channel = ::accept(listener, nullptr, nullptr);
std::clog << "info: client connected\n";
serve_requests(channel);
::closesocket(channel);
std::clog << "info: client disconnected\n";
}
Добавьте обработку ошибок accept()
— прерывайте цикл при ошибке.
Добавьте получение адреса подключившегося клиента и его печать перед вызовом serve_requests()
, как в ЛР № 3 для recvfrom()
.
Добавьте закрытие сокета после окончания цикла:
::closesocket(listener);
Для программы-сервера избежание утечки ресурсов еще более актуально, чем для клиента, поскольку на обслуживание каждого соединения заводится (временно расходуется) новый ресурс-сокет.
Проверьте работу программы — ее способность принимать подключения.
Временно замените вызов serve_requests()
на прием единственного байта:
char byte;
recv(channel, &byte, sizeof(byte), 0);
При помощи netcat присоединитесь к ней:
nc -nv 127.0.0.1 1234
Отправьте единственный байт (нажми Enter в netcat), чтобы завершить соединение (сервер считате один байт, завершит recv()
и вызовет closesocket()
).
Обслуживание запросов — еще один цикл:
void
serve_requests(SOCKET channel) {
while (serve_request(channel) {}
}
При обслуживании одного запроса нужно считать размер и тип сообщения, затем действовать и формировать ответ в зависимости от типа:
bool send_error(SOCKET channel, const std::string& error);
bool serve_file(SOCKET channel, uint32_t path_length);
bool serve_list(SOCKET channel);
bool process_unexpected_message(SOCKET channel, uint32_t length, Type type);
bool
serve_request(SOCKET channel) {
uint32_t length;
receive_some(client, &length, sizeof(length));
length = ::ntohl(length);
Type type;
receive_some(client, &type, sizeof(type));
switch (type) {
case TYPE_GET:
return serve_file(client, length - 1);
case TYPE_LIST:
return serve_list(client);
default:
return process_unexpected_message(client, length, type);
}
}
Используются receive_some()
и send_some()
из ЛР № 4.
Функция server_request()
должна возвращать true
, если запрос успешно обслужен, и false
в противном случае — при ошибках или отключении клиента.
Сетевые приложения должны работать корректно при любых прибывающих данных. В данном случае известно, что длина запроса клиента не превышает 300 байтов (самый длинный запрос содержит имя файла, которое протокол же ограничивает 255 байтами). Целесообразно вынести это значение в константу за пределами функций:
const uint32_t MAX_MESSAGE_LENGTH = 300;
После преобразования длины добавьте ее проверку. В случае нарушения вызывайте функцию, отправляющую клиенту сообщение об ошибке:
bool send_error(SOCKET channel, const std::string& message);
Реализуйте send_error()
.
Длина сообщения с ошибкой складывается из длины типа (1 байта) и длины сообщения. Длина передается в сетевом порядке байтов.
bool
send_error(SOCKET channel, const std::string& error) {
const uint32_t length = ::htonl(sizeof(Type) + error.size());
send_some(channel, &length, sizeof(length));
У сообщений об ошибке специальный тип, и клиенты из ЛР № 4 умеют его обрабатывать:
const Type type = TYPE_ERROR;
send_some(channel, &type, sizeof(type));
Содержимое сообщения - собственно текст ошибки:
send_some(channel, &error[0], error.size());
return true;
}
process_unexpected_message()
— точно такую же, как process_unexpected_response()
из ЛР № 4. В ее реализации потреюуется и hex_dump()
из ЛР № 2.Ключевая функция send_file()
обслуживает запрос на загрузку файла. Она зеркальна функции download_file()
из ЛР № 4.
send_file()
с обработкой возможных ошибок (которая не делается в приведенном ниже коде).Имя файла для загрузки не передается, а принимается. Буфер для приема в виде вектора заполняется нулями и на один байт больше, чем нужно. Дополнительный байт не заполняется и остается '\0'
, таким образом указатель на начало вектора является указателем на завершающуюся нулем строку, т. н. строку C.
bool
serve_file(SOCKET channel, uint32_t path_length) {
std::vector<char> path(path_length + 1, '\0');
receive_some(channel, &path[0], path_length);
Полученная строка C используется для открытия файла. В случае любых ошибок при открытии клиенту сообщается о невозможности доступа к файлу.
std::fstream input(&path[0], std::ios::in | std::ios::binary);
if (!input) {
return send_error(channel, "file is inaccessible");
}
При работе с файлом есть текущая позиция чтения из него: при открытии она 0, если прочитать 10 символов, она станет 10, а если еще 10 — станет 20 и т. д. Можно узнать позицию методом tellg()
и изменить ее методом seekg()
. Чтобы определить размер файла, можно сместиться к его концу (на нулевое смещение от конца), узнать эту позицию и вернуться в начало:
input.seekg(0, std::ios::end);
const auto size = input.tellg();
input.seekg(0, std::ios::beg);
Размер ответа — сумма размера типа (1 байт) и размера файла (size
байтов); передается в сетевом порядке байт:
const uint32_t length = ::htonl(sizeof(Type) + size);
send_some(channel, &length, sizeof(length));
Type type = TYPE_GET;
send_some(channel, &type, sizeof(type));
Чтение файла и отправка его содержимого по сети происходит аналогично приему: из файла читаются блоки фиксированного размера и отправляются по сети, пока не будет достигнут конец файла. Таким образом возможно отправлять даже очень крупные файлы, загружая в память лишь небольшие их фрагменты.
while (true) {
std::array<char, 4096> buffer;
auto bytes_to_send = input.readsome(&buffer[0], buffer.size());
Результат чтения из файла стоит проверять на ошибки:
if (input.bad()) {
std::fprintf(stderr, "error: %s: I/O failure %d\n", __func__, errno);
return false;
}
Метод readsome()
не пытается считать данные, если их больше не доступно, поэтому флаг input.eof()
никогда не будет взведен, зато можно проверить достижение конца файла по результату readsome()
и выйти из цикла:
if (bytes_to_send == 0) {
break;
}
send_some(channel, &buffer[0], bytes_to_send);
}
return true;
}
Получение списка файлов в каталоге делается по-разному в зависимости от ОС. Готовая функция list_files()
дана в listing.h
(изменен 31.03), она работает в Windows и Linux и возвращает вектор строк-имен файлов:
std::vector<std::string> list_files();
Файл предлагается сохранить в каталог своего проекта и подключить к программе:
#include "listing.h"
Функция list_files()
выдает список только обычных файлов (не скрытых, не директорий) в текущем каталоге. Гарантируется, что ни одно имя не будет длиннее 255 символов (байтов).
Список файлов получается в начале обработки запроса. Если он пуст, считается, что его по каким-то причинам не удалось получить (случай, когда рабочий каталог программы пуст, не рассматривается).
bool
serve_list(SOCKET channel) {
const auto files = list_files();
if (files.empty()) {
return send_error(channel, "unable to enumerate files");
}
Клиент принимал все содержимое сообщения за раз и разбирал его байт за байтом. Технически сервер не обязан так делать, можно отправлять длину и имя каждого файла отдельным вызовом send_some()
, а потоковая природа сокета скроет это. Однако для тренировки в формировании двоичных сообщений полезнее создать ответ единым блоком body
и отправить его сразу.
std::vector<uint8_t> body;
for (const auto& file : files) {
Переменная file
содержит строку-имя очередного файла. К динамическому массиву body
необходимо добавить один байт-длину file
(типа uint8_t
) и все байты строки file
. Для этого необходимо увеличить длину body
: новая длина равна сумме старой длины, одного байта и длины строки file
.
const auto old_body_size = body.size();
body.resize(old_body_size + sizeof(uint8_t) + file.length());
После изменения размера body
состоит из двух участков:
&body[0]
до &body[old_body_length - 1]
содержит данные, которые уже были в body
до изменения размера;&body[old_body_size]
и до конца предназначен для записи новых данных.Значение old_body_size
необходимо было сохранить до изменения размера, после этого его уже нельзя было бы вычислить — деление массива существует только с точки зрения логики программы, а не самого массива.
Записывать данные во вторую область последовательно удобно с помощью указателя на первый из еще не использованных байтов, названный place
:
uint8_t* place = &body[old_body_size];
Сначала в тот байт, на который указывает place
, записывается длина имени очередного файла (функция list_files()
гарантирует, что для любой из длин хватит восьми бит).
*place = file.length();
Указатель place
увеличивается на количество записанных данных, т. е. на один.
place++;
Следующим шагом все символы file
копируются в ту (свободную) область памяти, на которую указывает place
:
std::memcpy(place, &file[0], file.length());
}
Если бы после этого требовалось бы записывать еще какие-либо данные через place
, следовало бы увеличить place
на количество записанных данных, т. е. на file.length()
.
Длина сообщения, сложенная из размера типа и размера файла, тип и содержимое файла отправляются последовательно в качестве ответа клиенту:
const uint32_t length = ::htonl(sizeof(Type) + body.size());
send_some(channel, &length, sizeof(length));
Type type = TYPE_LIST;
send_some(channel, &type, sizeof(type));
send_some(channel, &body[0], body.size());
return true;
}
Во всех заданиях нужно расширить описание протокола, поддержать изменения в клиенте и сервере и подготовить демонстрацию работы программы. Опущенные детали (тексты ошибок, типы данных и т. п.) выберите сами.
Добавьте команду /time
, по которой клиент запрашивает системное время сервера сообщением нового типа 0x10
. Передавать время можно как число, получаемое функцией time()
, а отображать — strftime()
.
Добавьте к списку файлов (команда /list
) их размеры в байтах, изменив формат пакета типа 0x00
. Для этого можно воспользоваться усовершенствованным listing.h
.
Добавьте команду /find
с параметром-строкой, которая передается серверу в сообщении нового типа 0x12
. Сервер должен выдать список файлов, содержащих указанную строку в имени, подобно команде /list
.
Измените сервер, чтобы при запросе файла INFO
, есть он или нет, выдавалось не содержимое файла, а IP-адрес и порт клиента в виде текста. Получить адрес можно getpeername()
, формировать строку — stringstream
.
Добавьте команду /delete
и новый запрос, позволяющий удалить файл по имени. Это можно сделать DeleteFile()
(Windows) или unlink()
(*nix).
Добавьте команду /view
, аналогичную /get
, но с параметром-количеством первых байтов файла, которые пользователь желает скачать. Сервер должен выдавать не более этоно числа байтов в теле ответа.
Ограничьте количество данных (суммарный размер файлов), которые можно загрузить за одно подключение. При попытке превысить лимит сервер должен отдавать не ответ с файлом, а ответ с сообщением об ошибке (код 0xff
).
Добавьте команду /stat
и новый тип запроса (код 0x18
), по которому сервер отдает в двоичном виде статистику: количество подключений, количество запросов на файлы и суммарный размер выгруженных данных.
Добавьте новую команду /login
с двумя параметрами: именем пользователя и паролем, а также новый тип сообщения 0x19
для их передачи на сервер. Сервер должен отвечать сообщением об ошибке с требованием авторизоваться, пока не будет прислано верных учетных данных user
, secret
.
Добавьте отладочную команду /raw
, параметры которой (одна строка) — произвольные байты, которые нужно затем отправить на сервер. (В эти байты входят 4 байта длины и 1 байт типа сообщения.) Эту строку до конца можно считать getline()
, затем считать байты один за другим, как показано в примере.
Козлюк Д. А. для кафедры Управления и информатики НИУ «МЭИ», 2018 г.