Programação de Sockets em PHP

Neste artigo veremos uma introdução à programação de sockets com o PHP. Antes, recomendo que primeiro você leia o artigo Uma introdução a TCP, UDP e Sockets que discorre sobre a base teórica fundamental.

O PHP dispõe de uma extensão chamada Sockets, que foi introduzida ainda no PHP 4 e ela tem uma boa fidelidade em relação à forma como sockets são programados em C, além dela expor a possibilidade de se trabalhar com algumas características do sistema operacional, o que pode ser útil dependendo do que se precisa desenvolver. No entanto, essa extensão nos impõe alguns problemas e dificuldades, o primeiro é que ela não vem instalada por padrão, sendo necessário na compilação do PHP adicionar a flag -enable-sockets para ativá-la. Ela também não possui uma boa usabilidade para o desenvolvedor, é uma extensão difícil de usar, de bem mais baixo nível. Além disso, os resources abertos por ela só podem ser acessados pelas funções da mesma família, ou seja, as que iniciam pelo nome socket_ e isso é um fator que limita o desenvolvimento, principalmente porque o PHP dispõe de várias funções para trabalhar com streams como stream_get_contents(), fwrite(), fread() etc, que não podem ser utilizadas junto com as funções da extensão Sockets.

Mas a solução para os nossos problemas está na extensão Stream, nativa do PHP e que já vem habilitada por padrão. Como o próprio nome sugere, essa extensão lida com toda a abstração de I/O (input e output) do PHP e o detalhe mais importante pra nós é que desde o PHP 5 essa extensão nos permite trabalhar com TCP/IP Sockets e também com Unix Sockets. Os sockets criados usando as funções da extensão Stream (as que iniciam por stream_socket_) nos permitem usar as clássicas funções para lidar com arquivos, o que torna o desenvolvimento bem mais intuitivo.

Em resumo, temos duas formas de trabalhar com Sockets no PHP:

  • Usando as funções da extensão Sockets, que são funções de mais baixo nível, que nos expõem uma variedade maior de possibilidades ao lidar com algumas características de sockets a nível do sistema operacional, mas que prejudica o desenvolvimento por impor um grau maior de dificuldade;
  • Usando as funções da extensão Stream, que é uma extensão habilitada por padrão e utilizada por tudo que envolve streams no PHP. Essa extensão expõem funções de mais alto nível, mas ela pode ser um fator limitante se for preciso trabalhar com características de mais baixo nível, mas isso não costuma ser um problema para a grande maioria das aplicações que implementam o uso de sockets.

Echo Server

O primeiro e mais acessível exemplo que podemos construir para entender como um servidor de socket opera é o de um “Echo Server”, ele basicamente envia de volta tudo o que recebe do cliente. Crie um novo diretório onde normalmente você guarda os seus projetos e então crie um arquivo index.php com a seguinte implementação:

<?php

declare(strict_types=1);

// Queremos que o PHP reporte apenas erros graves, estamos explicitamente ignorando warnings aqui.
// Warnings que, por sinal, acontecem bastante ao se trabalhar com sockets.
error_reporting(E_ERROR | E_PARSE);

// Inicia o servidor na porta 7181
$server = stream_socket_server('tcp://127.0.0.1:7181', $errno, $errstr);

// Em caso de falha, para por aqui.
if ($server === false) {
    fwrite(STDERR, "Error: $errno: $errstr");

    exit(1);
}

// Sucesso, servidor iniciado.
fwrite(STDERR, sprintf("Listening on: %s\n", stream_socket_get_name($server, false)));

// Looping infinito para "escutar" novas conexões
while (true) {
    // Aceita uma conexão ao nosso socket da porta 7181
    // O valor -1 seta um timeout infinito para a função receber novas conexões (socket accept timeout) e isso significa que a execução ficará bloqueada aqui até que uma conexão seja aceita;
    $connection = stream_socket_accept($server, -1, $clientAddress);

    // Se a conexão foi devidamente estabelecida, vamos interagir com ela.
    if ($connection) {
        fwrite(STDERR, "Client [{$clientAddress}] connected \n");

        // Lê 2048 bytes por vez (leitura por "chunks") enquanto o cliente enviar.
        // Quando os dados não forem mais enviados, fread() retorna false e isso é o que interrompe o loop.
        // fread() também retornará false quando o cliente interromper a conexão.
        while ($buffer = fread($connection, 2048)) {
            if ($buffer !== '') {
                // Escreve na conexão do cliente
                fwrite($connection, "Server says: $buffer");
            }
        }

        // Fecha a conexão com o cliente
        fclose($connection);
    }
}

Na raiz do projeto, utilizando o terminal, execute o servidor:

$ php index.php

O resultado será algo como:

kennedytedesco@kennedy-pc ~/D/w/sockets> php index.php
Listening on: 127.0.0.1:7181

Como o próprio output explicita, agora temos um servidor rodando na porta 7181 do host local e ele está pronto para receber uma conexão de um cliente.

Para conectar ao servidor aberto, vou utilizar o cliente Telnet disponível no meu sistema operacional, bastando executar:

$ telnet 127.0.0.1 7181

Tendo sucesso na conexão:

kennedytedesco@kennedy-pc ~> telnet 127.0.0.1 7181
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

E o servidor imediatamente imprime qual cliente se conectou:

kennedytedesco@kennedy-pc ~/D/w/sockets> php index.php
Listening on: 127.0.0.1:7181
Client [127.0.0.1:52357] connected

A porta do cliente é conhecida pelo servidor no momento que esse se conecta a ele. A cada nova conexão uma porta aleatória é associada ao cliente.

A partir desse momento, o cliente pode interagir com o servidor, enviando mensagens, por exemplo:

kennedytedesco@kennedy-pc ~> telnet 127.0.0.1 7181
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello World!
Server says: Hello World!

O cliente enviou “Hello World” e recebeu como resposta “Server says: Hello World!“, essa é a dinâmica do nosso servidor, ele envia de volta tudo o que recebe. Apertando as teclas de atalho CTRL + C no terminal onde o servidor está rodando, você consegue pará-lo e, consequentemente, isso também desconecta o cliente.

Essa é a dinâmica da interação entre o cliente e o servidor:

O nosso servidor opera da seguinte maneira:

  • 1) Ele abre um socket, que na prática diz ao sistema operacional que todos os dados recebidos por esse socket devem ser enviados para o nosso programa;
  • 2) Ele passa a “ouvir” por conexões de clientes;
  • 3) Ele aceita uma conexão quando ela é estabelecida. Nesse momento, a comunicação entre cliente-servidor é possível;
  • 4) Ele troca dados com o cliente;
  • 5) Ele fecha a conexão ou deixa que o cliente a finalize;
  • 6) Quando uma conexão é finalizada, ele volta para o passo 2) e todo esse ciclo é executado enquanto novas conexões existirem e/ou enquanto o servidor estiver em execução.

Voltando ao nosso exemplo, definimos um timeout de -1 na stream_socket_accept e, conforme explicado no código, isso fará o fluxo da execução ficar bloqueado nessa parte até que uma nova conexão seja aceita/estabelecida. No entanto, podemos alterar o exemplo e definir um timeout de, por exemplo, 1 segundo. É que o vamos fazer. Os comentários definidos anteriormente foram suprimidos, pra melhorar a legibilidade:

<?php

declare(strict_types=1);

error_reporting(E_ERROR | E_PARSE);

$server = stream_socket_server('tcp://127.0.0.1:7181', $errno, $errstr);

if ($server === false) {
    fwrite(STDERR, "Error: $errno: $errstr");

    exit(1);
}

fwrite(STDERR, sprintf("Listening on: %s\n", stream_socket_get_name($server, false)));

while (true) {
    $connection = stream_socket_accept($server, 1, $clientAddress);

    if ($connection) {
        fwrite(STDERR, "Client [{$clientAddress}] connected \n");

        while ($buffer = fread($connection, 2048)) {
            if ($buffer !== '') {
                fwrite($connection, "Server says: $buffer");
            }
        }

        fclose($connection);
    } else {
        fwrite(STDERR, "Aguardando ... \n");
    }
}

Nessa alteração, definimos um timeout de 1 segundo e adicionamos esse else na verificação se a conexão foi aceita:

} else {
  fwrite(STDERR, "Aguardando ... \n");
}

Dessa forma, ao iniciar o servidor, a cada 1 segundo ele vai imprimir “Aguardando …” até que uma conexão seja aceita:

kennedytedesco@kennedy-pc ~/D/w/sockets> php index.php
Listening on: 127.0.0.1:7181
Aguardando ...
Aguardando ...
Aguardando ...
Aguardando ...
Aguardando ...
Aguardando ...
Client [127.0.0.1:54215] connected

A ideia por trás de definir um timeout não negativo para a stream_socket_accept() é a de liberar o fluxo da execução do programa ali dentro do while, para que ele possa desempenhar outras tarefas que julgar necessárias.

Aceitando múltiplos clientes

Você deve ter percebido que o nosso servidor só aceita um cliente por vez. Se você tentar abrir um terceiro terminal e se conectar a ele, verá que a conexão não será devidamente estabelecida se outra estiver aberta. Podemos resolver isso de duas formas:

  • 1) Abrir múltiplos processos “ouvindo” o mesmo socket (mas isso só vai funcionar bem em sistemas baseados em Unix, além disso, nem sempre é possível sair forkando processos, sem contar o custo disso pro sistema operacional que é alto (uso de memória e trocas de contexto), sendo mais efetivo trabalhar com threads ou coroutines);
  • 2) Manter um único processo (single-thread) mas receber todas as conexões e monitorar a mudança de estado delas para então decidir se vamos ler e/ou escrever;

Vamos implementar a segunda opção, o “segredo” dela está no uso da função stream_select() que executa uma chamada de sistema select que monitora um ou mais recursos (resources) por mudanças, dessa forma, conseguimos alternar entre diferentes recursos de forma não bloqueante. Esse que é um conceito intimamente relacionado à programação assíncrona. Recomendo, aproveitando o gatilho, a leitura do artigo Introdução à programação assíncrona em PHP usando o ReactPHP.

A implementação do nosso Echo Server que aceita múltiplas conexões de forma não bloqueante:

<?php

declare(strict_types=1);

error_reporting(E_ERROR | E_PARSE);

final class EchoSocketServer
{
    private $except;
    private $server;

    private $buffers = [];
    private $writable = [];
    private $readable = [];
    private $connections = [];

    public function __construct(string $uri)
    {
        $this->server = stream_socket_server($uri);
        stream_set_blocking($this->server, false);

        if ($this->server === false) {
            exit(1);
        }
    }

    public function run() : void
    {
        while (true) {
            $this->except = null;
            $this->writable = $this->connections;
            $this->readable = $this->connections;

            // Adiciona a stream do servidor no array de streams de somente leitura,
            // para que consigamos aceitar novas conexões quando disponíveis;
            $this->readable[] = $this->server;

            // Em um looping infinito, a stream_select() retornará quantas streams foram modificadas,
            // a partir disso iteramos sobre elas (tanto as de escrita quanto de leitura), lendo ou escrevendo.
            // A stream_select() recebe os arrays por referência e ela os zera (remove seus itens) até que uma stream muda de estado,
            // quando isso acontece, a stream_select() volta com essa stream para o array, é nesse momento que conseguimos iterar escrevendo ou lendo.
            if (stream_select($this->readable, $this->writable, $this->except, 0, 0) > 0) {
                $this->readFromStreams();
                $this->writeToStreams();
                $this->release();
            }
        }
    }

    private function readFromStreams() : void
    {
        foreach ($this->readable as $stream) {
            // Se essa $stream é a do servidor, então uma nova conexão precisa ser aceita;
            if ($stream === $this->server) {
                $this->acceptConnection($stream);

                continue;
            }

            // Uma stream é um resource, tipo especial do PHP,
            // quando aplicamos um casting de inteiro nela, obtemos o id desse resource;
            $key = (int) $stream;

            // Armazena no nosso array de buffer os dados recebidos;
            if (isset($this->buffers[$key])) {
                $this->buffers[$key] .= fread($stream, 4096);
            } else {
                $this->buffers[$key] = '';
            }
        }
    }

    private function writeToStreams() : void
    {
        foreach ($this->writable as $stream) {
            $key = (int) $stream;
            $buffer = $this->buffers[$key] ?? null;

            if ($buffer && $buffer !== '') {
                // Escreve no cliente o que foi recebido;
                $bytesWritten = fwrite($stream, "Server says: {$this->buffers[$key]}", 2048);

                // Imediatamente remove do buffer a parte que foi escrita;
                $this->buffers[$key] = substr($this->buffers[$key], $bytesWritten);
            }
        }
    }

    private function release() : void
    {
        foreach ($this->connections as $key => $connection) {
            // Quando uma conexão é fechada, ela entra no modo EOF (end-of-file),
            // usamos a feof() pra verificar esse estado e então devidamente executar fclose().
            if (feof($connection)) {
                fwrite(STDERR, sprintf("Client [%s] closed the connection; \n", stream_socket_get_name($connection, true)));

                fclose($connection);
                unset($this->connections[$key]);
            }
        }
    }

    private function acceptConnection($stream) : void
    {
        $connection = stream_socket_accept($stream, 0, $clientAddress);

        if ($connection) {
            stream_set_blocking($connection, false);
            $this->connections[(int) $connection] = $connection;

            fwrite(STDERR, sprintf("Client [%s] connected; \n", $clientAddress));
        }
    }
}

$server = new EchoSocketServer('tcp://127.0.0.1:7181');
$server->run();

Você pode testar agora abrir várias conexões ao servidor que ele responderá corretamente para cada uma delas. A implementação em sua essência não difere tanto da primeira que fizemos, com a diferença que agora estamos armazenamento as streams em arrays, controlando os buffers e monitorando as mudanças das streams pela stream_select().

Recomendação de leitura

Se você quer aprender programação assíncrona no PHP usando o ReactPHP, recomendo a leitura:

Concluindo

A última implementação que fizemos não foi muito trivial e ela também nem é a melhor opção para resolver o nosso problema. Uma opção melhor e que prefiro é usar programação assíncrona com o padrão Reactor (o mesmo usado por NodeJS e ReactPHP), torna o trabalho com sockets bem mais organizado. Para isso, temos bons recursos no ecossistema PHP, como:

Além disso, se você precisa trabalhar com sockets usando a extensão ext-socket (aquela de mais baixo nível que precisa estar previamente compilada), a library php-socket-raw fornece uma interface orientada a objetos que abstrai grande parte da dificuldade.

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

JUNTE-SE A MAIS DE 150.000 PROGRAMADORES