Οι υπηρεσίες δεν χρειάζονται ονόματα

πριν από 4 χρόνια Από Jiří Pudil  

Μου αρέσει η λύση του 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 αποθετήριο και στην τεκμηρίωση – ελπίζω να διαπιστώσετε ότι σας έδειξα μόνο την κορυφή του παγόβουνου και ότι ολόκληρο το πακέτο είναι πολύ πιο ισχυρό.

Πρόσφατες δημοσιεύσεις