.NET'te Dependency Injection Nedir, Neden Kullanılır
Herkese selam, bu yazımda ASP.NET'in kalbinin atmasını sağlayan ve diğer framework'lerden öne çıkmasını sağlayan, built-in gelen bir yapıdan bahsetmek istiyorum. Dependency Injection. Bu arkadaşın ismi Türkçede pek anlamlı değil, o yüzden ne olduğunu bilsek ve nasıl çalıştığını anlasak yeterli. İsminin Türkçe karşılığı bağımlılık enjeksiyonu (aslında fena da değil).
Konuya deep dive yapmadan önce bu ihtiyacı ortaya çıkarsak daha iyi olacak sanırım.
(Buradaki örneği bilerek çok absürt tutuyorum ki ilgili yazıyı söve söve okumaya devam edelim.)
Başlamadan önce
Bu yazıyı takip etmek için temel seviyede C# bilgisi yeterli: class, constructor, method ve interface kavramlarına aşina olmanız gerekiyor. Örnekleri kendiniz çalıştırmak isterseniz .NET 10 SDK kurulu olsun, veritabanı ya da başka bir kurulum gerekmiyor.
DI olmasaydı ne yazardık?
Yazmış olduğumuz kodda veriyi bir dosyada tuttuğumuzu düşünelim. Elimizde bir dosya yolu var, örneğin ne olsun, data/users.csv bu şekilde bir yolumuz olsun.
Örnek bağlamında tablolar ekleyeceğim, bu tabloları da olabildiğince basic tutarak ilerliyorum, her tablo bir CSV dosyası olsun. Örneğin users adlı tablomuz var, iki tane de işlemimiz olacak: kullanıcı kayıt ve kullanıcı okuma senaryosu. Diyelim ki yeni başladık ve functional programming'den geldik. Hemen açtık iki tane fonksiyon, kayıt ol ve kullanıcı oku diye. Her iki fonksiyonun başlangıcında da kullanıcıyı dosyaya kaydetmek için ve kullanıcıyı dosyadan okumak için dosya yolunu tekrar tekrar yazacağız.
Örnek olarak verdiğim uygulamada kullanıcıları saklamak için şöyle bir method yazdım:
void RegisterUser(string email, string displayName)
{
Directory.CreateDirectory("data");
File.AppendAllText("data/users.csv", $"{email},{displayName}{Environment.NewLine}");
}Ve kullanıcıları sonrasında okumak için de şöyle bir kod parçası yazdım:
string? ReadUser(int lineNumber)
{
if (!File.Exists("data/users.csv"))
{
return null;
}
var lines = File.ReadAllLines("data/users.csv");
if (lineNumber < 1 || lineNumber > lines.Length)
{
return null;
}
return lines[lineNumber - 1];
}Burada fark ettiğiniz üzere bir işi (ilgili dosya ile ilgili işlemi) iki farklı yerde yapmaya başladık.
Gelin isterseniz C#'ın nimetlerinden faydalanıp bunu bir obje hâline getirelim ve bir tane UserRepository yapalım. UserRepository de içerisinde şimdilik iki methodu olan (ihtiyacımız o kadar) bir sınıf olsun. Kullanıcı kaydet ve kullanıcı oku.
public class UserRepository
{
public void Save(string email, string displayName)
{
Directory.CreateDirectory("data");
File.AppendAllText("data/users.csv", $"{email},{displayName}{Environment.NewLine}");
}
public string? Read(int lineNumber)
{
if (!File.Exists("data/users.csv"))
{
return null;
}
var lines = File.ReadAllLines("data/users.csv");
if (lineNumber < 1 || lineNumber > lines.Length)
{
return null;
}
return lines[lineNumber - 1];
}
}Şimdi burada gördüğünüz üzere UserRepository, bizim daha önceki fonksiyonumuz ne yapıyorsa aynen yapmaya devam ediyor.
Şu an uygulamanın iki yerinde UserRepository'yi kullanıyoruz (Program.cs içerisinde). Buralarda bu bağımlılığın çözülmesi gerek; bizim bu iki methodumuz dolaylı yoldan UserRepository'ye bağlanmış oldu artık. Gördüğünüz gibi bu iki methodda da UserRepository'yi new'lemek durumunda kaldık.
if (choice == "1")
{
Console.Write("email: ");
var email = Console.ReadLine() ?? string.Empty;
Console.Write("display name: ");
var displayName = Console.ReadLine() ?? string.Empty;
var users = new UserRepository();
users.Save(email, displayName);
Console.WriteLine("saved");
} else if (choice == "2")
{
Console.Write("line number: ");
if (!int.TryParse(Console.ReadLine(), out var lineNumber))
{
Console.WriteLine("line number must be a number");
continue;
}
var users = new UserRepository();
Console.WriteLine(users.Read(lineNumber) ?? "no user on that line");
}Buradaki ufak örnekte sorun yok ancak bağımlılıklarımızın arttığı senaryoda bu new'ler başımızı ağrıtmaya başlayacak.
Örneğin kullanıcıların nereye kayıt edileceğini bize sağlayan bir class'ımız olduğunu düşünelim: TableResolver. TableResolver da kendi içinde method olarak GetUserTableSource'a sahip olsun (bu method sadece kullanıcıların nereye kayıt edileceğini gösteren string dönecek).
public class TableResolver
{
public string GetUserTableSource()
{
return "data/users.csv";
}
}Evet, gördüğünüz gibi bizim UserRepository'nin bağımlılığı artarak TableResolver eklendi ve artık bu şu demek: UserRepository oluşturulurken TableResolver adlı class'a ihtiyaç duyacak. Bu da daha önce yazmış olduğumuz Program.cs içerisindeki iki kodun değişmesi gerekiyor demek. Yani UserRepository kullanan herkese artık bana bunu sağlamalısın diyor, çünkü .NET elimde olmayan objeyi kullanamam diyor.
@@ -17,7 +17,7 @@
Console.Write("display name: ");
var displayName = Console.ReadLine() ?? string.Empty;
- var users = new UserRepository();
+ var users = new UserRepository(new TableResolver());
users.Save(email, displayName);
Console.WriteLine("saved");
@@ -32,7 +32,7 @@
continue;
}
- var users = new UserRepository();
+ var users = new UserRepository(new TableResolver());
Console.WriteLine(users.Read(lineNumber) ?? "no user on that line");
}Buradaki bağımlılığın sadece bizim projede değil de birçok yerde kullanıldığını düşünsenize. Kullanıcının tek istediği sadece UserRepository, ama birçok şeyi bilmesi gerekecek (bu örnekte sadece TableResolver ama örneğin büyüdüğünü ve bağımlılıkların arttığını düşünsenize).
İşin özü bu aslında, .NET'in bu dependency injection formülü buradan geliyor. Bu aslında Inversion of Control tasarım deseninden gelmekte.
IoC için şunu diyebiliriz o zaman: bir kodun nasıl çözümleneceğini belirttikten sonra gerisini ben hallederim der. Yani siz bir formül belirlersiniz. Örneğin çay yapmak bir süreçtir: çaydanlığı al, içine su koy, bekle, servis et. Bunları hazırlarsınız, talimatları IoC'ye girersiniz; daha sonra birisi çay yap dediğinde bu süreçleri kendisi işletir. IoC tam olarak budur. Biz de .NET içerisinde dependency injection kullanırken tam olarak bunu yapacağız. Şu an bağımlılıklarımız var ve örneğimizde bağımlılıkları (çayı) kendisi yüklemek zorunda, ancak bu işi sadece bana UserRepository ver mantığına çevirebiliriz.
Şimdi bizim örneğimizde dependency injection eklemek için bazı paketler eklememiz gerekiyor, bu paketler Microsoft'un kendi paketi zaten:
dotnet add package Microsoft.Extensions.DependencyInjection
Bu komut da csproj'a şu satırı ekliyor:
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
</ItemGroup>Ve burada birkaç terimle karşılaşacağız aslında: ServiceCollection, ServiceProvider. İlk olarak yeni bir ServiceCollection açıyoruz ve bu ServiceCollection bizim IoC'miz oluyor; burada neyi nasıl çözümleyeceğini tarif ediyoruz. Öncelikle UserRepository class'ımızı services'a ekleyelim.
var services = new ServiceCollection();
services.AddScoped<UserRepository>(sp => new UserRepository(new TableResolver()));
var provider = services.BuildServiceProvider();Buradaki kodu gittikçe değiştireceğiz, buraya takip edelim.
Servis eklerken birden fazla tanımlama yöntemi var, bunlara sonra değineceğiz; şimdilik servisi services.AddScoped şeklinde kayıt edebileceğimizi bilseniz yeterli. Daha sonrasında bu lifetime'ların nasıl yönetildiğine ve nasıl çalıştıklarına da değineceğiz.
Sonrasında usage'ımızı da değiştirmek lazım, artık new kullanmamıza gerek yok. ServiceProvider'dan eklemiş olduğumuz bağımlılığı çözebiliriz, bunun için öncelikle scope oluşturmak lazım. Daha sonrasında ise bağımlılığını istediğimiz yerde scope.ServiceProvider.GetRequiredService<UserRepository>() diyerek ilgili service'i isteyebiliriz.
using var scope = provider.CreateScope();Değişikliğin tamamı da şöyle görünüyor, iki yerdeki new satırı yerini service isteğine bıraktı:
@@ -1,3 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
+
+var services = new ServiceCollection();
+
+services.AddScoped<UserRepository>(sp => new UserRepository(new TableResolver()));
+
+var provider = services.BuildServiceProvider();
+
while (true)
{
ShowMenu();
@@ -9,6 +17,8 @@
break;
}
+ using var scope = provider.CreateScope();
+
if (choice == "1")
{
Console.Write("email: ");
@@ -17,7 +27,7 @@
Console.Write("display name: ");
var displayName = Console.ReadLine() ?? string.Empty;
- var users = new UserRepository(new TableResolver());
+ var users = scope.ServiceProvider.GetRequiredService<UserRepository>();
users.Save(email, displayName);
Console.WriteLine("saved");
@@ -32,7 +42,7 @@
continue;
}
- var users = new UserRepository(new TableResolver());
+ var users = scope.ServiceProvider.GetRequiredService<UserRepository>();
Console.WriteLine(users.Read(lineNumber) ?? "no user on that line");
}Ve artık ta-daa, biz buradaki ekleme yöntemini, bağımlılık resolve işlemini Dependency Injection'a aktardığımız için yeni kullanıcılar UserRepository'yi kullanırken new yazmak ve bağımlılıklarını bilmek zorunda kalmayacak.
Şimdi TableResolver olayına geri dönelim. Bildiğiniz üzere UserRepository sınıfımızın kendi içinde bağımlılığı olan TableResolver adlı bir class'ı vardı. .NET'in nimetleri saymakla bitmiyor. .NET der ki, eğer sen bir bağımlılığı dependency injection'a eklersen, bu bağımlılığı constructor içerisinde istediğinde ben sana veririm. Bizim TableResolver class'ımız UserRepository'ye nerede aktarılıyor, hemen bakalım.
private readonly TableResolver _tableResolver;
public UserRepository(TableResolver tableResolver)
{
_tableResolver = tableResolver;
}Gördüğünüz gibi constructor'da. O zaman yapmamız gereken şey artık dependency injection'a TableResolver'ı eklemek.
services.AddScoped<TableResolver>(sp => new TableResolver());Artık UserRepository'yi eklerken TableResolver'ı new'lemeye gerek kalmadı, onu da DI içinden alabiliriz.
services.AddScoped<UserRepository>(sp => new UserRepository(sp.GetRequiredService<TableResolver>()));Buradaki sp, servisin oluşturulduğu scope'un service provider'ını temsil eder. sp, ServiceProvider kısaltması.
Vee, arkadaşlar işin güzel tarafı ne biliyor musunuz? Eğer bir sınıfın bağımlılıkları hâli hazırda dependency injection'a eklenmişse veya herhangi bir bağımlılığı yoksa func delegate açmanıza bile gerek yok. Yani services.AddScoped<UserRepository>(); yazmak yeterli. Aynı şey tabii ki TableResolver için de geçerli, onun hiçbir bağımlılığı yok, .NET bunu otomatik anlayıp ilgili TableResolver'ı oluşturabilir.
services.AddScoped<TableResolver>();
services.AddScoped<UserRepository>();İki kayıt arasındaki fark da şöyle:
@@ -2,8 +2,8 @@
var services = new ServiceCollection();
-services.AddScoped<TableResolver>(sp => new TableResolver());
-services.AddScoped<UserRepository>(sp => new UserRepository(sp.GetRequiredService<TableResolver>()));
+services.AddScoped<TableResolver>();
+services.AddScoped<UserRepository>();
var provider = services.BuildServiceProvider();Evet, bu yazımızda .NET'in dependency injection ile kod kalitesini nasıl arttırdığını gördük. Tekrar hatırlatmak isterim ki bu kodların kullanım senaryolarında her seferinde ilgili bağımlılıkları resolve etmek çok can sıkıcı olabiliyor. Misal 5 tane bağımlılığı olan bir sınıf düşünsenize; her biri için ilgili bağımlılığı new yazarak üretsek, bu üretilen bağımlılıkların da bağımlılıkları olduğunu varsaysak, bir de bunları birden fazla yerde yaptığımızı düşünsek, değişiklik esnasında içinden çıkılmaz bir hâle gelir.
Yazımızın devamında, bu yazımızda ufak bir girizgâh yaptığımız AddScoped methoduyla ilişkili olarak lifetime'lardan bahsedeceğiz. Aynı zamanda hangi lifetime ne zaman kullanılmalı şeklinde bir konuya da giriş yapabiliriz.
Projenin son hâlini buradaki repository'den görebilirsiniz. Aynı zamanda buradaki kod değişikliklerini de commit geçmişi üzerinden (buradan) bakarak görüntüleyebilir, kodumuzun nasıl daha organize hâle geldiğini görebilirsiniz.