Serviços não precisam de nomes

há 4 anos De Jiří Pudil  

Gosto da solução do Nette Framework para injeção de dependência. Eu realmente a amo. Este artigo está aqui para compartilhar essa paixão e explicar por que acho que é a melhor solução de DI no ecossistema PHP atual.

(Este post foi originalmente publicado no blog do autor.)

O desenvolvimento de software é um processo iterativo infinito de abstração. Encontramos abstrações adequadas do mundo real na modelagem de domínio. Na programação orientada a objetos, usamos abstrações para descrever e impor contratos entre diferentes atores no sistema. Introduzimos novas classes no sistema para encapsular responsabilidades e definir seus limites, e então usamos composição para criar todo o sistema.

Estou falando sobre o impulso de extrair a lógica de autenticação do seguinte controller:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        if ($username !== 'admin' || $password !== 'p4ssw0rd!') {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Você provavelmente reconhecerá que a verificação de credenciais não pertence ali. O controller não tem a responsabilidade de determinar quais credenciais são válidas – de acordo com o princípio da responsabilidade única, o controller deve ter apenas um único motivo para mudar, e esse motivo deve estar dentro da interface do usuário da aplicação, não no processo de autenticação.

Vamos seguir o caminho óbvio e extrair a condição para a classe Authenticator:

final class Authenticator
{
    public function authenticate(string $username, string $password): bool
    {
        return $username === 'admin' && $password === 'p4ssw0rd!';
    }
}

Agora basta delegarmos do controller para este autenticador. Criamos o autenticador dependência do controller e o controller de repente precisa obtê-lo de algum lugar:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== fácil, apenas crio um novo!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Esta maneira ingênua funcionará. Mas apenas até que uma autenticação mais robusta seja implementada, o que exigirá que o autenticador consulte uma tabela de banco de dados de usuários. De repente, o Authenticator tem sua própria dependência, digamos UserRepository, que por sua vez depende de uma instância de Connection, que depende de parâmetros do ambiente específico. Isso escalou rapidamente!

Criar instâncias manualmente em todos os lugares não é uma maneira sustentável de gerenciar dependências. É por isso que temos o padrão de injeção de dependência, que permite ao controller apenas declarar a dependência do Authenticator e deixar para outra pessoa fornecer a instância real. E essa outra pessoa é chamada de contêiner de injeção de dependência.

O contêiner de injeção de dependência é o arquiteto principal da aplicação – ele sabe como resolver as dependências de qualquer serviço no sistema e é responsável por criá-los. Os contêineres de DI são tão comuns hoje que quase todo framework web maior tem sua própria implementação de contêiner, e existem até pacotes separados dedicados à injeção de dependência, por exemplo, PHP-DI.

Caindo na armadilha

A quantidade de opções acabou motivando um grupo de desenvolvedores a buscar uma abstração que as tornasse interoperáveis. Uma interface comum foi aprimorada com o tempo e finalmente proposta para o PHP-FIG na seguinte forma:

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

Esta interface ilustra uma propriedade muito importante dos contêineres de DI: **Eles são um bom servo, mas podem facilmente se tornar um mau mestre. São imensamente úteis se você souber como usá-los, mas se os usar incorretamente, eles vão te queimar. Considere o seguinte contêiner:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories['authenticator'] = fn() => new Authenticator($this->get('userRepository'));
        $this->factories['userRepository'] = fn() => new UserRepository($this->get('connection'));
        $this->factories['connection'] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Até agora, tudo bem. A implementação parece boa pelos padrões que estabelecemos: ela realmente sabe como criar cada serviço na aplicação e resolve recursivamente suas dependências. Tudo é gerenciado em um só lugar e o contêiner até aceita parâmetros, então a conexão ao banco de dados é facilmente configurável. Legal!

Mas agora que você vê apenas os dois métodos de ContainerInterface, talvez fique tentado a usar o contêiner desta forma:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get('authenticator');
        //...
    }
}

Parabéns, você acabou de cair na armadilha. Em outras palavras, o contêiner se tornou um mestre mau. Por que isso acontece?

Primeiro, você está confiando em um identificador de serviço arbitrário: 'authenticator'. A injeção de dependência trata de ser transparente sobre suas dependências, e usar um identificador artificial vai diretamente contra essa noção: faz com que o código dependa silenciosamente da definição do contêiner. Se um serviço for renomeado no contêiner, você terá que encontrar essa referência e atualizá-la.

E o que é pior, essa dependência está oculta: à primeira vista, de fora, o controller depende apenas da abstração do contêiner. Mas como desenvolvedor, você precisa ter conhecimento de como os serviços são nomeados no contêiner e que o serviço chamado authenticator é, na verdade, uma instância de Authenticator. Tudo isso seu novo colega terá que aprender. Desnecessariamente.

Felizmente, podemos recorrer a um identificador muito mais natural: o tipo do serviço. Afinal, isso é a única coisa que interessa a você como desenvolvedor. Você não precisa saber qual string aleatória está atribuída ao serviço no contêiner. Acredito que este código é muito mais simples de escrever e ler:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get(Authenticator::class);
        //...
    }
}

Infelizmente, ainda não apagamos as chamas. Nem um pouco. O problema maior é que você está humildemente colocando o contêiner no papel de localizador de serviços, o que é um enorme antipadrão. É como trazer a geladeira inteira para alguém pegar um lanche – muito mais sensato é trazer apenas o lanche.

Novamente, a injeção de dependência trata de transparência, e este controller ainda não é transparente sobre suas dependências. A dependência do autenticador está completamente oculta do mundo exterior por trás da dependência do contêiner. Isso torna o código mais difícil de ler. Ou usar. Ou testar! Mockar o autenticador em um teste unitário agora exige que você crie um contêiner inteiro ao redor dele.

E, a propósito, o controller ainda depende da definição do contêiner, e de uma maneira bastante ruim. Se o serviço autenticador não existir no contêiner, o código falhará apenas no método action(), o que é um feedback bastante tardio.

Cozinhando algo saboroso

Para ser justo, ninguém pode culpá-lo por ter chegado a este beco sem saída. Afinal, você estava apenas seguindo a interface projetada e comprovada por desenvolvedores inteligentes. A questão é que todos os contêineres de injeção de dependência são, por definição, também localizadores de serviços, e acontece que o padrão é realmente a única interface comum entre eles. Mas isso não significa que você deveria usá-los como localizadores de serviços. Na verdade, a própria especificação PSR avisa contra isso.

É assim que você pode usar o contêiner de DI como um bom serviço:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private Authenticator $authenticator,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $areCredentialsValid = $this->authenticator->authenticate($username, $password);
        //...
    }
}

No construtor, a dependência é declarada explicitamente, de forma clara e transparente. As dependências não estão mais ocultas espalhadas pela classe. Elas também são impostas: o contêiner não é capaz de criar uma instância de SignInController sem fornecer o Authenticator necessário. Se não houver autenticador no contêiner, a execução falhará prematuramente, não no método action(). Testar esta classe também se tornou muito mais fácil, pois você só precisa mockar o serviço autenticador sem qualquer boiler plate do contêiner.

E mais um detalhe pequeno, mas muito importante: introduzimos a informação sobre o tipo do serviço. O fato de ser uma instância de Authenticator – anteriormente implícito e desconhecido para o IDE, ferramentas de análise estática ou mesmo para um desenvolvedor desconhecedor da definição do contêiner – agora está estaticamente gravado no type hint do parâmetro promovido.

O único passo que resta é ensinar o contêiner a criar também o controller:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Você pode ter notado que o contêiner ainda usa internamente a abordagem de localizador de serviços. No entanto, isso não importa, desde que esteja contido (trocadilho intencional). O único lugar fora do contêiner onde a chamada do método get é permitida é em index.php, no ponto de entrada da aplicação, onde é necessário criar o próprio contêiner e, em seguida, carregar e executar a aplicação:

$container = bootstrap();

$application = $container->get(Best\Framework\Application::class);
$application->run();

A joia escondida

Mas não vamos parar por aí, permita-me levar esta afirmação adiante: o único lugar onde a chamada do método get é permitida é o ponto de entrada.

O código do contêiner é apenas fiação, são instruções de montagem. Não é código executável. De certa forma, não é importante. Embora sim, seja crucial para a aplicação, mas apenas do ponto de vista do desenvolvedor. Na verdade, não traz nenhum valor direto para o usuário e deve ser tratado com essa consideração.

Olhe novamente para o contêiner:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Isso se refere apenas a um segmento muito pequeno e simples da aplicação. À medida que a aplicação cresce, escrever manualmente o contêiner se torna incrivelmente tedioso. Como eu disse, o contêiner é apenas um manual de montagem – mas é muito complexo, tem muitas páginas, inúmeras referências cruzadas e muitos avisos escritos em letras pequenas. Queremos transformá-lo em um manual estilo IKEA, gráfico, conciso e com ilustrações de pessoas sorrindo enquanto colocam o ÅUTHENTICATÖR no tapete durante a montagem para não quebrá-lo.

É aqui que entra o Nette Framework.

A solução de DI do Nette Framework utiliza Neon, um formato de arquivo de configuração semelhante ao YAML, mas com esteroides. É assim que você definiria o mesmo contêiner usando a configuração Neon:

services:
    - SignInController
    - Authenticator
    - UserRepository
    - Connection(%database%)

Permita-me apontar duas coisas notáveis: primeiro, a lista de serviços é realmente uma lista, não um mapa hash – não há chaves, nenhum identificador de serviço artificial. Não existe authenticator, nem Authenticator::class. Segundo, você não precisa declarar explicitamente nenhuma dependência em lugar nenhum, exceto os parâmetros de conexão ao banco de dados.

Isso porque o Nette Framework depende do autowiring. Lembra como, graças à injeção de dependência, pudemos expressar o tipo da dependência com um typehint nativo? O contêiner de DI utiliza essa informação, então quando você solicita uma instância de Authenticator, ele ignora completamente quaisquer nomes e encontra o serviço correto exclusivamente pelo seu tipo.

Você pode argumentar que o autowiring não é um recurso exclusivo. E você estaria certo. O que torna o contêiner do Nette Framework único é o uso do sistema de tipos do PHP, enquanto em muitos outros frameworks, o autowiring ainda é internamente construído sobre nomes de serviços. Existem cenários em que outros contêineres ficam para trás. É assim que você definiria o serviço autenticador no contêiner Symfony DI usando a linguagem YAML:

services:
  Authenticator: ~

Na seção services, há um mapa hash e o bit Authenticator é o identificador do serviço. O til significa null em YAML, que o Symfony interpreta como “use o identificador do serviço como seu tipo”.

Logo, porém, os requisitos de negócios mudarão e você precisará suportar a autenticação via LDAP além da busca local no banco de dados. No primeiro passo, você altera a classe Authenticator para uma interface e extrai a implementação original para a classe LocalAuthenticator:

services:
  LocalAuthenticator: ~

De repente, o Symfony fica perdido. Isso porque o Symfony trabalha com nomes de serviços em vez de tipos. O controller ainda depende corretamente da abstração e lista a interface Authenticator como sua dependência, mas não há nenhum serviço com o nome Authenticator no contêiner. Você precisa dar uma dica ao Symfony, por exemplo, usando um alias de nome de serviço:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

O Nette Framework, por outro lado, não precisa de nomes de serviços nem de dicas. Ele não o força a duplicar na configuração informações que já estão expressas no código (através da cláusula implements). Ele está posicionado diretamente sobre o sistema de tipos do PHP. Ele sabe que LocalAuthenticator é do tipo Authenticator, e se for o único serviço que implementa essa interface, ele o conectará automaticamente com prazer onde essa interface for necessária, com base apenas nesta linha de configuração:

services:
    - LocalAuthenticator

Admito que, se você não está familiarizado com o autowiring, pode parecer um pouco mágico e talvez precise de algum tempo para aprender a confiar nele. Felizmente, funciona de forma transparente e determinística: quando o contêiner não consegue resolver inequivocamente as dependências, ele lança uma exceção durante a compilação, o que o ajuda a corrigir a situação. Desta forma, você pode ter duas implementações diferentes e ainda ter um bom controle sobre onde cada uma delas é usada.

No geral, o autowiring impõe uma carga cognitiva menor a você como desenvolvedor. Afinal, você só se preocupa com tipos e abstrações, então por que o contêiner de DI o forçaria a se preocupar também com implementações e identificadores de serviços? E, mais importante, por que você deveria se preocupar com qualquer contêiner? No espírito da injeção de dependência, você quer ter a capacidade de simplesmente declarar dependências e deixar que seja problema de outra pessoa fornecê-las. Você quer se concentrar totalmente no código da aplicação e esquecer a fiação. E é isso que o DI do Nette Framework permite que você faça.

Aos meus olhos, isso torna a solução de DI do Nette Framework a melhor que existe no mundo PHP. Ele fornece um contêiner que é confiável e impõe bons padrões arquiteturais, mas ao mesmo tempo é tão fácil de configurar e manter que você não precisa pensar nele.

Espero que este post tenha conseguido despertar sua curiosidade. Não se esqueça de conferir o repositório no Github e a documentação – talvez você descubra que mostrei apenas a ponta do iceberg e que todo o pacote é muito mais poderoso.