Hizmetlerin isimlere ihtiyacı yoktur

3 yıl önce Kimden Jiří Pudil  

Nette Framework'ün bağımlılık enjeksiyonu çözümünü seviyorum. Gerçekten seviyorum. Bu yazı, bu tutkuyu paylaşmak ve neden günümüz PHP ekosistemindeki en iyi DI çözümü olduğunu düşündüğümü açıklamak için burada.

(Bu yazı ilk olarak yazarın blogunda yayınlanmıştır).

Yazılım geliştirme sonsuz bir yinelemeli soyutlama sürecidir. Etki alanı modellemesinde gerçek dünyanın uygun soyutlamalarını buluruz. Nesne yönelimli programlamada, sistem içindeki çeşitli aktörler arasındaki sözleşmeleri tanımlamak ve uygulamak için soyutlamalar kullanırız. Sorumlulukları kapsüllemek ve sınırlarını tanımlamak için sisteme yeni sınıflar ekleriz ve ardından tüm sistemi oluşturmak için bileşimi kullanırız.

Kimlik doğrulama mantığını aşağıdaki denetleyiciden çıkarma dürtüsünden bahsediyorum:

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

Muhtemelen kimlik bilgileri kontrolünün oraya ait olmadığını söyleyebilirsiniz. Hangi kimlik bilgilerinin geçerli olduğunu söylemek denetleyicinin sorumluluğunda değildir – tek sorumluluk ilkesi uyarınca, denetleyicinin değiştirmek için yalnızca tek bir nedeni olmalıdır ve bu neden kimlik doğrulama sürecinde değil, uygulamanın kullanıcı arayüzünde olmalıdır.

Bunun için bariz bir yol izleyelim ve koşulu bir Authenticator sınıfına çıkaralım:

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

Şimdi tek yapmamız gereken controller'dan bu authenticator'a delege etmek. Kimlik doğrulayıcıyı yaptık denetleyicinin bağımlılığıdır ve denetleyicinin aniden bunu bir yerden alması gerekir:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== kolay, sadece yeni bir tane yaratacağım!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Bu naif yol işe yarayacaktır. Ancak yalnızca kimlik doğrulayıcının bir veritabanı kullanıcı tablosunu sorgulamasını gerektiren daha sağlam bir kimlik doğrulama uygulanana kadar. Authenticator aniden kendi bağımlılığına sahip olur, örneğin bir UserRepository, bu da belirli ortamın parametrelerine bağlı olan bir Connection örneğine bağlıdır. Bu hızla tırmandı!

Örnekleri her yerde elle oluşturmak, bağımlılıkları yönetmenin sürdürülebilir bir yolu değildir. Bu nedenle, denetleyicinin yalnızca bir Authenticator adresine olan bağımlılığını beyan etmesine ve gerçekte bir örnek sağlamanın başka birinin sorunu olmasına izin veren bağımlılık enjeksiyon modeline sahibiz. Ve bu başkasına bağımlılık enjeksiyonu konteyneri denir.

Bağımlılık enjeksiyonu konteyneri uygulamanın yüce mimarıdır – sistem içindeki herhangi bir hizmetin bağımlılıklarını nasıl çözeceğini bilir ve bunları oluşturmaktan sorumludur. DI kapsayıcıları günümüzde o kadar yaygındır ki, hemen hemen her büyük web çerçevesinin kendi kapsayıcı uygulaması vardır ve hatta PHP-DI gibi bağımlılık enjeksiyonuna adanmış bağımsız paketler bile vardır.

Biberleri yakmak

Seçeneklerin çokluğu, sonunda bir grup geliştiriciyi bunları birlikte çalışabilir hale getirmek için bir soyutlama aramaya motive etti. Ortak arayüz zaman içinde geliştirilmiş ve sonunda PHP-FIG'e aşağıdaki biçimde önerilmiştir:

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

Bu arayüz, DI konteynerlerinin çok önemli bir özelliğini göstermektedir: Ateş gibidirler. İyi bir hizmetkârdırlar ancak kolayca kötü bir efendiye dönüşebilirler. Nasıl kullanacağınızı bildiğiniz sürece son derece faydalıdırlar, ancak yanlış kullanırsanız sizi yakarlar. Aşağıdaki kabı düşünün:

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

Buraya kadar her şey yolunda. Uygulama, belirlediğimiz standartlara göre iyi görünüyor: gerçekten de uygulamadaki her hizmeti nasıl oluşturacağını biliyor ve bağımlılıklarını özyinelemeli olarak çözüyor. Her şey tek bir yerden yönetiliyor ve kapsayıcı parametreleri bile kabul ediyor, böylece veritabanı bağlantısı kolayca yapılandırılabiliyor. Çok güzel!

Ancak şimdi, ContainerInterface adresinin yalnızca iki yöntemini görünce, konteyneri bu şekilde kullanmak cazip gelebilir:

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

Tebrikler, az önce biberlerinizi yaktınız. Başka bir deyişle, kap kötü usta haline geldi. Neden böyle oldu?

İlk olarak, keyfi bir hizmet tanımlayıcısına güveniyorsunuz: 'authenticator'. Bağımlılık enjeksiyonu tamamen kişinin bağımlılıkları konusunda şeffaf olmasıyla ilgilidir ve yapay bir tanımlayıcı kullanmak bu kavrama doğrudan aykırıdır: kodu sessizce konteynerin tanımına bağımlı hale getirir. Eğer konteynerdeki hizmeti yeniden adlandırırsanız, bu referansı bulmanız ve güncellemeniz gerekir.

Ve daha da kötüsü, bu bağımlılık gizlidir: dışarıdan ilk bakışta, denetleyici yalnızca bir kapsayıcı soyutlamasına bağlıdır. Ancak bir geliştirici olarak, siz konteynerde hizmetlerin nasıl adlandırıldığı ve authenticator adlı bir hizmetin aslında Authenticator'un bir örneği olduğu bilgisine sahip olmalısınız. Yeni meslektaşınız tüm bunları öğrenmek zorunda. Gereksiz yere.

Neyse ki, çok daha doğal bir tanımlayıcıya başvurabiliriz: hizmet türü. Sonuçta, bir geliştirici olarak önemsediğiniz tek şey budur. Konteynerdeki hizmetin hangi rastgele dizeyle ilişkilendirildiğini bilmenize gerek yok. Bu kodun hem yazılmasının hem de okunmasının çok daha kolay olduğuna inanıyorum:

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

Ne yazık ki, alevleri henüz evcilleştiremedik. Azıcık bile değil. Daha büyük sorun, konteyneri bir hizmet bulucu rolüne indirgemenizdir ki bu da büyük bir anti-paterndir. Bu, birisine tek bir atıştırmalık alabilmesi için tüm buzdolabını getirmek gibi bir şey – ona sadece atıştırmalık vermek çok daha makul.

Yine, bağımlılık enjeksiyonu şeffaflıkla ilgilidir ve bu denetleyici bağımlılıkları konusunda hala şeffaf değildir. Bir kimlik doğrulayıcıya olan bağımlılık, konteynere olan bağımlılığın arkasında, dış dünyadan tamamen gizlenmiştir. Bu da kodun okunmasını zorlaştırıyor. Ya da kullanımı. Ya da test etmeyi! Bir birim testinde kimlik doğrulayıcıyı taklit etmek artık onun etrafında bütün bir kapsayıcı oluşturmanızı gerektiriyor.

Bu arada, kontrolör hala konteynerin tanımına bağlıdır ve bunu oldukça kötü bir şekilde yapar. Eğer kimlik doğrulayıcı hizmeti konteynerde mevcut değilse, kod action() metoduna kadar başarısız olmaz, ki bu oldukça geç bir geri bildirimdir.

Lezzetli bir şeyler pişirmek

Adil olmak gerekirse, bu çıkmaza girdiğiniz için kimse sizi suçlayamaz. Sonuçta, akıllı geliştiriciler tarafından tasarlanan ve kanıtlanan arayüzü takip ettiniz. Mesele şu ki, tüm bağımlılık enjeksiyon kapsayıcıları da tanım gereği hizmet konumlandırıcılardır ve bu modelin gerçekten de aralarındaki tek ortak arayüz olduğu ortaya çıkmıştır. Ancak bu, onları hizmet bulucu olarak kullanmanız gerektiği anlamına gelmez. Aslında, PSR'nin kendisi bu konuda uyarır.

DI konteynerini bu şekilde iyi bir hizmetkâr olarak kullanabilirsiniz:

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

Denetleyici, bağımlılığı açık, net ve şeffaf bir şekilde yapıcıda beyan eder. Bağımlılıklar artık sınıfın etrafına dağılmış şekilde gizli değildir. Ayrıca zorlanırlar: kapsayıcı gerekli Authenticator sağlamadan bir SignInController örneği oluşturamaz. Kapsayıcıda kimlik doğrulayıcı yoksa, yürütme action() yönteminde değil, erken başarısız olur. Bu sınıfı test etmek de çok daha kolay hale geldi, çünkü herhangi bir kapsayıcı boilerplate'i olmadan yalnızca kimlik doğrulayıcı hizmetini taklit etmeniz gerekiyor.

Ve son bir küçük ama çok önemli ayrıntı daha var: hizmetin türüyle ilgili bilgileri gizlice ekledik. Daha önce ima edilen ve IDE, statik analiz araçları ve hatta konteyner tanımından habersiz bir geliştirici tarafından bilinmeyen bir Authenticator örneği olduğu gerçeği, artık tanıtılan parametrenin tip ipucuna statik olarak işlenmiştir.

Geriye kalan tek adım, konteynere kontrolörün nasıl oluşturulacağını öğretmektir:

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

Konteynerin dahili olarak hala hizmet bulucu yaklaşımını kullandığını fark edebilirsiniz. Ancak içeride olduğu sürece (kelime oyunu amaçlanmıştır) bu sorun değildir. Konteynerin dışında get yönteminin çağrılmasının kabul edilebilir olduğu tek yer, konteynerin kendisini oluşturmanız ve ardından uygulamayı getirip çalıştırmanız gereken uygulamanın giriş noktası olan index.php adresidir:

$container = bootstrap();

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

Gizli mücevher

Ancak burada durmayalım, ifadeyi daha da ileri götürmeme izin verin: get yönteminin çağrılmasının kabul edilebilir olduğu tek yer giriş noktasıdır.

Konteynerin kodu sadece kablo tesisatıdır, montaj talimatlarıdır. Yönetici kodu değildir. Bir bakıma önemli değildir. Evet, uygulama için çok önemli olsa da, bu yalnızca geliştiricinin bakış açısından böyledir. Kullanıcıya doğrudan bir değer katmaz ve bu göz önünde bulundurularak ele alınmalıdır.

Konteynere tekrar bir göz atın:

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

Bu, uygulamanın yalnızca çok küçük ve basit bir bölümünü kapsar. Uygulama büyüdükçe, konteyneri elle yazmak inanılmaz derecede sıkıcı hale gelir. Daha önce de söylediğim gibi, konteyner sadece bir montaj kılavuzu – ancak çok sayıda sayfa, sayısız çapraz referans ve çok sayıda küçük harfli uyarı içeren aşırı karmaşık bir kılavuz. Bunu IKEA tarzı bir kılavuza dönüştürmek istiyoruz; grafik, özlü ve montaj sırasında ÅUTHENTICATÖR'ü kırılmaması için halının üzerine koyarken gülümseyen insanların resimlerini içeren bir kılavuz.

İşte Nette Framework burada devreye giriyor.

Nette Framework'ün DI çözümü, YAML'ye benzer ancak steroidli bir yapılandırma dosyası biçimi olan Neon kullanır. Neon yapılandırmasını kullanarak aynı konteyneri bu şekilde tanımlayabilirsiniz:

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

İki önemli noktaya dikkat çekmek istiyorum: Birincisi, hizmet listesi gerçekten bir liste, bir karma harita değil – anahtarlar yok, yapay hizmet tanımlayıcıları yok. authenticator yoktur ve Authenticator::class da yoktur. İkinci olarak, veritabanı bağlantı parametreleri dışında hiçbir yerde bağımlılıkları açıkça listelemeniz gerekmez.

Bunun nedeni Nette Framework'ün otomatik bağlantıya dayanmasıdır. Bağımlılık enjeksiyonu sayesinde, bağımlılığın türünü yerel bir typehint ile nasıl ifade edebildiğimizi hatırlıyor musunuz? DI konteyneri bu bilgiyi kullanır, böylece bir Authenticator örneğine ihtiyaç duyduğunuzda, herhangi bir ismi tamamen atlar ve yalnızca türüne göre doğru hizmeti bulur.

Otomatik kablolamanın benzersiz bir özellik olmadığını iddia edebilirsiniz. Haklı da olursunuz. Nette Framework'ün konteynerini benzersiz kılan şey PHP'nin tip sistemini kullanmasıdır, oysa diğer birçok çerçevede otomatik bağlantı hala dahili olarak hizmet adları üzerine kuruludur. Diğer kapsayıcıların yetersiz kaldığı senaryolar vardır. Symfony'nin DI konteynerinde YAML kullanarak kimlik doğrulayıcı hizmetini bu şekilde tanımlarsınız:

services:
  Authenticator: ~

services bölümü bir hash haritasıdır ve Authenticator biti bir hizmet tanımlayıcısıdır. Tilde, Symfony'nin “hizmet tanımlayıcısını türü olarak kullan” şeklinde yorumladığı YAML'de null anlamına gelir.

Ancak kısa süre sonra iş gereksinimleri değişir ve yerel veritabanı aramasına ek olarak LDAP aracılığıyla kimlik doğrulamayı da desteklemeniz gerekir. İlk adım olarak, Authenticator sınıfını bir arayüze dönüştürür ve orijinal uygulamayı bir LocalAuthenticator sınıfına çıkarırsınız:

services:
  LocalAuthenticator: ~

Aniden Symfony'nin hiçbir şeyden haberi olmaz. Bunun nedeni Symfony'nin türler yerine hizmet adlarıyla çalışmasıdır. Denetleyici hala doğru bir şekilde soyutlamaya dayanır ve Authenticator arayüzünü bağımlılığı olarak listeler, ancak kapsayıcıda adlı Authenticator hizmeti yoktur. Symfony'ye bir ipucu vermeniz gerekir, örneğin bir hizmet adı takma adı kullanarak:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Öte yandan Nette Framework, hizmet adlarına veya ipuçlarına ihtiyaç duymaz. Kodda zaten ifade edilmiş olan bilgileri ( implements cümlesi aracılığıyla) yapılandırmada çoğaltmaya zorlamaz. PHP'nin tip sisteminin tam üstüne oturur. LocalAuthenticator 'un * Authenticator türünde * olduğunu bilir ve arayüzü uygulayan tek hizmet olduğu sürece, sadece bu yapılandırma satırı göz önüne alındığında, arayüzün talep edildiği yerde onu mutlu bir şekilde otomatik olarak bağlar:

services:
    - LocalAuthenticator

Otomatik bağlantıya aşina değilseniz, biraz sihirli gelebileceğini ve ona güvenmeyi öğrenmek için biraz zamana ihtiyacınız olabileceğini kabul ediyorum. Neyse ki, şeffaf ve determinist bir şekilde çalışıyor: konteyner bağımlılıkları net bir şekilde çözemediğinde, durumu düzeltmenize yardımcı olacak bir derleme zamanı istisnası atıyor. Bu şekilde, iki farklı uygulamaya sahip olabilir ve yine de her birinin nerede kullanıldığını iyi bir şekilde kontrol edebilirsiniz.

Genel olarak, otomatik kablolama bir geliştirici olarak size daha az bilişsel yük bindirir. Sonuçta, yalnızca türler ve soyutlamalarla ilgileniyorsunuz, öyleyse neden bir DI kapsayıcısı sizi uygulamalar ve hizmet tanımlayıcılarıyla da ilgilenmeye zorlasın? Daha da önemlisi, neden ilk etapta bir kapsayıcıyı önemsemeniz gerekiyor? Bağımlılık enjeksiyonunun ruhuna uygun olarak, sadece bağımlılıkları beyan edebilmek ve bunları sağlamak için başka birinin sorunu olmak istersiniz. Tamamen uygulama koduna odaklanmak ve kablolamayı unutmak istersiniz. Ve Nette Framework'ün DI'si bunu yapmanıza olanak tanır.

Benim gözümde bu, Nette Framework'ün DI çözümünü PHP dünyasındaki en iyi çözüm haline getiriyor. Size güvenilir ve iyi mimari kalıpları uygulayan bir kapsayıcı sunuyor, ancak aynı zamanda yapılandırması ve bakımı o kadar kolay ki bu konuda hiç düşünmenize gerek kalmıyor.

Umarım bu yazı merakınızı uyandırmayı başarmıştır. Github repository ve docs 'a göz attığınızdan emin olun – umarım size sadece buzdağının görünen kısmını gösterdiğimi ve tüm paketin çok daha güçlü olduğunu öğrenirsiniz.