Работа с Веб-сокетами на PHP
PHP — едва ли первое, что придет в голову, когда стоит задача поднять сервер веб-сокетов. Практически каждая статья в интернете будет пестрить предложениями использовать для этого NodeJS, Python или Go. Но поскольку PHP — это однозначно первое, что приходит в голову, когда речь идет о веб-приложениях, почему бы не попробовать?
На самом деле, запуск сервера веб-сокетов на PHP довольно прост. Существует превосходная библиотека Ratchet, позволяющая работать на любом фреймворке (или вовсе без него) полноценно и легко.
Казалось бы, на этом разговор можно заканчивать, но мы неизбежно столкнемся с некоторыми ограничениями и проблемами, связанными с архитектурой конечного приложения и природой самого протокола веб-сокетов.
Авторизация
По умолчанию, сервер веб-сокетов открыт для любого подключения. Конечно, можно поставить сетевые ограничения по доменам или IP адресам, но для веб-приложения — это, мягко говоря, не эффективный подход. В обычной ситуации мы используем для таких ограничений тот или иной вариант сервиса авторизации — токены, сессии и т.д. Здесь же проблема в том, что мы не сможем отправить через протокол ws:// ни HTTP заголовок, ни cookies. Значительная часть привычных методов, таким образом, не сработает.
Архитектура
Основное приложение != сервер веб-сокетов. Для работы с ними всегда необходимо держать в голове, что мы имеем дело с двумя отдельными приложениями, вне зависимости от того, насколько тесно они взаимодействуют между собой. На первый взгляд это может показаться незначительным нюансом, однако такое положение вещей требует особого внимания к подготовке интерфейсов для интеграции основного приложения и сервера веб-сокетов. Ко всему прочему, это порождает еще одну проблему.
База данных
Поскольку сервер веб-сокетов — это отдельное от основного бэкенда приложение, он ничего не знает о существующей базе данных. Сложно представить себе современное приложение на PHP, написанное без использование какого-либо фреймворка и ORM, так что перед разработчиком встанет дополнительная задача интегрировать службы, сервисы и библиотеки для работы с БД в сторонний скрипт.
Решения
Для каждой из названных проблем вполне возможно отыскать соответствующее решение. Некоторые из потенциальных решений могут показаться шероховатыми, но главное, что они рабочие.
Авторизуем пользователей
В процессе подключения к серверу веб-сокетов существует этап, на котором исходный HTTP запрос преобразуется в WS запрос. Используемая нами библиотека Ratchet сохраняет этот начальный запрос в объекте Connection. Хотя возможности подцепить Bearer заголовок к запросу нет (для клиентского приложения запрос строится сразу как ws://websocket-server), мы можем передать токен (например, JWT) в параметрах запроса. При использовании HTTPS — это вполне безопасный способ передачи.
В итоге, запрос на подключение может выглядеть примерно так:
Строку параметров затем можно извлечь из упомянутого ранее объекта Connection.
После извлечения токен может использоваться в любом уже применяющемся механизме авторизации, реализованном в основном приложении.
Интегрируем базу данных
В 9 из 10 случаев основное приложение будет написано на одном из популярных фреймворков вроде Laravel или Symfony. Все, что нам необходимо реализовать в такой ситуации — внедрение службы, отвечающей за ORM, в конструктор сервера веб-сокетов. При условии, что для запуска сервера используется консольная команда, использующая компонент Symfony Console, мы можем сделать это в два этапа: первоначальной инъекцией в конструктор консольной команды, а оттуда передачей в конструктор основного класса веб-сокетов.
Разделяем приложения
Раз уж мы вынуждены расценивать основное приложение и сервер веб-сокетов как два отдельных компонента, ничто не мешает нам использовать API основного приложения внутри сервера веб-сокетов. Пожалуй, самый распространенный сценарий — сохранение сообщений в БД и последующая отдача их фронтенд-приложению.
В целом, после внедрения ORM в обработчик веб-сокетов, мы могли бы выполнять все это с помощью обычных CRUD-операций. Но гораздо более эффективным решением было бы использовать уже готовый API. Почему? Во-первых, это позволит избежать дублирования кода (ровно такие же CRUDы используются в контроллерах, отвечающих за API). Во-вторых, таким способом мы укладываемся в общую архитектуру разделенных компонентов, даже внутри монолитного решения. Более того, имея одновременно токен из исходного запроса и внедренный ORM, мы получаем возможность авторизовывать действия и валидировать данные при абсолютно каждом событии веб-сокетов, а это уже полноценная имперсонификация пользователя.
Выводы
PHP все еще может быть не первым вариантом для работы с веб-сокетами, но на нем все еще вполне возможен запуск и эксплуатация полноценного сервера веб-сокетов со всеми необходимыми соображениями безопасностями и прозрачной архитектуры.
Examples
This example shows a simple talkback server. Change the address and port variables to suit your setup and execute. You may then connect to the server with a command similar to: telnet 192.168.1.53 10000 (where the address and port match your setup). Anything you type will then be output on the server side, and echoed back to you. To disconnect, enter ‘quit’.
#!/usr/local/bin/php -q
error_reporting ( E_ALL );
/* Allow the script to hang around waiting for connections. */
set_time_limit ( 0 );
/* Turn on implicit output flushing so we see what we’re getting
* as it comes in. */
ob_implicit_flush ();
$address = ‘192.168.1.53’ ;
$port = 10000 ;
if (( $sock = socket_create ( AF_INET , SOCK_STREAM , SOL_TCP )) === false ) echo «socket_create() failed: reason: » . socket_strerror ( socket_last_error ()) . «\n» ;
>
if ( socket_bind ( $sock , $address , $port ) === false ) echo «socket_bind() failed: reason: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
>
if ( socket_listen ( $sock , 5 ) === false ) echo «socket_listen() failed: reason: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
>
do if (( $msgsock = socket_accept ( $sock )) === false ) echo «socket_accept() failed: reason: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
break;
>
/* Send instructions. */
$msg = «\nWelcome to the PHP Test Server. \n» .
«To quit, type ‘quit’. To shut down the server type ‘shutdown’.\n» ;
socket_write ( $msgsock , $msg , strlen ( $msg ));
do if ( false === ( $buf = socket_read ( $msgsock , 2048 , PHP_NORMAL_READ ))) echo «socket_read() failed: reason: » . socket_strerror ( socket_last_error ( $msgsock )) . «\n» ;
break 2 ;
>
if (! $buf = trim ( $buf )) continue;
>
if ( $buf == ‘quit’ ) break;
>
if ( $buf == ‘shutdown’ ) socket_close ( $msgsock );
break 2 ;
>
$talkback = «PHP: You said ‘ $buf ‘.\n» ;
socket_write ( $msgsock , $talkback , strlen ( $talkback ));
echo » $buf \n» ;
> while ( true );
socket_close ( $msgsock );
> while ( true );
Example #2 Socket example: Simple TCP/IP client
This example shows a simple, one-shot HTTP client. It simply connects to a page, submits a HEAD request, echoes the reply, and exits.
echo «
TCP/IP Connection
\n» ;
/* Get the port for the WWW service. */
$service_port = getservbyname ( ‘www’ , ‘tcp’ );
/* Get the IP address for the target host. */
$address = gethostbyname ( ‘www.example.com’ );
/* Create a TCP/IP socket. */
$socket = socket_create ( AF_INET , SOCK_STREAM , SOL_TCP );
if ( $socket === false ) echo «socket_create() failed: reason: » . socket_strerror ( socket_last_error ()) . «\n» ;
> else echo «OK.\n» ;
>
echo «Attempting to connect to ‘ $address ‘ on port ‘ $service_port ‘. » ;
$result = socket_connect ( $socket , $address , $service_port );
if ( $result === false ) echo «socket_connect() failed.\nReason: ( $result ) » . socket_strerror ( socket_last_error ( $socket )) . «\n» ;
> else echo «OK.\n» ;
>
$in = «HEAD / HTTP/1.1\r\n» ;
$in .= «Host: www.example.com\r\n» ;
$in .= «Connection: Close\r\n\r\n» ;
$out = » ;
echo «Sending HTTP HEAD request. » ;
socket_write ( $socket , $in , strlen ( $in ));
echo «OK.\n» ;
echo «Reading response:\n\n» ;
while ( $out = socket_read ( $socket , 2048 )) echo $out ;
>
echo «Closing socket. » ;
socket_close ( $socket );
echo «OK.\n\n» ;
?>
User Contributed Notes 3 notes
You can easily extend the first example to handle any number of connections instead of jsut one
#!/usr/bin/env php
error_reporting ( E_ALL );
/* Permitir al script esperar para conexiones. */
set_time_limit ( 0 );
/* Activar el volcado de salida implícito, así veremos lo que estamo obteniendo
* mientras llega. */
ob_implicit_flush ();
$address = ‘127.0.0.1’ ;
$port = 10000 ;
if (( $sock = socket_create ( AF_INET , SOCK_STREAM , SOL_TCP )) === false ) echo «socket_create() falló: razón: » . socket_strerror ( socket_last_error ()) . «\n» ;
>
if ( socket_bind ( $sock , $address , $port ) === false ) echo «socket_bind() falló: razón: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
>
if ( socket_listen ( $sock , 5 ) === false ) echo «socket_listen() falló: razón: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
>
//clients array
$clients = array();
do $read = array();
$read [] = $sock ;
$read = array_merge ( $read , $clients );
// Set up a blocking call to socket_select
if( socket_select ( $read , $write = NULL , $except = NULL , $tv_sec = 5 ) < 1 )
// SocketServer::debug(«Problem blocking socket_select?»);
continue;
>
// Handle new Connections
if ( in_array ( $sock , $read ))
if (( $msgsock = socket_accept ( $sock )) === false ) echo «socket_accept() falló: razón: » . socket_strerror ( socket_last_error ( $sock )) . «\n» ;
break;
>
$clients [] = $msgsock ;
$key = array_keys ( $clients , $msgsock );
/* Enviar instrucciones. */
$msg = «\nBienvenido al Servidor De Prueba de PHP. \n» .
«Usted es el cliente numero: < $key [ 0 ]>\n» .
«Para salir, escriba ‘quit’. Para cerrar el servidor escriba ‘shutdown’.\n» ;
socket_write ( $msgsock , $msg , strlen ( $msg ));
// Handle Input
foreach ( $clients as $key => $client ) < // for each client
if ( in_array ( $client , $read )) if ( false === ( $buf = socket_read ( $client , 2048 , PHP_NORMAL_READ ))) echo «socket_read() falló: razón: » . socket_strerror ( socket_last_error ( $client )) . «\n» ;
break 2 ;
>
if (! $buf = trim ( $buf )) continue;
>
if ( $buf == ‘quit’ ) unset( $clients [ $key ]);
socket_close ( $client );
break;
>
if ( $buf == ‘shutdown’ ) socket_close ( $client );
break 2 ;
>
$talkback = «Cliente < $key >: Usted dijo ‘ $buf ‘.\n» ;
socket_write ( $client , $talkback , strlen ( $talkback ));
echo » $buf \n» ;
>