Skip to content

Latest commit

 

History

History
576 lines (458 loc) · 29.2 KB

lab04-tcp-client.md

File metadata and controls

576 lines (458 loc) · 29.2 KB
title lang
Лабораторная работа № 4. \ Клиент TCP для передачи файлов
ru

Тестовые серверы

Обновлено 31.03.

Для тестирования своего клиента воспользуйтесь готовым сервером: Windows (32 бита), Linux (64 бита). Запустите его в каталоге с файлами, подходящими для выполнения задания (ниже) и подключитесь к серверу своей программой.

Цель работы

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

Задание

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

Протокол

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

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

Протокол является двоичным, то есть данные передаются не в текстовом виде. Порядок байт для числовых полей — big endian (от старшего к младшему). Формат пакета представляет собой разновидность TLV (type, length, value — тип, длина, значение/данные):

длина (4 байта)     тип (1 байт)    данные (nnnnnnnn байтов)
nn nn nn nn         tt              xx ...

Далее описаны сообщения протокола: формат запросов и ответов. Ответы следуют в том же порядке, что и запросы, и имеют тот же тип.

Сообщение об ошибке

Направляется сервером клиенту, если запрос не может быть выполнен. Содержит текст ошибки, который вместе с типом ff определяет длину. Пример (не найден файл):

00 00 00 11                 длина сообщения 17 = 1 + 16
ff                          тип сообщения
 F  i  l  e     n  o  t     текст ошибки (16 символов)
    f  o  u  n  d  !

Получение файла

Запрос

Содержит имя файла, которое вместе с типом сообщения 00 определяет длину. Пример:

00 00 00 09                 длина сообшения 9 = 1 + 8
00                          тип сообщения
 f  i  l  e  .  t  x  t     имя файла (8 символов)

Ответ

Содержит данные файла, которые вместе с типом сообщения 00 определяют длину. Пример:

00 00 00 14                         длина сообщения 20 = 1 + 19
00                                  тип сообщения
 C  o  n  t  e  n  t     o  f       данные файла (19 байт)
 f  i  l  e  .  t  x  t

Получение списка файлов

Запрос

Длина всегда 1, тип 01, дополнительных данных нет. То есть:

00 00 00 01     длина сообшения 1
01              тип сообщения

Ответ

Длина определяется содержимым, тип 01. Содержимое состоит их последовательности имен файлов, каждое из которых состоит из длины имени (1 байт) и самого имени этой длины. Пример:

00 00 00 16                         длина сообщения 22 = 1 + (1 + 8) + (1 + 11)
01                                  тип сообщения
08                                  длина имени первого файла
 f  i  l  e  .  t  x  t             имя первого файла
0b                                  длина имени второго файла
 a  n  o  t  h  e  r  .  j  p  g    имя второго файла

Задание

Потоковые сокеты (stream sockets) отличаются тем, что с точки зрения программиста передают не дейтаграммы (порции данных ограниченного размера), а байтовый поток, то есть отправитель записывает в сокет данные любыми порциями, а получатель вычитывает все байты в том же порядке, в котором их записывал отправитель. Этим определяются черты потоковых сокетов:

  • Наличие соединения (connection-oriented): до начала передачи наобходимо выбрать адрес и порт партнера и установить соединение с ним. Традиционно участника, который инициирует установку соединения, называют клиентом, а участника, ожидающего входщих подключений — сервером. Далее все данные передаются между двумя участниками соединения.

  • Надежная доставка данных (reliable communication): гарантируется, что либо будут доставлены все данные и по порядку, либо будет диагностирован разрыв соединения.

  • Сохранение порядка сообщений (order preservation): получатель считывает байты данных в том же порядке, как они были отправлены, без изъятий и перестановок.

  • Отсутствует сохранение границ сообщений (non message bounds-preserving): данные могут прийти иными порциями, нежели были отправлены. Например, если клиент отправил две порции по 400 байт, сервер может получить одну порцию в 800 байт или четыре порции по 200 байт.

Потоковые сокеты работают обычно по протоколу TCP.

Сжатое пособие по особенностям работы и API потоковых сокетов.

Установка соединения

(@) Создайте новый проект lab04-tcp-client. Подключите и инициализируйте бибилиотеку для работы с сокетами.

(@) Создайте потоковый сокет channel: auto channel = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

(@) Реализуйте ввод адреса и порта в переменную address типа sockaddr_in.

(@) Добавьте в программу установку соединения с сервером при помощи функции connect(): const int result = ::connect( channel, (const sockaddr*)&address, sizeof(address));

(@) Добавьте в программу закрытие соединения сразу после открытия: ::closesocket(channel);

При закрытии сокета установленное соединение разрывается и освобождаются связанные с сокетом ресурсы ОС, что экономит их. Над закрытым сокетом невозможны никакие операции API, поэтому вся описанная ниже работа с сокетом должна вестись раньше его закрытия. Только в Windows эта функция называется closesocket(), POSIX вместо нее использует close().

(@) Добавьте в программу обработку ошибок, которые могут возникнуть при вызове socket() и connect().

(@) Проверьте работоспособность программы, запустив в качестве сервера netcat локально: ncat -lkv 127.0.0.1 1234 Вывод своей программы и netcat занесите в отчет.

  • Ключ -l (listen) запускает netcat в режиме приема подключений.
  • Ключ -k (keep open) указывает netcat не завершаться после отключения клиента, а ждать новых подключений. Завершить ее можно через Ctrl+C.
  • Ключ -v (verbose) включает подробный вывод, в частности, без этого ключа netcat не сообщает, когда к ней подключился клиент.
  • По умолчанию, без ключа -u, netcat работает по TCP, что и нужно.

Прием и отправка сообщений произвольной длины

Отправка и прием данных из потоковых советов выполняется функциями send() и recv(). Их интерфейс подобен соответственно sendto() и recvfrom() дейтаграммных сокетов с тем отличием, что адреса узлов фиксированы соединением, и при каждом вызове их указывать не требуется. Однако, заметно отличается поведение, и следовательно, применение этих пар функций.

Потоковые сокеты не гарантируют, что за один вызов recv() удастся принять блок данных любого размера, больше одного байта. Хотя для мелких блоков (например, для четырех байтов длины в протоколе, реализуемом в ЛР) наблюдать прием в несколько вызовов recv() маловероятно, для крупных блоков это естественно (например, при загрузке файла больше полутора килобайт. Надежные сетевые программы не должны пренебрегать этой особенностью потоковых сокетов ни к одном из случаев.

Целесообразно реализовать функции для приема и передачи блока данных произвольного размера size:

int receive_some(SOCKET channel, void* data, size_t size);
int send_some(SOCKET channel, const void* data, size_t size);

Для приема необходимо вести учет количества принятых байт:

int
receive_some(SOCKET channel, void* data, size_t size) {
    auto bytes = reinterpret_cast<char*>(data);
    size_t bytes_received = 0;

До тех пор, пока не принято необходимое количество данных, требуется повторять вызов recv(), пытаясь считать все недостающие данные и разместить их в буфере, начиная с первой незанятой позиции:

    while (bytes_received < size) {
        int result = ::recv(
                channel, &bytes[bytes_received], size - bytes_received, 0);

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

        if (result <= 0) {
            return result;
        }
        bytes_received += result;
    }
}

(@) В целях отладки же после отправки сообщения добавьте прием блока данных размером 12 байт (обработку ошибок — показ кода — напишите сами): std::array<char, 128> data{}; const int result = receive_some(channel, &data[0], data.size()); if (result > 0) { std::cout.write(&data[0], result); }

(@) Проверьте работу программы при помощи netcat и занесите в отчет вывод своей программы и вывод necat.

* Запустите `netcat` и подключитесь своей программой к нему.
* Из терминала `netcat` напишите сообщение: `hello world` *(Enter)* —
    строго 12 байт вместе с переводом строки.
    Убедитесь, что сообщение принимается программой.
* Удалите отладочный код из программы по завершении эксперимента.

(@) Реализуйте самостоятельно функцию для отправки блока данных произвольной длины: int send_some(SOCKET channel, const void* data, size_t size);

(@) В целях отладки после установки соединения добавьте отправку заготовленного сообщения (проверку ошибок — показ кода — напишите сами): std::string message = "hello from my app\n"; int result = send_some(channel, &message[0], message.size());

(@) Проверьте работу программы при помощи netcat и занесите в отчет вывод своей программы и вывод necat.

* Запустите `netcat` и подключитесь своей программой к нему.
* Убедитесь, что сразу после подключения программы к `netcat`
    она отправляет сообщение, которое получается целиком.
* Удалите отладочный код из программы по завершении эксперимента.

Получение файлов

Работа приложения происходит в цикле ввода команд и их обработки:

while (true) {
    std::cerr << "> ";
    std::string command;
    std::cin >> command;

    if (command == "/quit") {
        break;
    }
    else if (command == "/get") {
        std::string path;
        std::cin >> path;
        download(channel, path);
    }
    else {
        std::cerr << "Commands:\n"
                "\t/get <file>\n"
                "\t/quit\n";
    }
}

Пользователь вводит /quit, чтобы выйти из программы, или /get файл, чтобы скачать файл. Для простоты предполагается, что файлы находятся в текущем каталоге и пользователь вводит всегда относительные пути к ним.

Функция download() отправляет запрос на загрузку файла и выполняет ее.

bool
download(SOCKET channel, const std::string& path) {

Алгоритм работы функции:

  1. Сформировать и отправить запрос на файл.
  2. Принять размер и тип ответа.
  3. В зависимости от типа ответа:
    • если это ошибка (тип ff), вывести ее текст;
    • если это содержимое файла (тип 00), сохранить его;
    • иначе сигнализировать о получении сообщения неожиданного типа.

Размер запроса складывается из размера поля-типа (1 байт) и длины имени файла:

    uint32_t length = htonl(sizeof(Type) + path.size());
    send_some(channel, &length, sizeof(length));

Коды типов сообщений целесообразно занести в константы типа Type, размер которого совпадает с uint8_t. Это делается выше функции:

enum Type : uint8_t {
    TYPE_GET = 0x00,
    TYPE_LIST = 0x01,
    TYPE_ERROR = 0xff
};

Объявленные константы используются далее в функции download():

    Type type = TYPE_GET;
    send_some(channel, &type, sizeof(type));

При отправке имени файла, хранящегося в переменной типа std::string, количество данных определяется не размером переменной, а длиной строки:

    send_some(channel, &path[0], path.size());

Прием запускается сразу же после отправки:

    receive_some(channel, &length, sizeof(length));
    receive_some(channel, &type, sizeof(type));

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

    length = ntohl(length) - 1;

Обработка ответа выполняется в зависимости от его типа одной из функций, объявляемых до download() и реализуемых отдельно.

    switch (type) {
    case TYPE_ERROR:
        return process_error_response(channel, length);
    case TYPE_GET:
        return process_get_response(channel, path, length);
    default:
        return process_unexpected_response(channel, length, type);
    }
}

Для обработки сообщения об ошибке достаточно принять текст ошибки целиком и отобразить его:

bool
process_error_response(SOCKET channel, uint32_t text_size) {
    std::string text(text_size + 1, '\0');
    receive_some(channel, &text[0], text_size);
    std::cerr << "Server error: " << text << '\n';
    return true;
}

В случае прибытия сообщения неожиданного типа целесообразно печатать максимум информации для отладки: полное содержимое прибывшего сообщения. Для этого можно воспользоваться функцией hex_dump() из ЛР № 2:

bool
process_unexpected_response(SOCKET channel, uint32_t length, Type type) {
    std::cerr << "Protocol error: unexpected message "
            << "(type=" << (int)type << ", length=" << length << ")\n";

    std::vector<uint8_t> data(length);
    receive_some(channel, &data[0], data.size());

    std::cerr << "Payload dump:\n";
    hex_dump(&data[0], data.size());
    return true;
}

Для загрузки файла принципиально возможно применить read_some(), однако нерационально: файл потенциально большого размера придется целиком загружать в буфер в памяти. Целесообразен иной подход: считывать данные ограниченными порциями из сокета и сразу же записывать очередную порцию в файл. Затраты памяти при этом (примерно размер порции) не зависят от размера файла.

bool
process_get_response(
        SOCKET channel, const std::string& pathm uint32_t file_size) {
    std::clog << "Downloading " << file_size << " bytes...\n";
Обратите внимание, что файл необходимо открыть строго в двоичном режиме (флаг `std::ios::binary`), потому что в текстовом режиме `std::fstream` автоматически преобразует переводы строк в зависимости от платформы (`"\r\n"` для Windows, `"\n"` для Linux, `"\r"` для Mac), что испортит двоичные файлы, например, изображения. Флаг `std::ios::trunc` указывает очистить файл при открытии, что позволяет не заботиться о том, был ли уже скачан файл с тем же именем — старая версия будет при открытии стерта. ``` std::fstream output(path, std::ios::out | std::ios::trunc | std::ios::binary); ```
    uint32_t bytes_received = 0;
    while (bytes_received < file_size) {
        std::array<char, 4096> buffer;
        const int result = ::recv(channel, &buffer[0], buffer.size(), 0);
        if (result <= 0) {
            return false;
        }
        output.write(&buffer[0], result);
        bytes_received += result;
    }
    return true;
}

(@errors) Добавьте в функцию download_file() и во вспомогательные функции обработку ошибок: проверки результатов receive_some() и send_some(). В случае ошибок необходимо печатать пояснение (суть ошибки, ее код) и возвращать false.

(@) В основном цикле программы добавьте проверку результата download_file(): есть произошла критическая ошибка (результат false), цикл нужно прервать.

(@test) Проверьте работу программы. Подключившись к тестовому серверу, скачайте в рамках одного сеанса:

* маленький текстовый файл (меньше 1,5 КБ);
* крупный файл-изображение (несколько КБ);
* исполняемый файл тестового сервера `lab05-tcp-server` (Linux)
    или `lab05-tcp-server.exe` (Windows);
* файл с несуществующим именем (сервер вернет ошибку,
    которая должна быть обработана).

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

Получение списка файлов

(@) Добавьте в основной цикл программы поддержку новой команды /list: else if (command == "/list") { list_files(channel); }

Функция list_files() принципиально может быть устроена так же, как download_file(): формировать запрос и получать в ответ данные, сообщение об ошибке или сообщение непредвиденного типа.

bool
list_files(SOCKET channel) {

(@) Сформируйте и отправьте запрос на получение списка файлов в соответствии с протоколом: четыре байта длины, равной 1, и один байт типа.

(@) Примите длину ответа и его тип. Ответ-сообщения об ошибке и ответ непредвиденного типа обработайте так же, как в функции download_file().

(@) Выполните разбор ответа типа TYPE_LIST, печатая имена файлов на экране.

В целях тренировки разберем ответ так: считаем все оставшееся сообщение и будем вычленять отдельные имена файлов, анализируя полученный байтовый массив. Это более эффективно, чем последовательные вызовы recv() (каждый вызов — обращение к ОС), и практично: например, точно так же устроена часть протокола установки защищенного соединения TLS, используемого для HTTPS.

bool
process_list_response(SOCKET channel, uint32_t data_length) {
    std::vector<uint8_t> data(length);
    receive_some(channel, &data[0], data.size());

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

    const uint8_t* entry = &data[0];
    while (entry <= &data.back()) {

Чтобы считать через entry длину массива, достаточно разыменовать entry. Затем entry увеличивается (сдвигается) на длину прочитанных данных — на 1. Если бы длина имени хранилась не в одном, а в нескольких байтах, перед разыменованием потребовалось бы приведение типов.

        const auto entry_length = *entry;
        entry++;

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

        const auto file_name = reinterpret_cast<const char*>(entry);
        entry += entry_length;

Для вывода имени файла используется std::cout.write(), а не обычный вывод через <<, потому что в конце области, на которую указывает file_name, в пакете нет завершающего '\0', и оператор << продолжил бы выводить данные за пределами entry_length.

        std::cout.write(file_name, entry_length);
        std::cout << '\n';
    }
    return true;
}

(@) Добавьте в list_files(), process_list_response() и main() проверки ошибок, аналогичные пункту @errors.

(@) Проверьте работу программы, запросив список файлов у тестового сервера, указанного преподавателем в пункте @test. Занесите полученный список в отчет.