Bağımlılık Enjeksiyonu Nedir?
Bu bölüm, herhangi bir uygulama yazarken izlemeniz gereken temel programlama uygulamalarını tanıtıyor. Bunlar; temiz, anlaşılır ve bakımı kolay kod yazmak için gereken temellerdir.
Bu kuralları benimseyip izlerseniz, Nette her adımda size destek olur. Rutin işleri sizin yerinize üstlenir ve en yüksek rahatlığı sağlar; böylece siz mantığın kendisine odaklanabilirsiniz.
Burada göstereceğimiz ilkeler oldukça basit. Korkulacak bir şey yok.
İlk Programınızı Hatırlıyor musunuz?
Onu hangi dilde yazdığınızı bilmiyoruz, ama PHP olsaydı muhtemelen şöyle görünürdü:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // 24 yazdırır
Birkaç önemsiz satır kod, ama içlerinde ne çok temel kavram saklı. Değişkenlerin var olduğu. Kodun fonksiyon gibi daha küçük birimlere bölündüğü. Onlara girdi argümanları aktardığımız ve sonuç döndürdükleri. Yalnızca koşullar ve döngüler eksik.
Bir fonksiyona girdi verisi aktarmamız ve onun sonuç döndürmesi, matematik gibi başka alanlarda da kullanılan, gayet anlaşılır bir kavramdır.
Bir fonksiyonun imzası vardır; adı, parametre listesi ile türleri ve son olarak dönüş değerinin türünden oluşur. Kullanıcı olarak bizi imza ilgilendirir; iç gerçekleştirim hakkında genellikle bir şey bilmemize gerek yoktur.
Şimdi fonksiyon imzasının şöyle olduğunu düşünün:
function addition(float $x): float
Tek parametreli toplama? Bu tuhaf… Peki ya bu?
function addition(): float
Bu artık gerçekten tuhaf, değil mi? Fonksiyon nasıl kullanılıyor?
echo addition(); // ne yazdıracak?
Böyle bir koda bakınca kafamız karışırdı. Yalnızca yeni başlayan biri değil, becerikli bir programcı bile böyle bir kodu anlamazdı.
Böyle bir fonksiyonun içeriden nasıl göründüğünü merak ediyor musunuz? Toplayacağı sayıları nereden alırdı? Muhtemelen onları bir şekilde kendisi bulurdu, belki şöyle:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Fonksiyonun gövdesinde, başka genel fonksiyonlara ya da statik metotlara gizli bağımlılıklar keşfettik. Sayıların gerçekten nereden geldiğini bulmak için daha derine inmemiz gerekir.
Böyle Olmaz!
Az önce gösterdiğimiz tasarım, pek çok olumsuz özelliğin özüdür:
- Fonksiyonun imzası, toplayacağı sayılara ihtiyacı yokmuş gibi davrandı ve kafamızı karıştırdı.
- Fonksiyona iki farklı sayı toplatmayı nasıl yapacağımıza dair hiçbir fikrimiz yok.
- Sayıları nereden aldığını bulmak için koda bakmak zorunda kaldık.
- Gizli bağımlılıklar keşfettik.
- Tümüyle anlamak için bu bağımlılıkları da incelemek gerekiyor.
Peki girdileri elde etmek toplama fonksiyonunun işi mi? Elbette hayır. Onun sorumluluğu yalnızca toplamanın kendisidir.
Böyle bir kodla karşılaşmak istemeyiz ve kesinlikle böyle kod yazmak da istemeyiz. Düzeltmesi basit: temele dönün ve yalnızca parametreleri kullanın:
function addition(float $a, float $b): float
{
return $a + $b;
}
Kural #1: Bırakın Size Aktarılsın
En önemli kural şudur: fonksiyonların ya da sınıfların ihtiyaç duyduğu tüm veriler onlara verilmelidir.
Veriyi elde etmeleri için gizli yollar icat etmek yerine yalnızca parametreleri verin. Kodunuzu kesinlikle iyileştirmeyecek gizli yollar icat etmek için harcanan zamandan tasarruf edersiniz.
Bu kurala her yerde her zaman uyarsanız, gizli bağımlılıkları olmayan bir koda giden yoldasınız demektir. Yalnızca yazarı için değil, sonradan okuyan herkes için anlaşılır bir koda. Her şeyin fonksiyon ve sınıf imzalarından anlaşıldığı, gerçekleştirimde gizli ayrıntılar aramaya gerek olmayan bir koda.
Bu tekniğin uzmanlık dilindeki adı Dependency Injection (bağımlılık enjeksiyonu). Veriler ise bağımlılık diye anılır. Bu yalnızca düz parametre aktarımıdır, fazlası değil.
Lütfen bir tasarım deseni olan Dependency Injection'ı, bir araç olan ve kökten farklı bir şey olan “Dependency Injection container” ile karıştırmayın. Container'ları sonra ele alacağız.
Fonksiyonlardan Sınıflara
Peki bu sınıflara nasıl uygulanır? Bir sınıf, basit bir fonksiyondan daha karmaşık bir varlıktır, ama Kural #1 burada da tümüyle geçerlidir. Yalnızca argüman aktarmanın daha fazla yolu vardır. Örneğin fonksiyon durumuna oldukça benzer biçimde:
class Math
{
public function sum(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->sum(23, 1); // 24
Ya da başka metotlarla veya doğrudan yapıcıyla:
class Sum
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$sum = new Sum(23, 1);
echo $sum->calculate(); // 24
Her iki örnek de Dependency Injection ile tümüyle uyumludur.
Gerçek Hayattan Örnekler
Gerçek dünyada sayı toplayan sınıflar yazmayacaksınız. Pratik örneklere geçelim.
Bir blog makalesini temsil eden bir Article sınıfımız olsun:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// makaleyi veritabanına kaydet
}
}
kullanımı da şöyle olsun:
$article = new Article;
$article->title = 'Kilo vermek hakkında bilmeniz gereken 10 şey';
$article->content = 'Her yıl milyonlarca insan ...';
$article->save();
save() metodu makaleyi bir veritabanı tablosuna kaydeder. Onu Nette
Database ile gerçekleştirmek dolaysız olurdu, tek bir püf noktası olmasa: Article veritabanı
bağlantısını, yani Nette\Database\Connection sınıfından bir nesneyi nereden alacak?
Görünüşe göre pek çok seçeneğimiz var. Onu statik bir değişkenden alabilir. Ya da veritabanı bağlantısını sunan bir sınıftan türeyerek. Ya da bir singleton kullanarak. Ya da Laravel'de kullanılan facade'ları:
use Illuminate\Support\Facades\DB;
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
DB::insert(
'INSERT INTO articles (title, content) VALUES (?, ?)',
[$this->title, $this->content],
);
}
}
Harika, sorunu çözdük.
Öyle mi?
Kural #1: Bırakın Size Aktarılsın kuralını anımsayalım: sınıfın ihtiyaç duyduğu tüm bağımlılıklar ona aktarılmalıdır. Çünkü kuralı çiğnersek, gizli bağımlılıklarla dolu, anlaşılmaz bir kodun yoluna girmiş oluruz; sonuç da bakımı ve geliştirilmesi baş belası bir uygulama olur.
Article sınıfının kullanıcısının, save() metodunun makaleyi nereye sakladığına dair
hiçbir fikri yoktur. Bir veritabanı tablosuna mı? Hangisine, üretim mi test veritabanına mı? Ve bu nasıl
değiştirilebilir?
Kullanıcının, save() metodunun nasıl gerçekleştirildiğine bakması gerekir ve DB::insert()
metodunun kullanıldığını görür. Yani bu metodun veritabanı bağlantısını nasıl elde ettiğini daha da araştırması
gerekir. Gizli bağımlılıklar da epey uzun bir zincir oluşturabilir.
Temiz ve iyi tasarlanmış kodda gizli bağımlılık, Laravel facade'ları ya da statik değişken asla bulunmaz. Temiz ve iyi tasarlanmış kodda argümanlar verilir:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Sonra göreceğimiz gibi, yapıcıyı kullanmak daha da pratiktir:
class Article
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function save(): void
{
$this->db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Deneyimli bir programcıysanız, Article sınıfının hiç save() metodu olmaması
gerektiğini; salt bir veri yapısını temsil etmesi ve kaydetmeyi ayrı bir repository'nin üstlenmesi gerektiğini
düşünebilirsiniz. Bu mantıklı. Ama bu bizi konunun, yani bağımlılık enjeksiyonunun ve basit örnekler verme amacının
epey dışına taşırdı.
Çalışması için örneğin veritabanı gerektiren bir sınıf yazıyorsanız, onu nereden alacağınızı icat etmeyin, size aktarılmasını sağlayın. Belki yapıcının ya da başka bir metodun parametresi olarak. Bağımlılıkları kabul edin. Onları sınıfınızın API'sinde kabul edin. Anlaşılır ve öngörülebilir bir kod elde edersiniz.
Peki hata mesajlarını günlükleyen şu sınıf:
class Logger
{
public function log(string $message): void
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Sizce Kural #1: Bırakın Size Aktarılsın kuralına uyduk mu?
Uymadık.
Anahtar bilgi, yani günlük dosyasını içeren dizin, sınıfın kendisi tarafından bir sabitten elde ediliyor.
Kullanım örneğine bakın:
$logger = new Logger;
$logger->log('Sıcaklık 23 °C');
$logger->log('Sıcaklık 10 °C');
Gerçekleştirimi bilmeden, mesajların nereye yazıldığını söyleyebilir miydiniz? Çalışması için
LOG_DIR sabitinin var olması gerektiği aklınıza gelir miydi? Ve başka bir yere yazacak ikinci bir örnek
oluşturabilir miydiniz? Kesinlikle hayır.
Sınıfı düzeltelim:
class Logger
{
public function __construct(
private string $file,
) {
}
public function log(string $message): void
{
file_put_contents($this->file, $message . "\n", FILE_APPEND);
}
}
Sınıf artık çok daha anlaşılır, yapılandırılabilir ve dolayısıyla daha yararlı.
$logger = new Logger('/path/to/log.txt');
$logger->log('Sıcaklık 15 °C');
Ama Beni İlgilendirmiyor!
“Bir Article nesnesi oluşturup save() çağırdığımda veritabanıyla uğraşmak istemiyorum; yalnızca yapılandırdığım veritabanına kaydedilsin istiyorum.”
“Logger kullandığımda mesajın yazılmasını istiyorum, nereye yazıldığıyla uğraşmak istemiyorum. Genel ayarlar kullanılsın.”
Bunlar geçerli noktalar.
Örnek olarak, bülten dağıtan ve sonucu günlükleyen bir sınıf gösterelim:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('E-postalar gönderildi');
} catch (Exception $e) {
$logger->log('Gönderim sırasında hata oluştu');
throw $e;
}
}
}
Artık LOG_DIR sabitini kullanmayan iyileştirilmiş Logger, yapıcıda dosya yolunu ister. Bu nasıl
çözülür? NewsletterDistributor sınıfı mesajların nereye yazıldığıyla ilgilenmez; yalnızca
günlüklenmesini ister.
Çözüm yine Kural #1: Bırakın Size Aktarılsın: sınıfın ihtiyaç duyduğu tüm verileri aktarırız.
Peki bu, günlük yolunu yapıcı üzerinden aktarıp Logger nesnesini oluştururken kullanacağımız anlamına
mı gelir?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ BÖYLE DEĞİL!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Böyle değil! Çünkü yol, NewsletterDistributor sınıfının ihtiyaç duyduğu bir veri değildir; ona
Logger ihtiyaç duyar. Farkı görüyor musunuz? NewsletterDistributor sınıfının ihtiyacı olan şey
logger'ın kendisidir. Öyleyse logger'ın kendisini aktaracağız:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('E-postalar gönderildi');
} catch (Exception $e) {
$this->logger->log('Gönderim sırasında hata oluştu');
throw $e;
}
}
}
Artık NewsletterDistributor sınıfının imzasından, günlüklemenin onun işlevinin bir parçası olduğu
anlaşılıyor. Logger'ı, belki test için, bir başkasıyla değiştirme işi de tümüyle dolaysız. Üstelik
Logger sınıfının yapıcısı değişse bile bunun sınıfımıza etkisi olmaz.
Kural #2: Kendine Ait Olanı Al
Kafanız karışmasın ve bağımlılıklarınızın bağımlılıklarını kabul etmeyin. Yalnızca kendi bağımlılıklarınızı kabul edin.
Bu sayede başka nesneleri kullanan kod, onların yapıcılarındaki değişikliklerden tümüyle bağımsız olur. API'si daha isabetli olur. Ve en önemlisi, bu bağımlılıkları başkalarıyla değiştirmek dolaysız hâle gelir.
Aileye Yeni Katılan
Geliştirme ekibi ikinci bir logger, veritabanına yazan bir logger yapmaya karar verdi. Böylece bir
DatabaseLogger sınıfı oluşturuyoruz. Artık iki sınıfımız var, Logger ve
DatabaseLogger; biri dosyaya, öteki veritabanına yazıyor… adlandırma biraz tuhaf görünmüyor mu?
Logger adını FileLogger yapmak daha iyi olmaz mıydı? Kesinlikle.
Ama bunu akıllıca yapalım. Özgün adı kullanarak bir arayüz oluşturuyoruz:
interface Logger
{
function log(string $message): void;
}
… her iki logger da bunu uygulayacak:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
Ve bu sayede, logger'ın kullanıldığı kodun geri kalanında hiçbir şeyi değiştirmeye gerek kalmayacak. Örneğin
NewsletterDistributor sınıfının yapıcısı parametre olarak Logger istemekle yetinmeyi
sürdürecek. Ona hangi örneği vereceğimiz de bize kalacak.
Bu yüzden arayüz adlarına asla Interface son ekini ya da I önekini eklemeyiz. Aksi hâlde
kodu bu kadar şık genişletmek olanaklı olmazdı.
Houston, Bir Sorunumuz Var
Uygulamanın tamamında, ister dosya ister veritabanı tabanlı olsun, tek bir logger örneğiyle idare edip onu günlükleme
yapılan her yere aktarabilirken, Article sınıfında durum epey farklıdır. Onun örneklerini gerektikçe, hatta
defalarca oluştururuz. Yapıcısındaki veritabanı bağımlılığını nasıl ele alırız?
Örnek olarak, bir form gönderildikten sonra makaleyi veritabanına kaydetmesi gereken bir controller düşünelim:
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Olası bir çözüm açık görünüyor: veritabanı nesnesini yapıcı üzerinden EditController sınıfına
aktaralım ve $article = new Article($this->db) kullanalım.
Tıpkı Logger ile dosya yolunu içeren önceki durumda olduğu gibi, bu doğru yaklaşım değildir. Veritabanı,
EditController sınıfının değil, Article sınıfının bağımlılığıdır. Veritabanını
aktarmak böylece Kural #2: Kendine ait olanı al kuralını çiğner.
Article sınıfının yapıcısı değişirse (yeni bir parametre eklenirse), örneklerin oluşturulduğu tüm
yerlerde kodu değiştirmeniz gerekir. Of.
Houston, öneriniz nedir?
Kural #3: Bırakın Factory Halletsin
Gizli bağımlılıkları ortadan kaldırıp tüm bağımlılıkları argüman olarak aktararak daha yapılandırılabilir ve esnek sınıflar elde ettik. Bu yüzden bu daha esnek sınıfları bizim yerimize oluşturup yapılandıracak fazladan bir şeye ihtiyacımız var. Bunlara factory diyeceğiz.
Kural şudur: Bir sınıfın bağımlılıkları varsa, örneklerinin oluşturulmasını bir factory'ye devredin.
Factory'ler, bağımlılık enjeksiyonu dünyasında new operatörünün daha akıllı alternatifidir.
Lütfen bunu, factory kullanımının belirli bir biçimini anlatan ve bu konuyla ilgisi olmayan factory method tasarım deseniyle karıştırmayın.
Factory
Factory, nesneleri oluşturup yapılandıran bir metot ya da sınıftır. Article üreten sınıfa
ArticleFactory diyeceğiz ve şöyle görünebilir:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Controller'daki kullanımı şöyle olacak:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// bırakın nesneyi factory oluştursun
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Bu noktada, Article sınıfının yapıcı imzası değişirse, tepki vermesi gereken tek kod parçası
ArticleFactory sınıfının kendisidir. Article nesneleriyle etkileşen diğer tüm kod, örneğin
EditController, etkilenmez.
Şimdi kafanızı kaşıyıp durumu gerçekten iyileştirip iyileştirmediğimizi merak ediyor olabilirsiniz. Kod miktarı arttı ve her şey kuşku uyandıracak kadar karmaşık görünmeye başladı.
Merak etmeyin, birazdan Nette DI container'a geleceğiz. Ve onun, bağımlılık enjeksiyonu kullanan uygulamalar kurmayı
büyük ölçüde kolaylaştıracak birkaç numarası var. Örneğin ArticleFactory sınıfı yerine yalnızca bir arayüz yazmak yetecek:
interface ArticleFactory
{
function create(): Article;
}
Ama kendimizden ileri gidiyoruz, bizi izlemeye devam edin :-)
Özet
Bu bölümün başında, temiz kod tasarlamanın bir yolunu göstereceğimizi söylemiştik. Yalnızca sınıfların şunları sağladığından emin olun:
- ihtiyaç duydukları bağımlılıklar onlara aktarılsın
- ve tersine, doğrudan ihtiyaç duymadıkları şeyler onlara aktarılmasın
- ve bağımlılıkları olan nesneler en iyi factory'lerde oluşturulsun
İlk bakışta belli olmayabilir, ama bu üç kuralın uzun erimli sonuçları vardır. Kod tasarımına kökten farklı bir bakışa yol açarlar. Buna değer mi? Eski alışkanlıklarını bırakıp bağımlılık enjeksiyonunu tutarlı biçimde kullanmaya başlayan programcılar, bu adımı meslek hayatlarının belirleyici anı sayarlar. Onlara anlaşılır ve bakımı kolay uygulamalar dünyasının kapısını açtı.
Peki kod bağımlılık enjeksiyonunu tutarlı biçimde kullanmıyorsa ne olur? Statik metotlar ya da singleton'lar üzerine kurulmuşsa? Bu sorunlara yol açar mı? Evet, hem de çok ciddi sorunlara.