title | lang |
---|---|
Лабораторная работа № 5. \ Сервер TCP для передачи файлов |
ru |
Научиться создавать серверы TCP в блокирующем режиме работы сокетов.
Реализовать сервер протокола обмена файлами из ЛР № 4.
После запуска сервер требует ввода адреса и порта для привязки и приема входящих подключений. Затем сервер бесконечно принимает новое подключение и обслуживает запросы клиента до его отсоединения (то есть обслуживается один клиент за раз).
(@) Создайте новый проект lab05-tcp-server
, подключите необходимые библиотеки
для работы с API сокетов. Для Windows инициализируйте API.
Сервер TCP работает по более сложной схеме, чем клиент (см. ту же презентацию, что в ЛР № 4):
- Создается сокет-слушатель входящих подключений при помощи
socket()
. Изначально он ничем не отличается от сокета-передатчика, как в клиенте. - Сокет привязывается к адресу и порту функцией
bind()
. - Сокет переводится в режим слушателя функцией
listen()
. - Входящее подключение принимается функцией
accept()
. При этом создается сокет-передатчик для данных принятого соединения. - Через сокет-передатчик ведется обмен данными с удаленным узлом
стандартными фнукциями
send()
иrecv()
. - Сокет-передатчик закрывается функцией
closesocket()
илиclose()
. - Сокет-слушатель может принять новое подключение, вернувшись к пункту 4;
либо оо может быть закрыт вызовом
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);
```
* Запустите программу, привяжите ее к адресу 127.0.0.1 и порту 1234.
* При помощи 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());
Метод 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()
][cppref/strftime].
[cppref/strftime]: http://en.cppreference.com/w/cpp/chrono/c/strftime
#. Добавьте к списку файлов (команда /list
) их размеры в байтах,
изменив формат пакета типа 0x00
. Для этого можно воспользоваться
усовершенствованным listing.h
.
#. Добавьте команду /find
с параметром-строкой, которая передается серверу
в сообщении нового типа 0x12
. Сервер должен выдать список файлов,
содержащих указанную строку в имени, подобно команде /list
.
#. Измените сервер, чтобы при запросе файла INFO
, есть он или нет,
выдавалось не содержимое файла, а IP-адрес и порт клиента в виде текста.
Получить адрес можно [getpeername()
][msdn/getpeername],
формировать строку — stringstream
.
[msdn/getpeername]: https://msdn.microsoft.com/en-us/library/windows/desktop/ms738533(v=vs.85).aspx
#. Добавьте команду /delete
и новый запрос, позволяющий удалить файл по имени.
Это можно сделать [DeleteFile()
][msdn/DeleteFile] (Windows)
или [unlink()
][man/unlink] (*nix).
[msdn/DeleteFile]: https://msdn.microsoft.com/en-us/library/windows/desktop/aa363915(v=vs.85).aspx
[man/unlink]: https://linux.die.net/man/2/unlink
#. Добавьте команду /view
, аналогичную /get
, но с параметром-количеством
первых байтов файла, которые пользователь желает скачать. Сервер должен
выдавать не более этоно числа байтов в теле ответа.
#. Ограничьте количество данных (суммарный размер файлов), которые можно
загрузить за одно подключение. При попытке превысить лимит сервер должен
отдавать не ответ с файлом, а ответ с сообщением об ошибке (код 0xff
).
#. Добавьте команду /stat
и новый тип запроса (код 0x18
), по которому
сервер отдает в двоичном виде статистику: количество подключений,
количество запросов на файлы и суммарный размер выгруженных данных.
#. Добавьте новую команду /login
с двумя параметрами: именем пользователя
и паролем, а также новый тип сообщения 0x19
для их передачи на сервер.
Сервер должен отвечать сообщением об ошибке с требованием авторизоваться,
пока не будет прислано верных учетных данных user
, secret
.
#. Добавьте отладочную команду /raw
, параметры которой (одна строка) —
произвольные байты, которые нужно затем отправить на сервер.
(В эти байты входят 4 байта длины и 1 байт типа сообщения.)
Эту строку до конца можно считать getline()
, затем считать байты один
за другим, как показано [в примере][line-parser].
[line-parser]: /study/courses/sdt/17/lecture03_structured-program.slides.pdf#page=28