librats: новая библиотека для распределённых P2P-приложений

от автора

Всем привет! Я являюсь создателем распределённого поисковика rats-search на базе DHT (GitHub). Его принцип работы довольно прост: поисковик собирает торренты у всех участников сети и формирует большую распределённую базу для поиска, включая метаданные (например, описания и прочую информацию).

В этой статье я хочу рассказать о своей новой библиотеке для построения распределённых приложений (p2p), где знание IP-адресов участников не обязательно, а поиск ведётся через различные протоколы — DHT, mDNS, peer-exchange и другие. Думаю, с учётом постоянных неприятностей, которые происходят вокруг, это может оказаться полезным ;).

Немного истории

rats-search — проект довольно старый, я написал его много лет назад на Electron. Сейчас появилась необходимость переписать p2p-составляющую на C++ для улучшения качества связи и производительности.

Для начала я ради интереса попробовал перенести всё на libp2p и был неприятно удивлён его производительностью. Буквально при ~100 пирах процессор (Ryzen 5700) был загружен на 100% в момент шагов по DHT сети, а потребление памяти достигало 500–600 МБ. Конечно, я понимаю, что libp2p использует модные корутинные библиотеки типо it- и тянет за собой 300–400 МБ зависимостей в node_modules, но это же не дело.

Да, это JS, и сравнивать его напрямую с C++ некорректно, но даже JS-приложения обычно не дают таких плохих результатов. Более того, libp2p поддерживает не все языки: какие-то реализации урезаны, какие-то отсутствуют вовсе. Например, в версии для C++ протокол mDNS просто не реализован. В итоге эталонными считаются только реализации для Go и JS, и они заметно отличаются друг от друга.

Реализации на всех языках у libp2p это полный разнобой, повезло больше всего go и javascript

Реализации на всех языках у libp2p это полный разнобой, повезло больше всего go и javascript

Почему я решил написать свою библиотеку

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

Так появилась librats — новая библиотека на C++, которая станет будущим ядром Rats, а также может применяться и в любых других распределённых приложениях.

Что уже реализовано

  • Поиск участников через DHT

  • Коммуникация между участниками

  • Обмен данными / файлами / директориями

  • Протокол mDNS для поиска участников в локальных сетях

  • Historical peers — база данных участников для повторных подключений

  • Протокол gossipsub — для построения обмена сообщениями в больших распределённых сетях

  • Обмен пирами (peer exchange)

  • Поддержка пользовательских (кастомных) протоколов

  • ICE / STUN протоколы

  • Шифрование на основе протокола noise (Curve25519 + ChaCha20-Poly1305)

Поддерживаемые платформы и языки

  • Сборка под Windows, macOS, Linux, Android

  • Компиляторы: GCC, Clang, MSVC

  • Использование: напрямую в C++, а также в C и Java через JNI; в Android — через NDK

Примеры

Теперь давайте рассмотрим технические примеры и решения некоторых задач.

Базовый пример: создание клиента

#include "librats.h" #include <iostream> #include <thread> #include <chrono>  int main() {     // Создаём простой P2P клиент     librats::RatsClient client(8080);          // Настройка колбэка при подключении     client.set_connection_callback([](socket_t socket, const std::string& peer_id) {         std::cout << "✅ Новый пир подключился: " << peer_id << std::endl;     });          // Настройка колбэка для сообщений     client.set_string_data_callback([](socket_t socket, const std::string& peer_id, const std::string& message) {         std::cout << "💬 Сообщение от " << peer_id << ": " << message << std::endl;     });          // Запускаем клиент     if (!client.start()) {         std::cerr << "Не удалось запустить клиент" << std::endl;         return 1;     }          std::cout << "🐀 librats клиент запущен на порту 8080" << std::endl;          // Подключение к другому пиру (необязательно)     // client.connect_to_peer("127.0.0.1", 8081);          // Отправляем сообщение всем подключенным пирам     client.broadcast_string_to_peers("Hello from librats!");          // Оставляем программу работать     std::this_thread::sleep_for(std::chrono::minutes(1));          return 0; }

В основе работы лежит передача трёх типов данных:

  • бинарных,

  • текстовых,

  • JSON-данных.

Можно выбирать слушателей и настраивать тип передачи данных в зависимости от задачи.

Определение собственного протокола

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

#include "librats.h" #include <iostream>  int main() {     librats::RatsClient client(8080);          // Настройка пользовательского протокола для вашего приложения     client.set_protocol_name("my_app");     client.set_protocol_version("1.0");          std::cout << "Protocol: " << client.get_protocol_name()                << " v" << client.get_protocol_version() << std::endl;     std::cout << "Discovery hash: " << client.get_discovery_hash() << std::endl;          client.start();          // Запуск DHT-обнаружения с пользовательским протоколом     if (client.start_dht_discovery()) {         // Объявляем о своём присутствии         client.announce_for_hash(client.get_discovery_hash());                  // Поиск других пиров, использующих тот же протокол         client.find_peers_by_hash(client.get_discovery_hash(),              [](const std::vector<std::string>& peers) {                 std::cout << "Found " << peers.size() << " peers" << std::endl;             });     }          return 0; }

Построение простого чата

Довольно типичная задача — организация чата. Для начала можно использовать простые функции, без применения mesh-сети.

#include "librats.h" #include <iostream> #include <string>  int main() {     librats::RatsClient client(8080);          // Настройка обработчиков сообщений с использованием современного API     client.on("chat", [](const std::string& peer_id, const nlohmann::json& data) {         std::cout << "[CHAT] " << peer_id << ": " << data["message"].get<std::string>() << std::endl;     });          client.on("user_join", [](const std::string& peer_id, const nlohmann::json& data) {         std::cout << "[JOIN] " << data["username"].get<std::string>() << " присоединился" << std::endl;     });          // Колбэк при подключении     client.set_connection_callback([&](socket_t socket, const std::string& peer_id) {         std::cout << "✅ Пир подключился: " << peer_id << std::endl;                  // Отправляем приветственное сообщение         nlohmann::json welcome;         welcome["username"] = "User_" + client.get_our_peer_id().substr(0, 8);         client.send("user_join", welcome);     });          client.start();          // Отправляем чат-сообщение     nlohmann::json chat_msg;     chat_msg["message"] = "Hello, P2P chat!";     chat_msg["timestamp"] = std::time(nullptr);     client.send("chat", chat_msg);          return 0; }

Работа с mesh-сетями

Если требуется построить более крупную сеть (например, для обмена сообщениями между большим количеством узлов), можно использовать протокол gossipsub.

#include "librats.h" #include <iostream>  int main() {     librats::RatsClient client(8080);          // Настройка обработчиков сообщений для топиков     client.on_topic_message("news", [](const std::string& peer_id, const std::string& topic, const std::string& message) {         std::cout << "📰 [" << topic << "] " << peer_id << ": " << message << std::endl;     });          client.on_topic_json_message("events", [](const std::string& peer_id, const std::string& topic, const nlohmann::json& data) {         std::cout << "🎉 [" << topic << "] Событие: " << data["type"].get<std::string>() << std::endl;     });          // Уведомления о присоединении/выходе пиров     client.on_topic_peer_joined("news", [](const std::string& peer_id, const std::string& topic) {         std::cout << "➕ " << peer_id << " присоединился к " << topic << std::endl;     });          client.start();     client.start_dht_discovery();          // Подписка на топики     client.subscribe_to_topic("news");     client.subscribe_to_topic("events");          // Публикация сообщений     client.publish_to_topic("news", "Breaking: librats is awesome!");          nlohmann::json event;     event["type"] = "celebration";     event["reason"] = "successful_connection";     client.publish_json_to_topic("events", event);          std::cout << "📊 Пиров в 'news': " << client.get_topic_peers("news").size() << std::endl;          return 0; }

Обмен данными

Библиотека поддерживает обмен данными, включая файлы и целые директории.

#include "librats.h" #include <iostream>  int main() {     librats::RatsClient client(8080);          // Настройка колбэков передачи файлов     client.on_file_transfer_progress([](const librats::FileTransferProgress& progress) {         std::cout << "📁 Передача " << progress.transfer_id.substr(0, 8)                    << ": " << progress.get_completion_percentage() << "% завершено"                   << " (" << (progress.transfer_rate_bps / 1024) << " KB/s)" << std::endl;     });          client.on_file_transfer_completed([](const std::string& transfer_id, bool success, const std::string& error) {         if (success) {             std::cout << "✅ Передача завершена: " << transfer_id.substr(0, 8) << std::endl;         } else {             std::cout << "❌ Ошибка передачи: " << error << std::endl;         }     });          // Автоматическое принятие входящих файловых передач     client.on_file_transfer_request([](const std::string& peer_id,                                        const librats::FileMetadata& metadata,                                        const std::string& transfer_id) {         std::cout << "📥 Входящий файл: " << metadata.filename                    << " (" << metadata.file_size << " байт) от " << peer_id.substr(0, 8) << std::endl;         return true; // Автопринятие     });          // Разрешаем запросы файлов из директории "shared"     client.on_file_request([](const std::string& peer_id, const std::string& file_path, const std::string& transfer_id) {         std::cout << "📤 Запрос: " << file_path << " от " << peer_id.substr(0, 8) << std::endl;         return file_path.find("../") == std::string::npos; // Защита от выхода за пределы пути     });          client.start();          // Настройка параметров передачи     librats::FileTransferConfig config;     config.chunk_size = 64 * 1024;       // чанки по 64KB     config.max_concurrent_chunks = 4;    // 4 параллельных чанка     config.verify_checksums = true;      // проверка целостности     client.set_file_transfer_config(config);          // Примеры передач (замените "peer_id" на реальный ID пира)     // std::string file_transfer = client.send_file("peer_id", "my_file.txt");     // std::string dir_transfer = client.send_directory("peer_id", "./my_folder");     // std::string file_request = client.request_file("peer_id", "remote_file.txt", "./downloaded_file.txt");          std::cout << "Файловая передача готова. Подключите пиров и обменивайтесь файлами!" << std::endl;          return 0; }

Настройка конфигурации

Например, можно задать конфигурацию для логирования:

#include "librats.h" #include <iostream>  int main() {     librats::RatsClient client(8080);          // Включение и настройка логирования     client.set_logging_enabled(true);     client.set_log_file_path("librats_app.log");     client.set_log_level("INFO");  // DEBUG, INFO, WARN, ERROR     client.set_log_colors_enabled(true);     client.set_log_timestamps_enabled(true);          // Настройка ротации лог-файлов     client.set_log_rotation_size(5 * 1024 * 1024);  // максимальный размер файла 5MB     client.set_log_retention_count(3);               // хранить 3 старых лог-файла          std::cout << "📝 Логирование в: " << client.get_log_file_path() << std::endl;     std::cout << "📊 Уровень логирования: " << static_cast<int>(client.get_log_level()) << std::endl;     std::cout << "🎨 Цветное логирование: " << (client.is_log_colors_enabled() ? "Да" : "Нет") << std::endl;          client.start();          // Все операции librats теперь будут логироваться     client.broadcast_string_to_peers("Это действие будет зафиксировано в логах!");          // Очистка лог-файла при необходимости (раскомментируйте для использования)     // client.clear_log_file();          return 0; }

И можно поработать с конфигурацией сохранения данных:

#include "librats.h" #include <iostream>  int main() {     librats::RatsClient client(8080);          // Set custom data directory for config files     client.set_data_directory("./my_app_data");          // Load saved configuration (if exists)     if (client.load_configuration()) {         std::cout << "📄 Loaded existing configuration" << std::endl;     } else {         std::cout << "📄 Using default configuration" << std::endl;     }          // Get our persistent peer ID     std::cout << "🆔 Our peer ID: " << client.get_our_peer_id() << std::endl;          client.start();          // Try to reconnect to previously connected peers     int reconnect_attempts = client.load_and_reconnect_peers();     std::cout << "🔄 Attempted to reconnect to " << reconnect_attempts << " previous peers" << std::endl;          // Configuration is automatically saved when client stops     // Files created: config.json, peers.rats, peers_ever.rats          // Manual save if needed     client.save_configuration();     client.save_historical_peers();          std::cout << "💾 Configuration will be saved to: " << client.get_data_directory() << std::endl;          return 0; }

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

Представьте ситуацию: «птица залетела» в сервера местного законопослушного провайдера и часть маршрутов или соединений внезапно оказалась заблокирована. В таком случае наличие базы известных пиров позволяет быстро восстановить работу сети без необходимости заново искать всех участников.

Ну и напоследок, раз заявлены другие языки, пример на Java на Android. Я решил привести пример Activity целиком, фокусируем внимание на setupRatsClient():

public class MainActivity extends AppCompatActivity {     private static final String TAG = "LibRatsExample";     private static final int PERMISSION_REQUEST_CODE = 1;          private RatsClient ratsClient;     private TextView statusText;     private TextView messagesText;     private EditText hostInput;     private EditText portInput;     private EditText messageInput;     private Button startButton;     private Button connectButton;     private Button sendButton;          @Override     protected void onCreate(Bundle savedInstanceState) {         super.onCreate(savedInstanceState);         setContentView(R.layout.activity_main);                  initViews();         checkPermissions();         setupRatsClient();     }          private void initViews() {         statusText = findViewById(R.id.statusText);         messagesText = findViewById(R.id.messagesText);         hostInput = findViewById(R.id.hostInput);         portInput = findViewById(R.id.portInput);         messageInput = findViewById(R.id.messageInput);         startButton = findViewById(R.id.startButton);         connectButton = findViewById(R.id.connectButton);         sendButton = findViewById(R.id.sendButton);                  startButton.setOnClickListener(this::onStartClicked);         connectButton.setOnClickListener(this::onConnectClicked);         sendButton.setOnClickListener(this::onSendClicked);                  // Устанавливаем значения по умолчанию         hostInput.setText("192.168.1.100");         portInput.setText("8080");         messageInput.setText("Hello from Android!");                  updateUI();     }          private void checkPermissions() {         String[] permissions = {             Manifest.permission.INTERNET,             Manifest.permission.ACCESS_NETWORK_STATE,             Manifest.permission.ACCESS_WIFI_STATE,             Manifest.permission.CHANGE_WIFI_MULTICAST_STATE         };                  boolean allGranted = true;         for (String permission : permissions) {             if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {                 allGranted = false;                 break;             }         }                  if (!allGranted) {             ActivityCompat.requestPermissions(this, permissions, PERMISSION_REQUEST_CODE);         }     }          private void setupRatsClient() {         try {             // Включаем логирование             RatsClient.setLoggingEnabled(true);             RatsClient.setLogLevel("INFO");                          // Создаём клиент на порту 8080             ratsClient = new RatsClient(8080);                          // Настраиваем колбэки             ratsClient.setConnectionCallback(new ConnectionCallback() {                 @Override                 public void onConnection(String peerId) {                     runOnUiThread(() -> {                         appendMessage("Подключились к пиру: " + peerId);                         updateUI();                     });                 }             });                          ratsClient.setStringCallback(new StringMessageCallback() {                 @Override                 public void onStringMessage(String peerId, String message) {                     runOnUiThread(() -> {                         appendMessage("Сообщение от " + peerId + ": " + message);                     });                 }             });                          ratsClient.setDisconnectCallback(new DisconnectCallback() {                 @Override                 public void onDisconnect(String peerId) {                     runOnUiThread(() -> {                         appendMessage("Отключились от пира: " + peerId);                         updateUI();                     });                 }             });                          appendMessage("Клиент LibRats успешно создан");             appendMessage("Версия: " + RatsClient.getVersionString());                      } catch (Exception e) {             Log.e(TAG, "Не удалось создать RatsClient", e);             appendMessage("Ошибка: " + e.getMessage());         }     }          private void onStartClicked(View view) {         if (ratsClient == null) return;                  try {             int result = ratsClient.start();             if (result == RatsClient.SUCCESS) {                 appendMessage("Клиент успешно запущен");                 appendMessage("Наш Peer ID: " + ratsClient.getOurPeerId());                 updateUI();             } else {                 appendMessage("Не удалось запустить клиент: " + result);             }         } catch (Exception e) {             Log.e(TAG, "Ошибка при запуске клиента", e);             appendMessage("Ошибка при запуске клиента: " + e.getMessage());         }     }          private void onConnectClicked(View view) {         if (ratsClient == null) return;                  String host = hostInput.getText().toString().trim();         String portStr = portInput.getText().toString().trim();                  if (host.isEmpty() || portStr.isEmpty()) {             Toast.makeText(this, "Введите хост и порт", Toast.LENGTH_SHORT).show();             return;         }                  try {             int port = Integer.parseInt(portStr);             int result = ratsClient.connectWithStrategy(host, port, RatsClient.STRATEGY_AUTO_ADAPTIVE);                          if (result == RatsClient.SUCCESS) {                 appendMessage("Подключение к " + host + ":" + port);             } else {                 appendMessage("Не удалось подключиться: " + result);             }         } catch (NumberFormatException e) {             Toast.makeText(this, "Неверный номер порта", Toast.LENGTH_SHORT).show();         } catch (Exception e) {             Log.e(TAG, "Ошибка подключения", e);             appendMessage("Ошибка подключения: " + e.getMessage());         }     }          private void onSendClicked(View view) {         if (ratsClient == null) return;                  String message = messageInput.getText().toString().trim();         if (message.isEmpty()) {             Toast.makeText(this, "Введите сообщение", Toast.LENGTH_SHORT).show();             return;         }                  try {             // Получаем подключённых пиров             String[] peerIds = ratsClient.getPeerIds();             if (peerIds.length == 0) {                 Toast.makeText(this, "Нет подключённых пиров", Toast.LENGTH_SHORT).show();                 return;             }                          // Отправляем первому подключённому пиру             int result = ratsClient.sendString(peerIds[0], message);             if (result == RatsClient.SUCCESS) {                 appendMessage("Отправлено: " + message);                 messageInput.setText("");             } else {                 appendMessage("Не удалось отправить сообщение: " + result);             }         } catch (Exception e) {             Log.e(TAG, "Ошибка при отправке сообщения", e);             appendMessage("Ошибка при отправке сообщения: " + e.getMessage());         }     }          private void appendMessage(String message) {         Log.d(TAG, message);         messagesText.append(message + "\n");                  // Прокрутка вниз         messagesText.post(() -> {             int scrollAmount = messagesText.getLayout().getLineTop(messagesText.getLineCount())                                - messagesText.getHeight();             if (scrollAmount > 0) {                 messagesText.scrollTo(0, scrollAmount);             } else {                 messagesText.scrollTo(0, 0);             }         });     }          private void updateUI() {         if (ratsClient == null) {             statusText.setText("Статус: Не инициализирован");             startButton.setEnabled(false);             connectButton.setEnabled(false);             sendButton.setEnabled(false);             return;         }                  try {             int peerCount = ratsClient.getPeerCount();             statusText.setText("Статус: " + peerCount + " пиров подключено");                          // Включение/отключение кнопок в зависимости от состояния             startButton.setEnabled(true);             connectButton.setEnabled(true);             sendButton.setEnabled(peerCount > 0);                      } catch (Exception e) {             statusText.setText("Статус: Ошибка");             Log.e(TAG, "Ошибка обновления UI", e);         }     }          @Override     protected void onDestroy() {         super.onDestroy();         if (ratsClient != null) {             try {                 ratsClient.stop();                 ratsClient.destroy();             } catch (Exception e) {                 Log.e(TAG, "Ошибка при уничтожении клиента", e);             }         }     }          @Override     public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {         super.onRequestPermissionsResult(requestCode, permissions, grantResults);         if (requestCode == PERMISSION_REQUEST_CODE) {             boolean allGranted = true;             for (int result : grantResults) {                 if (result != PackageManager.PERMISSION_GRANTED) {                     allGranted = false;                     break;                 }             }                          if (!allGranted) {                 Toast.makeText(this, "Для LibRats необходимы сетевые разрешения", Toast.LENGTH_LONG).show();             }         }     } }

Использование из C в виде библиотеки

На C доступны основные функции внутри библиотеки, их можно посмотреть:

https://github.com/DEgITx/librats/blob/master/src/librats_c.h — весь список, аналог вызовов из C++

пример использования когда на C:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h>  #include "librats_c.h"  // Глобальный дескриптор клиента static rats_client_t client = NULL; static volatile int running = 1;  // Функции-колбэки void on_peer_connected(void* user_data, const char* peer_id) {     printf("Пир подключился: %s\n", peer_id); }  void on_peer_disconnected(void* user_data, const char* peer_id) {     printf("Пир отключился: %s\n", peer_id); }  void on_string_message(void* user_data, const char* peer_id, const char* message) {     printf("Сообщение от %s: %s\n", peer_id, message); }  void signal_handler(int sig) {     printf("Завершение работы...\n");     running = 0; }  int main(int argc, char* argv[]) {     printf("Пример\n");     printf("========================\n\n");          // Установка обработчика сигнала     signal(SIGINT, signal_handler);          // Создаём клиента на порту 8080     client = rats_create(8080);     if (!client) {         fprintf(stderr, "Не удалось создать клиента!\n");         return 1;     }          // Настраиваем колбэки     rats_set_connection_callback(client, on_peer_connected, NULL);     rats_set_disconnect_callback(client, on_peer_disconnected, NULL);     rats_set_string_callback(client, on_string_message, NULL);          // Запускаем клиента     if (rats_start(client) != RATS_SUCCESS) {         fprintf(stderr, "Не удалось запустить клиента!\n");         rats_destroy(client);         return 1;     }          // Выводим наш Peer ID     char* peer_id = rats_get_our_peer_id(client);     if (peer_id) {         printf("Наш Peer ID: %s\n", peer_id);         rats_string_free(peer_id);     }          printf("Клиент запущен на порту 8080\n");     printf("Нажмите Ctrl+C для остановки\n\n");          // Если переданы аргументы командной строки — пробуем подключиться     if (argc >= 3) {         const char* host = argv[1];         int port = atoi(argv[2]);         printf("Подключение к %s:%d...\n", host, port);         rats_connect(client, host, port);     }          // Главный цикл     int counter = 0;     while (running) {         sleep(1);         counter++;                  // Каждые 5 секунд отправляем сообщение         if (counter % 5 == 0) {             int peer_count = rats_get_peer_count(client);             if (peer_count > 0) {                 char message[100];                 snprintf(message, sizeof(message), "Здарова: %d", counter / 5);                                  int sent = rats_broadcast_string(client, message);                 printf("Широковещательная отправка %d пирам: %s\n", sent, message);             } else {                 printf("Нет подключённых пиров. Запустите другой экземпляр командой:\n");                 printf("  %s localhost 8080\n", argv[0]);             }         }     }          // Очистка     printf("Остановка клиента...\n");     rats_stop(client);     rats_destroy(client);          printf("Баюшки!\n");     return 0; }

Заключение

Ну и напоследок — о производительности. Помните те 500–600 МБ памяти при работе p2p через libp2p? Давайте посмотрим, что получается с librats:

…не правда ли, результат явно лучше?

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


ссылка на оригинал статьи https://habr.com/ru/articles/943652/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *