I servizi non hanno bisogno di nomi

3 anni fa Da Jiří Pudil  

Adoro la soluzione di iniezione delle dipendenze di Nette Framework. Davvero. Questo post vuole condividere questa passione, spiegando perché ritengo che sia la migliore soluzione DI nell'attuale ecosistema PHP.

(Questo post è stato originariamente pubblicato sul blog dell'autore).

Lo sviluppo del software è un processo iterativo di astrazione senza fine. Troviamo astrazioni appropriate del mondo reale nella modellazione del dominio. Nella programmazione orientata agli oggetti, utilizziamo le astrazioni per descrivere e applicare i contratti tra i vari attori del sistema. Introduciamo nuove classi nel sistema per incapsulare le responsabilità e definirne i confini, e poi usiamo la composizione per costruire l'intero sistema.

Sto parlando dell'esigenza di estrarre la logica di autenticazione dal seguente controllore:

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

Probabilmente si può dire che il controllo delle credenziali non appartiene a questo punto. Non è responsabilità del controllore dire quali credenziali sono valide: seguendo il principio della responsabilità unica, il controllore dovrebbe avere un solo motivo per cambiare e questo motivo dovrebbe essere all'interno dell'interfaccia utente dell'applicazione, non nel processo di autenticazione.

Prendiamo la via d'uscita più ovvia ed estraiamo la condizione in una classe Authenticator:

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

Ora tutto ciò che dobbiamo fare è delegare dal controllore a questo autenticatore. Abbiamo reso l'autenticatore una dipendenza del controllore e improvvisamente il controllore ha bisogno di ottenerlo da qualche parte:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== Facile, ne creo uno nuovo!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Questo modo ingenuo funzionerà. Ma solo finché non verrà implementata un'autenticazione più robusta, che richieda all'autenticatore di interrogare una tabella di database con gli utenti. Authenticator ha improvvisamente una sua dipendenza, ad esempio UserRepository, che a sua volta dipende da un'istanza di Connection, che dipende dai parametri dell'ambiente specifico. La situazione è degenerata rapidamente!

Creare istanze a mano ovunque non è un modo sostenibile di gestire le dipendenze. Ecco perché esiste il pattern dependency injection, che consente al controllore di dichiarare semplicemente la sua dipendenza da un'istanza di Authenticator, lasciando a qualcun altro il compito di fornire effettivamente un'istanza. E questo qualcun altro è chiamato “contenitore” di iniezione di dipendenze.

Il contenitore di dependency injection è l'architetto supremo dell'applicazione: sa come risolvere le dipendenze di qualsiasi servizio all'interno del sistema ed è responsabile della loro creazione. I contenitori DI sono così comuni al giorno d'oggi che quasi tutti i principali framework web hanno la propria implementazione del contenitore e ci sono persino pacchetti autonomi dedicati all'iniezione di dipendenze, come PHP-DI.

Bruciare i peperoni

L'abbondanza di opzioni ha spinto un gruppo di sviluppatori a cercare un'astrazione per renderle interoperabili. L'interfaccia comune è stata perfezionata nel tempo e infine proposta a PHP-FIG nella forma seguente:

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

Questa interfaccia illustra un attributo molto importante dei contenitori DI: **Sono un buon servitore, ma possono facilmente diventare un cattivo padrone. Sono tremendamente utili finché si sa come usarli, ma se li si usa in modo errato, ci si brucia. Considerate il seguente contenitore:

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

Fin qui tutto bene. L'implementazione sembra buona per lo standard che abbiamo stabilito: sa effettivamente come creare ogni servizio dell'applicazione, risolvendo ricorsivamente le sue dipendenze. Tutto è gestito in un unico posto e il contenitore accetta persino dei parametri, in modo che la connessione al database sia facilmente configurabile. Bello!

Ma ora, vedendo gli unici due metodi di ContainerInterface, potreste essere tentati di usare il contenitore in questo modo:

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

Congratulazioni, avete appena bruciato i vostri peperoni. In altre parole, il contenitore è diventato il cattivo maestro. Perché?

Innanzitutto, ci si affida a un identificatore di servizio arbitrario: 'authenticator'. L'iniezione di dipendenza consiste nell'essere trasparenti sulle proprie dipendenze e l'uso di un identificatore artificiale va contro questa nozione: rende il codice silenziosamente dipendente dalla definizione del contenitore. Se si dovesse rinominare il servizio nel contenitore, si dovrà trovare questo riferimento e aggiornarlo.

E quel che è peggio è che questa dipendenza è nascosta: a prima vista dall'esterno, il controllore dipende solo da un'astrazione di contenitore. Ma come sviluppatore, dovete conoscere il modo in cui i servizi sono denominati nel contenitore e che un servizio chiamato authenticator è, in realtà, un'istanza di Authenticator. Il vostro nuovo collega deve imparare tutto questo. Inutilmente.

Per fortuna, possiamo ricorrere a un identificatore molto più naturale: il tipo di servizio. Dopo tutto, è l'unica cosa che interessa allo sviluppatore. Non è necessario sapere quale stringa casuale sia associata al servizio nel contenitore. Credo che questo codice sia molto più semplice sia da scrivere che da leggere:

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

Purtroppo, non abbiamo ancora domato le fiamme. Neanche un po'. Il problema più grande è che state sminuendo il contenitore nel ruolo di un localizzatore di servizi, il che è un enorme anti-pattern. È come portare a qualcuno l'intero frigorifero per fargli prendere un singolo snack: è molto più ragionevole portargli solo lo snack.

Ancora una volta, l'iniezione di dipendenza riguarda la trasparenza e questo controllore non è ancora trasparente sulle sue dipendenze. La dipendenza da un autenticatore è completamente nascosta al mondo esterno, dietro la dipendenza dal contenitore. Questo rende il codice più difficile da leggere. O da usare. O testato! La presa in giro dell'autenticatore in un test unitario richiede ora la creazione di un intero contenitore attorno a esso.

Tra l'altro, il controllore dipende ancora dalla definizione del contenitore e lo fa in modo piuttosto negativo. Se il servizio authenticator non esiste nel contenitore, il codice fallisce solo nel metodo action(), che è un feedback piuttosto tardivo.

Cucinare qualcosa di delizioso

A dire il vero, nessuno può biasimarvi per essere finiti in questo vicolo cieco. Dopo tutto, si è semplicemente seguita l'interfaccia progettata e provata da sviluppatori intelligenti. Il fatto è che tutti i contenitori per l'iniezione di dipendenze sono per definizione anche localizzatori di servizi e si scopre che lo schema è davvero l'unica interfaccia comune tra loro. Ma questo non significa che si debba usarli come localizzatori di servizi. Infatti, lo stesso PSR mette in guardia su questo.

Ecco come si può usare un contenitore DI come un buon servitore:

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

Il controllore dichiara la dipendenza in modo esplicito, chiaro e trasparente nel costruttore. Le dipendenze non sono più nascoste e sparse per la classe. Sono anche applicate: il contenitore non è in grado di creare un'istanza di SignInController senza fornire il necessario Authenticator. Se non c'è un autenticatore nel contenitore, l'esecuzione fallisce in anticipo, non nel metodo action(). Anche il test di questa classe è diventato molto più semplice, perché è sufficiente prendere in giro il servizio authenticator, senza alcun boilerplate del contenitore.

C'è un ultimo piccolo, ma importantissimo dettaglio: abbiamo inserito di nascosto le informazioni sul tipo di servizio. Il fatto che si tratti di un'istanza di Authenticator, prima implicito e sconosciuto all'IDE, agli strumenti di analisi statica o anche a uno sviluppatore ignaro della definizione del contenitore, è ora scolpito staticamente nel typehint del parametro promosso.

L'unico passo che resta da fare è insegnare al contenitore come creare anche il controllore:

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

Si può notare che il contenitore utilizza ancora internamente l'approccio del localizzatore di servizi. Ma va bene così, purché sia contenuto (gioco di parole). L'unico posto al di fuori del contenitore in cui è accettabile chiamare il metodo get è index.php, nel punto di ingresso dell'applicazione, dove è necessario creare il contenitore stesso e poi recuperare ed eseguire l'applicazione:

$container = bootstrap();

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

La gemma nascosta

Ma non fermiamoci qui, permettetemi di portare avanti l'affermazione: l'unico posto in cui è accettabile chiamare il metodo get è l'entrypoint.

Il codice del contenitore è solo cablaggio, sono istruzioni di montaggio. Non è codice esecutivo. Non è importante, in un certo senso. Sebbene sia cruciale per l'applicazione, lo è solo dal punto di vista dello sviluppatore. Non apporta alcun valore diretto all'utente e deve essere trattato tenendo presente questo aspetto.

Guardate di nuovo il contenitore:

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

Questo copre solo un segmento molto piccolo e semplice dell'applicazione. Quando l'applicazione cresce, scrivere a mano il contenitore diventa incredibilmente noioso. Come ho già detto, il contenitore è solo un manuale di assemblaggio, ma è eccessivamente complicato, con molte pagine, innumerevoli riferimenti incrociati e molte avvertenze in caratteri piccoli. Vogliamo trasformarlo in un manuale in stile IKEA, grafico, conciso e con illustrazioni di persone che sorridono quando appoggiano l'ÅUTHENTICATÖR sul tappeto durante il montaggio per evitare che si rompa.

È qui che entra in gioco Nette Framework.

La soluzione DI di Nette Framework utilizza Neon, un formato di file di configurazione simile a YAML, ma con gli steroidi. Ecco come definire lo stesso contenitore, utilizzando la configurazione Neon:

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

Permettetemi di sottolineare due cose degne di nota: primo, l'elenco dei servizi è veramente un elenco, non una mappa hash – non ci sono chiavi, né identificatori artificiali di servizi. Non esiste authenticator, e nemmeno Authenticator::class. In secondo luogo, non è necessario elencare esplicitamente le dipendenze, a parte i parametri di connessione al database.

Questo perché Nette Framework si basa sul cablaggio automatico. Ricordate che, grazie alla dependency injection, siamo stati in grado di esprimere il tipo di dipendenza in un typehint nativo? Il contenitore DI utilizza questa informazione, in modo che quando si richiede un'istanza di Authenticator, ignora completamente i nomi e trova il servizio giusto solo in base al suo tipo.

Si potrebbe obiettare che l'autowiring non è una caratteristica unica. E avreste ragione. Ciò che rende unico il contenitore di Nette Framework è l'utilizzo del sistema di tipi di PHP, mentre in molti altri framework l'autowiring è ancora costruito internamente sui nomi dei servizi. Ci sono scenari in cui altri contenitori non sono all'altezza. Ecco come definire il servizio authenticator nel contenitore DI di Symfony, usando YAML:

services:
  Authenticator: ~

La sezione services è una mappa hash e il bit Authenticator è un identificatore di servizio. La tilde sta per null in YAML, che Symfony interpreta come “usa l'identificatore del servizio come tipo”.

Ben presto, però, i requisiti aziendali cambiano e si ha bisogno di supportare l'autenticazione tramite LDAP, oltre a una ricerca nel database locale. Come primo passo, si trasforma la classe Authenticator in un'interfaccia e si estrae l'implementazione originale in una classe LocalAuthenticator:

services:
  LocalAuthenticator: ~

Improvvisamente, Symfony non sa più che pesci pigliare. Questo perché Symfony lavora con i nomi dei servizi invece che con i tipi. Il controllore si appoggia ancora correttamente all'astrazione ed elenca l'interfaccia Authenticator come sua dipendenza, ma non c'è alcun servizio nominato Authenticator nel contenitore. È necessario dare a Symfony un suggerimento, per esempio usando un alias nome del servizio:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, invece, non ha bisogno di nomi di servizi o suggerimenti. Non costringe a duplicare nella configurazione le informazioni che sono già espresse nel codice (tramite la clausola implements ). Si posiziona proprio sopra il sistema di tipi di PHP. Sa che LocalAuthenticator è di tipo Authenticator e, finché è l'unico servizio che implementa l'interfaccia, lo autocabla volentieri dove l'interfaccia è richiesta, con questa sola riga di configurazione:

services:
    - LocalAuthenticator

Ammetto che se non si ha familiarità con l'autowiring, potrebbe sembrare un po' magico e potrebbe essere necessario un po' di tempo per imparare a fidarsi. Fortunatamente, funziona in modo trasparente e deterministico: quando il contenitore non riesce a risolvere in modo univoco le dipendenze, lancia un'eccezione in tempo di compilazione che aiuta a risolvere la situazione. In questo modo, si possono avere due implementazioni diverse e avere comunque un buon controllo sull'utilizzo di ciascuna di esse.

Nel complesso, l'autowiring riduce il carico cognitivo dello sviluppatore. Dopo tutto, a voi interessano solo i tipi e le astrazioni, quindi perché un contenitore DI dovrebbe costringervi a preoccuparvi anche delle implementazioni e degli identificatori dei servizi? E soprattutto, perché mai dovreste preoccuparvi di un contenitore? Nello spirito della dependency injection, si vuole essere in grado di dichiarare le dipendenze e lasciare che qualcun altro le fornisca. Ci si vuole concentrare completamente sul codice dell'applicazione e dimenticare il cablaggio. E la DI di Nette Framework vi permette di farlo.

A mio avviso, questo rende la soluzione DI di Nette Framework la migliore nel mondo PHP. Offre un contenitore affidabile e che applica buoni modelli architettonici, ma allo stesso tempo è così facile da configurare e mantenere che non ci si deve pensare affatto.

Spero che questo post sia riuscito a stimolare la vostra curiosità. Assicuratevi di dare un'occhiata al repository di Github e ai docs: spero che scoprirete che vi ho mostrato solo la punta dell'iceberg e che l'intero pacchetto è molto più potente.