Bazen kodu düzenlerken amacımız yalnızca daha okunabilir bir yapı elde etmek oluyor. Birkaç satırı if içerisine taşımak, varsayılan değerleri düzenlemek veya gereksiz görünen bir değişkeni kaldırmak ilk bakışta oldukça masum değişiklikler.
Ancak C# tarafında bu tür düzenlemelerin her zaman yalnızca görünümü değiştirmediğini fark ettim. Özellikle using var kullandığımızda, değişkenin hangi kapsamda tanımlandığı doğrudan nesnenin yaşam süresini etkiliyor.
Yakın zamanda OpenTelemetry kullanan bir kod üzerinde çalışırken bu konu yeniden karşıma çıktı. Uygulama başlarken bir TracerProvider oluşturuluyordu. Değişken başka bir yerde kullanılmadığı için ilk bakışta gereksizmiş gibi görünüyordu. Üstelik kodu bir if bloğuna taşıyarak daha düzenli hâle getirmek de mümkündü.
Fakat burada küçük görünen bir ayrıntı vardı: Provider'ın ne zaman dispose edildiği.
Bu örnekten yola çıkarak hem TracerProvider yapısını hem de C#'ta using var, IDisposable ve scope ilişkisinin neden önemli olduğunu açıklamak istiyorum.
1. TracerProvider nedir? Neden başka yerde kullanılmıyor?
Önce karşıma çıkan kodun ne yaptığını anlamak gerekiyordu.
OpenTelemetry, uygulamaların çalışma davranışını izlemek için kullanılan bir gözlemlenebilirlik altyapısı. Örneğin bir işlemin ne kadar sürdüğünü, uygulama içerisinde hangi adımlardan geçtiğini veya bir isteğin hangi bileşenlerden etkilendiğini takip etmek istediğimizde tracing mekanizmasından yararlanabiliyoruz.
Bu yapıda TracerProvider, izleme altyapısının yapılandırılmasını sağlayan temel bileşenlerden biri.
Örneğin şöyle bir kodumuz olsun:
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService("MyApplication"))
.AddSource("MyApplication.DataProcessing")
.AddConsoleExporter()
.Build();
Burada birkaç farklı işlem gerçekleştiriliyor:
AddService()ile izleme kayıtlarının hangi servisle ilişkilendirileceği belirleniyor.AddSource()ile hangiActivitySourceüzerinden oluşturulan aktivitelerin izleneceği belirtiliyor.AddConsoleExporter()ile uygun aktivitelerin konsola aktarılması yapılandırılıyor.Build()çağrısıyla provider oluşturuluyor ve izleme altyapısı devreye alınıyor.
Peki, tracerProvider değişkenini sonrasında hiç kullanmazsak ne olur?
Açıkçası bu noktada ben de önce değişkenin neden tutulduğunu sorguladım. Sonuçta bir metoda gönderilmiyor, üzerinden herhangi bir işlem yapılmıyor. Ancak burada değişkenin doğrudan kullanılmasından ziyade, temsil ettiği nesnenin yaşam süresi önemli.
Provider oluşturulduğunda izleme bileşenleri yapılandırılıyor. Uygulamanın başka bir bölümünde aynı isimle bir ActivitySource üzerinden aktivite üretildiğinde, ilgili kaynak bu provider tarafından dinleniyorsa kayıtlar izleme pipeline'ına aktarılabiliyor.
Örneğin:
private static readonly ActivitySource ActivitySource =
new("MyApplication.DataProcessing");
public void ProcessData()
{
using var activity = ActivitySource.StartActivity("ProcessData");
// Veriler işlenir.
}
ActivitySource ile aktivite üretmek, TracerProvider ile bu aktiviteleri dinleyip işlemek farklı sorumluluklar. Provider'ın referansını saklamamızın nedenlerinden biri de yapılandırılmış altyapının ihtiyaç duyulan süre boyunca aktif kalmasını ve doğru zamanda kapatılmasını sağlamak.
Buradan çıkardığım sonuç şu: Bir değişkenin başka bir yerde kullanılmaması, temsil ettiği nesnenin gereksiz olduğu anlamına gelmiyor.
2. IDisposable ve Dispose ne işe yarıyor?
Konunun diğer tarafında IDisposable var.
.NET'te bazı nesneler dosya akışları, bağlantılar veya başka kaynaklarla çalışıyor. Bu kaynakların işimiz bittiğinde serbest bırakılması gerekiyor. IDisposable arayüzü, bu işlemi gerçekleştirmek için kullanılan standart mekanizmalardan biri.
Basit bir dosya okuma örneğine bakalım:
var stream = File.OpenRead("data.json");
// Dosya üzerinde işlemler yapılır.
ProcessData(stream);
stream.Dispose();
Burada kaynakları kendimiz kapatıyoruz. Ancak kodun herhangi bir noktasında hata oluşursa Dispose() çağrısına ulaşamayabiliriz. Bu da kaynak yönetimini daha kırılgan hâle getirir.
Aynı işlemi using var ile yazabiliriz:
using var stream = File.OpenRead("data.json");
ProcessData(stream);
SaveResult();
Bu kullanımda kaynak, tanımlamanın bulunduğu kapsam sona erdiğinde otomatik olarak dispose edilir. Normal akışın yanı sıra kapsamdan bir istisna nedeniyle çıkılması durumunda da temizleme mekanizması devreye girer.
Burada önemli bir ayrım var: Garbage collector ile Dispose() aynı işi yapmıyor. Garbage collector yönetilen belleğin geri kazanılmasını sağlar. Dispose() ise nesnenin sahip olduğu kaynakları açıkça ve öngörülebilir bir zamanda serbest bırakmak için kullanılır.
Bu nedenle yalnızca nesnenin bellekten ne zaman kaldırılacağını düşünmek yeterli değil. Dosya veya bağlantı gibi kaynakların hangi noktada kapatıldığına da dikkat etmek gerekiyor.
3. using var ile klasik using arasındaki fark
using var ifadesinin pratik olmasının en büyük nedenlerinden biri, kodu daha az iç içe blokla yazabilmemiz.
Örneğin klasik kullanım şöyle:
using (var stream = File.OpenRead("data.json"))
{
ProcessData(stream);
}
SaveResult();
Aynı kodu şu şekilde de yazabiliriz:
using var stream = File.OpenRead("data.json");
ProcessData(stream);
SaveResult();
İki kullanımın da amacı kaynağın güvenli biçimde kapatılması. Fakat kapsamları aynı olmak zorunda değil.
İlk örnekte stream, using bloğunun sonunda dispose edilir. İkinci örnekte ise değişken, tanımlandığı kapsamın sonuna kadar yaşar.
Benim asıl dikkat ettiğim nokta da bu oldu. using var ifadesini başka bir bloğa taşıdığımızda, yalnızca kodun yerini değiştirmiş olmuyoruz. Dispose işleminin gerçekleşeceği noktayı da değiştirebiliyoruz.
4. Bir if bloğuna taşırsak ne değişir?
Şimdi başlangıçtaki senaryoya dönelim.
Uygulamada izleme özelliğinin bir konfigürasyon değerine göre açılıp kapatıldığını düşünelim:
using var tracerProvider = appSettings.EnableMetrics
? StartMetricLogs(appSettings.MetricsFileRemoveDay)
: null;
Burada EnableMetrics aktifse provider oluşturuluyor, değilse null atanıyor. Böylece izleme kapalıyken gereksiz bir provider oluşturulmasının önüne geçiliyor.
Kodun biraz daha düzenli görünmesini ve bazı ayarların yalnızca özellik aktifken hesaplanmasını isteyebiliriz. İlk akla gelen düzenleme şu olabilir:
if (appSettings.EnableMetrics)
{
var removeDay = appSettings.MetricsFileRemoveDay ?? 3;
if (removeDay <= 0)
removeDay = 3;
using var tracerProvider = StartMetricLogs(removeDay);
}
// Uygulamanın geri kalan işlemleri
İlk bakışta gayet mantıklı. Ayar açıksa gerekli değerler hesaplanıyor ve provider oluşturuluyor.
Fakat bu kodu incelerken gözden kaçırmamamız gereken bir detay var: tracerProvider, artık if bloğunun içerisinde tanımlanıyor.
Dolayısıyla provider, bu bloğun sonunda dispose ediliyor. Uygulamanın geri kalan işlemleri başladığında izleme altyapısı çoktan kapatılmış olabilir.
Eğer amacımız provider'ı yalnızca kısa bir işlem boyunca kullanmaksa bu davranış doğru olabilir. Ancak uygulamanın ana akışı boyunca aktif kalmasını bekliyorsak yaptığımız düzenleme, mevcut davranışı değiştirmiş oluyor.
Bence bu örneğin en önemli tarafı şu: Kod derlenmeye devam etse bile yaşam döngüsüyle ilgili bir hata yapmış olabiliriz.
5. Ben nasıl düzenlerdim?
Bu senaryoda benim tercihim, varsayılan değer kontrolü ile provider oluşturma işlemini birbirinden ayırmak olurdu:
var removeDay = appSettings.MetricsFileRemoveDay ?? 3;
if (removeDay <= 0)
removeDay = 3;
using var tracerProvider = appSettings.EnableMetrics
? StartMetricLogs(removeDay)
: null;
Bu düzenlemede her şey daha net:
removeDaydeğerinullveya sıfırdan küçükse3olarak belirleniyor.EnableMetricskapalıysaStartMetricLogs()çağrılmıyor.- Provider oluşturulursa tanımlamanın bulunduğu kapsam sona erene kadar yaşamaya devam ediyor.
Burada removeDay hesabı, izleme kapalı olsa bile çalışıyor. Bu kadar basit bir işlem için bunun önemli bir maliyeti yok. Dolayısıyla sırf bu hesabı koşullu hâle getirmek adına kapsamı değiştirmeye gerek görmüyorum.
Elbette bütün hazırlık işlemlerinin yalnızca özellik aktifken yapılması gerekiyorsa farklı bir düzen de tercih edilebilir:
TracerProvider? tracerProvider = null;
if (appSettings.EnableMetrics)
{
var removeDay = appSettings.MetricsFileRemoveDay ?? 3;
if (removeDay <= 0)
removeDay = 3;
tracerProvider = StartMetricLogs(removeDay);
}
using (tracerProvider)
{
// Uygulamanın ana işlemleri
}
Burada provider dış kapsamda tutuluyor, oluşturma işlemi ise koşula bağlanıyor. Böylece if bloğunun sonunda dispose edilmesinin önüne geçiyoruz.
Yalnızca bir koşul kontrolü için bu kadar kod yazmayı tercih etmeyebilirim. Ancak yapılandırma işlemlerinin de koşullu olması gerektiği durumlarda bu yaklaşım işimize yarayabilir.
6. Bir nesneyi dispose etmek her zaman basit bir temizlik işlemi değildir
Bu konuyu yalnızca dosya örneğiyle sınırlamak istemiyorum. Çünkü bazı nesnelerin kapatılması, başka bileşenlerin çalışma şeklini de etkileyebiliyor.
Örneğin TracerProvider dispose edildiğinde yalnızca bir nesnenin yaşamı sona ermiyor. Provider'ın yönettiği bileşenler kapatılıyor ve yapılandırmaya bağlı olarak exporter'ların sonlandırılması, bekleyen izleme verilerinin aktarılması gibi işlemler yürütülebiliyor.
Benzer durumları veritabanı bağlantılarında, akışlarda ve başka kaynak yöneticilerinde de görebiliriz.
Bu yüzden kod düzenlerken kendime şu soruyu sormayı faydalı buluyorum: Bu nesneyi burada kapatırsam yalnızca kullandığım kaynağı mı serbest bırakıyorum, yoksa uygulamanın başka bir bölümünün ihtiyaç duyduğu altyapıyı da mı sonlandırıyorum?
Özellikle uygulama başlangıcında oluşturulan ve uygulama boyunca aktif kalması beklenen nesnelerde bu soru önemli.
Bir diğer nokta da Dispose() işleminin her sınıfta aynı sonucu doğurmadığı. Nesnenin neyi yönettiğine ve kendi implementasyonuna bakmak gerekiyor. Bu nedenle yalnızca IDisposable uyguluyor diye bütün nesnelerin yaşam döngüsünü aynı varsayımlarla değerlendirmemek lazım.
7. Kod incelemesinde benim kontrol ettiğim noktalar
Bu örnekten sonra using var kullanan kodlara bakarken birkaç noktayı özellikle kontrol ediyorum:
- Nesne hangi kapsamda oluşturuluyor?
- Gerçekte ne zamana kadar aktif kalması gerekiyor?
- Kod bir
ifveya başka bir bloğa taşındığında dispose zamanı değişiyor mu? - Özellik kapalı olduğunda nesnenin hiç oluşturulmaması mümkün mü?
- Nesnenin kapatılması, arka plandaki işlemleri veya başka bileşenleri etkiliyor mu?
Bunlar oldukça basit sorular gibi görünebilir. Ancak özellikle refactoring sırasında yalnızca kodun daha kısa veya daha okunabilir olmasına odaklanmak, yaşam döngüsüyle ilgili ayrıntıları gözden kaçırmamıza neden olabilir.
Benim için burada önemli olan, en az satırla yazılmış kodu bulmaktan çok, ne zaman oluşturulduğu ve ne zaman kapatıldığı açık olan bir yapı kurmak.
Sonuç olarak
Bu örnekte benim için asıl ders, using var kullanımının yalnızca otomatik Dispose() çağrısından ibaret olmadığıydı. Değişkenin hangi scope içerisinde tanımlandığı, nesnenin ne kadar süre yaşayacağını da belirliyor.
TracerProvider gibi başka bir yerde doğrudan kullanılmayan nesneler bile uygulamanın çalışma altyapısının bir parçası olabilir. Bunları daha dar bir bloğa taşımak, farkında olmadan beklenenden erken kapatılmalarına ve akışın düzgün çalışmamasına neden olabilir.
Bence kod düzenlerken şu alışkanlığı kazanmak önemli: Bir nesnenin yerini değiştirdiğimizde yalnızca derlenip derlenmediğine değil, artık ne zaman dispose edildiğine de bakalım. Bazen birkaç satırlık bir değişikliğin asıl etkisi tam olarak burada ortaya çıkıyor.
Keyifli kodlamalar 🖖