Servislerin İsimlere İhtiyacı Yoktur

4 yıl önce Yazan Jiří Pudil  

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

(Bu gönderi ilk olarak yazarın blogunda yayınlanmıştır.)

Yazılım geliştirme, sonsuz bir yinelemeli soyutlama sürecidir. Alan modellemesinde gerçek dünyanın uygun soyutlamalarını buluruz. Nesne yönelimli programlamada, sistemdeki farklı aktörler arasındaki sözleşmeleri tanımlamak ve zorlamak 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 kompozisyon kullanırız.

Aşağıdaki denetleyiciden kimlik doğrulama mantığını çı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 bilgisi kontrolünün oraya ait olmadığını anlayacaksınız. Denetleyicinin hangi kimlik bilgilerinin geçerli olduğunu belirleme sorumluluğu yoktur – tek sorumluluk prensibine göre, denetleyicinin yalnızca tek bir değiştirme nedeni olmalıdır ve bu neden, kimlik doğrulama sürecinde değil, uygulamanın kullanıcı arayüzü kapsamında olmalıdır.

Bundan bariz yolu izleyelim ve koşulu 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 denetleyiciden bu kimlik doğrulayıcıya yetki vermek. Kimlik doğrulayıcıyı denetleyicinin bir bağımlılığı yaptık ve denetleyicinin aniden onu bir yerden alması gerekiyor:

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 oluştururum!
        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 kullanıcıların veritabanı tablosunu sorgulamasını gerektirecek daha sağlam bir kimlik doğrulama uygulanana kadar. Aniden Authenticator'ın kendi bağımlılığı vardır, diyelim ki UserRepository, bu da belirli ortamın parametrelerine bağlı olan bir Connection örneğine bağlıdır. Bu hızla büyüdü!

Her yerde örnekleri manuel olarak oluşturmak, bağımlılıkları yönetmenin sürdürülebilir bir yolu değildir. Bu yüzden, denetleyicinin yalnızca Authenticator'a olan bağımlılığını bildirmesine ve örneği gerçekten sağlamayı başkasına bırakmasına izin veren bağımlılık enjeksiyonu modeline sahibiz. Ve bu başka birine bağımlılık enjeksiyonu konteyneri denir.

Bağımlılık enjeksiyonu konteyneri, uygulamanın baş mimarıdır – sistemdeki herhangi bir servisin bağımlılıklarını çözebilir ve bunları oluşturmaktan sorumludur. DI konteynerleri bugün o kadar yaygındır ki, neredeyse her büyük web framework'ünün kendi konteyner uygulaması vardır ve hatta PHP-DI gibi bağımlılık enjeksiyonuna adanmış ayrı paketler bile vardır.

Biber Yakmak

Seçeneklerin çokluğu sonunda bir grup geliştiriciyi onları birlikte çalışabilir hale getirecek bir soyutlama aramaya motive etti. Ortak bir arayüz zamanla geliştirildi ve sonunda PHP-FIG için aşağıdaki biçimde önerildi:

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österiyor: İyi bir hizmetkardır, ancak kolayca kötü bir efendi olabilirler. Nasıl kullanılacağını biliyorsanız son derece faydalıdırlar, ancak yanlış kullanırsanız sizi yakarlar. Aşağıdaki konteyneri ele alalım:

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

Şimdiye kadar iyi. Uygulama, belirlediğimiz standartlara göre iyi görünüyor: gerçekten uygulamadaki her servisi oluşturabilir ve bağımlılıklarını özyinelemeli olarak çözebilir. Her şey tek bir yerden yönetilir ve konteyner parametreleri bile kabul eder, böylece veritabanı bağlantısı kolayca yapılandırılabilir. Güzel!

Ancak şimdi yalnızca iki ContainerInterface metodunu gördüğünüzde, konteyneri şu şekilde kullanmaya yönelebilirsiniz:

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 biberi yaktınız. Başka bir deyişle, konteyner kötü bir efendi haline geldi. Neden böyle?

İlk olarak, rastgele bir servis tanımlayıcısına güveniyorsunuz: 'authenticator'. Bağımlılık enjeksiyonu, bağımlılıklarınız konusunda şeffaf olmakla ilgilidir ve yapay bir tanımlayıcı kullanmak bu kavrama doğrudan aykırıdır: kodun sessizce konteyner tanımına bağımlı olmasına neden olur. Konteynerdeki bir servis yeniden adlandırılırsa, bu referansı bulup güncellemeniz gerekir.

Daha da kötüsü, bu bağımlılık gizlidir: dışarıdan ilk bakışta, denetleyici yalnızca konteyner soyutlamasına bağlıdır. Ancak bir geliştirici olarak sizin konteynerdeki servislerin nasıl adlandırıldığı ve authenticator adlı servisin aslında Authenticator'ın bir örneği olduğu hakkında bilgi sahibi olmanız gerekir. Tüm bunları yeni iş arkadaşınızın öğrenmesi gerekir. Gereksiz yere.

Neyse ki, çok daha doğal bir tanımlayıcıya başvurabiliriz: servisin tipi. Sonuçta, bir geliştirici olarak sizi ilgilendiren tek şey budur. Konteynerdeki bir servise hangi rastgele dizenin atandığını bilmenize gerek yok. Bu kodun yazılmasının ve 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);
        //...
    }
}

Maalesef, henüz alevleri kontrol altına alamadık. Hiçbir şekilde. Daha büyük sorun, konteyneri alçakgönüllülükle bir servis bulucu rolüne indirgemenizdir, ki bu büyük bir anti-desendir. Bu, birinden bir atıştırmalık alabilmesi için ona bütün bir buzdolabını getirmek gibidir – ona sadece o atıştırmalığı getirmek çok daha mantıklıdır.

Yine, bağımlılık enjeksiyonu şeffaflıkla ilgilidir ve bu denetleyici hala bağımlılıkları konusunda şeffaf değildir. 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, kodun okunmasını zorlaştırır. Veya kullanılmasını. Veya test edilmesini! Birim testinde kimlik doğrulayıcıyı taklit etmek (mocking) artık etrafında bütün bir konteyner oluşturmanızı gerektiriyor.

Ve bu arada, denetleyici hala konteyner tanımına oldukça kötü bir şekilde bağlıdır. Konteynerde kimlik doğrulayıcı servisi yoksa, kod ancak action() metodunda başarısız olur, ki bu oldukça geç bir geri bildirimdir.

Lezzetli Bir Şey Pişirmek

Adil olmak gerekirse, bu çıkmaza girdiğiniz için kimse sizi suçlayamaz. Sonuçta, sadece akıllı geliştiriciler tarafından tasarlanmış ve kanıtlanmış bir arayüzü takip ediyordunuz. Mesele şu ki, tüm bağımlılık enjeksiyonu konteynerleri tanım gereği aynı zamanda servis buluculardır ve desenin aralarındaki tek ortak arayüz olduğu ortaya çıkıyor. Ancak bu, onları servis bulucu olarak kullanmanız gerektiği anlamına gelmez. Aslında, PSR spesifikasyonunun kendisi buna karşı uyarıyor.

DI konteynerini iyi bir servis olarak şu şekilde 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);
        //...
    }
}

Yapıcıda, bağımlılık açıkça, net ve şeffaf bir şekilde bildirilir. Bağımlılıklar artık sınıfın her yerine dağılmış gizli değildir. Ayrıca zorunludurlar: konteyner, gerekli Authenticator'ı sağlamadan SignInController örneğini oluşturamaz. Konteynerde kimlik doğrulayıcı yoksa, yürütme action() metodunda değil, erkenden başarısız olur. Bu sınıfı test etmek de çok daha kolay hale geldi, çünkü herhangi bir konteyner kazanı olmadan yalnızca kimlik doğrulayıcı servisini taklit etmeniz yeterlidir.

Ve bir küçük ama çok önemli detay daha: servisin tipi hakkındaki bilgiyi gizlice içeri soktuk. Bunun bir Authenticator örneği olduğu gerçeği – daha önce örtük ve IDE'ler, statik analiz araçları veya hatta konteyner tanımına aşina olmayan bir geliştirici tarafından bilinmeyen – şimdi terfi ettirilen parametrenin tip ipucuna statik olarak kazınmıştır.

Geriye kalan tek adım, konteynere denetleyiciyi nasıl oluşturacağı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 servis bulucu yaklaşımını kullandığını fark etmiş olabilirsiniz. Ancak, kapsandığı sürece (kelime oyunu amaçlı) bu sorun değil. Konteyner dışında get metodu çağrısının izin verildiği tek yer, uygulamanın giriş noktası olan index.php'dedir; burada konteynerin kendisinin oluşturulması ve ardından uygulamanın yüklenip çalıştırılması gerekir:

$container = bootstrap();

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

Gizli Mücevher

Ama burada durmayalım, bu iddiayı daha da ileri götürmeme izin verin: get metodu çağrısının izin verildiği tek yer giriş noktasıdır.

Konteyner kodu sadece kablolamadır, montaj talimatlarıdır. Yürütülebilir kod değildir. Bir bakıma önemli değildir. Evet, uygulama için kritik öneme sahiptir, ancak yalnızca geliştirici açısından. Aslında kullanıcıya doğrudan bir değer getirmez ve bu gerçek göz önünde bulundurularak ele alınmalıdır.

Konteynere tekrar bakı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 yalnızca uygulamanın çok küçük ve basit bir bölümünü kapsar. Uygulama büyüdükçe, konteyneri manuel olarak yazmak inanılmaz derecede sıkıcı hale gelir. Daha önce de söylediğim gibi, konteyner sadece bir montaj kılavuzudur – ancak çok karmaşıktır, birçok sayfası, sayısız çapraz referansı ve küçük puntolarla yazılmış bir sürü uyarısı vardır. Onu IKEA tarzı bir kılavuza dönüştürmek istiyoruz; grafiksel, özlü ve montaj sırasında kırılmaması için ÅUTHENTICATÖR'ü halıya koyarken gülümseyen insanların resimleriyle.

İşte burada Nette Framework devreye giriyor.

Nette Framework'ün DI çözümü, YAML'ye benzeyen ancak steroidler üzerinde olan bir yapılandırma dosyası formatı olan Neon'u kullanır. Aynı konteyneri Neon yapılandırması kullanarak şu şekilde tanımlarsınız:

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

İki dikkate değer şeye dikkat çekeyim: birincisi, servis listesi gerçekten bir listedir, bir hash haritası değil – anahtar yok, yapay servis tanımlayıcıları yok. authenticator yok, Authenticator::class bile yok. İkincisi, veritabanı bağlantı parametreleri dışında hiçbir yerde açıkça herhangi bir bağımlılık belirtmeniz gerekmez.

Bunun nedeni, Nette Framework'ün otomatik kablolamaya (autowiring) dayanmasıdır. Bağımlılık enjeksiyonu sayesinde bağımlılığın tipini yerel bir tip ipucuyla nasıl ifade edebildiğimizi hatırlıyor musunuz? DI konteyneri bu bilgiyi kullanır, böylece bir Authenticator örneği istediğinizde, herhangi bir ismi tamamen atlar ve doğru servisi yalnızca tipine göre bulur.

Otomatik kablolamanın benzersiz bir özellik olmadığını iddia edebilirsiniz. Ve haklı olurdunuz. Nette Framework konteynerini benzersiz kılan şey, PHP tip sistemini kullanmasıdır, oysa diğer birçok framework'te otomatik kablolama hala dahili olarak servis adlarına dayanır. Diğer konteynerlerin yetersiz kaldığı senaryolar vardır. Symfony DI konteynerinde kimlik doğrulayıcı servisini YAML kullanarak şu şekilde tanımlarsınız:

services:
  Authenticator: ~

services bölümünde bir hash haritası vardır ve Authenticator biti servis tanımlayıcısıdır. Tilda, YAML'de null anlamına gelir, ki Symfony bunu “servis tanımlayıcısını tipi olarak kullan” şeklinde yorumlar.

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

services:
  LocalAuthenticator: ~

Aniden Symfony çaresiz kalır. Bunun nedeni, Symfony'nin tipler yerine servis adlarıyla çalışmasıdır. Denetleyici hala soyutlamaya doğru bir şekilde güvenir ve Authenticator arayüzünü bağımlılığı olarak listeler, ancak konteynerde Authenticator adında bir servis yoktur. Symfony'ye bir ipucu vermeniz gerekir, örneğin bir servis adı takma adıyla:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework ise servis adlarına veya ipuçlarına ihtiyaç duymaz. Sizi, kodda zaten ifade edilmiş olan bilgileri ( implements yan tümcesi aracılığıyla) yapılandırmada kopyalamaya zorlamaz. Doğrudan PHP tip sisteminin üzerine yerleştirilmiştir. LocalAuthenticator'ın Authenticator tipinde olduğunu bilir ve eğer bu arayüzü uygulayan tek servis buysa, bu arayüzün istendiği yerde, yalnızca şu yapılandırma satırına dayanarak memnuniyetle otomatik olarak bağlar:

services:
    - LocalAuthenticator

Otomatik kablolamaya aşina değilseniz, biraz sihirli görünebileceğini ve ona güvenmeyi öğrenmek için biraz zamana ihtiyacınız olabileceğini kabul ediyorum. Neyse ki, şeffaf ve deterministik bir şekilde çalışır: konteyner bağımlılıkları kesin olarak çözemediğinde, durumu düzeltmenize yardımcı olacak bir derleme zamanı istisnası fırlatır. Bu şekilde, iki farklı uygulamanız olabilir ve yine de hangisinin nerede kullanıldığı üzerinde iyi bir kontrole sahip olabilirsiniz.

Genel olarak, otomatik kablolama bir geliştirici olarak size daha az bilişsel yük bindirir. Sonuçta, yalnızca tipler ve soyutlamalarla ilgilenirsiniz, öyleyse DI konteyneri sizi neden uygulamalar ve servis tanımlayıcılarıyla da ilgilenmeye zorlasın? Daha da önemlisi, neden herhangi bir konteynerle ilgilenesiniz ki? Bağımlılık enjeksiyonu ruhuyla, sadece bağımlılıkları bildirebilmek ve bunları sağlamanın başkasının sorunu olmasını istersiniz. Tamamen uygulama koduna odaklanmak ve kablolamayı unutmak istersiniz. Ve Nette Framework DI bunu yapmanızı sağlar.

Benim gözümde, bu Nette Framework'ün DI çözümünü PHP dünyasında var olan en iyisi yapar. Size güvenilir ve iyi mimari desenleri zorlayan bir konteyner sunar, ancak aynı zamanda yapılandırması ve bakımı o kadar kolaydır ki, hakkında hiç düşünmeniz gerekmez.

Umarım bu gönderi merakınızı uyandırmayı başarmıştır. Github depo'suna ve dokümantasyon'a göz atmayı unutmayın – 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 keşfedeceksiniz.