Unity'de Yazılım Mimarisi Oluşturma: Event Tabanlı Mimari

Unity'de Yazılım Mimarisi Oluşturma: Event Tabanlı Mimari

·

12 min read

Bir yazılım mimarisi oluştururken, o mimariden beklenen bazı kriterler vardır. Bu kriterlerden biri, mimarinin kolay bir şekilde test edilebilmesidir. Aynı zamanda, mimariye ekleme yapıldığı durumlarda da bunun çok zaman almadan yapılabilmesi çok önemlidir. Mimariye bir ekleme yapıldığı durumlarda, o mimariye eklenen yeni eklemelerin (bu eklemeler modüller olabileceği gibi herhangi bir class da olabilir.) sisteme eklenirken mümkün olduğu kadar az bir dependency maliyeti ile eklenmesi de kritik bir önem taşıyor. Mimarinin, projenizde gerçekleşen bir olay sonrası ilgili modüllere ve diğer projenin paydaşı olan classları bu olay hakkında bilgilendirip organize edebilmesi de beklenir. İşte tam bu noktada “Event” dediğimiz bir yazılım mühendisliği konsepti devreye girer.

Event Tabanlı Mimari

Event, bir projede gerçekleşen bir olayı temsil eder. Bu olayı bilmesi gereken diğer classlar bu eventi dinlerler ve bu event gerçekleştiği anda (yani söz konusu olay oluştuğunda) kendi yapmaları gereken fonksiyonelliği gerçekleştirirler. Bunu yapmaları için, o evente abone olmaları gerekir. Abone oldukları zaman da o olayın gerçekleşmesi durumunda otomatik olarak kendilerine haber gider ve böylece event gerçekleştiğinde kendi yapmaları gereken işlerini yaparlar.

Bu tarz bir yapılanmanın, yazılım dizaynı alanında bir adı da vardır ve buna “Observer” denir. Observer yapılanması, yazılım dizaynı için belirlenen “örüntü” adı verilen prensipler arasında en popüler prensiplerden birisidir. Observer örüntüsünde iki önemli kavram vardır:

  • Subject: Eventleri gönderendir. Projede gerçekleşen eventleri göndermekle sorumludur. Birden fazla subject olabilir. Hatta mümkün olduğu kadar fazla subject olmasının, bu örüntünün projelere eklendiği durumlarda faydalı bir yaklaşımdır. Fakat gerektiğinden fazla subject olması da iyi bir pratik değildir. (Bundan yazının sonunda bahsedeceğiz.

  • Observer: Eventleri dinleyenlerdir. Subjectlerin gönderdiği eventleri dinleyerek, eventlerin gerçekleştiği durumlarda aksiyon alırlar. Bu aksiyonlar, kendilerine tanımlanan davranışlara göre de bu aksiyonlar değişkenlik gösterir. Popüler bir eventin daha fazla observer’ı vardır.

Observer örüntüsünde, observer objelerin subjectlere bir bağlılığa sahiptir. Eğer bir obje üzerinde event gerçekleşiyorsa, bu eventin gerçekleştiği objenin yerine o objeye bağlı bir subject bulunur. Bu subject, gerçekleşen olayı olayın gerçekleştiği objenin durumunu bir şekilde dinleyerek fark eder. Daha sonra da bu subject üzerinden, gerçekleşen olay event olarak subject tarafından çağrılır. Subject’i dinleyen observer objeler de, böylece olayın gerçekleşmesinden haberdar olurlar.

Peki böyle bir örüntünün nasıl bir avantajı var?:

  • Birbirinden farklı classlar, birbiri ile bir bağlılık kurmak zorunda kalmıyor. Böylece bu classların yönetimi daha rahat oluyor. Bu classların davranışlarının genişletilmesi veya daraltılması söz konusu olduğunda, bunun maliyeti düşmüş oluyor çünkü classların bağlılık seviyeleri düşük kalmış oluyor. Bağlılık seviyesinin düşük kalması, bir classı değiştirirken diğer class özelinde bir müdahalenin yapılmasına gerek kalmaması anlamına geliyor. Otomatik olarak iş maliyetimiz düşüyor.

  • Mimariye yapılan eklemelerin maliyeti düşüyor. Yani belirli bir event ile alakası olan yeni bir class eklendiğinde, sadece evente abonelik sağlanması yeterli oluyor. Event eklenmeyen bir senaryoda, direkt olarak iki class arasında bir bağlantı yaratmak gerekiyor olacak. Duruma göre kötü bir tercih olmasa da pek çok durumda classlar arası bağlantıları düşük tutmanın çok faydası var. İdeal bir dünyada, iki classın gereksiz bir şekilde birbirlerinin işleyişine müdahale etmeden bir iletişim kurması çok daha sağlıklı bir mimari oluşturmamızı sağlar. Event bazlı iletişimin de böyle bir avantajı var.

  • Proje geliştirme maliyetini düşürüyor. Bir proje geliştirirken event bazlı bir iletişimi barındıran mimaride çalışmak, projeye yapılabilecek geliştirmeleri ve düzenlemeleri daha geniş bir çerçevede mümkün kılıyor. Monolitik veya pek çok classın birbirine direkt bağlı olduğu bir mimaride geliştirme yapmak ile event bazlı bir mimaride geliştirme yapmanın çok bariz farklı olduğu durumlar bulunuyor. Yayınlanma odaklı bir proje geliştirilen ortamlarda, event bazlı iletişimi destekleyen mimariler daha yaygındır. Aksi durumlar olsa da, event bazlı bir mimarinin daha çok tercih edildiğini varsayabiliriz.

C# özelinde, bu tarz bir geliştirme yaklaşımını destekleyen çeşitli syntax yapıları bulunuyor. Bu yapıları bu yazımızda inceleyeceğiz ve bir örnek üzerinden de event bazlı geliştirme yapmak üzerine konuşuyor olacağız. Bu örnekte 3 class üzerinden ilerleyeceğiz:

İlk classımız, “PlayerEventDispatcher” classı olacak. Bu class, Observer örüntüsündeki “Subject” olarak hizmet ediyor olacak. Bir oyun projesinde çalıştığınızı hayal edin, oyundaki player karakterde gerçekleşen tüm kritik eventler, bu subject üzerinden projenin diğer classlarına paylaşılacak. Kısaca buradaki syntax’ten de bahsetmek istiyorum.

Delegate Yapısı

Öncelikle class tanımlamasının en başında “delegate” tipinde tanımlanmış 3 değişkenimiz var. Peki bu değişkenlerin tanımlandığı “delegate” türü nedir?

Delegate türü, referans tipinde olan ve C#’da yazılan kod fonksiyonlarının adresini tutabilen özel bir değişken türüdür. Referans tipinde olduğu için heap belleğinde tutulur.

C# özelinde, kodlarımızda yazdığımız fonksiyonlar (yani metotlar) “kod segmenti” adı verilen özel bir bellek alanında tutulurlar. Bu alan sadece okuma izinli bir alandır ve bizim yazdığımız uygulama tarafından değiştirilemez. Sabittir ve projedeki metotların kendi büyüklüğünün toplamı kadardır. Delegate türü, bu alana erişim sağlar ve çeşitli metotların bu segmentteki adresini tutar. Bu nedenle, event bazlı mimarinin tam da işine yarayan bir değişkendir. Birden fazla classtaki metotların adresine erişim sağlayabildiği için delegate türü kullanılarak event bazlı bir iletişim inşaa edilir.

Aynı zamanda, bu metot adresi atama işlemini bizim manuel yapmamıza gerek yoktur. C# runtime, kendi içerisinde bunu halleder. Delegate tanımlamalarında, delegatelerin yönlendirildiği metotların adresi runtime tarafından delegatelere otomatik atanır. Delegatelerde, tıpkı classlarda olduğu gibi, constructor tanımlaması bulunur. Instance metotlar, adından da anlaşılacağı gibi, birden fazla kopya ile segmentte yer alabilir. Her bir class kopyası yaratıldığında, bu metotların da yeni kopyaları oluşur. Bundan dolayı her bir kopyaya ait adresin bilinmesi gerekir. Delegatelerin de bu bilgiye ihtiyacı vardır. Bunlar onları tanımlamakta kullanılır ve hedef metodun yer aldığı classın da adresinin bu constructorda tanımlanması gerekir. Runtime, otomatik olarak constructor’a bu adresi tanımlar ve enjeksiyon yapmış olur. Fakat statik metotlarda bu işleme gerek yoktur ve adres enjeksiyonu yapılmaz. Çünkü statik metotlar zaten tek bir örneği olan metotlardır ve birden fazla olamazlar, yani doğal olarak tek bir mümkün adresi vardır. Bu nedenle sadece metot adresi enjekte edilirken metoda ait class’ın referansı enjekte edilmez.

Delegateler, normal bir metot gibi çeşitli return türleri ile de tanımlanabilirler. Bundan dolayı da, delegateler sadece kendileri ile aynı return türüne sahip metotlar ile eşleşebilirler. void türündeki bir delegate, sadece void türündeki metotların adresine erişebilir. Bundan dolayı, delegateler type-safe kod yazmayı destekler. Bu yapıları sayesinde, kod tabanında bir kaos yaratmanın önüne geçilmiş olunur. Aynı zamanda, parametre türleri için de bu geçerlidir. Delegateler, hem return türlerinin hem de kendilerine tanımlanan parametrelerin tür ve adetlerinin birebir aynı olduğu metotlarla eşleşebilir. Bu da yine delegatelerin kendi yapılarından farklı metotların adresini çekmesini engellemiş olur.

Örneğimize geri dönersek, bu örnekte 3 delegate yer alıyor:

  • HealthSetupDelegate(float health): Player’ın health değerinin ilk oluşturulduğu anda bu delegate’i kullanacağız. Player’ın health değerine ihtiyaç duyan metotlar, delegate içerisindeki health parametresi ile player’daki bu değeri çekebiliyor olacak.

  • HealthChangedDelegate(float currentHealth): Player’ın health değeri değiştiği anda bu delegate’i kullanacağız. Player’ın kalan health değerine ihtiyaç duyan metotlar, delegate içerisindeki currentHealth parametresi ile player’daki bu değeri çekebiliyor olacak.

  • PlayerDiedDelegate(): Player öldüğü zaman (yani health değişkeni 0 olduğunda) bu delegate’i kullanacağız. Player’ın ölüm anını dinlemek isteyen classlardaki metotların herhangi bir return tipine ve parametreye sahip olması gerekmiyor çünkü delegate void türünde tanımlandı ve içinde bir parametre bulunmuyor.

Event Fonksiyonu

Bu delegate değişkenlerinin altında, “event” türünde ve tanımladığımız delegate türleri ile tanımlanan fonksiyonlar da var. Peki, bu tanımlama ile tanımladığımız eventler nedir?

C# özelinde, event adını verdiğimiz bir tür tanımlanır. Event türü, bildiğimiz klasik değişken türlerinden farklıdır. Daha çok bir “wrapper” görevi gören bir fonksiyon olarak tanımlanır. Bu fonksiyon, bir delegate türü ile tanımlanır. Opsiyonel değildir ve event tanımlaması yapılırken mutlaka bir delegate ile beraber tanımlanmalıdır. Çünkü eventler, delegatelere metot tanımlaması yapılmasında görevlidir. Bunu yaparken de hangi delegate’e tanımlama yapacağını bilmesi gerekir. Event bünyesinde bir delegate listesi tanımlanır. Bu liste, event’e tanımlanan delegate türündedir. Mimarideki observerlar, ne zaman bir event'e abone olurlarsa yeni bir delegate yaratılır ve eventin içerisindeki listeye bu yeni delegate eklenir.

Örneğimize geri dönecek olursak, player’ın health değeri ile alakalı 3 kritik olayın her birini bir event olarak tanımladık. Bu eventleri şu şekilde açıklayabiliriz:

  • OnHealthChanged: Player’da her health değişkeninde değişim yaşandığında, bu event çağrılacak. Bu eventi dinleyen observerlar da buna göre kendi işlemlerini yapıyor olacak.

  • OnPlayerDied: Player’da öldüğünde (yani health değişkeni 0 olduğunda) bu event çağrılacak. Bu eventi dinleyen observerlar da buna göre kendi işlemlerini yapıyor olacak.

  • OnHealthSetup: Player’ın health değeri oluşturulduğunda bu event çağırılacak. Bu eventi dinleyen observerlar da buna göre kendi işlemlerini yapıyor olacak.

PlayerHealth classı, player’a ait olan health değişkenini manipüle etmek ile odaklanan bir class olarak tanımlandı. Update metodu içerisinde, her mouse sağ tık inputunda, player’ın health değerinden 10 eksiltiyoruz. Her bir health değer değişimi yaşandığında, kendisine tanımlanan event subjectindeki (yani yukarıda görüntüsünü paylaştığım PlayerEventDispatcher classındaki) health değişim metotlarını çağırıyor. PlayerHealth classı, sadece subject ile (yani yukarıda görüntüsünü paylaştığım PlayerEventDispatcher classı ile) etkileşime giriyor. Trigger metotlarının içerisinde de, bu health değişkenlerinde çağrılması gereken eventler hakkında aşağıdaki kod parçalarında olduğu gibi işlem yapılıyor:

Bu ekran görüntüsündeki trigger metotlarının PlayerHealth classından çağrıldığını görmüştük. Bu metotlar içerisinde de, yukarıda tanımlamasından bahsettiğimiz event fonksiyonlarının Invoke metodu ile tetiklendiğini görüyoruz. Peki, invoke işlemi nedir?

Event fonksiyonlarından Invoke metodu çağırıldığında, invoke işlemi yapılan eventin içindeki tüm delegateler gezilmeye başlanıyor ve delegatelerin işaret ettiği metotlar çalıştırılıyor. Ama bunun için de öncelikle evente abone olunması gerekiyor. Peki abonelik nasıl gerçekleşiyor?

PlayerHealthUI adında yeni bir class tanımlıyoruz. Yukarıda bahsettiğimiz subject classı, bu classa tanımlanıyor. Bu class içerisindeki OnEnable metodunda, subject içerisinde tanımlanan eventlere abonelik sağlanıyor. Bu abonelik, “+=” operatörü ile sağlanıyor. Event isminin yanına bu operatör eklenerek, operatörün diğer tarafına abone olması istenen metodun ismi yazılıyor. Böylece abonelik işlemi tamamlanıyor.

Daha önce bahsettiğimiz OnPlayerDied eventine, bu class içerisindeki PlayerDied metodu abone oluyor. Böylece event içerisinde yeni bir PlayerDiedDelegate delegate değişkeni tanımlanıyor ve PlayerHealthUI classının adresi de bu delegate içerisine tanımlanıyor çünkü PlayerDied metodu statik bir metot değil. Bu nedenle abone olan metodun hangi classtan geldiğinin delegate constructorında tanımlanması gerekiyor. Böylece ilgili eventler çağrıldığında, PlayerHealthUI classı içerisinde bu eventlere abone olan metotlar da çalıştırılmış olacak.

Peki bu yaklaşım ile nasıl avantajlar elde ettik?:

  • PlayerHealth ve PlayerHealthUI classları, birbirlerine bağlılık kurmak zorunda kalmadı. PlayerHealthUI classının, PlayerHealth classında gerçekleşen hesaplamaları bilmesine gerek yok. Çünkü bu classın tek görevi, health değerini UI katmanında ekrana yansıtmaktır. Bundan dolayı da sadece health değişkeninin ne olduğuyla ilgilenir. Event bazlı iletişim ile de bu ihtiyacı karşılamış olduk. Her iki class özelinde bir düzenleme yaptığımızda, diğerine de müdahale etme veya birbirlerinden ayıklama yapma ihtiyacı oluşmadı.

  • Player’ın health değerine erişim sağlaması gereken diğer tüm classlar, sadece subject (yani PlayerEventDispatcher classı) ile etkileşime giriyor olacak. Bağlılığın kaçınılmaz olduğu bu durumda, bunu yapmakla görevli bir araç tanımladık ve bu araç aracılığıyla ilgili classlara data dağıtımı gerçekleştirilecek. Bu da diğer classlar arası bağlılık oranını minimize ediyor. Mimarinin geliştirilmesi veya değiştirilmesi durumunda oluşacak maliyetin nispeten düşük olacağını ön görebiliriz.

NOT: Eventler olmadan, delegatelere direkt olarak da abone olunabilir. Fakat çoğu durumda bunun yapılmaması tavsiye edilir. Delegate’e erişim sağlayan class, delegate’i direkt olarak manipüle edebilme imkanına sahip olur. Delegate’i null değere eşitleyebilir, delegate’i kendi başına çağırabilir (Delegate’in çağrılması gereken class dışında başka bir classtan çağrılması, ön görülemeyen davranışlar yaratır ve delegate’e abone olan başka metotların da olmadık bir zamanda çağrılmasına sebep olur. Yani olması gereken akışı göremiyor olacağız.) ve delegate’in işaret ettiği metot adresleri değiştirilebilir (Az önce bahsettiğim konudan dolayı bu da istenmeyen bir durumdur.). Eğer delegate’in pek çok class ile iletişime geçmesi gerekiyorsa, bunu bir event kullanarak yapmamız delegate’in korunması açısından önemlidir. Event, delegate’i enkapsüle eder ve diğer classların bu delegate’e erişmesini engeller. Bundan dolayı da event bazlı kod yazarken delegatelerin direkt dahil olmaması tavsiye edilir.

Dikkat Edilmesi Gerekenler

Event bazlı mimarinin avantajlarından bahsettik, geliştirme maliyeti açısından sağladığı avantajlara da değindik. Fakat her yazılım dizaynında olduğu gibi, bu mimari anlayışının da çözüm ürettiği problemlere bir yerden sonra sebep olmaya başlayabileceğini de unutmamak gerekiyor.

Eventlerin sayısının oldukça fazla olduğu bir senaryoda, test ve debug maliyeti de oldukça yüksek olacaktır. Çünkü test edilmesi gereken event akışları da haliyle daha fazla olacaktır. Gereğinden fazla event tanımlandığı durumlarda, bu sefer geliştirme maliyeti artmaya başlayacaktır ve eventlerin maliyet düşürme özelliği kaybolacaktır. Eventlere fazla bağlılığın olduğu kod tabanına ekleme yapıldığı durumlarda, regresyon testlerinin (kod tabanına ekleme yapıldıktan sonra her şeyin doğru çalıştığından emin olmak için yapılan testlerin) yapılması için pek çok eventin çağrıldığı yerlerin tespiti ve bu eventlere abone olan metodların herhangi bir sorun yaratmadığından da emin olunması maliyeti eklenecektir. Aynı zamanda test maliyetinin artmaya başlaması ile birlikte, projenin kod tabanına olan hakimiyet de azalmaya başlar ve kaotik bugların ortaya çıkma ihtimali yükselir. Eventleri kimin dinlediğini tek tek tespit etmek zaman alacağı için de pek çok eventin olduğu durumlarda developerların sistemi çok iyi ezberlemesi gerekir. Mümkün olduğu kadar da sistemde belirli bir seviyeye kadar bağlılık ilişkisine izin verilmesinde yanlış bir durum yoktur.

Eğer bir olayın sadece 2-3 class tarafından dinlenmesi gerekiyorsa ve bu classların zaten birbirlerine belirli bir bağlılık sağlamaları da gerekiyorsa o zaman eventlerin burada dahil olmaması daha iyi bir çözümdür. Eğer önemli bir olayın pek çok class tarafından dinlenmesi gerekiyorsa da, event bazlı iletişim daha iyi bir alternatif olur. Yani doğru zamanda ve doğru yerde eventleri kullanmak gerekir.

Bir evente oldukça fazla abone metodun olması, bellek yönetimi açısından da iyi değildir. Çünkü event içerisinde, delegatelerden oluşan bir liste yer alır. Her abonelik işleminde, yeni bir delegate heap belleğinde yaratılır ve event içerisindeki listeye dahil edilir. Çok fazla event aboneliğinin olduğu durumlarda da bu bir probleme dönüşmeye başlar çünkü heap belleğinde delegateler ile dolu büyük listeler yer almaya başlar. Yine yukarıda bahsettiğimiz gibi eventleri doğru zamanda ve doğru yerde kullanarak bunun önüne geçebiliriz. Fakat artık dinlenmesi gerekmeyen eventlerden class metotlarının aboneliği bırakması da önemlidir. Peki bunu nasıl gerçekleştirebiliriz?

Bu ekran görüntüsünde, PlayerHealthUI classına geri döndük ve hatırlarsanız OnDisable metodunun içerisinde bu sefer “-=” operatörünü kullandık. Bu operatör, event üzerindeki spesifik metot aboneliğini iptal etmek için kullanılır. Bu işlem gerçekleştirildiğinde, bu metodu işaret eden delegate boşa çıkmış olur çünkü işaret ettiği metot artık evente abone değildir. Bu delegate, eventin delegate listesinden silinir ve boşa çıktığı için de kullanılmayan obje olarak Garbage Collector tarafından tespit edilir ve heap belleğinden silinmiş olur. Eğer abonelik artık gerekmiyorsa ama biz aboneliği bitirmediysek, o metodu işaretleyen delegate bellekte yer tutmaya devam eder ve belleği gereksiz yere işgal etmiş olur. Bundan dolayı da, aboneliklerin gereksiz yere tutulmaması ve ihtiyaç olmadığı anda iptal edilmesi gerekir. Bu örnekte de, bu classın yer aldığı objeyi sahnede görünmez hale getirdiğimizde artık bu classın fonksiyonelliğini yapmasını istemediğimiz için de eventlerden aboneliğimizi iptal ettik. Böylece gereksiz yere heap belleğinde yer kaplayan delegateler olmasını engelledik.

Alabileceğimiz bir diğer önlem, gerçekleşen eventleri sekanslara bölmektir. Her sekans için event tanımlamak yerine, bu sekanslar içerisinde gerçekleşen olaylardan popüler olanlarını (yani pek çok class tarafından dinlenmesi gerek olayları) event olarak tanımlayıp invoke edersek event kullanımını minimal tutarken event bazlı iletişimin avantajlarını korumuş oluruz.

Başka bir sıkıntı da, düz mantıklı bir event bazlı mimaride eventlere abone olan metotların hangi sırayla çağrılacağının bir garantisi yoktur. Bu da özellikle belirli bir sırada çeşitli aksiyonların gerçekleşmesi gereken durumlarda sıkıntı yaratır. Farklı classların birbiriyle sıralı ve entegre çalışması gereken durumlarda, çeşitli sekans organizatörleri aracılığıyla bunların yapılması bu problemi engellemiş olur. Bu da aslında dolaylı yoldan fazla event kullanmanın bir yan etkisi olarak değerlendirilebilir. Event fazlalığının yarattığı kaostan ötürü projede gerçekleşmesi gereken sekansların test maliyeti yükselir ve entegrasyonu olması gerekenden daha uzun sürer.

Sonuç

Her yazılım dizayn tercihi, tamamen tek başına her problemi çözmez. Event bazlı mimarı için de bu geçerlidir. Fazla kullanımı zararlıyken az kullanımı da zararlı olabilir. Aradaki dengeyi tutturmak, oldukça kritik bir önem taşıyor. Event bazlı yaklaşımın başka dizayn tercihleri ile ortak kullanılması, yani hibrit bir yaklaşım izlenmesi, en ideal yaklaşımdır. Observer örüntüsünün yanı sıra, diğer örüntülere de (State, Strategy, Factory vs…) yer verildiği durumlarda mimarinizin verimini arttırmış olursunuz.

Herkese iyi çalışmalar diliyorum!