Escrevemos um bate-papo on-line no Websockets usando o Swoole



O tema dos Websocketsfoi abordado repetidamente em Habré, em particular as opções para implementação em PHP foram consideradas. No entanto, mais de um ano se passou desde o último artigo, com uma visão geral de várias tecnologias, e o mundo PHP tem algo para se gabar ao longo do tempo.

Neste artigo, quero apresentar o Swoole à comunidade de língua russa - estrutura assíncrona de código-fonte aberto para PHP, escrita em C e entregue como uma extensão pecl.

Fontes no github .

Por que swoole?


Certamente, haverá pessoas que, em princípio, serão contra o uso do PHP para tais fins, no entanto, elas podem jogar em favor do PHP:

  • Relutância em criar um zoológico de várias línguas no projeto
  • Capacidade de usar uma base de código já desenvolvida (se o projeto estiver em PHP).

No entanto, mesmo comparando com o node.js / go / erlang e outras linguagens que oferecem nativamente um modelo assíncrono, o Swoole - uma estrutura escrita em C e combinando um limite de entrada baixo e uma funcionalidade poderosa pode ser um bom candidato.

Recursos da estrutura:

  • Evento, modelo de programação assíncrono
  • APIs cliente / servidor TCP / UDP / HTTP / Websocket / HTTP2 assíncronas
  • Suporte IPv4 / IPv6 / Unixsocket / TCP / UDP e SSL / TLS
  • Rápida serialização / desserialização de dados
  • Alto desempenho, extensibilidade, suporte para até 1 milhão de conexões simultâneas
  • Agendador de tarefas em milissegundos
  • Código aberto
  • Suporte para Coroutines

Possíveis casos de uso:

  • Microsserviços
  • Servidores de jogos
  • Internet das coisas
  • Sistemas de comunicação ao vivo
  • API WEB
  • Quaisquer outros serviços que exijam resposta instantânea / alta velocidade / execução assíncrona

Exemplos de código podem ser vistos na página principal do site . Na seção de documentação, informações mais detalhadas sobre toda a funcionalidade da estrutura.

Vamos começar


Abaixo, descreverei o processo de escrever um servidor Websocket simples para bate-papo online e as possíveis dificuldades com isso.

Antes de começar: Mais informações sobre as classes swoole_websocket_server e swoole_server (a segunda classe herda da primeira).
Fontes do próprio bate-papo.

Instalando a estrutura
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 o preenchimento automático no IDE, propõe-se usar o ide-helper

Modelo mínimo de 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 é o identificador de conexão.
Obter conexões atuais:

 $server->connections; 

$ Frame contém todos os dados enviados. Aqui está um exemplo de um objeto que entrou na função onMessage:

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

Os dados são enviados ao cliente usando a função

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

Leia mais sobre quadros e opcodes em russo em learn.javascript . Seção "formato de dados"

Tanto quanto possível sobre o protocolo Websocket - RFC

E como salvar os dados que chegaram ao servidor?
Swoole apresenta funcionalidade para trabalhar de forma assíncrona com MySQL , Redis , E / S de arquivo

Assim como swoole_buffer , swoole_channel e swoole_table
Eu acho que as diferenças não são difíceis de entender na documentação. Para armazenar nomes de usuário, selecionei swoole_table. As próprias mensagens são armazenadas no MySQL.

Portanto, inicialização da tabela de nome de usuário:

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

O preenchimento de dados é o seguinte:

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

Para trabalhar com o MySQL, decidi não usar o modelo assíncrono ainda, mas acessá-lo da maneira padrão, a partir do servidor de soquete da web, através do PDO

Apelo à 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, foi decidido emitir na forma de uma classe e iniciá-lo no construtor:

Construtor
 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. Um usuário conectado ao bate-papo desconecta após 60 segundos se não houver troca de pacotes (ou seja, o usuário não enviou nem recebeu nada)
  2. O servidor da Web perde a conexão com o MySQL se nenhuma interação ocorrer por um longo tempo

Solução:

Nos dois casos, precisamos de uma implementação da função ping, que fará ping constantemente no cliente a cada n segundos no primeiro caso, e no banco de dados MySQL no segundo.

Como ambas as funções devem funcionar de forma assíncrona, elas devem ser chamadas nos processos filhos do servidor.

Para fazer isso, eles podem ser inicializados com o evento "workerStart". Já o definimos no construtor e, com esse evento, o método $ this-> onWorkerStart já é chamado:
O protocolo Websocket suporta ping-pong pronto para uso. Abaixo você pode ver a implementação no 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); } }); } 


Em seguida, implementei uma função simples para executar ping em um servidor MySQL a cada N segundos usando swoole \ Timer:

DatabaseHelper
O próprio cronômetro inicia no initPdo se ainda não estiver ativado:

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


A parte principal do trabalho consistiu em escrever lógicas para adicionar, salvar, enviar mensagens (não mais complicadas que o CRUD usual) e, em seguida, um grande escopo para melhorias.

Até agora, trouxe meu código para uma forma mais ou menos legível e um estilo orientado a objetos, implementei um pouco de funcionalidade:

- Login pelo nome;

- Verifique se o nome não 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); } } 

PS: Sim, a verificação está no ID da conexão. Talvez faça sentido substituí-lo nesse caso, por exemplo, pelo endereço IP do usuário.

Também não tenho certeza de que nessa situação foi o swoole_channel que melhor se adequou. Penso depois para rever este momento.

- Proteção XSS simples usando ezyang / htmlpurifier

- Filtro de spam simples
Com a capacidade de adicionar verificações adicionais no 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; } } 


O bate-papo de front-end ainda é muito cru, porque Estou mais atraído pelo back-end, mas quando houver mais tempo, tentarei torná-lo mais agradável.

Onde obter informações, obter notícias sobre o framework?


  • Site oficial em inglês - links úteis, documentação atualizada, poucos comentários dos usuários
  • Twitter - notícias atuais, links úteis, artigos interessantes
  • Rastreador de problemas (Github) - bugs, perguntas, comunicação com os criadores do framework. Eles respondem muito bem (eles responderam à minha pergunta com uma pergunta em algumas horas, ajudaram na implementação do pingloop).
  • Questões encerradas - também aconselho. Um grande banco de dados de perguntas dos usuários e respostas dos criadores da estrutura.
  • Testes escritos por desenvolvedores - quase todos os módulos da documentação têm testes escritos em PHP, mostrando casos de uso.
  • Quadro wiki chinês - todas as informações como em inglês, mas muito mais comentários dos usuários (tradutor do google para ajudar).

Documentação da API - uma descrição de algumas classes e funções da estrutura de forma bastante conveniente.

Sumário


Parece-me que o Swoole se desenvolveu muito ativamente no ano passado, saiu do palco quando poderia ser chamado de "bruto" e agora está em plena concorrência com o uso do node.js / go em termos de programação assíncrona e implementação de protocolos de rede.

Ficarei feliz em ouvir opiniões diferentes sobre o tópico e comentários daqueles que já têm experiência com o Swoole

Você pode conversar na sala de bate-papo descrita pelo link
As fontes estão disponíveis no Github .

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


All Articles