Skip to content

Latest commit

 

History

History
1073 lines (875 loc) · 61.8 KB

TUTORIAL.md

File metadata and controls

1073 lines (875 loc) · 61.8 KB

Содержание

Введение

Данное руководство посвящено работе асинхронного ввода-вывода, который в основном используется для сетевого взаимодействия. Для лучшего понимания происходящего, вы должны быть знакомы с современным C++, STL и Boost, а также с базовыми принципами сетевого взаимодействия и многопоточности.

Мы будем использовать Boost.Asio, Boost.Beast, а также C++20 Networking library. Чтобы добиться асинхронности, мы будем использовать обработчики завершения, сопрограммы (или корутины) и фиберы.

Чтобы скомпилировать исходный код из примеров, вам понадобиться установить компилятор, поддерживающий стандарт C++17, а также библиотеку Boost. При компиляции вам потребуется добавить Boost в include directories и слинковать исходный код вашего приложения с ним.

На самом деле, для большинства примеров достаточно скомпилировать boost/libs/system/src/error_code.cpp, поскольку остальная часть исходного кода библиотеки Boost — это header-only библиотеки.

Обычно сетевое взаимодействие считается очень сложным предметом для изучения. Неужели это действительно так сложно? Что ж, ответ — и да, и нет. Потребуется время, чтобы стать экспертом в этой области, однако мы попробуем сделать так, чтобы вам было понятно то, что происходит в этом руководстве.

Когда вы разрабатываете какое-либо приложение, вам следует использовать пространства имен (namespaces) и псевдонимы типов (type aliases), чтобы код было удобно читать. Мы начнем это делать позднее, после того, как у вас появится четкое понимание откуда берутся те или иные вещи. Поэтому первое время вы будете видеть что-то по типу boost::asio::ip::tcp::socket. Конечно же, в реальном коде это должно быть заменено на что-то вроде tcp::socket.

Двумя важными элементами сетевого взаимодействия являются клиенты и серверы. Обычно подобные руководства начинаются с изучения работы клиента, поскольку это более простая тема для рассмотрения. Однако в этом руководстве мы начнем с серверов. Почему? Во-первых, сервера — это то место, где C++ проявляет себя с наилучшей стороны, а во-вторых, сервера не так страшны, как кажутся на первый взгляд.

На этом вступление окончено. Теперь вы готовы приступить к погружению в сетевое программирование на C++.

TCP и UDP

Существует два основных протокола транспортного уровня, которые мы будем использовать — TCP и UDP. Протокол — это набор соглашений о том, как должны передаваться данные по сети.

Transmission Control Protocol — TCP

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

  • При работе с файлом мы можем узнать его размер. В случае TCP-соединения это невозможно.
  • Вы можете изменять положение указателя, когда работаете с файлом. Этот трюк также нельзя провернуть с TCP-соединением.

Другими словами, файл предоставляет вам произвольный доступ, в то время как TCP-соединение представляет собой двунаправленный последовательный поток.

User Datagram Protocol — UDP

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

  • В UDP отсутствуют соединения, поскольку это не поток данных. Из этого следует, что нет необходимости создавать или закрывать UDP-сокет. Все, что вам требуется — это отправлять или получать данные.
  • Буфер, используемый для получения UDP-пакета должен быть достаточно большим, чтобы вместить весь пакет целиком. В противном случае, вы ничего не получите. Из этого следует, что необходимо заранее знать верхнюю границу размера пакетов, которые вы собрались получать.
  • Порядок входящих пакетов, как правило, не соответствует порядку их отправки. Это означает, что необходимо самостоятельно контролировать порядок пакетов.
  • Нет никаких гарантий, что все отправленные пакеты будут доставлены. Это означает, что потеря UDP-пакетов — обычное дело. Следовательно, необходимо самостоятельно контролировать, что все отправленные UDP-пакеты доставлены.

Как вы можете понять, UDP немного сложнее в использовании, чем TCP. Тем не менее, у UDP есть свои преимущества, которые мы обсудим позднее.

Это все, что вам необходимо знать о протоколах на данный момент. Значит, мы можем двигаться дальше.

Самый простой сервер

Согласно Википедии,

Сервер — программный компонент вычислительной системы, выполняющий сервисные (обслуживающие) функции по запросу клиента, предоставляя ему доступ к определённым ресурсам или услугам.

Это определение очень точно подмечает тот факт, что сервер — это всего лишь приложение, которое получает какие-то данные от других приложений и возвращает некоторые данные обратно.

Мы начнем с самого простого сервера, который приходит на ум — эхо UDP-сервер. Он выполняет следующие действия:

  • Получает любые данные, которые были отправлены на UDP-порт 15001.
  • Отправляет полученные данные обратно отправителю «как есть».

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

Теперь давайте взглянем на следующий исходный код:

#include <boost/asio.hpp>

int main() {
    std::uint16_t port = 15001;

    boost::asio::io_context io_context;
    boost::asio::ip::udp::endpoint receiver(boost::asio::ip::udp::v4(), port);
    boost::asio::ip::udp::socket socket(io_context, receiver);

    while (true) {
        char buffer[65536];
        boost::asio::ip::udp::endpoint sender;
        std::size_t bytes_transferred =
            socket.receive_from(boost::asio::buffer(buffer), sender);
        socket.send_to(boost::asio::buffer(buffer, bytes_transferred), sender);
    }

    return 0;
}

Вам даже не обязательно отдельно скачивать .cpp файл сервера, поскольку вышеприведенный код — это полноценный эхо UDP-сервер. Мы не реализовали логирование и обработку ошибок, чтобы код выглядел максимально просто. Об обработке ошибок мы поговорим позднее. Давайте разберемся, что происходит в этом коде:

  • boost::asio::io_context — основной поставщик услуг ввода-вывода. В данный момент вы можете рассматривать его как исполнителя (executor) запланированных задач. Вы поймете его назначение сразу после того, как мы перейдем к асинхронному потоку управления, что произойдет очень скоро.
  • boost::asio::ip::udp::endpoint — это пара IP-адреса и порта.
  • boost::asio::ip::udp::socket — это сокет. Вы можете рассматривать его как дескриптор файла, предназначенный для сетевого взаимодействия. Обычно, когда вы открываете файл, вы получаете дескриптор файла. Когда вы взаимодействуете по сети, вы используете сокет.
  • Каждый сокет прикреплен к некоторому io_context, а потому каждый сокет конструируется с помощью ссылки на io_context. Второй параметр конструктора сокета — endpoint — IP-адрес и порт, который используется для получения входящих дейтаграмм (в случае UDP) или соединений (в случае TCP).
  • boost::asio::ip::udp::v4() возвращает объект, который в данный момент вы должны рассматривать как просто сетевой интерфейс UDP по умолчанию.
  • boost::asio::buffer() — это представление буфера, которое содержит указатель и размер, причем это представление не владеет памятью. В нашем случае оно указывает на массив char.
  • socket::receive_from ожидает входящий UDP-пакет, заполняет buffer полученными данными, а также заполняет sender информацией об отправителе, которая также включает в себя пару IP-адреса и порта.
  • socket::send_to отправляет UDP-пакет, используя данные из представления буфера. Получатель пакета передается вторым аргументом. В нашем случае получателем является отправитель, поскольку речь идет об эхо-сервере.

Итак, мы сделали следующее:

  • Создали UDP-сокет и настроили его на ожидание UDP-пакетов на порту 15001.
  • Запустили бесконечный цикл, в котором ожидаем входящие UDP-пакеты, а после получения отправляем их обратно отправителю.

Поздравляем! Вы только что создали ваш первый сервер с помощью C++ и Boost.Asio!

Прощаемся с синхронностью

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

Вы можете обойти это ограничение с помощью создания дополнительных потоков. Например, один поток может обрабатывать ввод-вывод, а другой управлять пользовательским интерфейсом. Однако такой подход приведет к усложнению вашего приложения, поскольку в какой-то момент вам придется синхронизировать эти потоки. Более того, не существует безопасного способа отменить блокирующую операцию ввода-вывода из другого потока. Хотя это и может работать так, как вы ожидаете, но в целом это не безопасная операция. А потому вы можете столкнуться с неопределенным поведением, если что-нибудь измениться в вашем рабочем окружении (например, если вы скомпилируете код для новой платформы, с которой вы раньше не работали).

Асинхронный подход лишен этих недостатков. Проще говоря, выполнение асинхронного кода можно представить так: «Начни делать это в фоновом режиме, а после того, как закончишь, вызови эту функцию. Тем временем я займусь другими задачами, которые необходимо выполнить». Таким образом, выполнение асинхронного кода — это неблокирующая операция, а значит вы можете совершать другие действия, пока ваши задачи выполняются в фоновом режиме. Кроме того, асинхронные задачи могут быть безопасно отменены в любое время.

Вспомним код из предыдущего раздела, в котором используется синхронный подход:

// Эта операция заблокирует поток управление до тех пор, пока не будет получено сообщение
std::size_t bytes_transferred = socket.receive_from(buffer, sender);
std::cout << "Message is received, message size is " << bytes_transferred;

Асинхронные версии функций ввода-вывода в Boost.Asio начинаются с приставки async_. Теперь взгляните на тот же код, переписанный в асинхронном стиле:

// Эта операция не блокирующая: выполнение кода продолжится сразу после вызова функции
socket.async_receive_from(
    buffer,
    sender,
    [&](boost::system::error_code error, std::size_t bytes_transferred) {
        // Эта лямбда-функция будет вызвана после получения сообщения
        std::cout << "Message is received, message size is "
                  << bytes_transferred;
    });

В C++ нам нравится держать все под контролем. Первое, что вы должны спросить: «Эй, где именно выполняется это фоновая задача? Должны ли мы создавать поток для нее?». Вы получите ответ на этот вопрос в следующем разделе. А пока, пришло время сказать «прощай» синхронному коду и двигаться дальше.

Асинхронный TCP-сервер

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

Теперь наш сервер будет делать следующее:

  • Слушать порт 15001 и ожидать входящее TCP-соединение.
  • Принимать входящее соединение.
  • Читать данные из соединения до тех пор, пока не встретится символ конца строки (т. е. символ \n).
  • Выводить полученные данные в стандартный вывод.
  • Закрывать соединение.

Теперь давайте взглянем на полноценный пример такого сервера. Ниже мы все разложим по полочкам и посмотрим как все устроено. Как и прежде, мы пренебрегаем обработкой ошибок, чтобы код выглядел более понятным. Об обработке ошибок мы поговорим позже.

#include <boost/asio.hpp>
#include <iostream>
#include <optional>

class session: public std::enable_shared_from_this<session> {
  public:
    session(boost::asio::ip::tcp::socket&& socket) :
        socket(std::move(socket)) {}

    void start() {
        boost::asio::async_read_until(
            socket,
            streambuf,
            '\n',
            [self = shared_from_this()](
                boost::system::error_code error,
                std::size_t bytes_transferred) {
                std::cout << std::istream(&self->streambuf).rdbuf();
            });
    }

  private:
    boost::asio::ip::tcp::socket socket;
    boost::asio::streambuf streambuf;
};

class server {
  public:
    server(boost::asio::io_context& io_context, std::uint16_t port) :
        io_context(io_context),
        acceptor(
            io_context,
            boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), port)) {}

    void async_accept() {
        socket.emplace(io_context);

        acceptor.async_accept(*socket, [&](boost::system::error_code error) {
            std::make_shared<session>(std::move(*socket))->start();
            async_accept();
        });
    }

  private:
    boost::asio::io_context& io_context;
    boost::asio::ip::tcp::acceptor acceptor;
    std::optional<boost::asio::ip::tcp::socket> socket;
};

int main() {
    boost::asio::io_context io_context;
    server srv(io_context, 15001);
    srv.async_accept();
    io_context.run();
    return 0;
}

По сравнению с предыдущим сервером, этот код занимает значительно больше места. Но не стоит паниковать, здесь всего 57 строк кода, которые представляют из себя полноценный асинхронный TCP-сервер.

В прошлый раз мы упомянули, что все функции с приставкой async_ выполняются в фоновом режиме. Так где же находится этот «фоновый режим»? Что ж, фоновый режим находится где-то внутри операционной системы. На самом деле, вам не нужно заботиться о том, как это происходит. Единственное, что должно вас волновать — откуда вызываются обработчики завершения. И это происходит внутри io_context.run(). Давайте взглянем на функцию main:

int main() {
    boost::asio::io_context io_context;
    server srv(io_context, 15001);
    srv.async_accept();
    io_context.run();
    return 0;
}

Функция boost::asio::io_context::run — это своего рода функция цикла событий (event loop), которая управляет всеми операциями ввода-вывода. При вызове функции run поток управления блокируется до тех пор, пока не выполнятся все асинхронные операции, связанные с io_context. Все операции с приставкой async_ связаны с каким-либо io_context. В некоторых языках программирования (например, JavaScript) функция цикла событий спрятана от разработчика. Но в C++ нам нравится все держать под контролем, поэтому мы решаем, где именно функция цикла событий будет запущена.

Теперь давайте рассмотрим класс server. Здесь встречается сразу несколько новых вещей, которые находятся в private секции класса:

  • boost::asio::ip::tcp::socket — этот тот же самый сокет, что и до этого, только теперь он работает в рамках протокола TCP (вместо UDP, как это было ранее).
  • boost::asio::ip::tcp::acceptor — это объект, который принимает входящие соединения.

Если вы посмотрите на конструктор класса acceptor, вы увидите то, что он очень похож на метод receive_from у UDP-сокета:

acceptor(io_context, boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), port))

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

Теперь давайте рассмотрим вызов функцию async_accept у acceptor:

acceptor.async_accept(*socket, [&](boost::system::error_code error) {
    std::make_shared<session>(std::move(*socket))->start();
    async_accept();
});

Словами это можно описать так: «Ожидай входящее соединение, а после того как установишь его, свяжи это соединение с сокетом и вызови обработчик завершения». Как вы помните, функции с приставкой async_ не блокируют вызывающий поток.

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

Обратите внимание, что серверу все равно, что происходит с установленным соединением. Сервер сразу же начинает ожидать следующее входящее соединение не беспокоясь о том, что происходит с предыдущим соединением. Установленные соединения выполняются в фоновом режиме. Одновременно может существовать почти любое количество соединений (количество открытых файловых дескрипторов ограничено ОС), выполняемых в фоновом режиме. Это и есть принцип работы асинхронного ввода-вывода.

Отлично, теперь мы знаем, как работает наш сервер. Давайте рассмотрим класс session. Сессия — это класс, который поддерживает соединение. Сессия содержит некоторые данные, связанные с соединением и предоставляет некоторый набор функций, связанный с соединением. Давайте рассмотрим функцию start:

void start() {
    boost::asio::async_read_until(
        socket,
        streambuf,
        '\n',
        [self = shared_from_this()](
            boost::system::error_code error,
            std::size_t bytes_transferred) {
            std::cout << std::istream(&self->streambuf).rdbuf();
        });
}

Дословно, код выполняет следующее: «Читай данные из сокета в streambuf, а когда встретишь символ "\n", остановись и вызови обработчик завершения».

boost::asio::streambuf — это класс, унаследованный от std::streambuf. Можете рассматривать его как реализацию streambuf в библиотеке Boost.Asio.

Итого, сессия считывает данные из сокета до тех пор, пока не встретит символ "\n", а после записывает полученные данные в стандартный вывод.

Обратите внимание, что класс session унаследован от класса std::enable_shared_from_this. Также заметим, что сессия захватывает в лямбду обработчика завершения указатель на разделяемую копию себя посредством shared_from_this. Мы делаем это, чтобы продлить время жизни сессии до тех пор, пока не будет вызван обработчик завершения. После этого нам не нужно ничего делать — указатель на разделяемый объект выйдет из области видимости и сразу же уничтожится после завершения работы обработчика. В большинстве случаев (но не во всех), это обычный способ для работы с сессиями.

Теперь вы знаете как написать простой асинхронный TCP-сервер и как он работает. Последнее, что нам необходимо сделать — протестировать в реальной жизни. Запустим сервер в терминале:

./server

Теперь в другом терминале запустим telnet, подключимся к 15001 порту, введем Hello asio! и нажмем Enter (который введет ожидаемый символ "\n"):

telnet localhost 15001
Hello asio!

В первом терминале вы должны увидеть это сообщение:

./server
Hello asio!

Круто! Вы только начали, а уже знаете, как написать почти любой профессиональный асинхронный TCP-сервер с помощью современного C++ и Boost.Asio. Поздравляем!

Обработка ошибок

Синхронные функции

В Boost.Asio все синхронные функции ввода-вывода имеют две перегрузки для обработки ошибок: первая выбрасывает исключение, а вторая возвращает ошибку по ссылке (подход с возвращением значения).

Исключения, которые выбрасывается из функций Boost.Asio, являются экземплярами класса boost::system::system_error, который, в свою очередь, унаследован от класса std::runtime_error.

При возврате ошибки по ссылке, используется экземпляр класса boost::system::error_code.

Пример. Исключения

try {
    socket.connect(endpoint);
} catch (const boost::system::system_error& e) {
    std::cerr << e.what() << "\n";
}

Вы можете получить error_code из system_error, вызвав метод code():

catch (const boost::system::system_error& e) {
    boost::system::error_code error = e.code();
    std::cerr << error.message() << "\n";
}

Пример. Возвращаемое значение

Если вы не хотите возиться с исключениями, вы можете использовать перегрузку, чтобы получить ошибку по ссылке:

boost::system::error_code error;
socket.connect(endpoint, error);

if (error) {
    std::cerr << error.message() << "\n";
}

Асинхронные функции

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

socket.async_connect(endpoint, [&](boost::system::error_code error) {
    if (!error) {
        // Асинхронная операция успешно завершена
    } else {
        // Что-то пошло не так
        std::cerr << error.message() << "\n";
    }
});

error_code

Рассмотрим некоторый функционал error_code, который может вам пригодиться при работе с ним.

  • Если вы хотите получить удобочитаемое описание ошибки из boost::system::error_code, нужно вызвать метод message(), который вернет std::string с описанием ошибки.
  • Если вы не хотите выделять дополнительную память для std::string, вы можете использовать перегрузку message(char const* buffer, std::size_t size).
  • Если вы хотите получить системный код (который имеет тип int) ошибки из error_code, вызовите метод value().

Если удаленное соединение было закрыто, то будет выброшена end-of-file ошибка. В некоторых случаях вы не хотите рассматривать end-of-file ошибку как ошибку приложения. Например, вы хотите получить некоторое сообщение от удаленного хоста. После передачи сообщения хост разрывает соединение, что является нормальным поведением. Для обработки такой ситуации вы могли бы написать:

socket.async_receive(
    buffer,
    [&](boost::system::error_code error, std::size_t bytes_transferred) {
      if (!error) {
        // Асинхронная операция выполнена успешно.
        // Соединение все еще установлено
      } else if (error == boost::asio::error::eof) {
        // Соединение было разорвано.
        // В буфере по-прежнему хранятся полученные данные,
        // численно равные `bytes_transferred` (в байтах)
      } else {
        // Что-то пошло не так
        std::cerr << error.message() << "\n";
      }
    });

У вас может возникнуть вопрос: как передавать boost::system::error_code в функции? По ссылке или по значению? С одной стороны, если вы откроете документацию Boost.Asio, то увидите, что автор библиотеки передает error_code по ссылке. С другой стороны, error_code содержит в себе один int, один bool и один сырой указатель.

Вспомним класс std::string_view: он содержит в себе один std::size_t и один сырой указатель. Поскольку его размер невелик, его стоит передавать по значению. В нашем случае error_code занимает столько же места, сколько и std::string_view (в большинстве случаев это так, зависит от платформы). Поэтому в нашем коде мы передаем error_code по значению.

Дальнейшее изучение

В следующем разделе мы рассмотрим пример более масштабного сервера — TCP чат-сервера. Но перед этим вы должны кое-что узнать.

У сокета есть функционал, о котором мы еще не говорили. Мы можем узнать у сокета о его конечных устройствах с обеих сторон соединения:

boost::asio::ip::tcp::endpoint endpoint;
endpoint = socket.local_endpoint(); // IP:порт локальной стороны соединения
endpoint = socket.remote_endpoint(); // IP:порт удаленной стороны соединения

Обратите внимание, что эти функции могут выбросить исключение. Если вы не хотите возиться с исключениями, вы можете использовать перегрузку, чтобы получить ошибку по ссылке:

boost::system:error_code error;
auto endpoint = socket.remote_endpoint(error);

endpoint можно использовать с iostreams:

boost::system:error_code error;
auto endpoint = socket.remote_endpoint(error);
std::cout << "Remote endpoint: " << endpoint << "\n";

Если запустить этот код, вы скорее всего увидите что-то на подобии этого:

  Remote endpoint: 127.0.0.1:38529

Иногда вам может понадобиться отменить асинхронную операцию, которая была запланирована ранее. Единственный надежный и переносимый способ сделать это — закрыть связанный с операцией сокет.

boost::asio::async_read(socket, buffer, completion_handler);
// ...
socket.close();

Обратите внимание, что socket::close может выбросить исключение. Как всегда, существует перегрузка для получения ошибки по ссылке:

boost::system::error_code error;
socket.close(error);

Также существует метод socket::cancel, который позволяет отменить выполняемую в данный момент асинхронную операцию без закрытия сокета. Однако это поведение специфично для конкретной платформы. Метод может работать так, как вы этого ожидание, а может быть и проигнорирован ОС. Такая особенность почти наверняка говорит о том, что система имеет плохой дизайн. Старайтесь избегать этой операции.

Когда вы отправляете данные по сети, вы всегда знаете точно, сколько байт должно быть передано. Когда вы получите данные,вы также можете ожидать получения некоторого фиксированного количества байтов. Однако в некоторых случаях вам нужно считывать данные до тех пор, пока не выполнится какое-то условие. Например, до тех пор, пока не встретится определенная последовательность (например, символ "\n"). В этом случае использование boost::asio::streambuf может быть более удобным, чем использование буфера с фиксированным размером. При использовании этого контейнера вы должны установить верхнюю границу размера streambuf, передав максимальный размер в конструктор:

boost::asio::streambuf streambuf(65536);

В противном случае, размер буфера может расти до тех пор, пока не закончится память. После того, как вы обработали часть полученных данных, необходимо стереть эти данные из streambuf, чтобы размер буфера увеличивался. Это можно сделать с помощью метода consume():

boost::asio::async_read_until(
    socket,
    streambuf,
    "\n",
    [&](boost::system::error_code error, std::size_t bytes_transferred) {
        // Обработка полученных данных
        // ...
        streambuf.consume(bytes_transferred);
    });

Библиотека самостоятельно не ставит в очередь асинхронные операции. Чтобы запланировать новую задачу, вам необходимо дождаться завершения текущей задачи. Это значит, что вы должны управлять очередью задач самостоятельно. Конечно, это проблема, если мы говорим об одном и том же типе операций: несколько чтений или несколько записей. Однако операции async_read и async_write могут быть запланированы параллельно без каких-либо проблем.

Временем жизни объекта сессии можно управлять разными способами. Это зависит от логики сервера. Иногда достаточно захватить указатель на разделяемый объект в обработчик завершения. Таким образом мы можем продлить время жизни сессии до тех пор, пока не завершится текущая асинхронная операция.

Однако иногда серверу нужно знать обо всех активных клиентах. Например, чтобы иметь возможность получить доступ к каждому клиенту. Этого можно добиться, поместив указатели на клиентов в некоторый специальный контейнер.

Иногда объект сессии должен оставаться в памяти тогда, когда отсутствуют запланированные задачи, т. е. нет обработчиков завершения, которые могли бы хранить указатель на разделяемый объект. Этого можно добиться, если хранить указатель на разделяемый объект сессии где-нибудь в другом месте (например, в контейнере сервера). Также этого можно достичь использованием сырых указателей. Работа с сырыми указателями может показаться странной — в конце концов, мы говорим о C++. Однако в некоторых особых случаях этот способ очень хорош для управления асинхронным взаимодействием. Мы обсудим метод с сырыми указателями позднее.

Обычно сервер знает о классе сессии, с которым он работает. Однако классу сессии также может понадобиться передать некоторую информацию серверу. Это приводит нас к необходимости цикличной видимости. Для достижения этого мы могли бы использовать предварительное объявление (forward declaration) класса сервера. После чего мы бы передавали ссылку на сервер в конструктор сессии. Однако это не очень хороший дизайн. Более хороший способ решить эту проблему — использовать функции обработчиков событий:

using message_handler = std::function<void(std::string)>;

// На стороне сервера
void server::create_session() {
    auto client = std::make_shared<session>([&](const std::string& message) {
        std::cout << "We got a message: " << message;
    });
}

// На стороне клиента
void session::session(message_handler&& handler) :
    on_message(std::move(handler)) {}

void session::async_receive() {
    boost::asio::async_receive(socket, [...](...) { on_message(some_buffer); });
}

Более хороший не означает, что лучший. Есть несколько способов передавать данные между сессиями и сервером. Какой из них является лучшим — зависит от деталей реализации вашего приложения.

Отлично, теперь вы знаете все, что нужно знать, чтобы рассмотреть следующий пример — простой TCP чат-сервер.

TCP чат-сервер

Вы уже знаете какие вещи откуда берутся, поэтому отныне мы будем использовать псевдонимы типов, чтобы сделать названия типов короче.

В этом разделе мы рассмотрим очень простой чат-сервер. Этот сервер не будет поддерживать пользовательские ники, цвета и другие аспекты, специфичные для конкретного пользователя. Мы отказываемся от этого, чтобы сервер был более простым.

В предыдущем разделе мы подробно обсудили новые вещи, которые будут использоваться в этом сервере. Поэтому в этом разделе мы рассмотрим сервер лишь вкратце.

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

Ну что ж, начнем. Первое, что вы увидите в исходном коде — это включение заголовков и использование псевдонимов типов:

#include <boost/asio.hpp>

#include <optional>
#include <queue>
#include <unordered_set>

namespace io = boost::asio;
using tcp = io::ip::tcp;
using error_code = boost::system::error_code;
using namespace std::placeholders;

using message_handler = std::function<void(std::string)>;
using error_handler = std::function<void()>;

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

int main() {
    io::io_context io_context;
    server srv(io_context, 15001);
    srv.async_accept();
    io_context.run();
    return 0;
}

В этот раз класс сервера и класс сессии немного больше, но поскольку мы уже разобрали часть кода, рассмотрим только ключевые моменты.

Давайте начнем с ключевых моментов сессии. Функция start теперь принимает обработчики событий:

void start(message_handler&& on_message, error_handler&& on_error) {
    this->on_message = std::move(on_message);
    this->on_error = std::move(on_error);
    async_read();
}

Функция post добавляет в очередь сообщение, адресованное клиенту. Отправка сообщения начинается, если в данный момент не отправляется предыдущее сообщение:

void post(const std::string& message) {
    bool idle = outgoing.empty();
    outgoing.push(message);

    if (idle) {
        async_write();
    }
}

Асинхронные функции чтения и записи выделены в отдельные методы класса сессии. Функция async_read считывает данные с удаленного клиента в streambuf, а функция async_write отправляет первое в очереди сообщение удаленному клиенту:

void async_read() {
    io::async_read_until(
        socket,
        streambuf,
        "\n",
        std::bind(&session::on_read, shared_from_this(), _1, _2));
}

void async_write() {
    io::async_write(
        socket,
        io::buffer(outgoing.front()),
        std::bind(&session::on_write, shared_from_this(), _1, _2));
}

Обработчик чтения выполняет следующие действия:

  1. форматирует сообщение, полученное от клиента;
  2. передает отформатированное сообщение в обработчик сообщений;
  3. начинает ожидать следующего сообщения.

Кроме того, он также выполняет обработку ошибок:

void on_read(error_code error, std::size_t bytes_transferred) {
    if (!error) {
        std::stringstream message;
        message << socket.remote_endpoint(error) << ": "
                << std::istream(&streambuf).rdbuf();
        streambuf.consume(bytes_transferred);
        on_message(message.str());
        async_read();
    } else {
        socket.close(error);
        on_error();
    }
}

Обработчик записи работает так:

  1. удаляет сообщение из очереди;
  2. если в очереди еще остались сообщения, начинает отправку следующего сообщения.

Он также выполняет обработку ошибок:

void on_write(error_code error, std::size_t bytes_transferred) {
    if (!error) {
        outgoing.pop();

        if (!outgoing.empty()) {
            async_write();
        }
    } else {
        socket.close(error);
        on_error();
    }
}

У класса сессии следующие атрибуты:

tcp::socket socket;               // Сокет клиента
io::streambuf streambuf;          // Буфер для входящих данных
std::queue<std::string> outgoing; // Очередь исходящих сообщений
message_handler on_message;       // Обработчик сообщений
error_handler on_error;           // Обработчик ошибок

Теперь давайте рассмотрим класс сервера. Начнем с атрибутов:

io::io_context& io_context;
tcp::acceptor acceptor;
std::optional<tcp::socket> socket;
std::unordered_set<session::pointer> clients;  // Список подключенных клиентов

Функция post рассылает сообщение всем подключенным клиентам. Эта функция также используется в качестве обработчика сообщений (см. далее):

void post(const std::string& message) {
    for (auto& client : clients) {
        client->post(message);
    }
}

Функция async_accept приветствует только что подключившегося клиента и сообщает всем остальным клиентам о новоприбывшем. Здесь также реализована обработка ошибок, которая в случае чего удаляет сессию из списка клиентов, после чего уведомляет об этом остальных клиентов:

void async_accept() {
    socket.emplace(io_context);

    acceptor.async_accept(*socket, [&](error_code error) {
        auto client = std::make_shared<session>(std::move(*socket));
        client->post("Welcome to chat\n\r");
        post("We have a newcomer\n\r");

        clients.insert(client);

        client->start(
            std::bind(&server::post, this, _1),
            [&, weak = std::weak_ptr(client)] {
                if (auto shared = weak.lock();
                    shared && clients.erase(shared)) {
                    post("We are one less\n\r");
                }
            });

        async_accept();
    });
}

Теперь давайте запустим наш сервер:

./server

Также запустим клиент telnet:

telnet localhost 15001

Welcome to chat

Запустив второй клиент telnet, на первом клиенте вы увидите:

telnet localhost 15001

Welcome to chat
We have a newcomer

Запустите еще один клиент, напишите что-нибудь в чат и нажмите Enter:

telnet localhost 15001

Welcome to chat
Hello guys

Остальные клиенты должны увидеть что-то вроде этого:

telnet localhost 15001

Welcome to chat
We have a newcomer
We have a newcomer
127.0.0.1:47235: Hello guys

Упрощаем код

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

Вы могли бы заметить: «Но C++ — это не Python и не Erlang. Разве C++ не является низкоуровневым языком программирования?». Ответ: и да, и нет. C++ очень гибкий язык, который позволяет работать и на низком, и на высоком уровне. Однако такая свобода возлагает на программиста большую ответственность: нужно быть аккуратным, чтобы код не превратился в непонятную кашу.

Вы конечно можете работать с сырой памятью и сырыми указателями. Вы можете париться по поводу порядка определенных байтов. Ваш код может генерировать неустранимые ошибки, которые приведут к падению вашего приложения. И еще тысяча особенностей, с которыми вы не столкнетесь, если бы вы будете использовать Python. Однако в C++ вы можете спроектировать свой код таким образом, чтобы каждый слой абстракции имел узкий набор обязанностей. Тем самым, вы можете сделать свой код таким же высокоуровневым, как Python или Erlang.

Обобщая вышесказанное, C++ — это язык программирования, используя который вы должны делить код на определенные слои абстракции, причем делать это нужно очень осторожно.

Boost.Asio — это библиотека, которая предоставляет вам низкоуровневую функциональность. В настоящем приложении вам не следует напрямую использовать Boost.Asio, равно как и использовать мьютексы или функцию fopen. Boost.Beast — это библиотека, основанная на Boost.Asio, которая предоставит вам всю необходимую функциональность, связанную с HTTP и Web-сокетами. Однако даже Boost.Beast вам не следует использовать напрямую в вашем приложении. По словами Vinnie Falco (автор Boost.Beast), библиотека Boost.Beast — это не готовый для использования сервер или клиент. Это набор инструментов, который вы должны использовать, чтобы создавать свои собственные библиотеки. Причем ваше приложение должно основываться на этих библиотеках, основанных на Boost.Beast, который, в свою очередь, основан на Boost.Asio.

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

#include <chat/server.hpp>

using message_type = std::string;
using session_type = chat::session<message_type>;
using server_type = chat::server<session_type>;

class server {
  public:
    server(io::io_context& io_context, std::uint16_t port) :
        srv(io_context, port) {
        srv.on_join([&](session_type& client) {
            client.post("Welcome to chat");
            srv.broadcast("We have a newcomer");
        });

        srv.on_leave([&] { srv.broadcast("We are one less"); });

        srv.on_message(
            [&](message_type const& message) { srv.broadcast(message); });
    }

    void start() {
        srv.start();
    }

  private:
    server_type srv;
};

int main() {
    io::io_context io_context;
    server srv(io_context, 15001);
    srv.start();
    io_context.run();
    return 0;
}