Nous écrivons un chat en ligne sur Websockets en utilisant Swoole



Le thème des Websockets a déjà été abordé à plusieurs reprises sur Habré, en particulier des options d'implémentation en PHP ont été envisagées. Cependant, plus d'un an s'est écoulé depuis le dernier article avec un aperçu des différentes technologies, et le monde PHP a de quoi se vanter au fil du temps.

Dans cet article, je veux présenter Swoole à la communauté russophone - Framework Open Source asynchrone pour PHP, écrit en C, et livré en tant qu'extension pecl.

Sources sur github .

Pourquoi swoole?


Il y aura sûrement des gens qui seront en principe contre l'utilisation de PHP à de telles fins, cependant, ils peuvent souvent jouer en faveur de PHP:

  • Réticence à élever un zoo de différentes langues sur le projet
  • Possibilité d'utiliser une base de code déjà développée (si le projet est en PHP).

Néanmoins, même en comparant avec node.js / go / erlang et d'autres langages qui proposent nativement un modèle asynchrone, Swoole - un framework écrit en C et combinant un seuil d'entrée bas et des fonctionnalités puissantes peut être un bon candidat.

Caractéristiques du cadre:

  • Événement, modèle de programmation asynchrone
  • API client / serveur TCP / UDP / HTTP / Websocket / HTTP2 asynchrones
  • Prise en charge IPv4 / IPv6 / Unixsocket / TCP / UDP et SSL / TLS
  • Sérialisation / désérialisation rapide des données
  • Haute performance, extensibilité, prise en charge de jusqu'à 1 million de connexions simultanées
  • Planificateur de tâches en millisecondes
  • Open source
  • Assistance: Coroutines

Cas d'utilisation possibles:

  • Microservices
  • Serveurs de jeux
  • Internet des objets
  • Systèmes de communication en direct
  • API WEB
  • Tout autre service nécessitant une réponse instantanée / haute vitesse / exécution asynchrone

Des exemples de code peuvent être vus sur la page principale du site . Dans la section documentation, des informations plus détaillées sur toutes les fonctionnalités du framework.

Commençons


Ci-dessous, je décrirai le processus d'écriture d'un simple serveur Websocket pour le chat en ligne et les difficultés possibles avec cela.

Avant de commencer: Plus d'informations sur les classes swoole_websocket_server et swoole_server (la deuxième classe hérite de la première).
Sources du chat lui-même.

Installer le framework
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


Pour utiliser la saisie semi-automatique dans l'IDE, il est proposé d'utiliser ide-helper

Modèle de serveur Websocket minimum:

 <?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 est l'identifiant de connexion.
Obtenez les connexions actuelles:

 $server->connections; 

$ Frame contient toutes les données envoyées. Voici un exemple d'un objet entré dans la fonction onMessage:

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

Les données sont envoyées au client à l'aide de la fonction

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

En savoir plus sur les cadres et les opcodes en russe sur learn.javascript . Section "format des données"

Autant que possible sur le protocole Websocket - RFC

Et comment enregistrer les données qui sont arrivées sur le serveur?
Swoole introduit des fonctionnalités pour travailler de manière asynchrone avec MySQL , Redis , les E / S de fichiers

Ainsi que swoole_buffer , swoole_channel et swoole_table
Je pense que les différences ne sont pas difficiles à comprendre à partir de la documentation. Pour stocker les noms d'utilisateurs, j'ai sélectionné swoole_table. Les messages eux-mêmes sont stockés dans MySQL.

Donc, initialisation de la table des noms d'utilisateurs:

 $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(); 

Le remplissage des données est le suivant:

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

Pour travailler avec MySQL, j'ai décidé de ne pas encore utiliser le modèle asynchrone, mais d'y accéder de manière standard, depuis le serveur de socket web, via PDO

Appel à 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; } 


Serveur Websocket, il a été décidé d'émettre sous forme de classe, et de le démarrer dans le constructeur:

Constructeur
 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(); } 


Problèmes rencontrés:

  1. Un utilisateur connecté au chat se déconnecte après 60 secondes s'il n'y a pas d'échange de paquets (c'est-à-dire que l'utilisateur n'a rien envoyé ni reçu)
  2. Le serveur Web perd la connexion avec MySQL si aucune interaction ne se produit pendant longtemps

Solution:

Dans les deux cas, nous avons besoin d'une implémentation de la fonction ping, qui testera en permanence le client toutes les n secondes dans le premier cas, et la base de données MySQL dans le second.

Étant donné que les deux fonctions doivent fonctionner de manière asynchrone, elles doivent être appelées dans les processus enfants du serveur.

Pour ce faire, ils peuvent être initialisés avec l'événement "workerStart". Nous l'avons déjà défini dans le constructeur, et avec cet événement, la méthode $ this-> onWorkerStart est déjà appelée:
Le protocole Websocket prend en charge le ping-pong hors de la boîte. Ci-dessous, vous pouvez voir l'implémentation sur 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); } }); } 


Ensuite, j'ai implémenté une fonction simple pour envoyer un ping à un serveur MySQL toutes les N secondes en utilisant swoole \ Timer:

DatabaseHelper
Le temporisateur lui-même démarre dans initPdo s'il n'est pas déjà activé:

  /** * 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 partie principale du travail consistait à écrire une logique pour ajouter, enregistrer, envoyer des messages (pas plus compliqué que le CRUD habituel), puis une énorme marge d'amélioration.

Jusqu'à présent, j'ai apporté mon code sous une forme plus ou moins lisible et un style orienté objet, j'ai implémenté un peu de fonctionnalité:

- Connectez-vous par nom;

- Vérifiez que le nom n'est pas occupé
 /** * @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; } 


- Limiteur 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); } } 

PS: Oui, la vérification est sur l'identifiant de connexion. Il est peut-être judicieux de le remplacer dans ce cas, par exemple, par l'adresse IP de l'utilisateur.

Je ne suis pas sûr non plus que dans cette situation, ce soit swoole_channel qui soit le mieux adapté. Je pense plus tard à revoir ce moment.

- Protection XSS simple en utilisant ezyang / htmlpurifier

- Filtre anti-spam simple
Avec la possibilité d'ajouter des contrôles supplémentaires à l'avenir.

 <?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; } } 


Le chat frontal est encore très brut, car Je suis plus attiré par le backend, mais quand il y aura plus de temps, j'essaierai de le rendre plus agréable.

Où obtenir des informations, obtenir des nouvelles sur le cadre?


  • Site officiel anglais - liens utiles, documentation à jour, quelques commentaires des utilisateurs
  • Twitter - actualités, liens utiles, articles intéressants
  • Issue tracker (Github) - bugs, questions, communication avec les créateurs du framework. Ils répondent très intelligemment (ils ont répondu à ma question avec une question dans quelques heures, aidé à la mise en œuvre de pingloop).
  • Problèmes résolus - je conseille également. Une grande base de données de questions d'utilisateurs et de réponses des créateurs du framework.
  • Tests écrits par les développeurs - presque tous les modules de la documentation ont des tests écrits en PHP, montrant des cas d'utilisation.
  • Framework wiki chinois - toutes les informations comme en anglais, mais beaucoup plus de commentaires des utilisateurs (traducteur google pour vous aider).

Documentation API - une description de certaines classes et fonctions du framework sous une forme assez pratique.

Résumé


Il me semble que Swoole s'est développé très activement l'année dernière, il est sorti de l'étape où il pouvait être appelé "brut", et maintenant il est en pleine concurrence avec l'utilisation de node.js / go en termes de programmation asynchrone et de mise en œuvre de protocoles réseau.

Je serai heureux d'entendre différentes opinions sur le sujet et les commentaires de ceux qui ont déjà de l'expérience avec Swoole

Vous pouvez discuter dans la salle de chat décrite par le lien
Les sources sont disponibles sur Github .

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


All Articles