Enumerable CSV İşleme Algoritması
CSV başlığını model özellikleriyle bir kez eşleyip satırları IEnumerable üzerinden akışkan işleyen C# yaklaşımını açıklar. Reflection cache, GZip, tür dönüşümü, karmaşıklık ve CSV lehçesi sınırları değerlendirilir.
Büyük bir CSV dosyasını nesne listesine dönüştürmenin temel sorunu ayırıcı karakteri bulmak değildir. Asıl maliyet, her satırda sütun adlarını yeniden çözümlemek, özellikleri tekrar aramak, tüm dosyayı belleğe yüklemek ve farklı projeler için aynı eşleme kodunu yeniden yazmaktır.
Tamamen kendim geliştirdiğim bu C# yaklaşımında, veri modeli ile dosya başlığı arasındaki ilişki yalnız bir kez kuruluyor. Sonraki satırlar akış üzerinden okunuyor ve daha önce hazırlanan sütun-özellik eşlemesi kullanılarak doğrudan nesnelere dönüştürülüyor. Kodun sade kalmasına rağmen birçok projede işime yaramasının temel nedeni bu ayrımdır: değişken işlemler başlangıç aşamasına, tekrarlanan işlemler ise mümkün olduğunca kısa bir yürütme yoluna taşınmıştır.
Bu yapıyı farklı kurumsal veri aktarımlarına çok kısa sürede uyarlayabildim. Yeni bir dosya için çoğu zaman yapılması gereken, sütunları temsil eden sade bir sınıf tanımlamaktan ibaretti. Aynı çekirdek, sıkıştırılmamış metin dosyalarında ve GZip ile sıkıştırılmış büyük veri kümelerinde toplam kayıt sayısından bağımsız bir bellek düzeniyle çalışabildi.
Reflection ile şema eşleme
Algoritmanın dışarıya sunduğu temel soyutlama jenerik bir veri dizisidir:
\[
CSV<T>
\]Buradaki T, her satırın dönüştürüleceği nesne türüdür. Çalışma başlatıldığında bu türün erişilebilir özellikleri reflection ile alınır. PropertyInfo, .NET içinde bir özelliğin adı, türü ve değer atama işlemi gibi metadata bilgilerine erişim sağlar. Özellik değerlerinin çalışma anında atanması da SetValue üzerinden yürütülür.
Kodun önemli yönü, reflection işlemini her alan için yeniden başlatmamasıdır. Türün özellikleri bir kez incelenir ve normalize edilmiş adlarıyla bir sözlüğe yerleştirilir:
\[
\text{normalize(propertyName)} \rightarrow PropertyInfo
\]Normalizasyon iki basit kuraldan oluşur:
Alt çizgiler kaldırılır.
Harfler kültürden bağımsız olarak küçük biçime dönüştürülür.
Bu nedenle aşağıdaki adlar aynı eşleme anahtarına indirgenebilir:
CUSTOMER_ID CustomerId customer_id customerid
Bu yaklaşım, farklı sistemlerin adlandırma tercihleri arasındaki küçük farklılıkları veri modeli üzerinde ek öznitelikler veya yapılandırma dosyaları tanımlamadan tolere eder. Kurumsal aktarım dosyalarında sütun adlarının Java, C#, SQL veya eski raporlama sistemlerinden gelmesi sık karşılaşılan bir durumdur. Alt çizgi ve harf büyüklüğü farkını yok saymak, eşleme katmanını önemli ölçüde sadeleştirir.
Buradaki amaç genel bir nesne eşleme framework'ü geliştirmek değildir. Bilinen ve kontrollü tablosal veri kaynaklarını kısa sürede çalışan bir işleme hattına bağlamaktır. Reflection bu kapsamda soyutlama maliyeti değil, uyarlama süresini kısaltan bir araçtır.
İki aşamalı önbellek
Kaynak kodda cache yalnız PropertyInfo nesnelerinin bir sözlükte tutulmasından ibaret değildir. Gerçekte iki aşamalı bir ön hesaplama bulunur.
İlk aşamada özellik adları çözümlenir:
\[
C_1:
\text{özellik adı} \rightarrow PropertyInfo
\]İkinci aşamada CSV başlığı okunur ve her sütun konumu doğrudan ilgili özelliğe bağlanır:
\[
C_2:
\text{sütun indisi} \rightarrow PropertyInfo
\]Örneğin dosya başlığı kavramsal olarak şu biçimde olsun:
id;name;birth_date;source_code
Veri modelinde yalnız Id, Name ve BirthDate özellikleri bulunuyorsa konumsal tablo şu hale gelir:
0 -> Id
1 -> Name
2 -> BirthDate
3 -> nullBundan sonra satırlar işlenirken sütun adı karşılaştırması yapılmaz. Sözlük sorgusu da tekrarlanmaz. Döngü doğrudan sütun konumundaki PropertyInfo değerine erişir. Eşlenmeyen sütunlar null olduğu için atlanır.
Bu ayrım, cache kullanımının asıl değerini gösterir. Yalnız reflection metadata'sı değil, dosyanın şemasıyla nesne modelinin birleşimi de önceden hesaplanmıştır.
Naif bir gerçeklenim, her satırdaki her sütun için bütün özellikleri yeniden arayabilir:
\[
O(NHP)
\]Burada:
\(N\) satır sayısıdır.
\(H\) sütun sayısıdır.
\(P\) özellik sayısıdır.
Sözlük kullanılsa bile her hücrede ad çözümleme maliyeti kalır. Geliştirdiğim yapıda ise ad çözümleme yalnız başlıkta yapılır:
\[
O(P+H)
\]Satır işleme sırasında yalnız konumsal dizi erişimi kullanılır:
\[
O(NH)
\]Reflection tabanlı değer atama halen sıcak yolun içindedir. Buna karşılık özellik keşfi, ad normalizasyonu ve sütun eşleme işlemleri veri satırlarından tamamen çıkarılmıştır. Büyük veri üzerinde fark yaratan cache davranışı budur.
Reset çağrıldığında dosya yeniden açılır ve başlık tekrar okunur, ancak tür özelliklerinden oluşturulan sözlük korunur. Böylece aynı enumerator yeniden başlatıldığında reflection keşfi tekrarlanmaz.
Akışkan veri işleme
Yapı IEnumerable<T> ve IEnumerator<T> sözleşmeleri üzerine kurulmuştur. CsvEnumerable<T> yalnız dosya yolunu tutar. Gerçek dosya açma ve işleme işlemi enumerator istendiğinde başlar. MoveNext her çağrıldığında yalnız bir satır okunur ve tek bir T nesnesi üretilir.
IEnumerable<T> bir veri kümesinin IEnumerator<T> üzerinden sırayla dolaşılmasını sağlar ve C# foreach yapısının temel sözleşmelerinden biridir.
Bu tasarımın büyük veri açısından belirleyici sonucu şudur:
\[
\text{Bellek kullanımı} \not\propto \text{toplam satır sayısı}
\]Milyonlarca satırlık bir dosya önce bir List<T> içine alınmaz. Çağıran kod her nesneyi elde ettiği anda işleyebilir:
satırı oku nesneyi oluştur alanları ata işle sonraki satıra geç
Bu yapı özellikle aşağıdaki işlerde kullanışlıdır:
Toplu veritabanı aktarımı
İstatistik üretimi
Filtreleme
Veri dönüştürme
Büyük log ve rapor dosyalarının analizi
Satır bazlı dışa aktarma
GZip arşivlerinden doğrudan veri okuma
Çağıran taraf dolaşmayı erken durdurursa dosyanın geri kalanı okunmak zorunda değildir. foreach tamamlandığında veya yarıda kesildiğinde enumerator'ın Dispose edilmesi, açık okuyucu ve akışların kapatılmasını sağlar.
GZip ve büyük tampon kullanımı
Dosya adı .gz ile bitiyorsa kaynak önce GZipStream üzerinden açılır. Sıkıştırılmış içerik geçici bir dosyaya tamamen çıkarılmaz. GZip katmanı ile metin okuyucu ardışık biçimde birbirine bağlanır:
\[
\text{FileStream}
\rightarrow
\text{GZipStream}
\rightarrow
\text{StreamReader}
\rightarrow
\text{CSV satırı}
\]GZipStream, gzip biçimindeki verilerin akış üzerinden sıkıştırılması ve açılması için sağlanan .NET katmanıdır. Bu sayede açılmış verinin tamamını bellekte tutmadan sıralı okuma yapılabilir.
Dosya ve metin okuyucu için bir mebibaytlık tampon kullanılmıştır:
\[
1.048.576 \text{ bayt}
\]Bu seçim küçük varsayılan tamponlarla çok sayıda işletim sistemi okuması yapılmasını azaltmayı hedefler. Büyük ve ardışık veri dosyalarında, satırların tek tek işlenmesi ile fiziksel kaynağın küçük parçalar halinde okunması aynı şey değildir. Uygulama satır bazlı ilerlerken alttaki okuyucu daha büyük blokları önceden alabilir.
Metin okuyucu varsayılan olarak BOM içermeyen UTF-8 kullanır ve aynı zamanda byte order mark algılamasını etkinleştirir. .NET'in bu StreamReader kurucusu, uygun işaret bulunduğunda UTF-8, UTF-16 ve UTF-32 gösterimlerini tanıyabilir; aksi halde verilen kodlama kullanılır.
Dosyanın FileShare.ReadWrite ile açılması, başka bir süreç tarafından açık tutulan çıktıların da okunabilmesini kolaylaştırır. Bu özellik canlı üretilen veya başka bir sistem tarafından henüz kapatılmamış dosyalarda yararlı olabilir. Bunun karşılığında, okuma sırasında değişen bir dosyanın işlemsel olarak tutarlı olduğu garanti edilmez. Bu kullanımda dosya üretim protokolünün ayrıca tanımlanması gerekir.
Tür dönüştürme davranışı
Her satır için parametresiz oluşturucu üzerinden yeni bir T nesnesi meydana getirilir. Activator.CreateInstance<T>(), genel tür parametresinin parametresiz oluşturucusunu kullanarak çalışma anında örnek üretir.
Sütun değeri daha sonra hedef özelliğin türüne Convert.ChangeType ile çevrilir. Bu yöntem temel .NET türleri arasında genel amaçlı dönüşüm sağlar. Dönüşüm kültüre duyarlı olabilir; açık bir biçim sağlayıcısı verilmediğinde geçerli çalışma kültürü kullanılır.
Kodda dönüşüm veya atama hatası ilgili alan düzeyinde yakalanır. Tek bir bozuk değer bütün satırı veya dosyayı durdurmaz. Dönüştürülemeyen özellik varsayılan değerinde kalır ve diğer sütunların işlenmesine devam edilir.
Bu davranış, veri toplama ve keşif çalışmalarında oldukça kullanışlıdır. Tek bir bozuk tarih veya sayısal alan nedeniyle milyonlarca satırlık aktarım kaybedilmez. Bununla birlikte sessiz devam politikası, finansal veya hukuki doğruluk gereken işlemlerde tek başına yeterli değildir. Böyle bir kullanımda başarısız alan sayısı, satır konumu ve dönüşüm nedeni ayrıca kaydedilmelidir.
Convert.ChangeType temel türlerde sade bir çözüm sağlarken aşağıdaki türler için ek dönüştürücüler gerekebilir:
Nullable türler
Enum değerleriGuid
Kültüre özgü tarihler
Özel sayısal biçimler
Uygulamaya ait değer nesneleri
Bu gereksinim çekirdeğin tasarımını değiştirmez. Sütun-özellik cache'i korunarak yalnız dönüşüm katmanı genişletilebilir.
Karmaşıklık analizi
Aşağıdaki sembolleri kullanalım:
\(P\): modeldeki özellik sayısı
\(H\): dosyadaki sütun sayısı
\(N\): veri satırı sayısı
\(L_i\): ilgili satırın karakter uzunluğu
\(M\): eşlenmiş sütun sayısı
Başlangıç maliyeti yaklaşık olarak:
\[
O(P+H+L_0)
\]değerindedir. Burada \(L_0\), başlık satırının uzunluğudur.
Her veri satırında:
Satırın okunması
Ayırıcıya göre bölünmesi
Bir nesnenin oluşturulması
Eşlenmiş alanların dönüştürülmesi ve atanması
işlemleri yapılır.
Toplam çalışma süresi:
\[
O\left(
P+H+\sum_{i=1}^{N}L_i+NM
\right)
\]şeklinde ifade edilebilir.
Sütun sayısı sınırlı olduğunda algoritma dosyanın toplam metin büyüklüğüne göre doğrusal davranır:
\[
\Theta(\text{toplam karakter sayısı})
\]Yardımcı bellek maliyeti ise:
\[
O(P+H+L_{\max})
\]düzeyindedir. Toplam satır sayısı bu ifadede yer almaz. Yalnız mevcut satır, bölünmüş alanları, model nesnesi ve cache yapıları bellekte bulunur.
Split her satır için bir dizi ve alan dizeleri üretir. Bu nedenle algoritma sabit bellekli değildir, fakat toplam dosya boyutuna bağlı birikimli bellek de kullanmaz. Üretilen kısa ömürlü nesneler çöp toplayıcı tarafından geri alınabilir. Daha yeni bir gerçeklenimde Span<char> tabanlı ayrıştırma bu tahsisleri azaltabilir. Buna karşılık mevcut kodun en önemli kazanımı, düşük karmaşıklıkla hızlı uyarlanabilirlik sağlamasıdır.
CSV kapsamının doğru belirlenmesi
Kod, kontrollü ayraçlı metin dosyaları için geliştirilmiştir. Başlıkta sekme, noktalı virgül, virgül ve dik çizgi sırayla denenir. Birden fazla alan üreten ilk karakter ayırıcı olarak seçilir.
Bu otomatik algılama, kurum içi dışa aktarımlarda sık kullanılan dört biçimi yapılandırma gerektirmeden destekler. Ancak genel amaçlı CSV lehçesi algılaması değildir. Ayırıcı bir başlık değerinin içinde bulunuyorsa yanlış seçim yapılabilir.
Daha önemli sınır, satırların doğrudan ayırıcı karakterle bölünmesidir. RFC 4180 biçiminde alanlar çift tırnak içine alınabilir; ayırıcı, çift tırnak ve satır sonu karakterleri alan içeriğinin parçası olabilir.
Dolayısıyla bu çekirdek:
Tırnak içinde ayırıcı
Kaçırılmış çift tırnak
Alan içindeki satır sonu
Çok satırlı metin hücresi
durumlarını işleyen tam bir RFC 4180 ayrıştırıcısı değildir.
Bunu bir eksiklikten çok kullanım sözleşmesi olarak değerlendirdim. Çoğu projemde veri biçimi benim denetimimdeydi veya alanların ayırıcı ve satır sonu içermediği kurumsal çıktılar işleniyordu. Böyle bir veri alanında tam CSV durum makinesinin karmaşıklığını taşımak yerine ReadLine ve Split temelli yol yeterli ve hızlıydı.
Genel kullanıma açılacak bir kütüphanede ayrıştırma katmanı tırnak durumunu izleyen bir sonlu durum makinesiyle değiştirilebilir. Reflection cache'i, sütun eşlemesi, GZip desteği ve akışkan enumerator yapısı aynen korunabilir.
Hatalı satır politikası
Bir satırın alan sayısı başlıktaki sütun sayısıyla eşleşmezse enumerator dosyayı kapatır ve dolaşmayı sonlandırır. Bu davranış, şema bozulmasını sessizce geçmek yerine veri akışının güvenilirliğini korur.
Alan düzeyindeki dönüşüm hatası ile satır yapısındaki bozulma arasında bilinçli bir ayrım vardır:
Tek alan dönüştürülemiyorsa diğer alanlara devam edilir.
Satırın sütun yapısı bozuksa bütün okuma sona erer.
Bu karar, kaymış bir ayırıcının sonraki bütün değerleri yanlış özelliklere atamasını engeller. Büyük veri aktarımında yanlış eşlenmiş binlerce nesne üretmektense işlemi kontrollü biçimde durdurmak daha güvenli olabilir.
Farklı projelerde bu politika kolayca değiştirilebilir. Bozuk satır atlanabilir, karantina dosyasına yazılabilir veya hata nesnesi olarak ayrı bir akıştan üretilebilir. Çekirdeğin mevcut hali, sade ve muhafazakar davranışı tercih eder.
Sade kodun kurumsal değeri
Bu yaklaşımın yıllar boyunca farklı projelerde işime yaramasının nedeni yalnız hızlı çalışması değildir. Asıl değer, yeni bir veriye uyarlanırken çok az karar gerektirmesidir.
Yeni aktarım için süreç çoğunlukla şöyledir:
- Dosyadaki sütunları incelemek
- Karşılık gelen sade veri sınıfını tanımlamak
- Gerekirse birkaç özellik adını uyumlu hale getirmek
- Diziyi foreach ile işlemek
Özel mapper sınıfları, satır başına sözlükler, XML eşleme dosyaları veya büyük bir ORM katmanı gerekmez. Bilinmeyen sütunlar görmezden gelinir. Eksik eşleşmeler ilgili özellikleri varsayılan değerinde bırakır. Dosyanın sırası değişse bile başlık eşlemesi yeni konumları yeniden belirler.
Cache mekanizması burada performans optimizasyonuyla geliştirici verimliliğini aynı noktada birleştirir. Türün reflection bilgisi bir kez çıkarılır, başlık bir kez çözülür ve satırların geri kalanı konumsal olarak işlenir. Bu sayede reflection esnekliği korunurken en pahalı metadata aramaları döngü dışına taşınır.
Daha yüksek ham aktarım hızı gereken bir sürümde PropertyInfo.SetValue yerine önceden derlenmiş setter delegeleri kullanılabilir. Tür ve sütun eşlemesi bütün uygulama boyunca değişmiyorsa özellik cache'i jenerik tür başına statik olarak da tutulabilir. Bunlar mevcut tasarımın alternatifi değil, aynı ön hesaplama ilkesinin daha ileri uygulamalarıdır.
Benim kullanımımda bu küçük sınıf, soyutlama miktarı ile gerçek gereksinim arasında iyi bir denge kurdu. Büyük dosyaları bütünüyle belleğe almadan işledi, sıkıştırılmış kaynakları doğrudan okudu, değişen sütun sıralarına uyum sağladı ve yeni projelere kısa sürede taşınabildi.
Kodun asıl optimizasyonu karmaşık bir algoritma kullanması değildir. Satırdan satıra değişmeyen her şeyi önceden hesaplamasıdır. Reflection tabanlı eşleme ancak bu ayrım doğru yapıldığında hem esnek hem de büyük veri üzerinde yeterince verimli hale gelir.