У даній статі буде наданий опис роботи вебсокетного серверу на Prolog. Ці навички були здобуті студентами Дмитро Л. та Михайло Г. під час проходження курсу "Логічне Програмування" у НаУКМА.
1. Підготування
--1.1. Запуск серверу
--1.2. Підключення вебсокетів
2. Управління великою кількістю підключень
--2.1. Хаб
--2.2. Спілкування з клієнтами
3. Парсинг повідомлень
--2.1. Основи
--2.2. Робота з JSON
4. Динамічні предикати
Підключимо усі необхідні бібліотеки (на початку файлу):
:- use_module(library(http/websocket)). % для вебсокетів
:- use_module(library(http/thread_httpd)). % потоки
:- use_module(library(http/http_dispatch)). % запуск серверу
:- use_module(library(http/hub)). % для вебсокетів
:- use_module(library(http/json)). % для підтримки json
:- use_module(library(http/json_convert)). % для підтримки конвертації між json та вбудованими атомами, термами тощо.
Для успішного запуску серверу необхідно зробити дві дії:
- Викликати предикат http_server:
http_server(http_dispatch, [port(8083)])
У цьому прикладі ми запускаємо сервер на порті 8083.
- За допомогою предикату http_handler вказуємо роути для серверу:
:- http_handler(root(<ROUTE>), <pred>(Method, User),
[ method(Method),
methods([get,post,put])
]).
Наприклад, щоб створити роут для www.localhost/test, необхідно замість просто вписати test. Замість необхідно написати власний предикат, який буде викликатися при переході на цей роут користувачем. У квадратних дужках заключений список параметрів, повний список можна подивитися у офіційний документації http_handler.
Для підключення вебсокетів використовується предикат http_upgrade_to_websocket, наприклад:
:- http_handler(root(websocket),
http_upgrade_to_websocket(accept_socket,
[guarded(false), subprotocols([chat])]),
[id(battleships)]).
Тут, accept_socket це спеціальний предикат, що підключає кліента до хабу сокетів.
accept_socket(WebSocket) :-
hub_add(main, WebSocket, _Id).
Детальніше про хаби сокетів у наступному розділі.
Хаб - це набір предикатів з бібліотеки library(http/hub). По-суті, це пул клієнтів, що під'єднані до серверу. Хаб дозволяє відправляти їм повідомлення (кожному та усім одразу), видаляє автоматично при відключенні, отримує повідомлення від них. Базова конфігурація для роботи хабу:
hub_create(main, Hub, []), % main - назва хабу, по якій ми будемо до нього звертатися. Може бути довільною, а хабів може бути декілька
thread_create(listen_sockets(Hub), _, % Створюємо новий потік, який буде слухати чергу подій хабу.
[ alias(listen_sockets) ]).
listen_sockets(Hub) :-
thread_get_message(Hub.queues.event, Message),
handle_message(Message, Hub), % handle_message(Message, Hub) :- write(Message). (приклад)
listen_sockets(Hub).
Тобто, предикат listen_sockets - рекурсивний, на кожному кроці ми перевіряємо, чи не було отримано нове повідомлення. Це повідомлення відправляється у предикат handle_message - він виконує якусь внутрішню логіку. Приклад цього предикату буде наданий пізніше, у секції про парсинг повідомлень.
Нові підключення можна додавати через окремий роут, як було показано раніше:
accept_socket(WebSocket) :-
hub_add(main, WebSocket, _Id). % main - назва хабу
Окрім отримання повідомлень від клієнтів, ми можемо відправляти повідомлення їм. Є два способи це зробити:
- Через hub_send, який відправляє повідомлення окремому клієнту.
hub_send(Client, text("Hello World")).
Тут Client - це унікальний ідентифікатор кожного клієнта, який ми отримуємо при підключенні і потім можемо зберігати. Детальніше про отримання буде у секції по парсинг повідомлень. Другим параметром - повідомлення, обов'язково вкладається в один з визначених предикатів. Нас цікавлять два: text та json, але про це пізніше.
- Через hub_broadcast, який відправляє повідомлення усіх клієнтам, підключеним до хабу.
hub_broadcast(Hub.name, json(json([message='Hello world!']))).
На місці Hub.name знаходиться назва хабу, у який відправляємо повідомлення.
У предикаті handle_message ми можемо отримувати три типи повідомлень. Їх дуже зручно розбирати за вбудованим pattern-matching у Prolog.
- До хабу під'єднався новий клієнт (отримуємо, очевидно, лише один раз). У повідомленні вказаний лише Id клієнта.
handle_message(Message, _Room) :-
hub{joined:Id} :< Message, !,
assertz(visitor(Id)). % зберігаємо айді користувача у динамічному предикаті (чит. далі)
- Від хабу від'єднався клієнт (також отримуємо лише один раз).
handle_message(Message, _Room) :-
hub{left:Id} :< Message, !,
retract(visitor(Id)). % видаляємо айді користувача з динамічного предиката (чит. далі)
- Отримали якесь повідомлення від клієнта:
handle_message(Message, Hub) :-
websocket{client:Client,data:Data,format:string,hub:main,opcode:text} :< Message, !,
...
Як бачимо, в останньому предикаті ми отримуємо багато інформацї. Client - айді клієнту, який відправив повідомлення, саме по цій змінній зручному йому відповідати через hub_send. Data - його повідомлення у сирому форматі. format:string, hub:main, opcode:text - для фільтрації повідомлень, на випадок якщо маємо багато хабів, форматів тощо.
Очевидно, що найзручніший на найпопулярніший метод обміну даними це формат JSON.
Як парсити дані у форматі JSON? Дуже просто:
websocket{client:Client,data:Data,format:string,hub:main,opcode:text} :< Message, !,
atom_string(DataAtom, Data), % конвертуємо стрічку у атом
json:atom_json_dict(DataAtom, Json, []), % передаємо конвертовану стрічку у atom_json_dict, який парсить стрічку за нас
handle_json_message(Json, Client, Hub). % передаємо Json (представлений тепер у вигляді вбудованого у Prolog словнику)
Далі у предикаті handle_json_message виконуємо pattern-matching в залежності від того, які відповіді ми хочемо давати клієнту.
handle_json_message(_{status:"wait", params:Params, otherParams:OtherParam}, Client, Hub) :-
...
hub_send(Client, text("You are waiting...")),
...
handle_json_message(_{status:"ready", id:Id}, Client, Hub) :-
...
hub_broadcast(Hub.name, text("All players are ready..")),
...
Найпростіший спосіб дати відповідь у форматі JSON - підготувати словник з даними і конвертувати його в JSON на ходу. Це можна зробити так
hub_send(Client, json(json([status='ready'])))
Перший json тут позначає, що ми відправляємо повідомлення у форматі json, а другий - власне конвертація словнику [status='ready'] у JSON.
Ми не розглядаємо роботу з базами даних MySql, Mongo та інші, а використовуємо динамічні предикаті. На відміну від звичайних (статичних) предикатів Prolog, їх можна змінювати під час виконання програми. По-суті, ми отримуємо словник у вигляді глобальної зміні. Але оскільки робота з глобальними змінними у Prolog дуже ускладнена, простіше це робити саме через динамічні предикати. Спочатку необхідно оголосити, що предикат з заданою назвою та арністю буде динамічним:
:- dynamic(shipIdCnt/4). % предикат shipIdCnt з 4 параметрами
:- dynamic(game_id/2). % предикат fieldToShip з 2 параметрами
Додавання даних у предикат ми виконуємо за допомогою предикату assertz:
handle_message(Message, _Room) :-
hub{joined:Id} :< Message, !,
assertz(visitor(Id)). % зв'язуємо даний предикат з Id. Скажімо, що id = 3
Після чого ми можемо перевіряти цей предикат як звичайно:
visitor(3), % true
visitor(X). % X = 3
Якщо ми захочемо видалити це значення з предикату, зручно використовувати предикат retract:
retract(visitor(3)),
visitor(3). % false
Можна видалити усі значення у предикаті за допомогою retractall:
retractall(visitor(_)),
visitor(3). % false