/PrologServer

Prolog Server for the Battle Ships game

Primary LanguageProlog

Вступ

У даній статі буде наданий опис роботи вебсокетного серверу на 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 та вбудованими атомами, термами тощо.

Для успішного запуску серверу необхідно зробити дві дії:

  1. Викликати предикат http_server:
http_server(http_dispatch, [port(8083)])

У цьому прикладі ми запускаємо сервер на порті 8083.

  1. За допомогою предикату 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 - назва хабу

Окрім отримання повідомлень від клієнтів, ми можемо відправляти повідомлення їм. Є два способи це зробити:

  1. Через hub_send, який відправляє повідомлення окремому клієнту.
hub_send(Client, text("Hello World")).

Тут Client - це унікальний ідентифікатор кожного клієнта, який ми отримуємо при підключенні і потім можемо зберігати. Детальніше про отримання буде у секції по парсинг повідомлень. Другим параметром - повідомлення, обов'язково вкладається в один з визначених предикатів. Нас цікавлять два: text та json, але про це пізніше.

  1. Через hub_broadcast, який відправляє повідомлення усіх клієнтам, підключеним до хабу.
hub_broadcast(Hub.name, json(json([message='Hello world!']))).

На місці Hub.name знаходиться назва хабу, у який відправляємо повідомлення.

У предикаті handle_message ми можемо отримувати три типи повідомлень. Їх дуже зручно розбирати за вбудованим pattern-matching у Prolog.

  1. До хабу під'єднався новий клієнт (отримуємо, очевидно, лише один раз). У повідомленні вказаний лише Id клієнта.
handle_message(Message, _Room) :-
	hub{joined:Id} :< Message, !,
	assertz(visitor(Id)).            % зберігаємо айді користувача у динамічному предикаті (чит. далі)
  1. Від хабу від'єднався клієнт (також отримуємо лише один раз).
handle_message(Message, _Room) :-
	hub{left:Id} :< Message, !,
  retract(visitor(Id)).            % видаляємо айді користувача з динамічного предиката (чит. далі)
  1. Отримали якесь повідомлення від клієнта:
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

Як парсити дані у форматі 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 - підготувати словник з даними і конвертувати його в 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