Escribimos un chat en l铆nea en Websockets usando Swoole



El tema de Websockets ya se toc贸 repetidamente en Habr茅, en particular se consideraron opciones para la implementaci贸n en PHP. Sin embargo, ha pasado m谩s de un a帽o desde el 煤ltimo art铆culo con una descripci贸n general de las diferentes tecnolog铆as, y el mundo PHP tiene algo de qu茅 alardear con el tiempo.

En este art铆culo quiero presentar a Swoole a la comunidad de habla rusa: el marco as铆ncrono de c贸digo abierto para PHP, escrito en C y entregado como una extensi贸n pecl.

Fuentes en github .

驴Por qu茅 desmayarse?


Seguramente habr谩 personas que en principio estar谩n en contra del uso de PHP para tales fines, sin embargo, a menudo pueden jugar a favor de PHP:

  • Renuencia a criar un zool贸gico de varios idiomas en el proyecto
  • Posibilidad de utilizar una base de c贸digo ya desarrollada (si el proyecto est谩 en PHP).

Sin embargo, incluso comparando con node.js / go / erlang y otros lenguajes que ofrecen de forma nativa un modelo as铆ncrono, Swoole, un marco escrito en C y que combina un umbral de entrada bajo y una funcionalidad potente puede ser un buen candidato.

Caracter铆sticas del marco:

  • Evento, modelo de programaci贸n as铆ncrono
  • TCP / UDP / HTTP / HTTP / Websocket / HTTP2 API / cliente as铆ncrono
  • Compatible con IPv4 / IPv6 / Unixsocket / TCP / UDP y SSL / TLS
  • R谩pida serializaci贸n / deserializaci贸n de datos
  • Alto rendimiento, extensibilidad, soporte para hasta 1 mill贸n de conexiones simult谩neas
  • Planificador de tareas de milisegundos
  • C贸digo abierto
  • Soporte de corutinas

Posibles casos de uso:

  • Microservicios
  • Servidores de juegos
  • Internet de las cosas
  • Sistemas de comunicaci贸n en vivo
  • API WEB
  • Cualquier otro servicio que requiera respuesta instant谩nea / alta velocidad / ejecuci贸n asincr贸nica

Se pueden ver ejemplos de c贸digo en la p谩gina principal del sitio . En la secci贸n de documentaci贸n, informaci贸n m谩s detallada sobre todas las funcionalidades del framework.

Empecemos


A continuaci贸n, describir茅 el proceso de escribir un servidor Websocket simple para el chat en l铆nea y las posibles dificultades con esto.

Antes de comenzar: M谩s informaci贸n sobre las clases swoole_websocket_server y swoole_server (la segunda clase hereda de la primera).
Fuentes del chat en s铆.

Instalar el marco
Linux users

#!/bin/bash
pecl install swoole

Mac users

# get a list of avaiable packages
brew install swoole
#!/bin/bash
brew install homebrew/php/php71-swoole


Para usar el autocompletado en el IDE, se propone usar ide-helper

Plantilla m铆nima del servidor Websocket:

 <?php $server = new swoole_websocket_server("127.0.0.1", 9502); $server->on('open', function($server, $req) { echo "connection open: {$req->fd}\n"; }); $server->on('message', function($server, $frame) { echo "received message: {$frame->data}\n"; $server->push($frame->fd, json_encode(["hello", "world"])); }); $server->on('close', function($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); 

$ fd es el identificador de conexi贸n.
Obt茅n conexiones actuales:

 $server->connections; 

$ Frame contiene todos los datos enviados. Aqu铆 hay un ejemplo de un objeto que entr贸 en la funci贸n onMessage:

 Swoole\WebSocket\Frame Object ( [fd] => 20 [data] => {"type":"login","username":"new user"} [opcode] => 1 [finish] => 1 ) 

Los datos se env铆an al cliente usando la funci贸n

 Server::push($fd, $data, $opcode=null, $finish=null) 

Lea m谩s sobre marcos y c贸digos de operaci贸n en ruso en learn.javascript . Secci贸n "formato de datos"

Tanto como sea posible sobre el protocolo Websocket - RFC

驴Y c贸mo guardar los datos que llegaron al servidor?
Swoole presenta la funcionalidad para trabajar de forma as铆ncrona con MySQL , Redis , E / S de archivos

Adem谩s de swoole_buffer , swoole_channel y swoole_table
Creo que las diferencias no son dif铆ciles de entender por la documentaci贸n. Para almacenar nombres de usuario, seleccion茅 swoole_table. Los mensajes en s铆 se almacenan en MySQL.

Entonces, inicializaci贸n de la tabla de nombres de usuario:

 $users_table = new swoole_table(131072); $users_table->column('id', swoole_table::TYPE_INT, 5); $users_table->column('username', swoole_table::TYPE_STRING, 64); $users_table->create(); 

El llenado de datos es el siguiente:

 $count = count($messages_table); $dateTime = time(); $row = ['username' => $username, 'message' => $data->message, 'date_time' => $dateTime]; $messages_table->set($count, $row); 

Para trabajar con MySQL, decid铆 no usar el modelo asincr贸nico todav铆a, sino acceder a 茅l de manera est谩ndar, desde el servidor de socket web, a trav茅s de PDO

Apelar a la base
 /** * @return Message[] */ public function getAll() { $stmt = $this->pdo->query('SELECT * from messages'); $messages = []; foreach ($stmt->fetchAll() as $row) { $messages[] = new Message( $row['username'], $row['message'], new \DateTime($row['date_time']) ); } return $messages; } 


Servidor Websocket, se decidi贸 emitir en forma de clase e iniciarlo en el constructor:

Constructor
 public function __construct() { $this->ws = new swoole_websocket_server('0.0.0.0', 9502); $this->ws->on('open', function ($ws, $request) { $this->onConnection($request); }); $this->ws->on('message', function ($ws, $frame) { $this->onMessage($frame); }); $this->ws->on('close', function ($ws, $id) { $this->onClose($id); }); $this->ws->on('workerStart', function (swoole_websocket_server $ws) { $this->onWorkerStart($ws); }); $this->ws->start(); } 


Problemas encontrados:

  1. Un usuario conectado al chat se desconecta despu茅s de 60 segundos si no hay intercambio de paquetes (es decir, el usuario no envi贸 ni recibi贸 nada)
  2. El servidor web pierde la conexi贸n con MySQL si no se produce interacci贸n durante mucho tiempo

Soluci贸n:

En ambos casos, necesitamos una implementaci贸n de la funci贸n ping, que constantemente har谩 ping al cliente cada n segundos en el primer caso, y la base de datos MySQL en el segundo.

Dado que ambas funciones deben funcionar de forma as铆ncrona, se deben invocar en los procesos secundarios del servidor.

Para hacer esto, se pueden inicializar con el evento "workerStart". Ya lo definimos en el constructor, y con este evento el m茅todo $ this-> onWorkerStart ya se llama:
El protocolo Websocket admite ping-pong fuera de la caja. A continuaci贸n puede ver la implementaci贸n en Swoole.

onWorkerStart
 private function onWorkerStart(swoole_websocket_server $ws) { $this->messagesRepository = new MessagesRepository(); $ws->tick(self::PING_DELAY_MS, function () use ($ws) { foreach ($ws->connections as $id) { $ws->push($id, 'ping', WEBSOCKET_OPCODE_PING); } }); } 


Luego, implement茅 una funci贸n simple para hacer ping a un servidor MySQL cada N segundos usando swoole \ Timer:

DatabaseHelper
El temporizador se inicia en initPdo si a煤n no est谩 habilitado:

  /** * Init new Connection, and ping DB timer function */ private static function initPdo() { if (self::$timerId === null || (!Timer::exists(self::$timerId))) { self::$timerId = Timer::tick(self::MySQL_PING_INTERVAL, function () { self::ping(); }); } self::$pdo = new PDO(self::DSN, DBConfig::USER, DBConfig::PASSWORD, self::OPT); } /** * Ping database to maintain the connection */ private static function ping() { try { self::$pdo->query('SELECT 1'); } catch (PDOException $e) { self::initPdo(); } } 


La parte principal del trabajo consisti贸 en escribir la l贸gica para agregar, guardar y enviar mensajes (no m谩s complicado que el CRUD habitual), y luego un enorme margen para mejoras.

Hasta ahora he llevado mi c贸digo a una forma m谩s o menos legible y a un estilo orientado a objetos, he implementado un poco de funcionalidad:

- Iniciar sesi贸n por nombre;

- Compruebe que el nombre no est茅 ocupado
 /** * @param string $username * @return bool */ private function isUsernameCurrentlyTaken(string $username) { foreach ($this->usersRepository->getByIds($this->ws->connection_list()) as $user) { if ($user->getUsername() == $username) { return true; } } return false; } 


- Limitador de spam
 <?php namespace App\Helpers; use Swoole\Channel; class RequestLimiter { /** * @var Channel */ private $userIds; const MAX_RECORDS_COUNT = 10; const MAX_REQUESTS_BY_USER = 4; public function __construct() { $this->userIds = new Channel(1024 * 64); } /** * Check if there are too many requests from user * and make a record of request from that user * * @param int $userId * @return bool */ public function checkIsRequestAllowed(int $userId) { $requestsCount = $this->getRequestsCountByUser($userId); $this->addRecord($userId); if ($requestsCount >= self::MAX_REQUESTS_BY_USER) return false; return true; } /** * @param int $userId * @return int */ private function getRequestsCountByUser(int $userId) { $channelRecordsCount = $this->userIds->stats()['queue_num']; $requestsCount = 0; for ($i = 0; $i < $channelRecordsCount; $i++) { $userIdFromChannel = $this->userIds->pop(); $this->userIds->push($userIdFromChannel); if ($userIdFromChannel === $userId) { $requestsCount++; } } return $requestsCount; } /** * @param int $userId */ private function addRecord(int $userId) { $recordsCount = $this->userIds->stats()['queue_num']; if ($recordsCount >= self::MAX_RECORDS_COUNT) { $this->userIds->pop(); } $this->userIds->push($userId); } } 

PD: S铆, la verificaci贸n est谩 en la identificaci贸n de la conexi贸n. Quiz谩s tenga sentido reemplazarlo en este caso, por ejemplo, con la direcci贸n IP del usuario.

Tampoco estoy seguro de que en esta situaci贸n sea el swoole_channel el m谩s adecuado. Pienso luego en revisar este momento.

- Protecci贸n XSS simple usando ezyang / htmlpurifier

- Filtro de spam simple
Con la capacidad de agregar controles adicionales en el futuro.

 <?php namespace App\Helpers; class SpamFilter { /** * @var string[] errors */ private $errors = []; /** * @param string $text * @return bool */ public function checkIsMessageTextCorrect(string $text) { $isCorrect = true; if (empty(trim($text))) { $this->errors[] = 'Empty message text'; $isCorrect = false; } return $isCorrect; } /** * @return string[] errors */ public function getErrors(): array { return $this->errors; } } 


El chat frontend todav铆a es muy crudo, porque Me atrae m谩s el backend, pero cuando haya m谩s tiempo, intentar茅 hacerlo m谩s agradable.

驴D贸nde obtener informaci贸n, obtener noticias sobre el marco?


  • Sitio oficial en ingl茅s : enlaces 煤tiles, documentaci贸n actualizada, pocos comentarios de los usuarios
  • Twitter : noticias actuales, enlaces 煤tiles, art铆culos interesantes
  • Rastreador de problemas (Github) : errores, preguntas, comunicaci贸n con los creadores del marco. Responden muy inteligentemente (respondieron mi pregunta con una pregunta en un par de horas, ayudaron con la implementaci贸n de pingloop).
  • Problemas cerrados : tambi茅n aconsejo. Una gran base de datos de preguntas de los usuarios y respuestas de los creadores del marco.
  • Pruebas escritas por desarrolladores : casi todos los m贸dulos de la documentaci贸n tienen pruebas escritas en PHP, que muestran casos de uso.
  • Marco wiki chino : toda la informaci贸n en ingl茅s, pero muchos m谩s comentarios de los usuarios (traductor de Google para ayudar).

Documentaci贸n API : una descripci贸n de algunas clases y funciones del marco en una forma bastante conveniente.

Resumen


Me parece que Swoole se ha desarrollado muy activamente el a帽o pasado, ha salido del escenario cuando podr铆a llamarse "en bruto", y ahora est谩 en plena competencia con el uso de node.js / go en t茅rminos de programaci贸n asincr贸nica e implementaci贸n de protocolos de red.

Estar茅 encantado de escuchar diferentes opiniones sobre el tema y comentarios de aquellos que ya tienen experiencia en el uso de Swoole

Puedes chatear en la sala de chat descrita por el enlace
Las fuentes est谩n disponibles en Github .

Source: https://habr.com/ru/post/es427589/


All Articles