Los servicios no necesitan nombre

hace 3 años por Jiří Pudil  

Me encanta la solución de inyección de dependencias de Nette Framework. De verdad. Este post está aquí para compartir esta pasión, explicando por qué creo que es la mejor solución DI en el ecosistema PHP actual.

(Este post ha sido publicado originalmente en el blog del autor).

El desarrollo de software es un interminable proceso iterativo de abstracción. Encontramos abstracciones apropiadas del mundo real en el modelado de dominios. En la programación orientada a objetos, utilizamos abstracciones para describir y hacer cumplir contratos entre varios actores del sistema. Introducimos nuevas clases en el sistema para encapsular responsabilidades y definir sus límites, y luego utilizamos la composición para construir todo el sistema.

Me refiero a la necesidad de extraer la lógica de autenticación del siguiente controlador:

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);
    }
}

Probablemente puedes decir que la comprobación de credenciales no pertenece ahí. No es responsabilidad del controlador decir qué credenciales son válidas – siguiendo el principio de responsabilidad única, el controlador sólo debe tener una única razón para cambiar, y esa razón debe estar dentro de la interfaz de usuario de la aplicación, no en el proceso de autenticación.

Tomemos la salida obvia de esto y extraigamos la condición en una clase Authenticator:

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

Ahora todo lo que necesitamos hacer es delegar desde el controlador a este autenticador. Hemos hecho del autenticador una dependencia del controlador, y de repente el controlador necesita obtenerlo de algún sitio:

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

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

Esta forma ingenua funcionará. Pero sólo hasta que se implemente una autenticación más robusta, que requiera que el autenticador consulte una tabla de usuarios de la base de datos. El Authenticator de repente tiene una dependencia propia, digamos un UserRepository, que a su vez depende de una instancia Connection, que depende de los parámetros del entorno específico. ¡Eso escaló rápidamente!

Crear instancias a mano en todas partes no es una forma sostenible de gestionar las dependencias. Por eso tenemos el patrón de inyección de dependencias, que permite al controlador simplemente declarar su dependencia de un Authenticator, y dejar que sea otro el problema de proporcionar realmente una instancia. Y ese otro se llama contenedor de inyección de dependencia.

El contenedor de inyección de dependencia es el arquitecto supremo de la aplicación – sabe cómo resolver las dependencias de cualquier servicio dentro del sistema y es responsable de crearlas. Los contenedores DI son tan comunes hoy en día que casi todos los principales frameworks web tienen su propia implementación de contenedor, e incluso hay paquetes independientes dedicados a la inyección de dependencias, como PHP-DI.

Quemando las pimientas

La abundancia de opciones ha acabado motivando a un grupo de desarrolladores a buscar una abstracción que las haga interoperables. La interfaz común ha sido pulida con el tiempo y finalmente propuesta a PHP-FIG en la siguiente forma:

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

Esta interfaz ilustra un atributo muy importante de los contenedores DI: son como el fuego. Son un buen sirviente pero pueden convertirse fácilmente en un mal amo. Son tremendamente útiles siempre que sepas cómo usarlos, pero si los usas incorrectamente, te queman. Piensa en el siguiente recipiente:

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 { /* . . . */ }
}

Hasta aquí todo bien. La implementación parece buena según el estándar que hemos establecido: efectivamente sabe cómo crear cada servicio de la aplicación, resolviendo recursivamente sus dependencias. Todo se gestiona en un único lugar, y el contenedor incluso acepta parámetros, de modo que la conexión a la base de datos es fácilmente configurable. ¡Estupendo!

Pero ahora, viendo los dos únicos métodos de ContainerInterface, puedes sentir la tentación de usar el contenedor así:

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');
        //...
    }
}

Felicidades, acabas de quemar tus pimientos. En otras palabras, el contenedor se ha convertido en el amo malo. ¿Y eso por qué?

Primero, estás confiando en un identificador de servicio arbitrario: 'authenticator'. La inyección de dependencias consiste en ser transparente sobre las dependencias de uno, y usar un identificador artificial va directamente en contra de esa noción: hace que el código dependa silenciosamente de la definición del contenedor. Si alguna vez cambias el nombre del servicio en el contenedor, tendrás que encontrar esta referencia y actualizarla.

Y lo que es peor, esa dependencia está oculta: a primera vista desde fuera, el controlador sólo depende de una abstracción de un contenedor. Pero como desarrollador, debes saber cómo se nombran los servicios en el contenedor, y que un servicio llamado authenticator es, de hecho, una instancia de Authenticator. Tu nuevo colega tiene que aprender todo eso. Innecesariamente.

Por suerte, podemos recurrir a un identificador mucho más natural: el tipo de servicio. Al fin y al cabo, eso es lo único que te importa como desarrollador. No necesitas saber qué cadena aleatoria está asociada al servicio en el contenedor. Creo que este código es mucho más fácil tanto de escribir como de leer:

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);
        //...
    }
}

Desafortunadamente, aún no hemos domado las llamas. Ni un poquito. El mayor problema es que estás degradando el contenedor al papel de un localizador de servicios, lo que es un gran anti-patrón. Es como llevarle a alguien toda la nevera para que coja de ella un bocadillo; es mucho más razonable llevarle sólo el bocadillo.

De nuevo, la inyección de dependencias se basa en la transparencia, y este controlador todavía no es transparente sobre sus dependencias. La dependencia de un autenticador está completamente oculta al mundo exterior, detrás de la dependencia del contenedor. Esto hace que el código sea más difícil de leer. O de usar. O probar. Simular el autenticador en una prueba unitaria ahora requiere que crees un contenedor completo a su alrededor.

Y por cierto, el controlador todavía depende de la definición del contenedor, y lo hace de una manera bastante mala. Si el servicio del autenticador no existe en el contenedor, el código no falla hasta el método action(), que es una respuesta bastante tardía.

Cocinando algo delicioso

Para ser justos, nadie puede culparte por meterte en este callejón sin salida. Después de todo, acabas de seguir la interfaz diseñada y probada por desarrolladores inteligentes. La cuestión es que todos los contenedores de inyección de dependencias son por definición también localizadores de servicios, y resulta que el patrón es realmente la única interfaz común entre ellos. Pero eso no significa que deberías usarlos como localizadores de servicios. De hecho, el propio PSR advierte de ello.

Así es como puedes usar un contenedor DI como un buen servidor:

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);
        //...
    }
}

El controlador declara la dependencia de forma explícita, clara y transparente en el constructor. Las dependencias ya no están ocultas dispersas por la clase. También se hacen cumplir: el contenedor no puede crear una instancia de SignInController sin proporcionar el Authenticator necesario. Si no hay autenticador en el contenedor, la ejecución falla antes, no en el método action(). Probar esta clase se ha vuelto mucho más fácil también, porque sólo tienes que simular el servicio de autenticador sin ningún contenedor boilerplate.

Y hay un último pequeño, pero muy importante detalle: hemos colado la información sobre el tipo de servicio. El hecho de que sea una instancia de Authenticator – previamente implícito y desconocido para el IDE, las herramientas de análisis estático, o incluso para un desarrollador que desconozca la definición del contenedor – está ahora estáticamente tallado en el typehint del parámetro promovido.

El único paso que queda es enseñar al contenedor cómo crear también el controlador:

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 { /* . . . */ }
}

Puedes notar que el contenedor todavía utiliza internamente el enfoque del localizador de servicios. Pero eso está bien, siempre y cuando esté contenido (juego de palabras intencionado). El único lugar fuera del contenedor donde llamar al método get es aceptable, es en el index.php, en el punto de entrada de la aplicación donde necesitas crear el contenedor mismo y luego buscar y ejecutar la aplicación:

$container = bootstrap();

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

La gema oculta

Pero no nos detengamos ahí, permítanme llevar la afirmación más lejos: el único lugar donde llamar al método get es aceptable, es en el punto de entrada.

El código del contenedor es sólo cableado, son instrucciones de ensamblaje. No es código ejecutivo. No es importante, en cierto modo. Aunque sí, es crucial para la aplicación, eso es sólo desde la perspectiva del desarrollador. En realidad no aporta ningún valor directo al usuario, y debería tratarse con eso en mente.

Echa un vistazo al contenedor de nuevo:

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 { /* . . . */ }
}

Esto sólo cubre un segmento muy pequeño y simple de la aplicación. A medida que la aplicación crece, escribir a mano el contenedor se vuelve increíblemente tedioso. Como he dicho antes, el contenedor es un manual de montaje, pero demasiado complicado, con muchas páginas, innumerables referencias cruzadas y muchas advertencias en letra pequeña. Queremos convertirlo en un manual al estilo IKEA, gráfico, conciso y con ilustraciones de gente sonriendo cuando colocan el ÅUTHENTICATÖR sobre la alfombra durante el montaje para que no se rompa.

Aquí es donde entra en juego Nette Framework.

La solución DI Nette Framework utiliza Neon, un formato de archivo de configuración similar a YAML, pero con esteroides. Así es como se definiría el mismo contenedor, utilizando la configuración Neon:

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

Permítanme señalar dos cosas dignas de mención: en primer lugar, la lista de servicios es realmente una lista, no un mapa hash – no hay claves, no hay identificadores artificiales de servicio. No existe authenticator, ni tampoco Authenticator::class. En segundo lugar, no es necesario enumerar explícitamente ninguna dependencia en ninguna parte, aparte de los parámetros de conexión a la base de datos.

Esto se debe a que Nette Framework se basa en el autocableado. ¿Recuerdas cómo, gracias a la inyección de dependencias, hemos podido expresar el tipo de la dependencia en un typehint nativo? El contenedor DI utiliza esa información, de modo que cuando se requiere una instancia de Authenticator, pasa completamente por alto cualquier nombre y encuentra el servicio correcto únicamente por su tipo.

Podrías argumentar que el autocableado no es una característica única. Y tendrías razón. Lo que hace único al contenedor de Nette Framework es la utilización del sistema de tipos de PHP, mientras que en muchos otros frameworks, el autowiring todavía se construye internamente sobre los nombres de los servicios. Hay escenarios donde otros contenedores se quedan cortos. Así es como definirías el servicio authenticator en el contenedor DI de Symfony usando YAML:

services:
  Authenticator: ~

La sección services es un mapa hash y el bit Authenticator es un identificador de servicio. La tilde significa null en YAML, que Symfony interpreta como “usa el identificador de servicio como su tipo”.

Pero pronto, los requisitos del negocio cambian, y necesitas soportar la autenticación a través de LDAP además de una búsqueda en la base de datos local. Como primer paso, conviertes la clase Authenticator en una interfaz y extraes la implementación original en una clase LocalAuthenticator:

services:
  LocalAuthenticator: ~

De repente, Symfony no tiene ni idea. Eso es porque Symfony trabaja con nombres de servicio en lugar de tipos. El controlador sigue confiando correctamente en la abstracción y lista la interfaz Authenticator como su dependencia, pero no hay ningún servicio nombrado Authenticator en el contenedor. Necesitas darle a Symfony una pista, por ejemplo usando un alias nombre de servicio:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, por otro lado, no necesita nombres de servicio ni pistas. No te obliga a duplicar en la configuración la información que ya está expresada en el código (a través de la cláusula implements ). Se sienta justo encima del sistema de tipos de PHP. Sabe que LocalAuthenticator es del tipo Authenticator, y mientras sea el único servicio que implemente la interfaz, la autocableará felizmente donde se solicite la interfaz, dada sólo esta línea de configuración:

services:
    - LocalAuthenticator

Admito que si no estás familiarizado con el autocableado, puede parecer un poco mágico y puede que necesites algún tiempo para aprender a confiar en él. Afortunadamente, funciona de forma transparente y determinista: cuando el contenedor no puede resolver inequívocamente las dependencias, lanza una excepción en tiempo de compilación que te ayuda a arreglar la situación. De este modo, puedes tener dos implementaciones diferentes y seguir controlando bien dónde se utiliza cada una de ellas.

En conjunto, el autocableado pone menos carga cognitiva en ti como desarrollador. Después de todo, sólo te importan los tipos y las abstracciones, así que ¿por qué debería un contenedor DI forzarte a preocuparte también por las implementaciones y los identificadores de servicio? Y lo que es más importante, ¿por qué debería preocuparse por un contenedor en primer lugar? En el espíritu de la inyección de dependencias, quieres ser capaz de declarar las dependencias y que sea problema de otro proporcionarlas. Quieres centrarte completamente en el código de la aplicación y olvidarte del cableado. Y DI de Nette Framework te permite hacer eso.

En mi opinión, esto hace que la solución DI de Nette Framework sea la mejor que existe en el mundo PHP. Te da un contenedor que es fiable y aplica buenos patrones arquitectónicos, pero al mismo tiempo es tan fácil de configurar y mantener que no tienes que pensar en ello en absoluto.

Espero que este post haya conseguido picar tu curiosidad. Asegúrate de echar un vistazo al repositorio de Github y a los docs – con suerte, aprenderás que sólo te he mostrado la punta del iceberg y que el paquete completo es mucho más potente.