I servizi non hanno bisogno di nomi

4 anni fa Da Jiří Pudil  

Mi piace la soluzione di Nette Framework per la dependency injection. La adoro davvero. Questo articolo è qui per condividere questa passione e spiegare perché penso che sia la migliore soluzione DI nell'ecosistema PHP attuale.

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

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

Sto parlando dell'impulso di estrarre la logica di autenticazione dal seguente 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);
	}
}

Probabilmente riconoscerete che il controllo delle credenziali non appartiene lì. Il controller non ha la responsabilità di determinare quali credenziali sono valide – secondo il principio di singola responsabilità, il controller dovrebbe avere un solo motivo per cambiare, e questo motivo dovrebbe essere nell'ambito dell'interfaccia utente dell'applicazione, non nel processo di autenticazione.

Prendiamo la strada ovvia ed estraiamo la condizione nella classe Authenticator:

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

Ora basta delegare dal controller a questo autenticatore. Abbiamo creato l'autenticatore dipendenza dal controller e il controller improvvisamente 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 semplicemente 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 fino a quando non verrà implementata un'autenticazione più robusta, che richiederà che l'autenticatore interroghi una tabella di database degli utenti. Improvvisamente Authenticator ha la sua dipendenza, diciamo UserRepository, che a sua volta dipende da un'istanza di Connection, che dipende dai parametri dell'ambiente specifico. La situazione è degenerata rapidamente!

Creare istanze ovunque manualmente non è un modo sostenibile per gestire le dipendenze. Per questo abbiamo il pattern dependency injection, che consente al controller solo di dichiarare la dipendenza da Authenticator, e lasciare a qualcun altro il compito di fornire effettivamente l'istanza. E questo qualcun altro si chiama dependency injection container.

Il container DI è l'architetto supremo dell'applicazione – sa risolvere le dipendenze di qualsiasi servizio nel sistema ed è responsabile della loro creazione. I container DI sono oggi così comuni che quasi ogni framework web più grande ha la propria implementazione di un container, e esistono persino pacchetti separati dedicati all'iniezione delle dipendenze, ad esempio PHP-DI.

Bruciare il pepe

La quantità di opzioni alla fine ha motivato un gruppo di sviluppatori a cercare un'astrazione che le rendesse interoperabili. L'interfaccia comune è stata affinata nel tempo e alla fine proposta per PHP-FIG nella seguente forma:

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

Questa interfaccia illustra una proprietà molto importante dei container DI: **Sono un buon servitore, ma possono facilmente diventare un cattivo padrone. Sono estremamente utili se sapete come usarli, ma se li usate in modo errato, vi brucerete. Prendiamo il seguente container:

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

Finora tutto bene. L'implementazione sembra buona secondo gli standard che ci siamo prefissati: sa effettivamente creare ogni servizio nell'applicazione e risolve ricorsivamente le sue dipendenze. Tutto è gestito in un unico posto e il container accetta persino parametri, quindi la connessione al database è facilmente configurabile. Bello!

Ma ora che vedete solo i due metodi di ContainerInterface, potreste essere tentati di usare il container 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 il pepe. In altre parole, il container è diventato un cattivo padrone. Perché?

Innanzitutto, vi affidate a un identificatore di servizio arbitrario: 'authenticator'. L'iniezione delle dipendenze consiste nell'essere trasparenti riguardo alle proprie dipendenze, e l'uso di un identificatore artificiale va direttamente contro questo concetto: rende il codice silenziosamente dipendente dalla definizione del container. Se mai il servizio venisse rinominato nel container, dovreste trovare questo riferimento e aggiornarlo.

E peggio ancora, questa dipendenza è nascosta: a prima vista dall'esterno, il controller dipende solo dall'astrazione del container. Ma come sviluppatore voi dovete avere conoscenza di come si chiamano i servizi nel container e che il servizio chiamato authenticator è in realtà un'istanza di Authenticator. Tutto questo deve impararlo il vostro nuovo collega. Inutilmente.

Fortunatamente, possiamo ricorrere a un identificatore molto più naturale: il tipo del servizio. Questo è, dopotutto, l'unica cosa che vi interessa come sviluppatori. Non avete bisogno di sapere quale stringa casuale è assegnata al servizio nel container. Credo che questo codice sia molto più semplice da scrivere e 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);
		//...
	}
}

Sfortunatamente, non abbiamo ancora domato le fiamme. Nemmeno un po'. Il problema più grande è che state umilmente relegando il container al ruolo di service locator, il che è un enorme anti-pattern. È come portare a qualcuno l'intero frigorifero perché possa prendersi uno spuntino – molto più ragionevole portargli solo quello spuntino.

Ancora una volta, la dependency injection riguarda la trasparenza, e questo controller non è ancora trasparente riguardo alle sue dipendenze. La dipendenza dall'autenticatore è completamente nascosta al mondo esterno dietro la dipendenza dal container. Questo rende il codice più difficile da leggere. O da usare. O da testare! Il mocking dell'autenticatore in un unit test ora richiede la creazione dell'intero container attorno ad esso.

E a proposito, il controller dipende ancora dalla definizione del container, e in un modo piuttosto negativo. Se il servizio autenticatore non esiste nel container, il codice fallirà solo nel metodo action(), il che è un feedback piuttosto tardivo.

Cucinare qualcosa di gustoso

Per essere onesti, nessuno può biasimarvi per essere finiti in questo vicolo cieco. Dopotutto, avete solo seguito l'interfaccia progettata e collaudata da sviluppatori intelligenti. Il punto è che tutti i container per l'iniezione delle dipendenze sono per definizione anche service locator e si scopre che il pattern è effettivamente l'unica interfaccia comune tra loro. Ma questo non significa che dovreste usarli come service locator. In realtà, la stessa specifica PSR mette in guardia contro questo.

Ecco come potete usare il container DI come un buon servizio:

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

Nel costruttore, la dipendenza è dichiarata esplicitamente, chiaramente e trasparentemente. Le dipendenze non sono più nascoste sparse per la classe. Sono anche imposte: il container non è in grado di creare un'istanza di SignInController senza fornire l'Authenticator necessario. Se non c'è un autenticatore nel container, l'esecuzione fallirà prematuramente, non nel metodo action(). Testare questa classe è diventato anche molto più semplice, poiché è sufficiente effettuare il mock del servizio autenticatore senza alcuna complicazione legata al container.

E un altro dettaglio piccolo ma molto importante: abbiamo introdotto di nascosto l'informazione sul tipo del servizio. Il fatto che si tratti di un'istanza di Authenticator – precedentemente implicita e sconosciuta all'IDE, agli strumenti di analisi statica o persino a uno sviluppatore ignaro della definizione del container – è ora staticamente inciso nel type hint del parametro promosso.

L'unico passo rimasto è insegnare al container come creare anche il 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 { /* . . . */ }
}

Forse avrete notato che il container utilizza ancora internamente l'approccio del service locator. Tuttavia, questo non è un problema finché è contenuto (gioco di parole voluto). L'unico posto al di fuori del container in cui la chiamata al metodo get è consentita è in index.php, nel punto di ingresso dell'applicazione, dove è necessario creare il container stesso e quindi caricare ed eseguire l'applicazione:

$container = bootstrap();

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

La gemma nascosta

Ma non fermiamoci qui, permettetemi di spingere ulteriormente questa affermazione: l'unico posto in cui la chiamata al metodo get è consentita è il punto di ingresso.

Il codice del container è solo cablaggio, sono istruzioni per l'assemblaggio. Non è codice eseguibile. In un certo senso, non è importante. Anche se sì, è cruciale per l'applicazione, ma solo dal punto di vista dello sviluppatore. In realtà non porta alcun valore diretto all'utente e dovrebbe essere trattato tenendo conto di questo fatto.

Guardate di nuovo il container:

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 riguarda solo un segmento molto piccolo e semplice dell'applicazione. Man mano che l'applicazione cresce, scrivere manualmente il container diventa incredibilmente noioso. Come ho già detto, il container è solo un manuale di montaggio – ma è troppo complesso, ha molte pagine, innumerevoli riferimenti incrociati e un sacco di avvertenze scritte in piccolo. Vogliamo trasformarlo in un manuale stile IKEA, grafico, conciso e con illustrazioni di persone che sorridono mentre appoggiano ÅUTHENTICATÖR sul tappeto durante il montaggio per non romperlo.

Qui entra in gioco Nette Framework.

La soluzione DI di Nette Framework utilizza Neon, un formato di file di configurazione simile a YAML, ma sotto steroidi. Ecco come definireste lo stesso container usando la configurazione Neon:

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

Permettetemi di sottolineare due cose notevoli: primo, l'elenco dei servizi è effettivamente un elenco, non una mappa hash – non ci sono chiavi, nessun identificatore di servizio artificiale. Non esiste authenticator, né Authenticator::class. Secondo, non è necessario specificare esplicitamente alcuna dipendenza da nessuna parte, tranne i parametri di connessione al database.

Questo perché Nette Framework si affida all'autowiring. Ricordate come, grazie alla dependency injection, potevamo esprimere il tipo della dipendenza con un typehint nativo? Il container DI utilizza questa informazione, quindi quando richiedete un'istanza di Authenticator, aggira completamente qualsiasi nome e trova il servizio corretto esclusivamente in base al suo tipo.

Potreste obiettare che l'autowiring non è una caratteristica unica. E avreste ragione. Ciò che rende unico il container di Nette Framework è l'utilizzo del sistema di tipi di PHP, mentre in molti altri framework l'autowiring è ancora internamente basato sui nomi dei servizi. Esistono scenari in cui altri container rimangono indietro. Ecco come definireste il servizio autenticatore nel container DI di Symfony usando YAML:

services:
  Authenticator: ~

Nella sezione services c'è una mappa hash e il bit Authenticator è l'identificatore del servizio. La tilde in YAML significa null, che Symfony interpreta come “usa l'identificatore del servizio come suo tipo”.

Presto, però, i requisiti aziendali cambieranno e avrete bisogno di supportare l'autenticazione tramite LDAP oltre alla ricerca locale nel database. Nel primo passo, cambiate la classe Authenticator in un'interfaccia ed estraete l'implementazione originale nella classe LocalAuthenticator:

services:
  LocalAuthenticator: ~

Improvvisamente Symfony è perplesso. Questo perché Symfony lavora con i nomi dei servizi invece che con i tipi. Il controller si affida ancora correttamente all'astrazione e indica l'interfaccia Authenticator come sua dipendenza, ma nel container non c'è nessun servizio con il nome Authenticator. Dovete dare un suggerimento a Symfony, ad esempio usando un alias del nome del servizio:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, al contrario, non ha bisogno di nomi di servizio né di suggerimenti. Non vi costringe a duplicare nella configurazione informazioni che sono già espresse nel codice (tramite la clausola implements ). Si posiziona direttamente sopra il sistema di tipi di PHP. Sa che LocalAuthenticator è di tipo Authenticator, e se è l'unico servizio che implementa questa interfaccia, lo collegherà automaticamente con piacere dove questa interfaccia è richiesta, basandosi solo su questa riga di configurazione:

services:
	- LocalAuthenticator

Ammetto che se non conoscete l'autowiring, potrebbe sembrarvi un po' magico e potreste aver bisogno di un po' di tempo per imparare a fidarvi. Fortunatamente, funziona in modo trasparente e deterministico: quando il container non può risolvere univocamente le dipendenze, lancia un'eccezione in fase di compilazione, che vi aiuta a correggere la situazione. In questo modo, potete avere due implementazioni diverse e avere comunque un buon controllo su dove viene utilizzata ciascuna di esse.

Complessivamente, l'autowiring impone un carico cognitivo minore su di voi come sviluppatori. Dopotutto, vi preoccupate solo dei tipi e delle astrazioni, quindi perché il container DI dovrebbe costringervi a preoccuparvi anche delle implementazioni e degli identificatori di servizio? E, cosa più importante, perché dovreste preoccuparvi affatto di un qualche container? Nello spirito della dependency injection, volete avere la possibilità di dichiarare semplicemente le dipendenze e lasciare che sia problema di qualcun altro fornirle. Volete concentrarvi completamente sul codice dell'applicazione e dimenticarvi del cablaggio. E questo è ciò che vi permette di fare il DI di Nette Framework.

Ai miei occhi, questo rende la soluzione DI di Nette Framework la migliore esistente nel mondo PHP. Vi fornisce un container affidabile che promuove buoni pattern architetturali, ma allo stesso tempo è così facile da configurare e mantenere che non dovete pensarci affatto.

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