PHP: Função auxiliar com contexto de objeto?!

Se você é iniciante no PHP, essa introdução pode ter te deixado com uma super incógnita na cabeça... E tudo bem! Eu vou explicar direitinho. Mas antes de começar eu quero destacar o que eu não vou me aprofundar tanto assim, beleza?

O ponto forte deste artigo será sobre funções anônimas (closures). Se você não sabe nem o que é função, começa pelo meu artigo introdutório. Se já tem conhecimentos mas ainda se sente meio inseguro sobre o que é uma closure, lê meu artigo específico sobre Closures, Funções variáveis e Arrow Functions.

Há um outro pré-requisito que é estruturas de try-catch, que ainda não falei em nenhum artigo... Se só não souber isso, tenta ler assim mesmo. Ficando alguma dúvida, sinta-se a vontade para perguntar nos comentários ou mandando mensagem lá no meu Twitter!

Agora que já estabeleci os pré-requisitos para nos aventurarmos na leitura de hoje, vamos nos jogar de cabeça!

Mas Kiko, o que é uma função auxiliar?

É um bom começo, não é mesmo? Quem programa com Orientação a Objeto há algum tempo pode ter a mesma sensação que eu tenho hoje, sobre parecer um tabu escrever qualquer função que não seja um método de uma classe. Porém isso não é tão errado assim, tem momentos que uma função deixa o código bem mais legível do que instanciar uma classe para chamar apenas um método, sabe?

Um exemplo bem legal sobre isso vem de um problema que investiguei no trabalho hoje sobre transações de banco de dados. Calma, você não precisa entender isso a fundo para sacar o que está acontecendo, apenas pega o resumo e você vai entender o problema geral.

Quando nós enviamos um comando para o banco de dados fora de uma transação, esse comando é persistido nos dados instantaneamente (leia-se, no momento que o banco processar o seu comando). Já dentro de uma transação, você pode efetuar vários comandos em sequência, mas eles só serão persistidos quando você fizer o commit dos comandos. Isso serve para, caso algo dê errado, você possa reverter todos os comandos de uma só vez.

Então você tem a seguinte situação:

  1. Vou fazer vários comandos complexos, então inicio uma transação com o banco de dados;
  2. Faço um comando;
  3. Faço outro comando;
  4. ... Faço vários comandos de banco de dados;
  5. Se deu tudo certo, mando o sinal de commit e salto para o passo 7;
  6. Se algo deu errado, reverto com o sinal de rollback e salto para o próximo passo;
  7. Concluo transmitindo se deu certo ou não.

Em vários frameworks você terá uma classe que fornece esses comandos de banco de dados. Hoje eu vou me basear no Laravel, usando como referência a classe DB, que, por sinal, já fornece um método auxiliar sobre o que iremos ver mais abaixo.

Enfim, então você pode fazer os passos acima da seguinte forma:

DB::beginTransaction(); // passo 1
$success = false;
try {
    // passo 2
    // passo 3
    // ... passo 4
    DB::commit(); // passo 5
    $success = true;
} catch (Throwable $throwable) {
    DB::rollback(); // passo 6
    // eu colocaria um log aqui, mas não convém ao caso
}
return $success; // passo 7

O que é desagradável nessa situação, é ver essa estratégia repetida diversas vezes ao longo do código, sendo que temos pelo menos quatro passos iguais em todos os casos, concorda? Então por que não criar uma função que dá a possibilidade de executar um callabe?

Criando a função auxiliar transaction()

Se você encapsular isso tudo em uma função, teremos o seguinte cenário:

function transaction(): bool
{
    DB::beginTransaction(); // passo 1
    $success = false;
    try {
        // passo 2
        // passo 3
        // ... passo 4
        DB::commit(); // passo 5
        $success = true;
    } catch (Throwable $throwable) {
        DB::rollback(); // passo 6
    }
    return $success; // passo 7
}

Porém, os passos 2, 3, 4 e 7 não são exatamente responsabilidades dela. Isso precisa ser algo que possa ser criado do lado de fora dela... É aí que entra as funções anônimas: você dá para quem usar a função auxiliar o poder de escrever outra função para executar dentro.

Isso nos leva a criar um argumento callable $closure, chamando-o dentro da estrutura de try-catch:

function transaction(callable $closure): mixed
{
    DB::beginTransaction(); // passo 1
    $result = null;
    try {
        $result = $closure();
        DB::commit(); // passo 5
    } catch (Throwable $throwable) {
        DB::rollback(); // passo 6
    }
    return $result; // passo 7
}

Notou que mudei o $sucess para $result? Porque eu não quero forçar o desenvolvedor a só responder booleanos. Ele vai responder o que precisar e isso poderá ter a tipagem que precisar.

Se o PHP tivesse Generics, nós poderíamos forçar a criação de um tipo genérico, modificando a assinatura do argumento para algo como callable<Response> $closure e a assinatura da função para : Response. Assim poderíamos facilmente mapear a partir da criação das closures qual seria o tipo de resposta...

QUÊ?!

Ok, eu só estava pensando em como a linguagem ficaria ainda mais divertida se tivesse Generics, mas foi total fuga do tema: nota zero na redação. Mas, voltando ao assunto... Com o retorno mixed, o desenvolvedor já tem total controle para fazer algo assim:

transaction(function() {
    // passo 2
    // passo 3
    // ... passo 4
    return true; // sucesso
});

Você pode até ajudá-lo, dando opção para definir o valor padrão do retorno negativo:

function transaction(callable $closure, $defaultResult = null): mixed
{
    DB::beginTransaction(); // passo 1
    $result = $defaultResult;
    try {
        $result = $closure();
        DB::commit(); // passo 5
    } catch (Throwable $throwable) {
        DB::rollback(); // passo 6
    }
    return $result; // passo 7
}

Deixando o uso dessa forma:

transaction(
    function() {
        // passo 2
        // passo 3
        // ... passo 4
        return true; // sucesso
    },
    false // erro
);

De todo modo, isso ainda não está tão legal assim. A função faz o que promete: transaciona baseado no sucesso de tudo o que for executado na sua closure... Mas não te dá o controle de poder enviar sinal de commit quando quiser... Além de fornecer outros controles que vamos ver mais embaixo.

Para melhorar esse controle, você terá de ter uma classe de gerenciamento de transações, a qual irei chamar de TransactionManager. Ela terá os métodos públicos para inicializar a transação, comitar ou reverter:

class TransactionManager
{
    public function beginTransaction(): void
    {
        DB::beginTransaction();
    }

    public function commit(): void
    {
        DB::commit();
    }

    public function rollback(): void
    {
        DB::rollback();
    }

    public function bind(callable $closure): callable
    {
        return $closure->bindTo($this); // vincula a instância à closure
    }
}

Notou que coloquei um método bind? Esse método é o que vai tornar a execução de tudo bem mais legível... Você vai ver mais abaixo! Agora nós precisamos ajustar a função auxiliar para não chamar mais nada diretamente, apenas o gerenciador de transações:

function transaction(callable $closure, $defaultResult = null): mixed
{
    $transactionManager = new TransactionManager();
    $transactionManager->beginTransaction();
    $result = $defaultResult;
    try {
        $closure = $transactionManager->bind($closure);
        $result = $closure();
        $transactionManager->commit();
    } catch (Throwable $throwable) {
        $transactionManager->rollback();
    }
    return $result;
}

Ué, Kiko? O código ficou bem mais extenso de se ler... Não era pra ser simples?

Funções auxiliares raramente são simples. Elas estão ali para centralizar o grosso do código de modo que o seu controle, do lado de fora, seja simples. Quer ver na prática?

Bem, agora você pode controlar o commit e rollback de dentro da sua função:

transaction(
    function() {
        // passo 2
        $this->commit();
        // passo 3
        // ... passo 4
        return true; // sucesso
    },
    false // erro
);

Legal?! Percebeu que eu chamei $this->commit() dentro de uma closure que é chamada dentro de uma função? Em cenários comuns, isso resultaria em um belíssimo erro. O que nos possibilita chamar o $this aqui é aquele método bind que mencionei lá em cima.

Ele vincula a função anônima ao objeto $transactionManager, de modo que ela passa a ter praticamente o mesmo escopo dos outros métodos ali dentro. Eu acho isso iradíssimo! E esse é o ponto forte desse artigo, mas vamos além...

Kiko, e se eu quiser que alguma coisa rode após todo o fluxo de sucesso?

O céu é o limite! Você pode adicionar uma estrutura no gerenciador de transações para empilhar várias funções anônimas e chamar todas sequencialmente após qualquer commit. Para não deixar isso amarrado ao gerenciador de transações, vamos criar uma pilha de callbacks, pode ser? Chamarei de CallbackStackManager:

class CallbackStackManager
{
    private array $stack = [];

    public function addCallback(callable $callback): void
    {
        $this->stack[] = $callback;
    }

    private function popStack(): array
    {
        $stack = $this->stack;
        $this->stack = [];
        return $stack;
    }

    public function run(): void
    {
        foreach($this->popStack() as $callback) {
            $callback();
        }
    }
}

É um controle de pilha bem simples. Você vai adicionando suas funções anônimas com o método addCallback. Conforme o código for funcionando, em algum momento você irá rodar todos os callbacks chamando o método run(). Simples assim!

E o que é aquele popStack, Kiko?

É um método de segurança para evitar loops infinitos. Passei por um problema onde uma função desenvolvida por outra pessoa gerou um loop infinito por voltar ao ponto onde o código chamava o run() antes de resetar a stack. Com o método pop, você já reseta a stack antes mesmo de começar.

Ok, Kiko, e como unimos essa classe ao resto da obra de arte?

HEHEHE, primeiramente: nem pense em instanciar o CallbackStackManager dentro do TransactionManager. Se fizer isso, você estará gerando uma dependência interna... Essa classe precisa ser uma entrada no construtor do outro gerenciador, de modo a nos permitir mockar a dependência quando precisarmos escrever algum teste.

Na prática, já era pra termos escrito algum teste para validar nossa teoria... Mas aí iria ser mais um conhecimento de pré-requisito e eu quero tornar esse artigo mais acessível, ok? Se você não conhece o TDD (Test-Driven Development), recomendo a leitura.

Tá, tá, e como faz a parada?

Pra já:

class TransactionManager
{
    public function __construct(
        private CallbackStackManager $callbackStackManager
    ) { }

    public function runAfterCommit(callable $closure): void
    {
        $this->callbackStackManager->addCallback($closure);
    }

    public function beginTransaction(): void
    {
        DB::beginTransaction();
    }

    public function commit(): void
    {
        DB::commit();
        $this->callbackStackManager->run();
    }

    public function rollback(): void
    {
        DB::rollback();
    }

    public function bind(callable $closure): callable
    {
        return $closure->bindTo($this); // vincula a instância à closure
    }
}

O que alterei:

  • incluí um construtor, recebendo uma instância da nova classe CallbackStackManager;
  • incluí um método runAfterCommit para facilitar o uso na closure do desenvolvedor;
  • incluí uma chamada ao callbackStackManager->run() dentro do método commit.

Lembrando que, em cada run(), o gerenciador apaga todas as funções anônimas da pilha.

Além dessas alterações, precisaremos modificar a função auxiliar:

function transaction(callable $closure, $defaultResult = null): mixed
{
    $callbackStackManager = new CallbackStackManager();
    $transactionManager = new TransactionManager($callbackStackManager);
    $transactionManager->beginTransaction();
    $result = $defaultResult;
    try {
        $closure = $transactionManager->bind($closure);
        $result = $closure();
        $transactionManager->commit();
    } catch (Throwable $throwable) {
        $transactionManager->rollback();
    }
    return $result;
}

O que modifiquei:

  • instanciei a nova classe CallbackStackManager;
  • adicionei a nova instância no construtor da classe TransactionManager.

E como seria o uso disso na prática, Kiko?

transaction(
    function() {
        // passo 2
        // passo 3
        $this->runAfterCommit(function() {
            event(new Event('bonequinho_criado'));
        });
        // ... passo 4
        return true; // sucesso
    },
    false // erro
);

Resultado final

class CallbackStackManager
{
    private array $stack = [];

    public function addCallback(callable $callback): void
    {
        $this->stack[] = $callback;
    }

    private function popStack(): array
    {
        $stack = $this->stack;
        $this->stack = [];
        return $stack;
    }

    public function run(): void
    {
        foreach($this->popStack() as $callback) {
            $callback();
        }
    }
}


class TransactionManager
{
    public function __construct(
        private CallbackStackManager $callbackStackManager
    ) { }

    public function runAfterCommit(callable $closure): void
    {
        $this->callbackStackManager->addCallback($closure);
    }

    public function beginTransaction(): void
    {
        DB::beginTransaction();
    }

    public function commit(): void
    {
        DB::commit();
        $this->callbackStackManager->run();
    }

    public function rollback(): void
    {
        DB::rollback();
    }

    public function bind(callable $closure): callable
    {
        return $closure->bindTo($this); // vincula a instância à closure
    }
}

function transaction(callable $closure, $defaultResult = null): mixed
{
    $callbackStackManager = new CallbackStackManager();
    $transactionManager = new TransactionManager($callbackStackManager);
    $transactionManager->beginTransaction();
    $result = $defaultResult;
    try {
        $closure = $transactionManager->bind($closure);
        $result = $closure();
        $transactionManager->commit();
    } catch (Throwable $throwable) {
        $transactionManager->rollback();
    }
    return $result;
}

OBS.: você não vai conseguir rodar esse código se só copiar e colar, pois falta importar a classe DB do Laravel/Lumen. Além disso, não está nada organizado... O ideal é que cada classe esteja em um arquivo separado, assim como a função auxiliar.

Se ainda assim, quiser ver isso funcionando para brincar um pouco, eu criei um projeto no site paiza.io para te fornecer o código pronto para brincadeira. Dá uma olhada: https://paiza.io/projects/Qrb2UOhRGHHH3hdEBDrMVg.

Como o objetivo era apenas mostrar um bom uso de funções auxiliares para simplificar a legibilidade do código, acredito que atingi o objetivo desse artigo. Só não sei se está tão claro assim pra quem nunca viu transações de banco de dados... Mas não era o foco, tá bom?

Curtiu?! Comenta e compartilha! E me conta o que mais gostou, o que mais odiou e se concorda que dá sim pra ter funções auxiliares legais nos projetos? Estou ansioso para saber sua opinião! Sei que esperava um artigo da série PHP para Iniciantes, mas hoje resolvi quebrar a rotina, hahahah.

Inté!!