Trabalhando com Sockets no ReactPHP

No primeiro artigo da série fizemos uma Introdução à programação assíncrona em PHP usando o ReactPHP, depois vimos sobre Promises no ReactPHP. Hoje falaremos sobre Sockets, um assunto muito relevante para a criação de aplicações cliente-servidor.

PHP Básico
Curso de PHP Básico
CONHEÇA O CURSO

Antes, entretanto, tem dois artigos que muito recomendo você ler antes de continuar neste, que são estes:

Criando o primeiro servidor

No seu projeto, instale o componente:

$ composer require react/socket:^1.3

Não vamos instalar manualmente o componente Event Loop pois ele já é uma dependência de react/socket.

Já estamos prontos para criar o nosso primeiro servidor, baseado no do artigo sobre Programação de Sockets em PHP:

<?php

require './vendor/autoload.php';

use React\Socket\Server;
use React\EventLoop\Factory;
use React\Socket\ConnectionInterface;
use React\Stream\WritableResourceStream;

$loop = Factory::create();
$socket = new Server('127.0.0.1:7181', $loop);
$stdout = new WritableResourceStream(\STDOUT, $loop);

$socket->on('connection', static function (ConnectionInterface $connection) {
    $connection->write("Client [{$connection->getRemoteAddress()}] connected \n");
});

$stdout->write("Listening on: {$socket->getAddress()}\n");

$loop->run();

No construtor de Server informamos o IP (local) e a porta que o nosso servidor rodará, não informando o protocolo antes do IP, ele considera por padrão como sendo tcp. Depois, passamos a ouvir o evento connection e informamos um handler que é executado sempre que uma nova conexão é estabelecida com o nosso servidor. Esse handler recebe como parâmetro um objeto do tipo ConnectionInterface que nos permite trabalhar com a conexão que foi realizada. No caso, sempre que um cliente se conectar ao nosso servidor, vamos imprimir no lado dele a mensagem que definimos no método write().

Para testar esse exemplo, basta que você inicie o servidor:

$ php index.php

E para se conectar ao servidor, se seu sistema operacional é baseado em Unix, em outra janela do terminal:

telnet 127.0.0.1 7181

Ao se conectar, no lado do servidor teremos:

$ ~/D/w/reactphp&gt; php index.php
Listening on: tcp://127.0.0.1:7181

No lado do cliente:

$ telnet 127.0.0.1 7181
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Client [tcp://127.0.0.1:50353] connected

O objeto $connection também implementa a interface EventEmitterInterface que nos permite ouvir alguns eventos relacionados à conexão, por exemplo, através do evento data conseguimos receber os dados enviados pelo cliente.

Para testarmos de forma efetiva o evento data, desenvolveremos o exemplo “Echo Server” do artigo Programação de Sockets em PHP:

<?php

require './vendor/autoload.php';

use React\Socket\Server;
use React\EventLoop\Factory;
use React\Socket\ConnectionInterface;
use React\Stream\WritableResourceStream;

$loop = Factory::create();
$socket = new Server('127.0.0.1:7181', $loop);
$stdout = new WritableResourceStream(\STDOUT, $loop);

$socket->on('connection', static function (ConnectionInterface $connection) {
    $connection->write("Client [{$connection->getRemoteAddress()}] connected \n");

    $connection->on('data', static function ($data) use ($connection) {
        $connection->write("Server says: {$data}");
    });
});

$stdout->write("Listening on: {$socket->getAddress()}\n");

$loop->run();

Se você comparar essa implementação com a que implementamos usando apenas o “PHP puro” (no artigo Programação de Sockets em PHP), verá que essa é bem mais simples. O RectPHP abstrai toda a parte complicada de lidar com streams/buffers, oferecendo uma API de bem alto nível.

No terminal do cliente ao interagir com o servidor (submeter algumas entradas):

$ telnet 127.0.0.1 7181
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Client [tcp://127.0.0.1:50397] connected
Hello
Server says: Hello
World
Server says: World

Diferentemente da nossa implementação pura, os servidores criados usando o ReactPHP aceitam múltiplos clientes, sem que façamos nada de especial a respeito (a não ser que tenhamos algum caso de uso mais específico, que é o caso do nosso próximo exemplo).

PHP Intermediário
Curso de PHP Intermediário
CONHEÇA O CURSO

Desenvolvendo um chat

Avançando um pouquinho mais, vamos criar um simples chat que funcionará pelo terminal. Crie um novo projeto, no meu caso, vou chamá-lo de “reactchat”. Nele, instale a dependência do reactphp/socket:

$ composer require react/socket:^1.3

A diferença desse exemplo para o que criamos no artigo Programação de Sockets em PHP será:

  • Usará o componente de socket do ReactPHP;
  • Um membro poderá citar outro (usando @) para enviar uma mensagem privada;
  • O ReactPHP abstrai em muitos aspectos a programação de sockets, portanto, bem mais simples de desenvolver e manter do que usando as funções nativas da extensão Streams do PHP.

Usaremos como referência o exemplo desenvolvido pelo Sergey Zhuk no artigo: Build A Simple Chat With ReactPHP Socket: Server.

Publiquei o exemplo no Github, você pode obtê-lo de lá: KennedyTedesco/reactchat

O index.php inicia o servidor:

<?php

// https://github.com/KennedyTedesco/reactchat

require './vendor/autoload.php';

use ReactChat\Chat;
use ReactChat\Member;
use React\Socket\Server;
use React\EventLoop\Factory;
use React\Socket\ConnectionInterface;
use React\Stream\WritableResourceStream;

$loop = Factory::create();
$socket = new Server('127.0.0.1:7181', $loop);
$stdout = new WritableResourceStream(\STDOUT, $loop);

$chat = new Chat();

$socket->on('connection', static function (ConnectionInterface $connection) use ($chat) {
    $member = new Member($connection);
    $member->write('Informe o seu nome: ');

    $connection->on('data', static function ($data) use ($member, $chat) {
        if ($data !== '' && $member->getName() === null) {
            // Define o nome do membro
            $member->setName(\str_replace(["\r", "\n"], '', $data));
            // Adiciona o membro ao chat
            $chat->addMember($member);
        }
    });
});

$stdout->write("Listening on: {$socket->getAddress()}\n");

$loop->run();

Não obstante, quando uma conexão é estabelecida, a primeira coisa que ele faz é pedir o nome, considerando que é alguém que está querendo entrar no chat. Essa pessoa só é adicionada ao chat depois que definir o nome.

A classe Member encapsula uma conexão e oferece alguns métodos úteis para se trabalhar com essa conexão:

<?php

// https://github.com/KennedyTedesco/reactchat

declare(strict_types=1);

namespace ReactChat;

use Closure;
use React\Socket\ConnectionInterface;

final class Member
{
    private $name;
    private $connection;

    public function __construct(ConnectionInterface $connection)
    {
        $this->connection = $connection;
    }

    public function getName() : ?string
    {
        return $this->name;
    }

    public function setName(string $name) : void
    {
        $this->name = $name;
    }

    public function write(string $data) : void
    {
        $data = \str_replace(["\r", "\n"], '', $data);

        $this->connection->write($data . \PHP_EOL);
    }

    public function onData(Closure $handler) : void
    {
        $this->connection->on('data', $handler);
    }

    public function onClose(Closure $handler) : void
    {
        $this->connection->on('close', $handler);
    }
}

E é no Chat que adicionamos novos membros e definimos os handlers para os eventos de data e close das conexões dos membros.

<?php

// https://github.com/KennedyTedesco/reactchat

declare(strict_types=1);

namespace ReactChat;

use SplObjectStorage;

final class Chat
{
    private $members;

    public function __construct()
    {
        $this->members = new SplObjectStorage();
    }

    public function addMember(Member $member) : void
    {
        $this->members->attach($member);

        $this->newMessageTo("Bem-vindo(a), {$member->getName()}", $member);
        $this->newMessageToAll("{$member->getName()} entrou na sala;", $member);

        $member->onData(function ($data) use ($member) {
            $this->newMessage("{$member->getName()} diz: {$data}", $member);
        });

        $member->onClose(function () use ($member) {
            $this->members->detach($member);
            $this->newMessage("{$member->getName()} saiu da sala.", $member);
        });
    }

    private function newMessage(string $message, Member $exceptMember) : void
    {
        // Se um membro foi citado usando @, envia a mensagem apenas para ele (mensagem privada).
        // Caso contrário, envia a mensagem para todos os membros
        $mentionedMember = $this->getMentionedMember($message);

        if ($mentionedMember instanceof Member) {
            $this->newMessageTo($message, $mentionedMember);
        } else {
            $this->newMessageToAll($message, $exceptMember);
        }
    }

    private function newMessageTo(string $message, Member $to) : void
    {
        // Envia para um membro específico
        foreach ($this->members as $member) {
            if ($member === $to) {
                $member->write($message);

                break;
            }
        }
    }

    private function newMessageToAll(string $message, Member $exceptMember) : void
    {
        // Envia para todos, exceto para o $exceptMember (quem está enviando a mensagem)
        foreach ($this->members as $member) {
            if ($member !== $exceptMember) {
                $member->write($message);
            }
        }
    }

    private function getMentionedMember(string $message) : ?Member
    {
        \preg_match('/\B@(\w+)/i', $message, $matches);

        $nameMentioned = $matches[1] ?? null;

        if ($nameMentioned !== null) {
            /** @var Member $member */
            foreach ($this->members as $member) {
                if ($member->getName() === $nameMentioned) {
                    return $member;
                }
            }
        }

        return null;
    }
}

Se você acompanhou os outros artigos da série, não terá dificuldade para entender o que está acontecendo nesse exemplo. O ReactPHP torna as coisas bem simples do nosso lado.

O método addMember() é o mais importante da classe, pois ele adiciona um membro na pool de conexões, de tal forma que ele passará a receber as mensagens do chat e também poderá interagir com os outros membros.

O método getMentionedMember() avalia por uma regex se algum pattern @nome foi destacado na mensagem que será enviada, se sim, ele procura e retorna o membro que possui esse nome. Ele é o núcleo do funcionamento do envio das mensagens privadas lá no método newMessage().

Para testar, tudo o que você precisa é iniciar o servidor:

$ php index.php

Depois, abra umas três abas no seu terminal para inicializar alguns clientes do chat (dando nomes diferentes para eles):

telnet 127.0.0.1 7181

Você pode, inclusive, testar o envio de mensagens privadas usando @nomeDoMembro.

PHP Avançado
Curso de PHP Avançado
CONHEÇA O CURSO

Concluindo

Ao invés de utilizarmos o terminal para a conexão dos membros do chat, poderíamos usar uma interface web e, para se conectar ao servidor, utilizaríamos WebSockets que já é uma realidade estável em todos os principais navegadores. Inclusive, esse deverá ser o assunto do próximo artigo da série. =D

Até a próxima!

Deixe seu comentário

Head de desenvolvimento. Vasta experiência em desenvolvimento Web com foco em PHP. Graduado em Sistemas de Informação. Pós-graduando em Arquitetura de Software Distribuído pela PUC Minas. Zend Certified Engineer (ZCE) e Coffee Addicted Person (CAP). @KennedyTedesco