Escrevendo chat online em Websockets usando Swoole

, ,

https://www.techempower.com/benchmarks/#section=data-r16&hw=ph&test=json&l=hr9zpb

Embora o tópico de programação assíncrona não seja novo, em particular, nos artigos do ano passado foram mencionadas algumas implementações escritas em PHP ( Ratchet , Workerman ), acho que o mundo do PHP tem algo para se gabar no último ano

Eu quero apresentar a comunidade ao Swoole  – estrutura assíncrona de código aberto para PHP, escrita em C e fornecida como extensão pecl.

Você pode ver o aplicativo resultante (bate-papo) aqui e os códigos fontes no Github

Porque swoole?

Certamente existem pessoas que serão contra o uso do PHP para tais fins, mas em favor do PHP muitas vezes podem jogar:

  • Relutância em criar um zoológico de diferentes linguagens de programação no projeto
  • A capacidade de usar a base de código existente (se o projeto estiver em PHP).

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

Сapabilities:

  • Programação Assíncrona Orientada a Eventos para PHP
  • API do cliente / servidor TCP / UDP / HTTP / Websocket / HTTP2 assíncrono
  • Suporte a IPv4 / IPv6 / Unix socket / TCP / UDP e SSL / TLS
  • Serializador rápido / desserializador
  • Alto desempenho , escalável, suporte C1000K
  • Agendador de tarefas de milissegundos
  • Código aberto

Casos de uso:

  • Servidor de API para dispositivos móveis
  • Internet das Coisas
  • Micro serviços
  • API ou aplicativos da Web
  • Servidores de jogos
  • Sistemas de bate-papo ao vivo

Exemplos de código que você pode ver no site oficial . Na seção docs, você pode encontrar informações mais detalhadas sobre todas as funcionalidades do framework.

Vamos começar!

Abaixo, descreverei o processo de escrever um servidor Websocket simples para bate-papo on-line e a solução de possíveis dificuldades.

Antes de começar: Você pode aprender mais sobre as classes Swoole \ Websocket \ Server e Swoole \ Server (a Segunda classe é herdada da primeira). 
Fontes do chat .

Instalação:

Usuários Linux:

#! / bin / bash 
pecl install swoole

Usuários de Mac:

# obter uma lista de pacotes disponíveis 
brew instalar swoole 
#! / bin / bash 
brew instalar homebrew / php / php71-swoole

Para usar o preenchimento automático no IDE, é proposto usar o ide-helper

Modelo básico do servidor Websocket

<? php 
$ server = novo Swoole \ Websocket \ Server ("127.0.0.1", 9502); 

$ server-> on ('aberto', função ($ server, $ req) { 
    echo "conexão aberta: {$ req-> fd} \ n"; 
}); 

$ server-> on ('message', função ($ server, $ frame) { 
    echo "mensagem recebida: {$ frame-> data} \ n"; 
    $ server-> push ($ frame-> fd, json_encode ([ "olá", "mundo"])); 
}); 

$ server-> on ('fechar', função ($ server, $ fd) { 
    echo "conexão próxima: {$ fd} \ n"; 
}); 

$ server-> start ();

$ fd é o identificador de conexão;

Quadros ($ frame) contêm todos os dados enviados. Aqui está um exemplo do objeto que veio para o retorno de chamada on (‘message’):

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

Os dados são enviados para o cliente por função

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

Você pode aprender um pouco mais sobre o protocolo WS e a implementação do lado do cliente (JS) aqui

Como salvar os dados que chegaram ao servidor?

Swoole fornece funcionalidade para o trabalho assíncrona com MySQL , Redisarquivo de I / O .

Também baseado em armazenamentos de memória compartilhada: swoole_buffer , swoole_channel , swoole_table .

As diferenças estão bem descritas na documentação. Para armazenar os nomes dos usuários, escolho swoole_table e as mensagens são armazenadas no MySQL.

Então, inicialize uma tabela de nomes de usuários:

$this->users_table = new swoole_table(131072);
$this->users_table->column('username', swoole_table::TYPE_STRING, 100);
$this->users_table->create();

Arquivar os dados é o seguinte:

/**
 * Save user to table in memory;
 * @param User $user
 */
public function save(User $user) {
    $result = $this->users_table->set($user->getId(), ['username' => $user->getUsername()]);
    if ($result === false) {
        $this->reCreateUsersTable();
        $this->users_table->set($user->getId(), ['username' => $user->getUsername()]);
    }
}

Para trabalhar com o MySQL, decidi não usar o modelo assíncrono ainda e acessar o banco de dados diretamente dos workers via PDO:

/**
 * @return Message[]
 */
public function getAll() {
    $stmt = $this->pdo->query('SELECT * FROM messages ORDER BY date_time DESC LIMIT 100');
    $messages = [];
    foreach ($stmt->fetchAll() as $row) {
        $messages[] = new Message($row['username'], $row['message'], new \DateTime($row['date_time']));
    }
    return $messages;
}

Servidor Websocket que eu formalizo como uma classe e coloco a inicialização em __construct ().

Manipulando сallbacks (Open connection, new message, closed connection, worker start ), decidi implementar como métodos de classe:

public function __construct() {
    $this->requestsLimiter = new RequestLimiter();

    $this->initRepositories();

    $this->ws = new 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 (Server $ws) {
        $this->onWorkerStart($ws);
    });

    $this->ws->start();
}

Problemas encontrados:

  • O usuário conectado ao websocket é encerrado após 60 segundos se não houver troca de pacotes (ou seja, o usuário não enviou e não recebeu nada).
  • O servidor web perde a conexão com o MySQL se nenhuma interação ocorrer por um longo tempo

Uma solução em ambos os casos – implemente uma função “ping” que fará o ping constante do cliente a cada “n” segundos no primeiro caso, e o servidor MySQL no segundo.

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

Para fazer isso, eles devem ser inicializados após o evento “workerStart”. Nós já o definimos no construtor, e o método $ this-> onWorkerStart já é chamado neste evento: 
O protocolo Websocket suporta ping-pong fora da caixa. Abaixo você pode ver a implementação no Swoole.

onWorkerStart:

/**
 * @param Server $ws
 */
private function onWorkerStart(Server $ws) {
    $this->initAsyncRepositories();

    $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 no servidor MySQL após cada período de tempo usando o Swoole \ Timer:

/**
 * 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 foi escrever lógica para adicionar, salvar, enviar mensagens (não mais complicado do que o habitual CRUD) e, em seguida, um enorme espaço para melhorias.

Até agora, eu trouxe meu código para um formato mais legível e estilo orientado a objeto, implementado algumas funcionalidades:

  • Login por nome
  • Verifique se o nome não foi obtido por outro usuário:
private function isUsernameCurrentlyTaken(string $username) {
    foreach ($this->usersRepository->getByIds($this->ws->connection_list()) as $user) {
        if ($user->getUsername() == $username) {
            return true;
        }
    }
    return false;
}
  • Solicitar limitador para proteção contra spam:
use Swoole\Channel;

class RequestLimiter
{
    /**
     * @var Channel
     */
    private $userIds;

    const MAX_RECORDS_COUNT = 6;

    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);
    }
}
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 Frontend do chat é um pouco feio, porque fiquei mais atraído pelo backend, mas quando tenho mais tempo vou tentar torná-lo mais agradável.

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

  • Site oficial
  • Grupo do slack
  • Twitter – notícias reais, artigos interessantes
  • Issue tracker (Github) – bugs, perguntas, comunicação com os criadores do framework. Responda muito rapidamente.
  • Testes – quase todos os módulos da documentação possuem testes escritos em PHP, mostrando os casos de uso
  • Wiki chinês – todas as informações que estão em inglês, mas muito mais comentários de usuários (usei o google translator para ler).
  • Documentação da API – descrição de algumas classes e funções da estrutura em uma forma bastante conveniente.

Na conclusão

Parece-me que o Swoole tem se desenvolvido muito ativamente no ano passado, saiu do estágio em que poderia ser chamado de “raw”, e agora está competindo com o uso de node.js / go / etc. em termos de programação assíncrona e implementação de protocolos de rede.

Terei todo o prazer em ouvir opiniões e opiniões diferentes de quem já tem experiência em usar o Swoole

Se você tiver alguma dúvida ou feedback para mim, vamos nos conectar no Twitter, Telegram .

Para se comunicar no chat descrito, por favor siga o link
O código fonte está disponível no Github .

Artigo Original AQUI

Autor: Evgeniy Romashkan

Obrigado por enviar o seu comentário minha jóia!