Stream Wrappers personalizados no PHP

No artigo Streams no PHP, que é uma leitura pré-requisito deste, vimos o essencial sobre streams e como podemos operar sobre elas. Também vimos que o PHP implementa diversos wrappers nativamente, como http://, php://, file:// entre outros, que permitem que operemos determinados protocolos usando as funções nativas do PHP como fgets(), fopen(), fwrite() etc. Nesse artigo veremos como podemos implementar um protocolo próprio a partir de um stream wrapper customizado.

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

Não existe uma classe base a ser estendida ou uma interface a ser implementada, tudo o que o PHP fornece é um protótipo de quais métodos podem ser utilizados no wrapper personalizado. Isso pode ser visto na referência The StreamWrapper Class.

Funcionamento do Wrapper

SqliteJsonWrapper será o nome dele. Definiremos o protocolo sqlj:// e ele terá a responsabilidade de consultar um banco de dados SQLite e retornar os resultados no formato json.

O código completo do wrapper pode ser obtido no repositório KennedyTedesco/artigo-stream-wrappers-php.

Dinâmica de funcionamento:

Dentro da pasta resources do projeto temos uma base de dados SQLite chamada movies.sqlite3, nela existe uma tabela chamada movies com uma relação de filmes e alguns dados sobre eles, nesse formato:

filmgenrestudioyear
(500) Days of SummercomedyFox2009
27 DressesComedyFox2008
A Dangerous MethodDramaIndependent2011
A Serious ManDramaUniversal2009
Across the UniverseromanceIndependent2007
BeginnersComedyIndependent2011
Dear JohnDramaSony2010

Usando o componente illuminate/database do Laravel, vamos nos conectar a essa base de dados e realizar uma consulta, a depender do que será informado na URL do wrapper.

O uso do nosso wrapper se dará dessa forma:

$stream = \fopen('sqlj://movies/year/2009', 'rb', false, $context);

Informamos o protocolo sqlj://, movies é o nome da tabela que será consultada, year é o campo que será usado para delimitar a consulta, ou seja, nesse caso, estaremos resgatando apenas os filmes do ano de 2009 cadastrados na nossa base.

Se a ideia é obter todos os filmes, basta que informemos apenas o nome da tabela:

$stream = \fopen('sqlj://movies', 'rb', false, $context);

Ou, se quisermos obter apenas filmes do gênero drama:

$stream = \fopen('sqlj://movies/genre/drama', 'rb', false, $context);

Você já deve ter percebido que “The Patifaria Never Ends“, esse é um exemplo puramente didático. Não faz muito sentido um wrapper com essa implementação em uma aplicação real que realiza uma consulta em um banco de dados SQLite local, ademais, poderíamos nos conectar diretamente ao banco usando PDO.

A ideia desse artigo é mostrar que é possível abstrair qualquer tipo de coisa para streams e usar as funções nativas do PHP para lidar com esses chunks de dados, o que faz mais sentido quando operamos com grandes arquivos. Mas no final desse artigo veremos referências de casos de uso de Stream Wrappers que são implementados por grandes projetos. Vamos ao código?

Desenvolvedor PHP Sênior
Formação: Desenvolvedor PHP Sênior
Nesta formação você aprenderá aspectos avançados da linguagem PHP e de seu ecossistema, conhecimentos essenciais para se tornar um desenvolvedor diferenciado, e com isso, você terá conhecimento para desenvolver aplicações PHP usando as práticas mais modernas do mercado.
CONHEÇA A FORMAÇÃO

Show me the code

A classe SqliteJsonWrapper é a implementação do wrapper:

<?php

declare(strict_types=1);

use Illuminate\Database\Connection;
use Illuminate\Database\Capsule\Manager as Capsule;

final class SqliteJsonWrapper
{
    /** @var resource */
    public $context;

    /** @var string */
    private $result;

    /** @var Connection */
    private $connection;

    /** @var int */
    private $position = 0;

    public function stream_open(string $path, string $mode, int $options) : bool
    {
        // Resgata as informações de contexto da stream que foram passadas
        $streamContext = \stream_context_get_options($this->context);

        if (empty($streamContext['database'])) {
            throw new \RuntimeException('Missing Stream Context');
        }

        // Conecta à base de dados
        $this->connect($streamContext);

        // Realiza a pesquisa
        $this->query($path);

        return true;
    }

    public function stream_read(int $count)
    {
        $chunk = \mb_substr($this->result, $this->position, $count);

        $this->position += $count;

        return $chunk;
    }

    public function stream_eof() : bool
    {
        return ! ($this->position < \mb_strlen($this->result));
    }

    public function stream_stat() : ?array
    {
        return null;
    }

    private function connect(array $options) : void
    {
        $capsule = new Capsule();

        $capsule->addConnection([
            'driver'    => 'sqlite',
            'database'  =>  $options['database']['file'],
            'prefix'    => '',
        ]);

        $this->connection = $capsule->getConnection();
    }

    private function query(string $path) : void
    {
        // Extrai o nome da tabela
        $table = \parse_url($path, \PHP_URL_HOST);

        // Tenta extrair se é pra delimitar a consulta com where
        $where = [];
        if ($path = \parse_url($path, \PHP_URL_PATH)) {
            $criteria = \explode('/', $path);

            $where = [
                $criteria[1] => $criteria[2]
            ];
        }

        // Armazena os resultados no formato json
        $this->result = $this->connection->table($table)->where($where)->get()->toJson();
    }
}

A classe do wrapper precisa se basear no protótipo especificado em The StreamWrapper Class, mas utilizando apenas os métodos que fazem sentido para o caso de uso dela. No nosso caso, nos limitamos aos métodos:

  • stream_open(): Executado quando a stream é aberta usando fopen().
  • stream_read(): Executado quando alguma função de leitura é utilizada, como fgets() ou stream_get_contents(), ele retorna em chunks os dados e avança o ponteiro que guarda até que posição de bytes de dados já foram lidos, para que na próxima iteração ele pegue a partir daquele ponto.
  • stream_eof(): Executado quando a função feof() é invocada. Ele informa se está no final do arquivo.
  • stream_stat(): Executado em resposta à função fstat(), mas também quando a stream_get_contents() é chamada. Para o nosso exemplo, esse método não precisa retornar nenhum dado sobre o recurso que estamos operando, mas ele precisa existir na classe, mesmo que sem implementação.

Esses são os quatro métodos que permitem com que façamos as principais operações em cima dos dados resgatados.

Os outros métodos que a classe implementa são apenas da lógica dela para consulta na base de dados:

  • connect(): Esse método realiza a conexão com a base de dados;
  • query(): Uma vez que uma conexão foi aberta, esse método recebe a URL de consulta do wrapper para realizar uma pesquisa na base de dados.

Um uso básico do nosso wrapper:

<?php

declare(strict_types=1);

require './vendor/autoload.php';
require './SqliteJsonWrapper.php';

\stream_wrapper_register('sqlj', SqliteJsonWrapper::class);

$context = \stream_context_create([
    'database' => [
        'file' => './resources/movies.sqlite3',
    ],
]);

$stream = \fopen('sqlj://movies/year/2009', 'rb', false, $context);

$buffer = '';
while (\feof($stream) === false) {
    $buffer .= \fread($stream, 128);
}

echo $buffer;

fclose($stream);

O resultado:

[{"film":"(500) Days of Summer","genre":"comedy","studio":"Fox","year":"2009"},{"film":"A Serious Man","genre":"drama","studio":"Universal","year":"2009"},{"film":"Ghosts of Girlfriends Past","genre":"comedy","studio":"Warner Bros.","year":"2009"},{"film":"He's Just Not That Into You","genre":"comedy","studio":"Warner Bros.","year":"2009"},{"film":"It's Complicated","genre":"comedy","studio":"Universal","year":"2009"},{"film":"Love Happens","genre":"drama","studio":"Universal","year":"2009"},{"film":"Not Easily Broken","genre":"drama","studio":"Independent","year":"2009"},{"film":"The Invention of Lying","genre":"comedy","studio":"Warner Bros.","year":"2009"},{"film":"The Proposal","genre":"comedy","studio":"Disney","year":"2009"},{"film":"The Time Traveler's Wife","genre":"drama","studio":"Paramount","year":"2009"},{"film":"The Twilight Saga: New Moon","genre":"drama","studio":"Summit","year":"2009"},{"film":"The Ugly Truth","genre":"comedy","studio":"Independent","year":"2009"}]⏎

A stream_wrapper_register() registra o wrapper. Na variável $context criamos o contexto da stream com a informação de localização do arquivo da base de dados que será trabalhado. Depois disso, abrimos a stream em cima do protocolo que definimos e extraímos os dados dela em chunks de 128 bytes.

O mesmo exemplo também poderia ser reescrito para usar stream_get_contents():

<?php

declare(strict_types=1);

require './vendor/autoload.php';
require './SqliteJsonWrapper.php';

\stream_wrapper_register('sqlj', SqliteJsonWrapper::class);

$context = \stream_context_create([
    'database' => [
        'file' => './resources/movies.sqlite3',
    ],
]);

$stream = \fopen('sqlj://movies/year/2009', 'rb', false, $context);

echo stream_get_contents($stream);
PHP Intermediário
Curso de PHP Intermediário
CONHEÇA O CURSO

Concluindo

Igual comentei anteriormente, existem diversas implementações possíveis para Stream Wrappers, vou me delimitar a duas bem legais, que são:

  • Google Storage: No cliente do Google Cloud para PHP eles definiram um Stream Wrapper para que o usuário consiga recuperar ou gravar objetos no serviço usando as funções nativas do PHP a partir do protocolo gs://.
  • AWS S3: O mesmo existe na SDK da AWS para PHP, um Stream Wrapper para que seja possível usar o protocolo s3:// e recuperar / gravar objetos no serviço de storage S3.

No caso da AWS, ter uma implementação de Stream Wrapper para abstrair a comunicação com o serviço de storage dela, traz benefícios para o desenvolvedor que usa sua SDK tais como:

Para recuperar um arquivo:

$data = file_get_contents('s3://bucket/key');

Se precisa trabalhar com grandes arquivos:

// Abre a stream
if ($stream = fopen('s3://bucket/key', 'r')) {
    // Enquanto a stream continua aberta
    while (!feof($stream)) {
        // Lê 1024 bytes da stream
        echo fread($stream, 1024);
    }
    fclose($stream);
}

Como também permite que objetos sejam criados:

file_put_contents('s3://bucket/key', 'Hello!');

Bom, é isso! É bem possível que eu continue escrevendo sobre o assunto de streams nos próximos artigos, então, até breve!

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