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.
Curso Symfony - Fundamentos
Conhecer o cursoAntes, entretanto, tem dois artigos que muito recomendo você ler antes de continuar neste, que são estes:
- Uma introdução a TCP, UDP e Sockets: Uma introdução (base teórica) para TCP, UDP e Sockets, principalmente para desenvolvedores que desejam programar com Sockets.
- Streams no PHP: O essencial para se entender e trabalhar com streams no PHP.
- Programação de Sockets em PHP: Uma introdução à programação de sockets com o PHP.
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> 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).
Curso Laravel - Autenticação e autorização
Conhecer o cursoDesenvolvendo 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.
Curso PHP Avançado
Conhecer o cursoO 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
.
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!