Пишемо Policy server на C++ для Unity3d



Навіщо потрібен policy server?
Unity, починаючи з версії 3.0, для збірок під Web player використовуються механізми забезпечення безпеки, схожі на ті, що використовує Adobe Flash player. Суть його полягає в тому, що при зверненні до сервера клієнт запитує у нього «дозвіл», і якщо сервер не «дозволяє», то клієнт не буде пробувати до нього підключитися. Дані обмеження працюють для звернення до віддалених серверів через клас WWW з допомогою сокетів. Якщо ви хочете зробити запит по rest протоколу з вашого клієнта на віддалений сервер, необхідно, щоб в корені домену лежав спеціальний xml. Він повинен називатися crossdomain.xml і мати наступний формат:

<?xml version="1.0"?>
<cross-domain-policy>
<allow-access-domain from=".*"/>
</cross-domain-policy>

Клієнт перед запитом завантажить файл політики безпеки, перевірить його і, побачивши, що дозволені всі домени, продовжить виконувати зроблений вами запит.

Якщо вам потрібно підключитися до віддаленого сервера з допомогою сокетів (tcp/udp), перед підключенням клієнт зробить запит до сервера на 843 порт для отримання файлу політики безпеки, в якому буде описано до яких портів і з яких доменів можна підключатися:

<?xml version="1.0"?>
<cross-domain-policy>
<allow-access-domain from=".*" to-ports="1200-1220"/> 
</cross-domain-policy>"

Якщо дані клієнта не задовольняють всім параметрам (домен, порт), то клієнт згенерує виняток SecurityException і не буде намагатися підключитися до сервера.

У даній статті мова піде про написання сервера, який буде віддавати файли політики безпеки, надалі я буду називати його Policy server.

Як повинен працювати Policy server?
Схема роботи сервера проста:

  1. Сервер запускається і слухає 843 порт tcp протоколу. Є можливість перевизначити порт Security.PrefetchSocketPolicy()
  2. Клієнт підключається до сервера по протоколу tcp і відправляє xml c запитом файлу політики безпеки:

    <policy-file-request/>
    
  3. Сервер обробляє запит і відправляє клієнту xml з політикою безпеки
На практиці процес розбору запиту не має ніякого сенсу. Значення має час, який клієнт очікує до отримання файлу політики безпеки, так як воно збільшує затримку до підключення до цільового порту. Ми можемо модифицровать процес роботи сервера і віддавати клієнту файл політики безпеки відразу ж після підключення.

Що вже є?
На поточний момент є сервер, написаний на зв'язці Java + Netty, вихідний код з інструкцією і jar. Одним з ключових його недоліків є залежність від jre. В цілому, розгорнути jre на сервері linux не проблема, але часто розробники ігор — це клієнтські програмісти, які хочуть робити якомога менше рухів, тим більше вони не хочуть встановлювати jre і пізніше його адмініструвати. Тому було прийнято рішення написати Policy server на C++, який працював би як нативне програму на linux машині.

Написаний на C++ Policy server не повинен поступатися по продуктивності старому, в ідеалі повинен показувати результат набагато краще. Ключовими метриками продуктивності будуть: час, який клієнт витрачає на очікування файлу політики безпеки, і кількість клієнтів, які можуть одночасно отримувати файли політики безпеки, що, по суті, теж зводиться до часу очікування файлу політики.

Для тестування я використав цей скрипт. Працює він таким чином:

  1. Обчислює середній пінг до сервера
  2. Запускає кілька потоків (кількість вказується в скрипті)
  3. У кожному потоці запитує у Policy server'mssql а файл політики безпеки
  4. Якщо файл політики відповідає тому, який очікується, то для кожного запиту зберігається час, витрачений на очікування
  5. — Виводить результати в консоль. Нас цікавлять наступні значення: мінімальний час очікування, максимальний час очікування, середній час очікування і ті ж параметри без пінгу
Скрипт написаний на ruby, але так як в стандартному інтерпретатор ruby відсутня підтримка потоків рівня операційної системи, для роботи я використав jruby. Найзручніше використовувати rvm, команда для запуску скрипта буде виглядати так:

rvm jruby do ruby test.rb

Результати тестування Policy server'mssql а, написаного на Java+Netty:
Середня, мс 245
Мінімальне, мс 116
Максимальне, мс 693
знадобиться?
По суті, завдання — написати на C++ демон, який міг би слухати кілька портів, при підключенні клієнтів створювати сокет, копіювати в сокет текстову інформацію і закривати його. Бажано мати як можна менше залежностей, а якщо вони все-таки будуть, то вони повинні бути в репозиторіях найбільш поширених linux дистрибутивів. Для написання коду будемо використовувати стандарт c++11. В якості мінімального набору бібліотек візьмемо:

  • libev для роботи з циклом подій програми
  • boost program_options для роботи з параметрами командного рядка
  • Easylogging++ для роботи з логами
Один порт — один потік
Структура програми досить проста: знадобиться функціонал для роботи з параметрами командного рядка, класи для роботи з потоками, функціонал для роботи з мережею, функціонал для роботи з логами. Це прості речі, з якими не повинно виникнути проблем, тому я не буду на них детально зупинятися. Код можна подивитися тут. Проблемним місцем є організація обробки клієнтських запитів. Найпростіше рішення — після підключення клієнтського сокета відправляти всі дані і відразу ж закривати сокет. Тобто код, що відповідає за обробку нового підключення буде виглядати так:

void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;

client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);
if(client_sd < 0)
return;

const char *data = this->server->get_text()->c_str();
send(client_sd, (void*)data, sizeof(char) * strlen(data), 0);
shutdown(client_sd, 2);
close(client_sd);
}

Під час спроби протестувати на великій кількості потоків (300, по 10 підключень на кожен), я не зміг дочекатися закінчення роботи тестовго скрипта. З чого можна зробити висновок, що дане рішенням нам не підходить.

Async
Операція передачі даних по мережі є витратним за часом, очевидно, що необходмио розділити процес створення клієнтського сокета і процес відправки даних. Також непогано було б віддавати дані декількома потоками. Непогане рішення — використовувати std::async, який з'явився в стандарті C++11 async. Код, відповідальний за обробку нового підключення буде виглядати так:

void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;

client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);

std::async(std::launch::async, [client_addr, this](int client_socket) {
const char * data = this->server->get_text()->c_str();
send(client_socket, (void*)data, sizeof(char) * strlen(data), 0);
shutdown(client_socket, 2);
close(client_socket);
}, client_sd);
}

Мінусом такого рішення є безконтрольность ресурсів. Мінімальним втручанням в код ми отримуємо можливість асинхронно виконувати відправку даних клієнта, при цьому ми не можемо контролювати процес породження нових потоків. Процес створення нового потоку є витратним для операційної системи, також велику кількість потоків може знизити продуктивність сервера.

Pub/Sub
Підходящим рішенням для даної задачі є патерн видавець-читач. Схема роботи сервера повинна виглядати наступним чином:
  • Кілька видавців, по одному на кожен порт, зберігають в буфер ідентифікатори сокетів клієнтів, яким треба віддати файл політики безпеки
  • Кілька передплатників отримують з буфера ідентифікатори сокетів, копіюють в них файл політики безпеки і закривають сокет.
В якості буфера підходить чергу, першим підключився до сервера — першим отримаєш файл політики. У стандартної бібліотеки C++ є готовий контейнер черги, але він нам не підійде, так як потрібно потокобезопасная чергу. При цьому нам необхідно, щоб операція додавання нового елемента була не блокує, в той час як операція читання була блокує. Тобто при старті сервера будуть запущені кілька передплатників, які будуть чекати поки черга порожня. Як тільки там з'являються дані, один або кілька обробників спрацьовують. Видавці ж асинхронно записують в дану чергу ідентифікатори сокетів.

Трохи погугливши, я знайшов кілька готових реалізацій:
  1. https://github.com/cameron314/concurrentqueue.
    В даному випадку нас цікавить blockingconcurrentqueue, яка просто копіюється в проект як заголовковий .h файл. Досить зручно, і немає ніяких залежностей. Дане рішення володіє наступними мінусами:
    • Немає методів для зупинки передплатників. Єдиний спосіб зупинити їх — додавати в чергу дані, які будуть сигналізувати передплатникам про те, що треба зупинити роботу. Це досить незручно і потенційно може викликати дедлок
    • Підтримується однією людиною, коміти останнім часом з'являються досить рідко
  2. tbb concurrent queue.
    Багатопотокова чергу з бібліотеки tbb (Threading Building Blocks). Бібліотека розробляється і підтримується Intel, при цьому має все що нам необхідно:
    • Блокуючу читання з черги
    • Неблокирующая запис у чергу
    • Можливість у будь-який момент зупинити заблоковані на очікуванні даних потоки
    Серед мінусів можна відзначити, що таке рішення збільшує кількість залежностей, тобто кінцевим користувачам доведеться встановлювати на свій сервер tbb. У найбільш рапспространенных linux-репозиторіях tbb можна встановити через менеджер пакунків операційної системи, тому проблем з залежностями бути не повинно.
Таким чином код створення нового підключення буде виглядати наступним чином:

void Connector::connnect(ev::io& connect_event, int )
{
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_sd;

client_sd = accept(connect_event.fd, (struct sockaddr *)&client_addr, &client_len);

clients_queue()->push(client_sd);

this->handled_clients++;
}

Код обробки клієнтського сокету:

void Handler::run()
{
LOG(INFO) << "Handler with thread id " << this->thread.get_id() << " started";

while(this->is_run)
{
int socket_fd = clients_queue()->pop();
this->handle(socket_fd);
}

LOG(INFO) << "Handler with thread id " << this->thread.get_id() << " stopped";
}

Код для роботи з чергою:

void ClientsQueue::push(int client)
{
if(!this->queue.try_push(client))
LOG(WARNING) << "can't push socket " << client << " to queue";
}

int ClientsQueue::pop()
{
int result;

try
{
this->queue.pop(result);
}
catch(...)
{
result = -1;
}

return result;
}

void ClientsQueue::stop()
{
this->queue.abort();
}

Код проекту з інструкцією по установці можна знайти на тут. Результат тестового запуску з десятьма потоками обробниками:
Середня, мс 151
Мінімальне, мс 100
Максимальне, мс 1322
Підсумок
Сравнительаня таблиця результатів
Java+Netty C++ Pub/Sub
Середня, мс 245 151
Мінімальне, мс 116 100
Максимальне, мс 693 1322
Посилання:
PS: На поточний момент Unity Web player переживає важкі часи, в зв'язку з закриттям npapi в топових браузерах. Але якщо хтось ще використовує його і тримає сервера на linux машинах, то може скористатися даним сервером, сподіваюся він виявиться вам корисним. Окрема подяка themoonisalwaysspyingonyourfears за ілюстрацію до статті.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.