dotnet csharp

Singleton, Scoped, Transient: .NET DI Lifetime Farkı

Tekrardan selamlar. Bir önceki yazımda DI üzerine servis ekleme kalıbı olarak kullandığımız AddScoped methodunu bu yazıda ayrıntılı olarak inceleyeceğiz.

AddScoped aslında oluşturulacak olan nesnenin davranış tipini belirler. Yani bir nesne çağrıldığında nasıl davranış sergilesin, bu soruya cevap olur. Cevap basit: nasıl eklendiyse öyle çağrılır.

.NET bize 3 farklı davranış tipi sunar. Bu davranış tiplerine aslında .NET tarafından lifetime adı verilmiştir. Bunlar Singleton, Scoped, Transient olarak ayrılmakta. Bu davranış tiplerinin, ilgili provider'dan çağrıldıklarında scope içerisinde ne kadar obje oluşturduğunu gösteren tablo aşağıdaki gibidir.

lifetimeKaç obje
SingletonUygulama boyunca 1
ScopedHer scope için 1
TransientHer çağrıda 1 yeni

Bir üstteki paragrafta ilgili provider diye bahsettim. Hangi provider'dan çağrıldıkları çok önemli. Uygulama ömrü boyunca yaşayacak olan provider genelde root provider olarak isimlendirilir. Daha sonra ise root provider'dan, bir işlem ömrü boyunca yaşamasını istediğimiz scope'lar oluştururuz. Yani teorik olarak bir provider scope oluşturmak için kullanılabilir; fakat ilk başta BuildServiceProvider methodu ile oluşturduğumuz provider bizim root provider'ımızdır. Ve root provider uygulama ömrü ile paralel çalışacak şekilde tasarlanmıştır. Bu yüzden root provider üzerinden alınan scoped nesneler aslında scoped olarak davranmazlar, çünkü root provider'ın herhangi bir scope'u yoktur. Sonraki provider'ları oluştururken de zaten IServiceScope objesi türetilmesi için CreateScope methodu kullanılmaktadır; yani buradan bile bir service scope oluşturduğumuzu öğrenebiliriz.

var services = new ServiceCollection();

services.AddScoped<TableResolver>();
services.AddScoped<UserRepository>();

var provider = services.BuildServiceProvider();

Örnek olarak göstermiş olduğum ve bir önceki projede yazmış olduğumuz root provider Kod 1.1'de belirttiğim kısım , bir sonraki scope'larımız ise bu root provider'dan şu şekilde türetiliyor.

        break;
    }

    using var scope = provider.CreateScope();

    if (choice == "1")
    {

Bir önceki uygulamamızdaki kritik adımları hatırladığımıza göre, burada dependency injection içerisinde sadece scoped olarak değil, farklı obje yaratma davranışlarına göre servisleri nasıl kaydederiz onu inceleyeceğiz.

Örnek uygulama ve root provider

Burada Scoped, Transient ve Singleton objelerini içeren bir örneğe, başlamak istiyorum. Uygulamamız yine gereksiz işlemler silsilesi olacak; ancak kullanım senaryolarını kafanızda canlandırabilmeniz için gerçeğe yakın örnekler vermeye çalışacağım.

İlk olarak root provider'ımızı oluşturmakla başlayalım. Bu örnekte root provider'ı el ile oluşturuyoruz ve içine henüz bir servis eklemediğimiz için bomboş oluyor. Daha sonraki scope'ları da bu root provider'dan oluşturacağız.

var services = new ServiceCollection();

var provider = services.BuildServiceProvider();

Üç servis, üç lifetime kararı

Uygulamamız bir CLI uygulaması olacak ve tek işi kullanıcı kaydı almak. Kullanıcıdan isim ve eposta alıp bunları doğruluyoruz, geçerli olanları sisteme ekliyoruz. Bu sırada bir oturum içinde yapılan işlemleri de kayıt altına alıyoruz. Menüleri ve mesajları birden fazla dilde gösterebilmek için de basit bir çeviri desteği ekliyoruz.

Buradaki uygulamamızda 3 farklı servisimiz olacak. Servislerimizin sırasıyla ne olduklarına bakarak lifetime'larının ne olacağına karar verelim.

Translator

İlk servisimiz Translator olacak. Uygulamamıza çoklu dil desteği eklediğimizi düşünelim; Translator'ı key value pair olarak dil çevirilerini yapacağımız servis olarak düşünebilirsiniz.

Bu Translator servisini, daha önceden hazırlanmış olan bir kaynaktan (bu bir veri tabanı olabilirdi), yani bir metin belgesinden ilgili key value pair'leri dosyayı okuyarak yükleyen bir sınıf olarak düşünelim.

Uygulamamızın şu anki hâli bu. Gördüğünüz gibi constructor içerisinde bir reading messages.txt mesajı bulunuyor , yani bu servis ne zaman oluşturulursa messages.txt dosyasını tekrardan okuyor.

    public Translator()
    {
        Console.WriteLine("translator: reading messages.txt");

        var path = Path.Combine(AppContext.BaseDirectory, "messages.txt");

        foreach (var line in File.ReadAllLines(path))
        {
            var parts = line.Split('=');

            _messages[parts[0]] = parts[1];
        }
    }

Uygulamamızdaki şu anki kullanım senaryosu ise şu şekilde: oluşturulan scope üzerinden ilgili Translator objesini çağırıyoruz.

while (true)
{
    Console.Write("message key (0 = exit) > ");

    var key = Console.ReadLine();

    if (key == "0")
    {
        break;
    }

    using var scope = provider.CreateScope();

    var translator = scope.ServiceProvider.GetRequiredService<Translator>();

    Console.WriteLine(translator.Get(key ?? string.Empty));
}

Scoped olarak eklenen değerler her scope içinde ilk çağrıldığı zaman oluşturulur, ancak aynı scope içerisinde tekrar çağırırsak yeniden oluşturulmaz. Bu örneğimizde while döngüsünün sonunda ilgili scope sona eriyor, tekrardan 20. satıra geldiğimizde yeniden bir scope yaratılıyor (root provider tarafından). Yani aynı scope içerisinde GetRequiredService çağrısını iki kez yapsak da elimize gelen obje aynı obje oluyor.

Bu da uygulamamızda şuna sebep oluyor: hiçbir işlem yapmasam da, sadece enter'a bassam bile her yeni scope oluşturulduğunda Translator servisi yeniden oluşturuluyor.

Ancak bunun olmasına gerek var mı? messages.txt'yi her seferinde gerçekten yeniden okumaya ihtiyaç var mı?

Buradaki Translator servisi için doğru lifetime'ı bulmak adına Transient'i düşünelim. Transient, scope'tan bağımsız olarak her istendiğinde yeni bir obje oluşturan bir lifetime türü. Ama bizim ihtiyacımız bu da değil; biz aksine minimum yenilenme ihtiyacı duyuyoruz, çünkü kaynak içerisindeki değerler değişmiyor, hep aynı kalıyor.

Programlama dillerinde sabit disklerden veri okuma (bizim örneğimizde File.ReadAllLines) çok pahalı bir işlemdir, çünkü dosya okunurken ilgili thread bloke olur ve diskin işini bitirmesini beklemek zorunda kalır.

Bu yüzden bu işlemi bir kez yapmamız en mantıklısı olacak (çünkü kaynak, yani messages.txt, sürekli değişen bir ihtiyaç değil). Ve bunun state'i tüm scope'lar adına ortak kullanılabilir. Yani bir scope'da empty=değer boş olamaz ise diğer scope'da da aynısı olacak zaten.

Paylaşılan bir state olduğu için ve tek seferlik okuma yeteceği için buradaki implementasyonu singleton olarak değiştiriyoruz .

@@ -2,7 +2,7 @@
 
 var services = new ServiceCollection();
 
-services.AddScoped<Translator>();
+services.AddSingleton<Translator>();
 
 var provider = services.BuildServiceProvider();

Kod 2.4'te implementasyonumuzun değişmesi üzerine artık ilgili Translator objesi "ilk çağırım esnasında" oluşturulacaktır. Burası önemli, çünkü singleton olarak eklenen objelerin build esnasında oluşturulduğu inancı sık karıştırılan bir şey. Eğer biz bir objeye hiç ihtiyaç duymazsak ilgili obje .NET tarafından hiç construct (new) edilmez. Tabii bu, container'a tipi ya da bir factory delegate'i verdiğimiz senaryolar için geçerli; AddSingleton içine hazır bir instance verirsek o objeyi zaten biz oluşturmuş oluyoruz, container onu hiç construct etmiyor.

Bu yüzden çıktıda gördüğünüz üzere message key (0 = exit) > mesajı bizim constructor içindeki reading messages.txt'den önce gelmekte. Yani sadece GetRequiredService ile Translator istendiğinde bizim objemiz new'lenmektedir. 22. satırda olduğu gibi: var translator = scope.ServiceProvider.GetRequiredService<Translator>();

AddScoped<Translator>
❯ dotnet run
message key (0 = exit) >
translator: reading messages.txt

message key (0 = exit) >
translator: reading messages.txt

message key (0 = exit) >
translator: reading messages.txt

message key (0 = exit) >
AddSingleton<Translator>
❯ dotnet run
message key (0 = exit) >
translator: reading messages.txt

message key (0 = exit) >


message key (0 = exit) >


message key (0 = exit) >

Fakat enter tuşuna basarak devam ettiğimde ise artık tekrardan new'lenmiyor, yeni bir scope oluşturmaya devam ediyorum. Sadece ekleme şeklim değişti, bunun dışında hiçbir şey değişmedi, diff'de görüyoruz zaten ( Kod 2.4 ). Ancak çalışma şekli ise tamamen değişiyor.

Burada olan şeyin ortadan sadece bir metnin kaybolması değil, bir işlemin gitmesi olduğuna dikkat çekmek isterim. Artık dosya okuma işlemini yapmıyoruz; sadece ekrana yazdırma methodu çalışmıyor değil.

Burayı anladıktan sonra, uygulamamızı güzel bir hâle getirebilmek adına dil seçim menüsü eklemek istiyorum. Translator sınıfı içerisinde kullanıcının tercihini tutacak bir string olsun. Uygulamamız ilk başladığında (döngünün ilk başında) eğer Translator objemizin herhangi bir dil seçimi yok ise dil seçtirelim.

Ve messages.txt kısmını da ikiye ayıralım, uygulamamızda artık iki dil desteklensin: İngilizce ve Türkçe olacak şekilde ilerleyelim. messages.en.txt ve messages.tr.txt şeklinde devam edelim. Bu dosyalardan okunan metinleri de dictionary içerisinde saklayarak menülerde translation işlemlerini yapabiliriz.

Şimdilik menü isimlerimizi, kullanıcıya verilen mesajları ve hata mesajlarını çevirsek yeterli. Örneğin Lütfen eposta giriniz., Eposta hatalı ve dil değiştir şeklinde mesajları ekleyelim. Kullanıcının seçimine göre buradaki ekranda verilen mesajları da değiştiriyor olacağız.

menu.register=Kullanıcı kaydı
menu.language=Dil değiştir
menu.exit=Çıkış
prompt.choice=Seçiminiz
prompt.email=Lütfen eposta giriniz.
prompt.language=Dil seçin
error.email=Eposta hatalı.
error.choice=Bilinmeyen seçenek.
empty=değer boş olamaz
invalid=geçersiz eposta adresi
saved=kayıt edildi
@@ -1,23 +1,49 @@
 public class Translator
 {
-    private readonly Dictionary<string, string> _messages = new();
+    private static readonly string[] Languages = { "en", "tr" };
 
-    public Translator()
-    {
-        Console.WriteLine("translator: reading messages.txt");
+    private readonly Dictionary<string, Dictionary<string, string>> _catalogs = new();
 
-        var path = Path.Combine(AppContext.BaseDirectory, "messages.txt");
+    public string Language { get; private set; } = string.Empty;
 
-        foreach (var line in File.ReadAllLines(path))
+    public Translator()
+    {
+        foreach (var language in Languages)
         {
-            var parts = line.Split('=');
+            Console.WriteLine($"translator: reading messages.{language}.txt");
 
-            _messages[parts[0]] = parts[1];
+            _catalogs[language] = Read(language);
         }
     }
 
+    public void Use(string language)
+    {
+        Language = language;
+    }
+
     public string Get(string key)
     {
-        return _messages.TryGetValue(key, out var value) ? value : key;
+        if (_catalogs.TryGetValue(Language, out var messages) && messages.TryGetValue(key, out var value))
+        {
+            return value;
+        }
+
+        return key;
+    }
+
+    private static Dictionary<string, string> Read(string language)
+    {
+        var path = Path.Combine(AppContext.BaseDirectory, $"messages.{language}.txt");
+
+        var messages = new Dictionary<string, string>();
+
+        foreach (var line in File.ReadAllLines(path))
+        {
+            var parts = line.Split('=', 2);
+
+            messages[parts[0]] = parts[1];
+        }
+
+        return messages;
     }
 }
while (true)
{
    using var scope = provider.CreateScope();

    var translator = scope.ServiceProvider.GetRequiredService<Translator>();

    if (translator.Language.Length == 0)
    {
        ChooseLanguage(translator);
    }
❯ dotnet run
translator: reading messages.en.txt
translator: reading messages.tr.txt

1) English
2) Türkçe
> 2

1) Kullanıcı kaydı
2) Dil değiştir
0) Çıkış
Seçiminiz > 1
Lütfen eposta giriniz. > [email protected]
[email protected] -> kayıt edildi

1) Kullanıcı kaydı
2) Dil değiştir
0) Çıkış
Seçiminiz > 2

1) English
2) Türkçe
> 1

1) Register user
2) Change language
0) Exit
Your choice >

Buradaki seçilen dil aslında uygulamamızın dilini belirliyor, yani kullanıcının tercihi değil, ona dikkat edelim lütfen. Bu uygulamanın tek bir bilgisayarda tek bir kişi tarafından kullanıldığı varsayılıyor, çoklu kullanıcılar tarafından değil.

Bunu özellikle vurguluyorum, çünkü aynı Translator'ı bir web uygulamasında singleton olarak kaydetseydik seçilen dil bütün request'ler arasında ortak olurdu; bir kullanıcının dil tercihi diğerinin ekranını da değiştirirdi. Singleton içinde tutulan state uygulamanın tamamına aittir, kullanıcıya değil.

Buradaki mükemmel olan olay her seferinde language istememesi. İsterseniz burayı kendiniz, implementasyonu Scoped olarak değiştirerek, davranışı görebilirsiniz. Yorumlarda Scoped olarak değiştirdiğinizde uygulamanın nasıl davrandığını ve Singleton ile nasıl davrandığını paylaşabilirsiniz.

OperationLogger

Uygulamamızın ikinci servisi olan OperationLogger servisine geçmek istiyorum. Bu servise geçmeden önce şuna dikkat etmenizi rica edeceğim: hatırlatma olarak, bir scope'un yaşam döngüsü using bloğunun ömrü kadar. Bizim uygulamamızda scope olarak döngünün sonuna kadar diyebiliriz.

Bu hatırlatmayı aldıktan sonra OperationLogger nedir, niye kullanacağız, gelin ona bir bakalım.

Kullanıcımız bir takım işlemler yapıyor. Büyük uygulamalarda loglamalar gerçekten çok önemli. Gerçek kullanıcılar ile çalışırken onlara anlamsız hata göstermek istemezsiniz ama akışı da bilmek istersiniz. Neyse, laf kalabalığı yapmayalım.

OperationLogger özetle kullanıcıya hiç gösterilmeyecek, ancak kullanıcının bir döngü içerisinde neler yaptığını kayıt altına alacak bir servis olacak. Örneğin uygulama başladı, dil değiştirildi, yeni kullanıcı eklendi, veya şu kullanıcı listeye eklenmedi ve hatası buydu gibi.

OperationLogger sınıfımız log.txt adlı dosyanın içerisine ilgili scope'u yazmakla mükellef olacak. Yani bir scope başladığında ona bir scope Id atayacak, başlık olarak +++ BEGIN SCOPE = GUID +++, arada yapılan işlemler, sonrasında +++ END SCOPE = GUID +++ şeklinde bir mesajı log.txt dosyasına eklemekle mükellef olacak.

    private readonly List<string> _lines = new();

    public Guid Id { get; } = Guid.NewGuid();

    public void Add(string line)
    {
        _lines.Add(line);
    }

    public void Dispose()
    {
        var path = Path.Combine(AppContext.BaseDirectory, "log.txt");

        var body = new List<string> { $"+++ BEGIN SCOPE = {Id} +++" };

        body.AddRange(_lines);
        body.Add($"+++ END SCOPE = {Id} +++");

        File.AppendAllLines(path, body);
    }

Şu an uygulamamız her işlem sonrasında otomatik scope bitirdiği için anlamsız geliyor, o yüzden uygulamayı biraz değiştiriyorum. Kullanıcı eğer 1 seçeneği ile ilerler ise, çıkmak isteyene kadar (bu scope'u sonlandıracak aslında) eposta girmeye devam edebilir olacak. Bu senaryoda da eposta ile ilgili işlemler kullanıcıyı ekleme ve loga kaydetme işlevini yapacak, ta ki kullanıcı hiçbir eposta girmeden sadece =exit yazana kadar. Bunu yaptığında scope sonlanarak döngü başına tekrar geri geleceğiz ve yeni scope oluşturulacak.

    if (choice == "1")
    {
        Console.WriteLine(translator.Get("hint.email.exit"));

        while (true)
        {
            Console.Write($"{translator.Get("prompt.email")} > ");

            var email = Console.ReadLine();

            if (email == "=exit")
            {
                break;
            }

            Console.WriteLine(translator.Get("saved"));

            log.Add($"user added: {email}");
        }

        continue;
    }

Kod 2.9'da dikkat etmenizi istediğim, bizim scoped olarak oluşturduğumuz objenin içerisinde ne zaman dosyaya yazacağımız.

+++ BEGIN SCOPE = 5a283247-0e80-4e6b-b05b-989c1ddc90a1 +++
language selected: tr
user added: [email protected]
user added: [email protected]
+++ END SCOPE = 5a283247-0e80-4e6b-b05b-989c1ddc90a1 +++
+++ BEGIN SCOPE = 54784dbd-84e2-4aaf-8271-ec43a71028ff +++
user added: [email protected]
+++ END SCOPE = 54784dbd-84e2-4aaf-8271-ec43a71028ff +++
+++ BEGIN SCOPE = 4fd32ae3-1d4e-4407-9baf-736069610272 +++
exit requested
+++ END SCOPE = 4fd32ae3-1d4e-4407-9baf-736069610272 +++

Dosya içerisindeki kayıt yapımız yukarıda gördüğünüz gibi. Bir scope başlar ve biter. Dosya yazma ve okuma işlemleri gerçekten maliyetli, bundan dolayı bu tarz işlemleri buffer'lı olarak yapmak her zaman daha sağlıklı.

Örneğin bugün scope içerisinde 50 işlem yapıp hepsini aynı anda dosyaya yazıp kapatıyoruz. Eğer her log.Add dediğimizde ilgili dosyanın içini açıp yazsaydık, bu sefer seçeneklerimiz ya dosyayı her seferinde stream olarak tekrar açmak olacaktı, ya da bir kere açıp ilgili stream'i açık tutmak olacaktı (kapıyı açık tutmaya benziyor). Ama kapıyı açık tutarsak da biz hariç başka hiçbir kullanıcı ilgili dosyaya yazma işlemini yapamaz (bu da kapıyı nasıl açtığımıza bağlı, dosyayı paylaşıma açık şekilde de açabilirdik).

Ayrıca aynı bilgisayarda birden fazla uygulama açtığımızda da, bu log.Add işleminde ilgili satırı direkt dosyaya yazsaydık başka kullanıcının logları bizim scope'umuz arasına düşebilirdi. Şimdi ise biz scope sonunda bu işlemi yaptığımız için herhangi bir karışıklık olmuyor.

Şimdi sorduğum sorunun cevabı zaten örnekte var. Ne zaman dosyaya yazıyoruz? Dispose olduğu zaman . Peki, scoped bir obje ne zaman dispose oluyor? Tabii ki de scope bittiğinde, yani bizim senaryomuzda döngünün sonuna gelindiğinde ilgili scope'a bağlı disposable objeler otomatik olarak dispose oluyor.

services.AddSingleton<Translator>();
services.AddScoped<OperationLogger>();

var provider = services.BuildServiceProvider();

while (true)
{
    using var scope = provider.CreateScope();

    var translator = scope.ServiceProvider.GetRequiredService<Translator>();
    var log = scope.ServiceProvider.GetRequiredService<OperationLogger>();

Gördüğünüz gibi buradaki OperationLogger class'ı disposable ama biz OperationLogger instance'ı için hiç dispose çağırmıyoruz, using bloğu da eklemiyoruz. Dependency injection üzerinden aldığımız için dispose işlemini bizim yerimize o yönetiyor.

Burada bir noktanın altını çizmek istiyorum: container sadece scoped objeleri değil, o scope üzerinden çözümlenen transient disposable objeleri de takip eder ve scope kapanırken onları da dispose eder. Yani transient demek "kimse ilgilenmiyor" demek değil; obje kısa ömürlü olsa bile temizliği scope'a kalır. Uzun yaşayan bir scope üzerinde ya da root provider üzerinde durmadan transient disposable çözümlerseniz, bu objeler siz fark etmeden birikir.

FieldValidator

Şimdi de son servisimiz olan FieldValidator servisine geçmek istiyorum. Bu servis bizim için içerisinde bir hata listesi tutacak ve içerisine eklenen her field'in hatalarını ilgili listeye ekleyecek.

Epostayı eklemek için AddEmail(email) adında bir fonksiyon oluşturuyoruz ve bu fonksiyonun içerisinde ilgili validasyonlarımızı çağırıp epostanın validasyondan geçmesini sağlıyoruz.

Bizim uygulamamızda hem isim hem de eposta alıyoruz. FieldValidator sınıfına AddName methodunu da ekliyoruz ve AddName(name) methodu içerisinde de isim kontrollerimizi yapıyoruz. Örneğin en az 2 karakter olmalı, boş olmamalı şeklinde kontroller yapıp hata olduğu durumlarda hata listesine ekliyoruz.

public class FieldValidator
{
    private readonly List<string> _errors = new();

    public IReadOnlyList<string> Errors => _errors;

    public void AddName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            _errors.Add("error.name.empty");

            return;
        }

        if (name.Trim().Length < 2)
        {
            _errors.Add("error.name.short");
        }
    }

    public void AddEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
        {
            _errors.Add("error.email.empty");

            return;
        }

        if (!email.Contains('@'))
        {
            _errors.Add("error.email.invalid");
        }
    }
}

Daha sonrasında ise ilgili field validator için Error listesinin readonly bir listesini tutuyoruz ve burayı okuyarak count değeri 0'dan büyükse (daha önce hata eklenmişse) bir hata vardır diyerek loglarımıza yazıyoruz: kullanıcı eklenemedi, hata var gibisinden.

Buradaki FieldValidator'ü ilk başta scoped olarak eklemek ve davranışını göstermek istiyorum, o yüzden implementasyonu bilerek yanlış olan scoped şeklinde yaptık . Ve ilgili FieldValidator instance'ını da seçim 1 içerisindeki döngü içerisinde alıyorum ve kullanıcı her isim/eposta yazdığında kontrol ettiriyorum.

services.AddSingleton<Translator>();
services.AddScoped<OperationLogger>();
services.AddScoped<FieldValidator>();
        while (true)
        {
            Console.Write($"{translator.Get("prompt.name")} > ");

            var name = Console.ReadLine();

            if (name == "=exit")
            {
                break;
            }

            Console.Write($"{translator.Get("prompt.email")} > ");

            var email = Console.ReadLine();

            var validator = scope.ServiceProvider.GetRequiredService<FieldValidator>();

            validator.AddName(name ?? string.Empty);
            validator.AddEmail(email ?? string.Empty);

            if (validator.Errors.Count > 0)
            {
                foreach (var error in validator.Errors)
                {
                    Console.WriteLine($"  ! {translator.Get(error)}");
                }

                log.Add($"user not added: {name} / {email} -> {string.Join(", ", validator.Errors)}");

                continue;
            }

            Console.WriteLine(translator.Get("saved"));

            log.Add($"user added: {name} / {email}");
        }

Burada şuna dikkat etmeniz gerekiyor: biz her ne kadar GetRequiredService çağırsak da , aynı scope üzerinden devam ettiğimiz için (ana döngünün başında oluşturulan scope üzerinden) bizim FieldValidator içerisindeki hata mesajları durduğundan dolayı ilk girdimiz hatalı olduğunda sonraki tüm girdilerimiz de hatalı olacaktır.

İlk girdide ismi boş bırakıp hatalı bir eposta yazdım, sonrasındaki iki girdi ise tamamen geçerliydi.

Lütfen isim giriniz >
Lütfen eposta giriniz. > bad-email
  ! İsim alanı boş olamaz
  ! Eposta hatalı
Lütfen isim giriniz > Ali
Lütfen eposta giriniz. > [email protected]
  ! İsim alanı boş olamaz
  ! Eposta hatalı
Lütfen isim giriniz > Veli
Lütfen eposta giriniz. > [email protected]
  ! İsim alanı boş olamaz
  ! Eposta hatalı

log.txt tarafında da aynı şey görünüyor, Ali ve Veli hiçbir hata yapmadıkları hâlde eklenemedi.

+++ BEGIN SCOPE = efa8c3a8-1fdc-49e5-bf47-15433448c60c +++
language selected: tr
user not added:  / bad-email -> error.name.empty, error.email.invalid
user not added: Ali / [email protected] -> error.name.empty, error.email.invalid
user not added: Veli / [email protected] -> error.name.empty, error.email.invalid
+++ END SCOPE = efa8c3a8-1fdc-49e5-bf47-15433448c60c +++

Yani bu senaryoda gördüğünüz gibi ilk state, aynı scope içindeki diğer state'leri etkilemiş oluyor. Burada state'lerin birbirini etkilememesi adına (aynı scope içinde) buradaki objenin transient olarak eklenmesi en doğrusu olacaktır.

Eğer ekleme yöntemimizi transient olarak değiştirip aynı senaryoyu tekrar çalıştırırsak, bu sefer sadece gerçekten ilk başta hatalı olan kullanıcının eklenemediğini de görmüş oluruz.

@@ -4,7 +4,7 @@
 
 services.AddSingleton<Translator>();
 services.AddScoped<OperationLogger>();
-services.AddScoped<FieldValidator>();
+services.AddTransient<FieldValidator>();
 
 var provider = services.BuildServiceProvider();
Lütfen isim giriniz >
Lütfen eposta giriniz. > bad-email
  ! İsim alanı boş olamaz
  ! Eposta hatalı
Lütfen isim giriniz > Ali
Lütfen eposta giriniz. > [email protected]
kayıt edildi
Lütfen isim giriniz > Veli
Lütfen eposta giriniz. > [email protected]
kayıt edildi
+++ BEGIN SCOPE = 52501a7e-43c3-4b46-a4af-fd56f0e03781 +++
language selected: tr
user not added:  / bad-email -> error.name.empty, error.email.invalid
user added: Ali / [email protected]
user added: Veli / [email protected]
+++ END SCOPE = 52501a7e-43c3-4b46-a4af-fd56f0e03781 +++

Kod 2.14'teki senaryoda ise anlamamız gereken durum şu: aynı scope içerisinde transient lifetime olarak eklenmiş olan objeler, her GetRequiredService dendiğinde birbirinden bağımsız olarak oluşturulur. Yani ilk başta eklediğimiz hatalı alanlar bizim hata listemiz içerisinde sonraki senaryolarımızı etkilememiş olacak.

Bu durumun, içerisinde state tutulan senaryolarda geçerli olduğunu hatırlatmak isterim.

Bir de şunu söylemeden geçmeyeyim: biz burada davranışı gözle görebilmek için döngünün içinde GetRequiredService çağırıyoruz. Gerçek bir uygulamada servisleri bu şekilde tek tek istemek yerine constructor injection kullanırsınız; container'ı elle sorgulamak (service locator) örnek dışında tercih edilen bir yöntem değil.

Root provider tuzağı

Root provider üzerinden alınan scoped objelerin uygulama boyunca yaşadığından bahsetmiştim, gelin örneklem olması adına yanlış işlemi burada gerçekleştirelim. Oluşturduğumuz scope'tan değil de ilk başta yarattığımız root provider'ımızdan ilgili scoped olarak eklenen objeyi alalım ve çıktının ne olduğunu görelim.

@@ -13,7 +13,7 @@
     using var scope = provider.CreateScope();
 
     var translator = scope.ServiceProvider.GetRequiredService<Translator>();
-    var log = scope.ServiceProvider.GetRequiredService<OperationLogger>();
+    var log = provider.GetRequiredService<OperationLogger>();
 
     if (translator.Language.Length == 0)
     {

Objeyi oluştururken artık oluşturulan scope'tan değil root provider'dan almaya başladık .

Eğer ilgili OperationLogger'ı root provider'dan almaya başlarsak, her seferinde elde edeceğimiz obje aynı olacak. log.txt dosyasını ilk dispose anında "yoksa oluştur" mantığı ile oluşturmaya başladığı için, uygulama kapanana kadar log.txt dosyasının oluşmadığını ls log.txt komutunun çıktısı ile görebiliriz.

❯ ls log.txt
ls: log.txt: No such file or directory
@@ -95,6 +95,8 @@
     log.Add($"unknown option: {choice}");
 }
 
+provider.Dispose();
+
 void ChooseLanguage(Translator translator)
 {
     Console.WriteLine();

Uygulamanın sonunda çıkış yaptığımızda dispose edilme komutuna düşeceği için tüm buffer'ı tek seferde dosyaya yazacak. Ancak bu sefer her ne kadar tekrar tekrar GetRequiredService diyerek ilgili logger objesini çağırsak da, gördüğünüz gibi tek bir scope var. Bu da scope'un root provider'dan alınmasından kaynaklanıyor. Karşılaştırma olması adına log.txt'nin her seferinde oluşturulan scope üzerinden alınmış hâli ile root provider'dan oluşturulduğundaki çıktılarını yan yana gösteriyorum.

scope.ServiceProvider
+++ BEGIN SCOPE = 2f0f3a67-c7f9-4784-aaca-ef86949a565e +++
language selected: tr
user added: Ali / [email protected]
+++ END SCOPE = 2f0f3a67-c7f9-4784-aaca-ef86949a565e +++
+++ BEGIN SCOPE = ddd1fdf8-2cee-4e45-87b4-bb491f41a419 +++
user added: Veli / [email protected]
+++ END SCOPE = ddd1fdf8-2cee-4e45-87b4-bb491f41a419 +++
+++ BEGIN SCOPE = 732bf2f0-3348-45cb-a2b0-ef4eb1aa4bab +++
exit requested
+++ END SCOPE = 732bf2f0-3348-45cb-a2b0-ef4eb1aa4bab +++
provider
+++ BEGIN SCOPE = 74f4c56c-d3df-4be2-a714-477ab991e390 +++
language selected: tr
user added: Ali / [email protected]
user added: Veli / [email protected]
exit requested
+++ END SCOPE = 74f4c56c-d3df-4be2-a714-477ab991e390 +++

Sol tarafta üç ayrı scope, üç ayrı id var. Sağ tarafta ise üç scope çalışmasına rağmen tek bir id görüyoruz. Çünkü root provider, scoped bir servisi kendi scope'una ait bir obje olarak saklıyor; o obje de provider kapanana kadar yaşıyor. Yani scope'larımızın log içindeki sınırları tamamen kayboldu.

Burada şuna da dikkat etmek gerekir. Bir scope dispose edilirken içerisindeki disposable objeleri de cascade (alt alta) olacak şekilde dispose işlemini gerçekleştirir. Bizim root provider'ımızın dispose vakti de uygulamanın kapanması ile eş zamanlı olduğu için, OperationLogger objesi ilgili logları dosyaya yazmadan (dispose olamadan) uygulama kapanacaktır. O sebeple uygulama kapanmadan ilgili root provider'ı dispose etmeliyiz.

ValidateScopes

Dotnet üzerinde bu hatanın yapılmasını önlemek amaçlı bir altyapı mevcut: ValidateScopes. El ile oluşturulan service provider'larda ilgili değeri ayarlamazsanız varsayılan değer olarak false ayarlanır. Eğer true olarak değiştirirsek root provider'dan ilgili servis çekildiği anda lifetime check işlemi yapılacak ve hata fırlatılacaktır. Aynı kontrolü provider build edilirken yaptırmak isterseniz ValidateOnBuild seçeneği de mevcut.

@@ -6,7 +6,10 @@
 services.AddScoped<OperationLogger>();
 services.AddTransient<FieldValidator>();
 
-var provider = services.BuildServiceProvider();
+var provider = services.BuildServiceProvider(new ServiceProviderOptions
+{
+    ValidateScopes = true
+});
 
 while (true)
 {
Unhandled exception. System.InvalidOperationException: Cannot resolve scoped service 'OperationLogger' from root provider.

Bu exception'ı almamızın sebebi, scoped olarak eklediğimiz OperationLogger'ı root provider üzerinden çekmemiz. Çözümü de basit: ilgili objeyi tekrar oluşturduğumuz scope üzerinden alalım.

@@ -16,7 +16,7 @@
     using var scope = provider.CreateScope();
 
     var translator = scope.ServiceProvider.GetRequiredService<Translator>();
-    var log = provider.GetRequiredService<OperationLogger>();
+    var log = scope.ServiceProvider.GetRequiredService<OperationLogger>();
 
     if (translator.Language.Length == 0)
     {

Bu tek satırlık değişiklikten sonra exception ortadan kalkıyor ve loglar yeniden her scope için ayrı ayrı yazılıyor.

İlk başta bahsettiğim lifetime check işlemi ile şunu da önlemiş oluyoruz. Fark ettiğiniz üzere singleton objeler root provider üzerinde duruyor ve state'lerin birbirlerine karışmaması adına singleton objeler içerisinde scoped objeler oluşturulamaz. Çünkü scoped objenin lifetime'ı scope sonuna kadar ve biz bunu ValidateScopes ile engellersek root provider üzerinden de bu çözümlenemez. Ki çözümlenmesin de, zaten bu yanlış bir kurguya sebep olur. Scoped obje singleton obje üzerinde resolve edilebilirse uygulama sonuna kadar yaşayacak. Biz burada provider'ı manuel oluşturduğumuz için bu ayarı el ile açmamız gerekti.

Bunu görmek için singleton olan Translator'a scoped OperationLogger'ı bağımlılık olarak verelim.

@@ -4,10 +4,14 @@ public class Translator
 
     private readonly Dictionary<string, Dictionary<string, string>> _catalogs = new();
 
+    private readonly OperationLogger _log;
+
     public string Language { get; private set; } = string.Empty;
 
-    public Translator()
+    public Translator(OperationLogger log)
     {
+        _log = log;
+
         foreach (var language in Languages)
         {
             Console.WriteLine($"translator: reading messages.{language}.txt");
@@ -19,6 +23,8 @@ public Translator()
     public void Use(string language)
     {
         Language = language;
+
+        _log.Add($"language selected: {language}");
     }
 
     public string Get(string key)
Unhandled exception. System.InvalidOperationException: Cannot consume scoped service 'OperationLogger' from singleton 'Translator'.

Bu exception da singleton bir servisin scoped bir servise bağımlı olmasından kaynaklanıyor. Çözüm için bu bağımlılığı kaldırıyoruz.

@@ -4,14 +4,10 @@ public class Translator
 
     private readonly Dictionary<string, Dictionary<string, string>> _catalogs = new();
 
-    private readonly OperationLogger _log;
-
     public string Language { get; private set; } = string.Empty;
 
-    public Translator(OperationLogger log)
+    public Translator()
     {
-        _log = log;
-
         foreach (var language in Languages)
         {
             Console.WriteLine($"translator: reading messages.{language}.txt");
@@ -23,8 +19,6 @@ public Translator(OperationLogger log)
     public void Use(string language)
     {
         Language = language;
-
-        _log.Add($"language selected: {language}");
     }
 
     public string Get(string key)

Bağımlılık kalktıktan sonra uygulama tekrar sorunsuz çalışıyor.

Peki singleton bir servisin gerçekten scoped bir işe ihtiyacı varsa ne yapacağız? Bağımlılığı silmek her zaman mümkün olmayabilir. Böyle durumlarda singleton servise scoped objenin kendisini değil, IServiceScopeFactory veriyoruz. Bu factory singleton olarak kayıtlı olduğu için lifetime kontrolüne takılmaz; iş yapılacağı zaman CreateScope() diyerek kendi scope'umuzu açarız, scoped servisi o scope üzerinden çözümleriz ve iş bitince scope'u kapatırız. Yani scoped obje yine bir scope'a bağlı kalmış olur, singleton'ın içinde ömür boyu tutulmaz.

Üç lifetime, tek tabloda

lifetimeKaç objeİlk ne zaman oluşurBu yazıdaki örnek
SingletonUygulama boyunca 1İlk GetRequiredService çağrısındaTranslator
ScopedHer scope için 1O scope içindeki ilk çağrıdaOperationLogger
TransientHer çağrıda 1 yeniHer GetRequiredService çağrısındaFieldValidator

Aşağıdaki simülatörde servislerin lifetime'larını değiştirip container'ı kendiniz sürebilirsiniz. Scope açın, servis isteyin, scope'u kapatın. Her objenin oluşturulma sırasına göre bir id'si var, böylece hangisinin yeni obje olduğunu, hangisinin aynı obje olduğunu gözünüzle görebilirsiniz.

servicelifetime
Translator
OperationLog
FieldValidator

Bu yazıda lifetime konusunu kaç obje oluşuyor ve bu obje ne kadar yaşıyor sorusu üzerinden ele aldık. Kayıt yaparken seçtiğimiz tek kelimenin, uygulamanın çalışma şeklini baştan aşağı değiştirebildiğini gördük.

Bir sonraki yazıda ise hangi sınıfın new'leneceği sorusuna geçeceğiz. Interface'ler, AddScoped<IService, Impl>() şeklindeki kayıtlar ve container'ın hangi somut sınıfı oluşturduğunu runtime'da nasıl kanıtladığımız orada olacak.

Projenin adım adım hâlini buradaki repository'den inceleyebilir, bu yazıdaki bütün değişiklikleri lifetimes-01 ile lifetimes-13 arasındaki karşılaştırmadan takip edebilirsiniz.

Paylaş

Yorumlar