Οι υπηρεσίες δεν χρειάζονται ονόματα
Μου αρέσει η λύση του Nette Framework για το dependency injection. Πραγματικά την αγαπώ. Αυτό το άρθρο είναι εδώ για να μοιραστώ αυτό το πάθος και να εξηγήσω γιατί πιστεύω ότι είναι η καλύτερη λύση DI στο τρέχον οικοσύστημα της PHP.

(Αυτή η δημοσίευση δημοσιεύτηκε αρχικά στο ιστολόγιο του συγγραφέα.)
Η ανάπτυξη λογισμικού είναι μια ατελείωτη επαναληπτική διαδικασία αφαίρεσης. Κατάλληλες αφαιρέσεις του πραγματικού κόσμου βρίσκουμε στη μοντελοποίηση τομέα. Στον αντικειμενοστραφή προγραμματισμό, χρησιμοποιούμε αφαιρέσεις για να περιγράψουμε και να επιβάλουμε συμβάσεις μεταξύ διαφορετικών παραγόντων στο σύστημα. Εισάγουμε νέες κλάσεις στο σύστημα για να ενσωματώσουμε ευθύνες και να ορίσουμε τα όριά τους, και στη συνέχεια, χρησιμοποιώντας σύνθεση, δημιουργούμε ολόκληρο το σύστημα.
Μιλώ για την παρόρμηση να εξαχθεί η λογική αυθεντικοποίησης από τον ακόλουθο 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);
}
}
Πιθανότατα αναγνωρίζετε ότι ο έλεγχος διαπιστευτηρίων δεν ανήκει εκεί. Ο controller δεν έχει την ευθύνη να καθορίζει ποια διαπιστευτήρια είναι έγκυρα – σύμφωνα με την αρχή της μοναδικής ευθύνης, ο controller θα πρέπει να έχει μόνο έναν λόγο για αλλαγή, και αυτός ο λόγος θα πρέπει να είναι στο πλαίσιο της διεπαφής χρήστη της εφαρμογής, όχι στη διαδικασία ελέγχου ταυτότητας.
Ας ακολουθήσουμε την προφανή διαδρομή και
ας εξαγάγουμε τη συνθήκη στην κλάση
Authenticator
:
final class Authenticator
{
public function authenticate(string $username, string $password): bool
{
return $username === 'admin' && $password === 'p4ssw0rd!';
}
}
Τώρα αρκεί να αναθέσουμε από τον controller σε αυτόν τον authenticator. Δημιουργήσαμε τον authenticator ως εξάρτηση του controller και ο controller ξαφνικά χρειάζεται να τον αποκτήσει από κάπου:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = new Authenticator(); // <== εύκολο, απλά δημιουργώ έναν νέο!
if ( ! $authenticator->authenticate($username, $password)) {
return $this->render(__DIR__ . '/error.latte');
}
$this->signIn(new Identity($username));
return $this->redirect(HomepageController::class);
}
}
Αυτός ο αφελής τρόπος θα λειτουργήσει.
Αλλά μόνο μέχρι να υλοποιηθεί μια πιο
στιβαρή αυθεντικοποίηση, η οποία θα απαιτεί
ο authenticator να κάνει ερωτήματα στον πίνακα
χρηστών της βάσης δεδομένων. Ξαφνικά, ο
Authenticator
έχει τη δική του εξάρτηση, ας
πούμε UserRepository
, η οποία με τη σειρά
της εξαρτάται από μια περίπτωση
Connection
, η οποία εξαρτάται από
παραμέτρους του συγκεκριμένου
περιβάλλοντος. Αυτό κλιμακώθηκε γρήγορα!
Η δημιουργία περιπτώσεων παντού
χειροκίνητα δεν είναι ένας βιώσιμος τρόπος
διαχείρισης εξαρτήσεων. Γι' αυτό έχουμε το
πρότυπο dependency injection, το οποίο επιτρέπει στον
controller απλώς να δηλώσει την εξάρτηση από το
Authenticator
, και να αφήσει σε κάποιον
άλλο να παρέχει την περίπτωση στην
πραγματικότητα. Και αυτός ο κάποιος άλλος
ονομάζεται dependency injection container.
Ο dependency injection container είναι ο ανώτερος αρχιτέκτονας της εφαρμογής – μπορεί να επιλύσει τις εξαρτήσεις οποιασδήποτε υπηρεσίας στο σύστημα και είναι υπεύθυνος για τη δημιουργία τους. Οι DI containers είναι τόσο συνηθισμένοι σήμερα που σχεδόν κάθε μεγαλύτερο web framework έχει τη δική του υλοποίηση container, και υπάρχουν ακόμη και ξεχωριστά πακέτα αφιερωμένα στο dependency injection, για παράδειγμα το PHP-DI.
Παίζοντας με τη φωτιά
Η πληθώρα επιλογών τελικά παρακίνησε μια ομάδα προγραμματιστών να αναζητήσει μια αφαίρεση που θα τα καθιστούσε διαλειτουργικά. Μια κοινή διεπαφή τελειοποιήθηκε με τον καιρό και τελικά προτάθηκε για το PHP-FIG με την ακόλουθη μορφή:
interface ContainerInterface
{
public function get(string $id): mixed;
public function has(string $id): bool;
}
Αυτή η διεπαφή απεικονίζει ένα πολύ σημαντικό χαρακτηριστικό των DI containers: Είναι καλοί υπηρέτες, αλλά εύκολα μπορούν να γίνουν κακοί αφέντες. Είναι εξαιρετικά χρήσιμοι αν ξέρετε πώς να τους χρησιμοποιείτε, αλλά αν τους χρησιμοποιείτε λανθασμένα, θα καείτε. Πάρτε τον ακόλουθο 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 { /* . . . */ }
}
Μέχρι στιγμής, καλά. Η υλοποίηση φαίνεται καλή σύμφωνα με τα πρότυπα που θέσαμε: όντως μπορεί να δημιουργήσει κάθε υπηρεσία στην εφαρμογή και επιλύει αναδρομικά τις εξαρτήσεις της. Όλα διαχειρίζονται σε ένα μέρος και ο container δέχεται ακόμη και παραμέτρους, οπότε η σύνδεση με τη βάση δεδομένων είναι εύκολα διαμορφώσιμη. Ωραία!
Αλλά τώρα που βλέπετε μόνο τις δύο
μεθόδους του ContainerInterface
, ίσως μπείτε
στον πειρασμό να χρησιμοποιήσετε τον container
ως εξής:
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');
//...
}
}
Συγχαρητήρια, μόλις κάψατε την πιπεριά. Με άλλα λόγια, ο container έγινε κακός αφέντης. Γιατί συμβαίνει αυτό;
Πρώτον, βασίζεστε σε ένα αυθαίρετο
αναγνωριστικό υπηρεσίας: 'authenticator'
.
Το dependency injection έχει να κάνει με το να είστε
διαφανείς σχετικά με τις εξαρτήσεις σας,
και η χρήση ενός τεχνητού αναγνωριστικού
πηγαίνει ευθέως ενάντια σε αυτήν την
έννοια: καθιστά τον κώδικα σιωπηρά
εξαρτημένο από τον ορισμό του container. Αν ποτέ
μετονομαστεί μια υπηρεσία στον container,
πρέπει να βρείτε αυτήν την αναφορά και να
την ενημερώσετε.
Και το χειρότερο, αυτή η εξάρτηση είναι
κρυφή: με την πρώτη ματιά από έξω, ο controller
εξαρτάται μόνο από την αφαίρεση του container.
Αλλά ως προγραμματιστής εσείς πρέπει να
έχετε γνώση του πώς ονομάζονται οι
υπηρεσίες στον container και ότι η υπηρεσία με
το όνομα authenticator
είναι στην
πραγματικότητα μια περίπτωση του
Authenticator
. Όλα αυτά πρέπει να τα μάθει ο
νέος σας συνάδελφος. Περιττά.
Ευτυχώς, μπορούμε να καταφύγουμε σε ένα πολύ πιο φυσικό αναγνωριστικό: τον τύπο της υπηρεσίας. Αυτό είναι, τελικά, το μόνο που σας ενδιαφέρει ως προγραμματιστή. Δεν χρειάζεται να ξέρετε ποια τυχαία συμβολοσειρά έχει ανατεθεί στην υπηρεσία στον container. Πιστεύω ότι αυτός ο κώδικας είναι πολύ πιο εύκολος στη γραφή και την ανάγνωση:
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);
//...
}
}
Δυστυχώς, δεν έχουμε δαμάσει ακόμα τις φλόγες. Ούτε στο ελάχιστο. Το μεγαλύτερο πρόβλημα είναι ότι υποβιβάζετε ταπεινά τον container στον ρόλο του εντοπιστή υπηρεσιών (service locator), το οποίο είναι ένα τεράστιο anti-pattern. Είναι σαν να φέρνετε σε κάποιον ολόκληρο το ψυγείο για να πάρει ένα σνακ – πολύ πιο λογικό είναι να του φέρετε μόνο το σνακ.
Και πάλι, το dependency injection έχει να κάνει με τη διαφάνεια, και αυτός ο controller εξακολουθεί να μην είναι διαφανής σχετικά με τις εξαρτήσεις του. Η εξάρτηση από τον authenticator είναι εντελώς κρυμμένη από τον έξω κόσμο πίσω από την εξάρτηση από τον container. Αυτό καθιστά τον κώδικα πιο δύσκολο στην ανάγνωση. Ή στη χρήση. Ή στη δοκιμή! Το mocking του authenticator σε ένα unit test απαιτεί τώρα να δημιουργήσετε ολόκληρο τον container γύρω του.
Και παρεμπιπτόντως, ο controller εξακολουθεί
να εξαρτάται από τον ορισμό του container, και με
έναν αρκετά κακό τρόπο. Αν η υπηρεσία
authenticator δεν υπάρχει στον container, ο κώδικας θα
αποτύχει μόνο στη μέθοδο action()
, κάτι
που είναι αρκετά καθυστερημένη
ανατροφοδότηση.
Μαγειρεύοντας κάτι νόστιμο
Για να είμαστε δίκαιοι, κανείς δεν μπορεί να σας κατηγορήσει που μπήκατε σε αυτό το αδιέξοδο. Τελικά, απλώς ακολουθήσατε τη διεπαφή που σχεδιάστηκε και εγκρίθηκε από έξυπνους προγραμματιστές. Το θέμα είναι ότι όλοι οι containers για dependency injection είναι εξ ορισμού και service locators, και αποδεικνύεται ότι το πρότυπο είναι πράγματι η μόνη κοινή διεπαφή μεταξύ τους. Αλλά αυτό δεν σημαίνει ότι πρέπει να τους χρησιμοποιείτε ως service locators. Στην πραγματικότητα, η ίδια η προδιαγραφή του PSR προειδοποιεί εναντίον αυτού.
Έτσι μπορείτε να χρησιμοποιήσετε τον DI container ως καλή υπηρεσία:
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);
//...
}
}
Στον κατασκευαστή, η εξάρτηση δηλώνεται
ρητά, καθαρά και διαφανώς. Οι εξαρτήσεις δεν
είναι πλέον κρυμμένες διάσπαρτες στην
κλάση. Επίσης επιβάλλονται: ο container δεν
είναι σε θέση να δημιουργήσει μια περίπτωση
του SignInController
χωρίς να παρέχει τον
απαραίτητο Authenticator
. Αν δεν υπάρχει
authenticator στον container, η εκτέλεση θα αποτύχει
πρόωρα, όχι στη μέθοδο action()
. Η δοκιμή
αυτής της κλάσης έγινε επίσης πολύ πιο
εύκολη, καθώς χρειάζεται μόνο να κάνετε mock
την υπηρεσία authenticator χωρίς κανένα boilerplate του
container.
Και μια ακόμη μικρή, αλλά πολύ σημαντική
λεπτομέρεια: περάσαμε λαθραία την
πληροφορία για τον τύπο της υπηρεσίας. Το
γεγονός ότι πρόκειται για μια περίπτωση του
Authenticator
– προηγουμένως σιωπηρό και
άγνωστο στο IDE, στα εργαλεία στατικής
ανάλυσης ή ακόμα και στον προγραμματιστή
που δεν γνωρίζει τον ορισμό του container –
είναι τώρα στατικά χαραγμένο στο type hint της
προωθημένης παραμέτρου.
Το μόνο βήμα που απομένει είναι να διδάξουμε τον container πώς να δημιουργήσει και τον 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 { /* . . . */ }
}
Ίσως παρατηρήσατε ότι ο container εξακολουθεί
να χρησιμοποιεί εσωτερικά την προσέγγιση
του service locator. Αυτό όμως δεν πειράζει, εφόσον
περιέχεται (λογοπαίγνιο). Το μοναδικό μέρος
εκτός του container όπου η κλήση της μεθόδου
get
είναι επιτρεπτή, είναι στο
index.php
, στο σημείο εισόδου της
εφαρμογής, όπου πρέπει να δημιουργηθεί ο
ίδιος ο container και στη συνέχεια να φορτωθεί
και να εκτελεστεί η εφαρμογή:
$container = bootstrap();
$application = $container->get(Best\Framework\Application::class);
$application->run();
Κρυμμένος θησαυρός
Αλλά ας μην σταματήσουμε εκεί, επιτρέψτε
μου να προχωρήσω αυτόν τον ισχυρισμό
περαιτέρω: το μοναδικό μέρος όπου η
κλήση της μεθόδου get
είναι επιτρεπτή,
είναι το σημείο εισόδου.
Ο κώδικας του container είναι απλώς καλωδίωση, είναι οδηγίες συναρμολόγησης. Δεν είναι εκτελέσιμος κώδικας. Κατά κάποιο τρόπο, δεν είναι σημαντικός. Αν και ναι, είναι κρίσιμος για την εφαρμογή, αλλά μόνο από την άποψη του προγραμματιστή. Στον χρήστη, στην πραγματικότητα, δεν προσφέρει καμία άμεση αξία και θα πρέπει να αντιμετωπίζεται με αυτό κατά νου.
Κοιτάξτε ξανά τον 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 { /* . . . */ }
}
Αυτό αφορά μόνο ένα πολύ μικρό και απλό τμήμα της εφαρμογής. Καθώς η εφαρμογή μεγαλώνει, η χειροκίνητη γραφή του container γίνεται απίστευτα κουραστική. Όπως είπα ήδη, ο container είναι απλώς ένα εγχειρίδιο συναρμολόγησης – αλλά είναι πολύ περίπλοκο, έχει πολλές σελίδες, αμέτρητες διασταυρώσεις και πολλές προειδοποιήσεις γραμμένες με μικρά γράμματα. Θέλουμε να το κάνουμε ένα εγχειρίδιο τύπου IKEA, γραφικό, συνοπτικό και με εικόνες ανθρώπων που χαμογελούν καθώς τοποθετούν το ÅUTHENTICATÖR στο χαλί κατά τη συναρμολόγηση για να μην σπάσει.
Εδώ έρχεται το Nette Framework.
Η λύση DI του Nette Framework χρησιμοποιεί το Neon, ένα format αρχείων διαμόρφωσης παρόμοιο με το YAML, αλλά σε στεροειδή. Έτσι θα ορίζατε τον ίδιο container χρησιμοποιώντας τη διαμόρφωση Neon:
services:
- SignInController
- Authenticator
- UserRepository
- Connection(%database%)
Επιτρέψτε μου να επισημάνω δύο
αξιοσημείωτα πράγματα: πρώτον, η λίστα
υπηρεσιών είναι πράγματι μια λίστα, όχι
ένας hash map – δεν υπάρχουν κλειδιά, ούτε
τεχνητά αναγνωριστικά υπηρεσιών. Δεν
υπάρχει authenticator
, ούτε
Authenticator::class
. Δεύτερον, πουθενά δεν
χρειάζεται να δηλώσετε ρητά καμία εξάρτηση,
εκτός από τις παραμέτρους σύνδεσης της
βάσης δεδομένων.
Αυτό συμβαίνει επειδή το Nette Framework
βασίζεται στην αυτόματη καλωδίωση (autowiring).
Θυμάστε πώς χάρη στο dependency injection μπορούσαμε
να εκφράσουμε τον τύπο της εξάρτησης με ένα
εγγενές typehint; Ο DI container χρησιμοποιεί αυτήν
την πληροφορία, οπότε όταν ζητάτε μια
περίπτωση του Authenticator
, παρακάμπτει
εντελώς οποιαδήποτε ονόματα και βρίσκει τη
σωστή υπηρεσία αποκλειστικά βάσει του
τύπου της.
Μπορείτε να αντιτείνετε ότι το autowiring δεν είναι μοναδικό χαρακτηριστικό. Και θα είχατε δίκιο. Αυτό που καθιστά τον container του Nette Framework μοναδικό είναι η αξιοποίηση του συστήματος τύπων της PHP, ενώ σε πολλά άλλα frameworks το autowiring εξακολουθεί να βασίζεται εσωτερικά στα ονόματα των υπηρεσιών. Υπάρχουν σενάρια στα οποία άλλοι containers υστερούν. Έτσι θα ορίζατε την υπηρεσία authenticator στον container του Symfony DI χρησιμοποιώντας τη γλώσσα YAML:
services:
Authenticator: ~
Στην ενότητα services
υπάρχει ένας hash
map και το κομμάτι Authenticator
είναι το
αναγνωριστικό της υπηρεσίας. Η περισπωμένη
σημαίνει null
στο YAML, το οποίο το Symfony
ερμηνεύει ως “χρησιμοποίησε το
αναγνωριστικό της υπηρεσίας ως τον
τύπο της”.
Σύντομα, όμως, οι επιχειρηματικές
απαιτήσεις αλλάζουν και χρειάζεστε, εκτός
από την τοπική αναζήτηση στη βάση
δεδομένων, να υποστηρίξετε και την
αυθεντικοποίηση μέσω LDAP. Στο πρώτο βήμα,
αλλάζετε την κλάση Authenticator
σε interface
και εξάγετε την αρχική υλοποίηση στην κλάση
LocalAuthenticator
:
services:
LocalAuthenticator: ~
Ξαφνικά, το Symfony είναι αβοήθητο. Αυτό
συμβαίνει επειδή το Symfony δουλεύει με
ονόματα υπηρεσιών αντί για τύπους. Ο controller
εξακολουθεί να βασίζεται σωστά στην
αφαίρεση και δηλώνει το interface Authenticator
ως εξάρτησή του, αλλά στον container δεν υπάρχει
καμία υπηρεσία με το όνομα
Authenticator
. Πρέπει να δώσετε στο Symfony μια
υπόδειξη, για παράδειγμα, χρησιμοποιώντας
ένα ψευδώνυμο ονόματος υπηρεσίας:
services:
LocalAuthenticator: ~
Authenticator: '@LocalAuthenticator'
Το Nette Framework, αντίθετα, δεν χρειάζεται
ονόματα υπηρεσιών ούτε υποδείξεις. Δεν σας
αναγκάζει να διπλασιάζετε στη διαμόρφωση
πληροφορίες που εκφράζονται ήδη στον
κώδικα (μέσω της ρήτρας implements
).
Βρίσκεται ακριβώς πάνω από το σύστημα τύπων
της PHP. Γνωρίζει ότι το LocalAuthenticator
είναι τύπου Authenticator
, και αν είναι
η μόνη υπηρεσία που υλοποιεί αυτό το interface,
με χαρά θα το συνδέσει αυτόματα εκεί όπου
απαιτείται αυτό το interface, και αυτό μόνο
βάσει αυτής της γραμμής διαμόρφωσης:
services:
- LocalAuthenticator
Αναγνωρίζω ότι αν δεν γνωρίζετε το autowiring, μπορεί να σας φανεί λίγο μαγικό και ίσως χρειαστείτε λίγο χρόνο για να μάθετε να το εμπιστεύεστε. Ευτυχώς, λειτουργεί διαφανώς και ντετερμινιστικά: όταν ο container δεν μπορεί να επιλύσει μονοσήμαντα τις εξαρτήσεις, προκαλεί μια εξαίρεση κατά τη μεταγλώττιση, η οποία σας βοηθά να διορθώσετε την κατάσταση. Με αυτόν τον τρόπο, μπορείτε να έχετε δύο διαφορετικές υλοποιήσεις και παρόλα αυτά να έχετε καλό έλεγχο του πού χρησιμοποιείται η καθεμία.
Συνολικά, το autowiring επιβαρύνει λιγότερο γνωστικά εσάς ως προγραμματιστή. Τελικά, νοιάζεστε μόνο για τύπους και αφαιρέσεις, οπότε γιατί ο DI container θα έπρεπε να σας αναγκάζει να νοιάζεστε και για υλοποιήσεις και αναγνωριστικά υπηρεσιών? Και το πιο σημαντικό, γιατί θα έπρεπε να νοιάζεστε καθόλου για κάποιον container? Στο πνεύμα του dependency injection, θέλετε να έχετε τη δυνατότητα απλώς να δηλώνετε εξαρτήσεις και να είναι πρόβλημα κάποιου άλλου να τις παρέχει. Θέλετε να επικεντρωθείτε πλήρως στον κώδικα της εφαρμογής και να ξεχάσετε την καλωδίωση. Και αυτό σας επιτρέπει το DI του Nette Framework.
Στα μάτια μου, αυτό καθιστά τη λύση DI του Nette Framework την καλύτερη που υπάρχει στον κόσμο της PHP. Σας παρέχει έναν container που είναι αξιόπιστος και επιβάλλει καλά αρχιτεκτονικά πρότυπα, αλλά ταυτόχρονα είναι τόσο εύκολος στη διαμόρφωση και συντήρηση που δεν χρειάζεται να τον σκέφτεστε καθόλου.
Ελπίζω ότι αυτή η δημοσίευση κατάφερε να κεντρίσει την περιέργειά σας. Μην ξεχάσετε να ρίξετε μια ματιά στο Github αποθετήριο και στην τεκμηρίωση – ελπίζω να διαπιστώσετε ότι σας έδειξα μόνο την κορυφή του παγόβουνου και ότι ολόκληρο το πακέτο είναι πολύ πιο ισχυρό.
Για να υποβάλετε ένα σχόλιο, συνδεθείτε