Wir schreiben einen Online-Chat auf Websockets mit Swoole



Das Thema Websockets wurde bei Habré bereits wiederholt angesprochen, insbesondere wurden Optionen für die Implementierung in PHP in Betracht gezogen. Seit dem letzten Artikel mit einem Überblick über verschiedene Technologien ist jedoch mehr als ein Jahr vergangen, und die PHP-Welt hat im Laufe der Zeit etwas zu bieten.

In diesem Artikel möchte ich Swoole der russischsprachigen Community vorstellen - Asynchrones Open Source-Framework für PHP, geschrieben in C und geliefert als Pecl-Erweiterung.

Quellen zu Github .

Warum stürzen?


Sicherlich wird es Leute geben, die grundsätzlich gegen die Verwendung von PHP für solche Zwecke sind, aber sie können oft zugunsten von PHP spielen:

  • Widerwillen, einen Zoo in verschiedenen Sprachen im Projekt zu züchten
  • Möglichkeit, eine bereits entwickelte Codebasis zu verwenden (wenn das Projekt in PHP ausgeführt wird).

Selbst im Vergleich zu node.js / go / erlang und anderen Sprachen, die nativ ein asynchrones Modell bieten, kann Swoole - ein in C geschriebenes Framework, das eine niedrige Einstiegsschwelle und leistungsstarke Funktionen kombiniert - ein guter Kandidat sein.

Merkmale des Frameworks:

  • Ereignis, asynchrones Programmiermodell
  • Asynchrone TCP / UDP / HTTP / Websocket / HTTP2-Client / Server-APIs
  • Unterstützt IPv4 / IPv6 / Unixsocket / TCP / UDP und SSL / TLS
  • Schnelle Serialisierung / Deserialisierung von Daten
  • Hohe Leistung, Erweiterbarkeit und Unterstützung für bis zu 1 Million gleichzeitige Verbindungen
  • Millisekunden-Taskplaner
  • Open Source
  • Coroutines-Unterstützung

Mögliche Anwendungsfälle:

  • Microservices
  • Spieleserver
  • Internet der Dinge
  • Live-Kommunikationssysteme
  • WEB API
  • Alle anderen Dienste, die eine sofortige Antwort / hohe Geschwindigkeit / asynchrone Ausführung erfordern

Beispiele für Code finden Sie auf der Hauptseite der Website . Im Dokumentationsabschnitt finden Sie detailliertere Informationen zu allen Funktionen des Frameworks.

Fangen wir an


Im Folgenden werde ich den Prozess des Schreibens eines einfachen Websocket-Servers für den Online-Chat und die möglichen Schwierigkeiten damit beschreiben.

Bevor Sie beginnen: Weitere Informationen zu den Klassen swoole_websocket_server und swoole_server (Die zweite Klasse erbt von der ersten).
Quellen des Chats selbst.

Framework installieren
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


Um die automatische Vervollständigung in der IDE zu verwenden, wird vorgeschlagen, ide-helper zu verwenden

Minimale Websocket-Server-Vorlage:

 <?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 ist die Verbindungskennung.
Aktuelle Verbindungen abrufen:

 $server->connections; 

$ Frame enthält alle gesendeten Daten. Hier ist ein Beispiel für ein Objekt, das in die Funktion onMessage aufgenommen wurde:

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

Daten werden über die Funktion an den Client gesendet

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

Weitere Informationen zu Frames und Opcodes in russischer Sprache finden Sie unter learn.javascript . Abschnitt "Datenformat"

So viel wie möglich über das Websocket-Protokoll - RFC

Und wie speichere ich die Daten, die auf den Server kamen?
Swoole bietet Funktionen für die asynchrone Arbeit mit MySQL , Redis und Datei-E / A.

Sowie swoole_buffer , swoole_channel und swoole_table
Ich denke, die Unterschiede sind aus der Dokumentation nicht schwer zu verstehen. Um Benutzernamen zu speichern, habe ich swoole_table ausgewählt. Die Nachrichten selbst werden in MySQL gespeichert.

Also, Initialisierung der Benutzernamen-Tabelle:

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

Das Füllen mit Daten ist wie folgt:

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

Um mit MySQL zu arbeiten, habe ich mich entschieden, das asynchrone Modell noch nicht zu verwenden, sondern auf standardmäßige Weise vom Web-Socket-Server über PDO darauf zuzugreifen

Appell an die Basis
 /** * @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; } 


Websocket-Server, es wurde beschlossen, in Form einer Klasse auszugeben und es im Konstruktor zu starten:

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


Probleme aufgetreten:

  1. Ein mit dem Chat verbundener Benutzer trennt die Verbindung nach 60 Sekunden, wenn kein Paketaustausch stattfindet (d. H. Der Benutzer hat nichts gesendet oder empfangen).
  2. Der Webserver verliert die Verbindung zu MySQL, wenn längere Zeit keine Interaktion stattfindet

Lösung:

In beiden Fällen benötigen wir eine Implementierung der Ping-Funktion, die im ersten Fall den Client alle n Sekunden und im zweiten die MySQL-Datenbank ständig pingt.

Da beide Funktionen asynchron arbeiten müssen, müssen sie in den untergeordneten Prozessen des Servers aufgerufen werden.

Dazu können sie mit dem Ereignis "workerStart" initialisiert werden. Wir haben es bereits im Konstruktor definiert, und mit diesem Ereignis wird die Methode $ this-> onWorkerStart bereits aufgerufen:
Das Websocket-Protokoll unterstützt sofort Ping-Pong . Unten sehen Sie die Implementierung auf 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); } }); } 


Als nächstes implementierte ich eine einfache Funktion zum Pingen eines MySQL-Servers alle N Sekunden mit swoole \ Timer:

DatabaseHelper
Der Timer selbst startet in initPdo, falls er noch nicht aktiviert ist:

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


Der Hauptteil der Arbeit bestand darin, Logik zum Hinzufügen, Speichern, Senden von Nachrichten (nicht komplizierter als die übliche CRUD) zu schreiben und dann einen großen Spielraum für Verbesserungen zu schaffen.

Bisher habe ich meinen Code in eine mehr oder weniger lesbare Form und einen objektorientierten Stil gebracht. Ich habe ein paar Funktionen implementiert:

- Login mit Namen;

- Stellen Sie sicher, dass der Name nicht belegt ist
 /** * @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; } 


- Spam-Begrenzer
 <?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: Ja, die Überprüfung erfolgt über die Verbindungs-ID. Vielleicht ist es sinnvoll, es in diesem Fall beispielsweise durch die IP-Adresse des Benutzers zu ersetzen.

Ich bin mir auch nicht sicher, ob in dieser Situation swoole_channel am besten geeignet war. Ich denke später, um diesen Moment zu überarbeiten.

- Einfacher XSS-Schutz mit ezyang / htmlpurifier

- Einfacher Spamfilter
Mit der Möglichkeit, in Zukunft zusätzliche Prüfungen hinzuzufügen.

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


Frontend-Chat ist immer noch sehr roh, weil Das Backend zieht mich mehr an, aber wenn mehr Zeit bleibt, werde ich versuchen, es angenehmer zu gestalten.

Wo kann man Informationen erhalten und Neuigkeiten über das Framework erhalten?


  • Englische offizielle Seite - nützliche Links, aktuelle Dokumentation, wenige Kommentare von Benutzern
  • Twitter - aktuelle Nachrichten, nützliche Links, interessante Artikel
  • Issue Tracker (Github) - Fehler, Fragen, Kommunikation mit den Erstellern des Frameworks. Sie antworten sehr klug (sie beantworteten meine Frage in ein paar Stunden mit einer Frage, die bei der Implementierung von Pingloop half).
  • Geschlossene Ausgaben - ich rate auch. Eine große Datenbank mit Fragen von Benutzern und Antworten der Ersteller des Frameworks.
  • Von Entwicklern geschriebene Tests - Fast jedes Modul aus der Dokumentation enthält in PHP geschriebene Tests, die Anwendungsfälle zeigen.
  • Chinesisches Wiki-Framework - alle Informationen wie auf Englisch, aber viel mehr Kommentare von Nutzern (Google Übersetzer hilft).

API-Dokumentation - eine Beschreibung einiger Klassen und Funktionen des Frameworks in einer recht praktischen Form.

Zusammenfassung


Es scheint mir, dass Swoole sich im letzten Jahr sehr aktiv entwickelt hat, es ist aus dem Stadium herausgekommen, in dem es als "roh" bezeichnet werden könnte, und jetzt steht es in vollem Wettbewerb mit der Verwendung von node.js / go hinsichtlich der asynchronen Programmierung und Implementierung von Netzwerkprotokollen.

Ich freue mich über unterschiedliche Meinungen zu diesem Thema und Feedback von denen, die bereits Erfahrung mit Swoole haben

Sie können im beschriebenen Chatraum über den Link chatten
Quellen sind auf Github verfügbar.

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


All Articles