Onlarca “If – Else” Yazmak Yerine Daha Okunaklı Kod Nasıl Yazabiliriz ?
Selamlar,
Bu makalede, çok fazla sayıda koşulun kontrol edilmesi gereken bir durumda, kod okunaklığını bozan, anlaşılmasını ve değişiklik yapılmasını imkansız hale getiren, tek bir sınıfa onlarca görevin verilerek, “Single Responsibility “‘nin bozulmasına neden olan, “If” ve “Else”lerden nasıl kaçınabileceğimizi hep beraber inceleyeceğiz ?
Normal şartlarda, bir kaç if koşulu için üzerinde pek de düşünülmeye gerek olmasa da, bazen çok daha complex koşullar için farklı bir yaklaşıma ihtiyaç duyulabilmektedir. Bu bize, kod okunaklığının artışını, değişiklik ve eklemelerin kolaylıkla yapılabilmesini, test edilebilirliği ve hatanın çok daha rahat yakalanmasını sağlar. Aslında şu tarife biraz baktığımızda, ilk akla S.O.L.I.D ilkeleri ve Design Patternler geliyor :) Hadi gelin, gerçek hayatta bu Design Patternler ne işimize yarayacak canlı canlı görelim.
Bu makalede, bir “Authorization Token” kontroller zincirini, “if-else” olmadan yazılmaya çalışılacaktır.
Not: Öncelikle bu yazıda belirtilen adımlar, tamamen hayal ürünü ve baside indirgenmiş adımlardır. Gerçek hayatta, kontrol edilmesi gereken adım sayısı hem çok daha fazla ve hem de kuralların complexities, bu makaledekilere göre çok daha yüksektir.
Öncelikle birden çok adımın gerçekleşitiği ve onlarca kuralın koşturulduğu bir yapıda, büyük resmi görmek için en azından bir akış diyagramının çizilmesi gerekmektedir.
Projenin genel akış diyagramı
Kontrol Edilmesi Gereken Koşullar:
Yukarıdaki akış diyagramına bakıldığında, yapılacak sorguların 4 adıma bölünmesinde fayda vardır. Gerçek hayatta bu adımlar, 10-20 gibi çok daha büyük sayılara ulaşabilir. Bu makalede amaç, size mantığı en kısa yoldan aktarmaktır. Zira mantık anlaşıldıktan sonra, adım sayısının pek de bir önemli kalmayacaktır.
- Login olunan platformun bakılması ve Token var mı kontrolü.
- Platformlara göre Token ve RefreshToken’ın doğruluğunun kontrolü.
- Token Expire süresinin kontrolü.
- Token Expire süresine 45 dakkadan az bir zaman kalmış mı kontrolü.
- RefreshToken’ın doğruluğunun kotrolü ve Token, RefreshToken ve DBExpireTime’ın yenilenmesi.
Öncelikle dikkat edilir ise, geri dönülemez ileri doğru bir akışın söz konusu olduğu görülmektedir. Zaten ileri doğru akışın kesildiği noktada “Unauthorized 401”, hatası alınmaktadır. Bu durum, aynı döküman yönetiminde olduğu gibi koşullara göre ileri doğru yön değiştirmektedir. (Örn: Önce A kişisi, ilgili dökümanı imzalar. Sonra koşula göre ya B kişisi ya da C kişisi imzalar.)
Yani A Noktasından => B Noktasına gidilmesi bir koşula bağlıdır. İlgili koşul sağlanmaz ise C Noktasına gidilir. Ve sonunda da akış sonlanır.
Tüm iş parçacıkları belli bir sırada, biten iş parçacığının hemen sonrasında çalıştırılmaktadır ve ileri doğru hareket etmektedir. Bu açıklamaya dayanarak akıllara ilk “Chain of Responsibility” patterni gelir. O zaman, her koşulu ayrı bir sınıf haline getirelim ve hepsinde bulunan bir Next propertysi sayesinde, kendisinden sonra gelecek olan koşulu çağıralım.
Chain of Responsibility
Sınıfları gelişi güzel oluşturmak yerine, ortak noktalarını bir interface’de toplamak ve hepsinde çalıştırılacak ortak bir method oluşturmakta fayda var. Böylece sisteme yeni bir kural dahil edilmek istendiğinde, ilgili interfaceden türetmek ve ortak methodu override etmek yeterli olacaktır. Ayrıca zincirin hangi halkasında olunması isteniyor ise “Next” propertysine ilgili kural sınıfının tanımlanması yeterlidir. Bu açıklamaya bakıldığında akla ilk “Strategy Design Pattern” gelmektedir.
Resim Kaynağı: miro.medium.com
IRule Interface:
Şimdi önce, tüm kural sınıflarının miras alacağı “IRule” interface’ini tanımlayalım:
- “Run(RequestModel model)” : Tüm sınıflarda, ilgili kuralın çalıştırılacağı methoddur. RequestMethod’u yazının devamında inceleyeceğiz. Ama kısaca, ilgili kuralın çalıştırılması için, gerekli parametreleri barındıran bir sınıftır şeklinde tanımlayabiliriz.
- “Platform {get; set;}“: Tüm kural sınıfları için geçerli olan, çalıştırıldıkları platformu tanımlamaktadır (Web veya Mobile)
- “NextRule { get; set; }“: Kendinden sonra çalışacak, kural sınıfını tanımlamaktadır.
IRule.cs:
1 2 3 4 5 6 |
public interface IRule { public bool Run(RequestModel model); public Platform Platform { get; set; } public IRule NextRule { get; set; } } |
RequestModel Class:
Client tarafından fake olarak request çekilecek data modeli, aşağıdaki gibidir.
- “UserID”: Login olacak client ID.
- “IMEI”: Mobile bir cihazdan login olunmuş ise, ilgili cihazın IMEI ID’sidir. Web’den giriş yapılmış ise, bu alan boş olacaktır.
- “Token”: Client’ın Login işleminden sonra aldığı Token(string key)’dir. Her request için bu key kullanılacaktır.
- “RefreshToken”: Token Expire olduğu zaman, yenilenmesi için gerekli olan 2. bir RefreshToken(string key)’dir.
- “TokenExpireDate”: Token’ın belli bir ömrü vardır. İşte Token’ın son kullanım zamanı, bu property’de tanımlanmıştır. Gerçekte böyle bir alan yoktur. Sadece Login olunan süre tutulur.
RequestModel.cs:
1 2 3 4 5 6 7 8 |
public class RequestModel { public int UserID { get; set; } public string IMEI { get; set; } public string Token { get; set; } public string RefreshToken { get; set; } public DateTime TokenExpireDate { get; set; } } |
Rule.cs: Tüm kural sınıfları, “IRule” interface’i ve “Rule” sınıfından türüyecektir. “Rule” sınıfı, “IRule” sınıfındaki “NextRule” property’sini, diğer sınıflar için implemente etmektedir. Diğer sınıflarda bu property, sadece setlenerek kullanılacaktır.
1 2 3 4 |
public class Rule { public IRule NextRule { get; set; } } |
FakeData.cs: Sanki Redis veya DB’de kayıtlı login olmuş kullanıcı bilgileri, aşağıdaki gibi bir model’de, fake olarak saklanmıştır.
1 2 3 4 5 6 7 8 |
public static class FakeData { public static string Token = "234234223423"; public static string RefreshToken = "98349785389732"; public static DateTime TokenExpireDate = new DateTime(2022, 9, 23, 2, 50, 0); public static string IMEI = "111222333444555"; public static int UserID = 1; } |
Enum.cs: Çalışılacak platformun tanımlandığı enumdır.
1 2 3 4 5 |
public enum Platform { Web = 1, Gsm = 2 } |
Sıralı Bir şekilde İşletilecek Kurallar Serisi:
1-) CheckPlatformRule: İlk kural sınıfımız olup, daha en başta client’ın request yaptığı platformu belirlemek amacı ile tanımlanan bir kural sınıfıdır.[Mobile – Web]
- “NextRule = new CheckTokenRule()”: Bu kural başarılı ile geçilir ise, çalışacak bir sonraki kural, bu property ile tanımlanır.
- “if (model.IMEI == String.Empty || model.IMEI == null)“: IMEI kaydı yok ise, platform Web demektir.
- “return model.Token != String.Empty && model.Token != null” Token’ın boş olması durumunda, “Unauthorized 401” hatası ekrana basılmaktadır.
- “NextRule.Platform = Platform.Web” : Bir sonraki kural sınıfının çalıştığı platformun “Web” olduğu tanımlanır. Böylece bir sonraki adımda, tanımlanan platforma göre ilgili kurallar çalıştırılır.
- “else if (model.IMEI != String.Empty && model.IMEI != null)” : Mobile için de benzer işlemler, web platformunda olduğu gibi aynen tekrarlanmaktadır.
- Eğer Token ya da RefreshToken null değil ise, bir sonraki kurala (CheckTokenRule)’a geçilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class CheckPlatformRule : Rule, IRule { public CheckPlatformRule() { NextRule = new CheckTokenRule(); } public Platform Platform { get; set; } public bool Run(RequestModel model) { if (model.IMEI == String.Empty || model.IMEI == null) { NextRule.Platform = Platform.Web; return model.Token != String.Empty && model.Token != null; } else if (model.IMEI != String.Empty && model.IMEI != null) { NextRule.Platform = Platform.Gsm; return (model.Token != String.Empty && model.Token != null) || (model.RefreshToken != String.Empty && model.RefreshToken != null); } return false; } } |
2-) CheckTokenRule: “CheckPlatformRule”‘dan sonra çalışacak olan kural sınıfıdır. Token’ın geçerli olup olmadığına bakılır. Gsm ve Web ortamı için Token geçerlilik kuralları farklıdır.
- “NextRule = new CheckTokenExpireRule()”: İlgili kural başarı ile geçildiği zaman, bir sonraki çalışacak Rule sınıfı, “CheckTokenExpireRule”‘dur.
- “case Platform.Gsm: { NextRule.Platform = Platform.Gsm” : Eğer platform Gsm ise bir sonraki Rule sınıfının Platformu da, Gsm’dir.
- “return model.UserID == FakeData.UserID && (model.Token == FakeData.Token || model.RefreshToken == FakeData.RefreshToken)”: Mobile platformuna özel koşul, ilgili User ve Token veya RefreshToken doğru mu diye kontrol edilir.
- Mobile için yapılan kontrollerin aynısı Web için de geçerlidir. Tek fark web’de sadece User ve Token geçerli mi diye bakılır. Nedeni ,Mobile’de RefreshToken’ın expire süresi 1 yıldır. Amaç login olan user’ın bir daha login olmadan tokenlarının yenilenmesini sağlamaktır. Aynı durum Web için geçerli değildir. 1 saat boyunca giriş yapmamış bir client’ın, tekrar login olup Token ve RefreshToken alması istenmektedir.
- Eğer Token ya da RefreshToken geçerli ise, bir sonraki kurala (CheckTokenExpireRule)’a geçilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public class CheckTokenRule : Rule, IRule { public CheckTokenRule() { NextRule = new CheckTokenExpireRule(); } public Platform Platform { get; set; } public bool Run(RequestModel model) { switch (Platform) { case Platform.Gsm: { NextRule.Platform = Platform.Gsm; return model.UserID == FakeData.UserID && (model.Token == FakeData.Token || model.RefreshToken == FakeData.RefreshToken); } case Platform.Web: { NextRule.Platform = Platform.Web; return model.UserID==FakeData.UserID && model.Token == FakeData.Token; } default: { return false; } } } } |
3-)CheckTokenExpireRule: CheckTokenRule’dan sonra çalışacak olan kural sınıfıdır. Girilen Token’ın Expire olup olmadığına, yani süresinin geçip geçmediğine bakılır. Süresi geçmiş ise, “Unauthorized 401” hatası geri dönülür.
- “NextRule = new CheckRefreshToken()”: Kendinden sonra çalışacak olan son kural sınıfı, “CheckRefreshToken“‘dır.
- “if (model.TokenExpireDate < FakeData.TokenExpireDate.AddHours(2)): Eğer gelen model’in Expire tarihi, FakeData’daki TokenExpire zamanını geçmiş ise, “Unauthorized 401” hatası geri dönülmektedir. Mobile ve Web platformları için, geçerli expire süreleri farklıdır. Örneğin mobile expire süresi, web’e oranla 2 saat fazladır.
- Eğer Expire süresi geçilmemiş ise bir sonraki kurala (CheckRefreshToken)’a geçilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
public class CheckTokenExpireRule : Rule, IRule { public CheckTokenExpireRule() { NextRule = new CheckRefreshToken(); } public Platform Platform { get; set; } public bool Run(RequestModel model) { switch (Platform) { case Platform.Gsm: { NextRule.Platform = Platform.Gsm; if (model.TokenExpireDate < FakeData.TokenExpireDate.AddHours(2)) { return true; } return false; } case Platform.Web: { NextRule.Platform = Platform.Web; if (model.TokenExpireDate < FakeData.TokenExpireDate) { return true; } return false; } default: { return false; } } } } |
4-) CheckRefreshToken: Bundan sonra işletilecek bir kural yoktur. Bu, kurallar serisinin sonuncusu, yani kuyruğudur. İster koşul sağlansın ister sağlanmasın, geriye her zaman “200 OK” mesajı dönülecektir. Burada esas amaç, Token süresi bitmeye yakın ise yenilenmesidir.
- “NextRule = null“: Bundan sonra, işletilecek başka bir kural yoktur.
- “if ((FakeData.TokenExpireDate.AddHours(2) – model.TokenExpireDate).TotalMinutes < 45)“: Mobil platform’da ,Token’ın expire olma süresi 45 dakikadan az mı sorgulaması yapılır.
- “if (FakeData.RefreshToken == model.RefreshToken)“: Gönderilen Refresh Token’ın geçerli olup olmadığına bakılır. Çünkü Token, Refresh Token olmadan yenilenmemektedir.
Gerçek hayatta RefreshToken neden Token’ın yenilenmesi için gerekir ? Çünkü Token her request’de kullanılır. Yani yakalanması, sniff edilmesi çok daha yüksek bir ihtimaldir. Ama RefreshToken her saat başı sadece 1 kere kullanılmakta ve yakalanması çok daha zordur. Böylece Token’ın çalındığı bir durumda, ilgili kişi RefreshToken’ı bilmediği için, en fazla 1 saat ilgili Token ile işlem yapabilecektir.
- “FakeData.Token = “167948364512”; FakeData.RefreshToken = “73468317973648“”: İlgili koşullar sağlanmış ise Token, RefreshToken ve TokenExpireDate dummy olarak senaryo icabı yenilenir.
- Tüm benzer koşullar Web ortamı için de geçerlidir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
public class CheckRefreshToken : Rule, IRule { public Platform Platform { get; set; } public CheckRefreshToken() { NextRule = null; } public bool Run(RequestModel model) { switch (Platform) { case Platform.Gsm: { if ((FakeData.TokenExpireDate.AddHours(2) - model.TokenExpireDate).TotalMinutes < 45) { if (FakeData.RefreshToken == model.RefreshToken) { FakeData.Token = "167948364512"; FakeData.RefreshToken = "73468317973648"; FakeData.TokenExpireDate = new DateTime(2022, 9, 22, 5, 50, 0); } } break; } case Platform.Web: { if ((FakeData.TokenExpireDate - model.TokenExpireDate).TotalMinutes < 45) { if (FakeData.RefreshToken == model.RefreshToken) { FakeData.Token = "12345678912"; FakeData.RefreshToken = "98765432219842"; FakeData.TokenExpireDate = new DateTime(2022, 9, 22, 5, 50, 0); } } break; } default: { break; } } return true; } } |
Program.cs: Request Model’e göre tüm kuralların işletildiği methoddur.
- Request çekilen fake model.
- İlk Rule olarak “CheckPlatformRule” atanmıştır. “While” ile en son Rule’a kadar gidilecektir. Son kural sınıfı haricinde, tüm kural sınıflarının “NextRule” property’si vardır ve doludur.
- “Run()” Tüm Rule sınıflarında ortak çalıştırılacak methodudur. “IRule” interface’inden gelmektedir.
- “firstRule = firstRule.NextRule“: “Run()” methodundan başarılı ile geçildi ise, bir sonraki Rule sınıfının çalıştırılması için, firstRule değişkenine NextRule property’si atanır.
- “Run()” sınıfından false cevabı döner ise, “Unauthorized 401” hatası konsol’a basılır.
- “while” döngüsünden çıkıldıktan sonra, son Rule sınıfı(Tail Rule) çalıştırılmamış olur. O da aşağıdaki gibi while’dan sonra ayrıca çalıştırılır. Burada tek fark, diğer rullar geçilmiş ise, yani buraya kadar başarı ile gelinmiş ise her halükarda konsol’a “Authorized 200 OK!” mesajı basılır.Çünkü bu son adımda amaç bir geçerlilik kontrolü değil, tokenların değiştirilme zamanının gelip gelmediğine bakılması ve ona göre aksiyon alınmasıdır.
- Token, RefreshToken ve TokenExpireDate değerleri değişmiş mi diye, uygulama sonunda ekrana basılır.
Program.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
static void Main(string[] args) { RequestModel model = new(); model.IMEI = "111222333444555"; model.RefreshToken = "98349785389732"; model.UserID = 1; model.TokenExpireDate = DateTime.Now; IRule firstRule = new CheckPlatformRule(); while (firstRule.NextRule != null) { if (firstRule.Run(model)) { firstRule = firstRule.NextRule; } else { Console.WriteLine("Unauthorized 401!"); break; } } //Working Tail Rule Process if (firstRule.NextRule == null) { if (firstRule.Run(model)) { Console.WriteLine("Authorized 200 OK!"); } else { Console.WriteLine("Unauthorized 401!"); } } Console.WriteLine("".PadRight(60,'*')); Console.WriteLine($"Token: {FakeData.Token}"); Console.WriteLine($"RefreshToken: {FakeData.RefreshToken}"); Console.WriteLine($"Token Expire Date: ${FakeData.TokenExpireDate}"); } |
Aşağıda görüldüğü gibi tüm Rulelar, “if else” kullanılmadan sıra ile kendinden bir sonrakini çağrarak çalıştırılmışdır “Run()“. Eğer son koşul hariç, tüm koşullardan geçilir ise, “200 OK” sonucu konsola yazdırılır. Sonuncusu hariç diğer üç rule’dan bir tanesinden bile olumsuz yanıt alınır ise, “401 Unauthoriezed” yetki hatası ekrana basılır.
Geldik bir makalenin daha sonuna. Bu makale, aslında twitter’da yukarıda sorulan bir soru üzerine yazılmıştır.
Yukarıdaki soru, günümüzde DDD, Micorservisler, Cloud, Devops, gRPC, ElasticSearch, Kafka, Redis, CQRS gibi daha birçok teknolojinin konuşulduğu, hararetli tartışmaların havada uçuştuğu bir dönemde, ne kadar da masum ve içten bir soru değil mi ? :) 50 tane “if”‘i iç içe koymak yerine nasıl bir yol izlemeliyiz ? Bu kadar complex işler ile uğraşırken, bazen çok temel konuları atlayabiliyoruz. Bu benim aklıma hemen, belki de hayatın dayatması yüzünden yatayda bu kadar büyümek yerine, dikeyede daha mı çok vakit geçirsek sorusunu getirdi.
Bu yazımızda, complex koşulların topluca bakıldığı bir yapıda, tek bir sınıfa bütün görevlerin verilip 10larca “if-else” ile tüm koşulların doğrulanması yerine:
- Öncelikle büyük resmi görmek adına, bir akış diyagramının çizilmesi gerektiğini.
- Kontrol edilecek koşulların senaryosuna göre farklı sınıflara atanarak, “Single Responsibility“‘nin sağlanmasını.
- “Strategy Design Pattern” ile Rule sınıflarının nasıl ortak bir “Run()” methodu ile tek bir sınıfmış gibi “IRule” interface’inden türetilebileceğini.
- İleri yönlü bir akışda, “Chain of Responsibility Pattern” kullanılarak, her bir Rule sınıfının “NextRule” propertysi ile bir sonraki Rule’u nasıl çağırdığını ve bu sayede iç içe 10larca “if-else”‘den nasıl kurtulunabileceğini gördük.
?İlerde yeni bir Rule sınıfı geldiği zaman tek yapılması gereken, ilgili yeni kuralın “IRule” interface’i ve “Rule” sınıfından türetilip, “Run()” methodunu override edilmesidir. Sonradan bu akışta, ilgili Rule nereye konulacak ise, “NextRule” propertysine bir sonraki çalışacak Rule ve kendinden önceki Rule sınıfının NextRule property’sine de, kendisi atanarak kolayca injection yapılabilir ?
Yeni bir makalede görüşmek üzere hepinize hoşçakalın..
Source Codes: https://github.com/borakasmer/SequentialRules
Aklınıza, Emeğinize sağlık, oldukça yol gösterici, temiz kod. Her yiğidin bir yoğurt yiyişi vardır naçizane bir alternatif sunmak isterim: main metod içerisindeki while döngüsünü yazmak yerine, her kural kendi içinde run ederken bir sonraki kuralın run metodunu çağırabilir.
Flow analizi ve pattern implementasyonu konusunda gerçekten ufuk açıcı bir yazı olmuş ellerinize sağlık.
Lakin bu örnekte clean code’u anlatırken memory cost kısmına da değinmek gerekir. Özellikle chain of responsiblity impelementasyonlarında farkında olmadan gerekiz memory allocation’a sebebiyet verebiliyor.
Açıkcası ben yukarıda ki implementasyon yerine birden fazla condition’ı daha mantıklı buluyorum. Complexity de daha karmaşık geldi bana.
Teşekkürler.
Selam projeyi forklayip kendimce yorumladim. Mevcut akis diagrami uzerinden gidecek olursak strategy pattern tek basina yeterli olabilecegini dusunuyorum. Chain of responsibility uygularken birden fazla akisi ayni anda validate etmek zorunda kaliyoruz ve single responsiblity biraz zorluyormusuz hissiyatina kapildim ve yeni bir validasyon eklemek istedigimizde condition’larin arasina eklemenin cok kolay olacagini dusunmuyorum ama her bir strategy’nin execute functioninda prosedurel programlama gibi alt alt validasyonlari yapmanin daha okunabilir olacaginin kanisindayim.
Emeginize saglik makale icin ciddi efor sarfedildigi belli.
Merhaba Bora hocam , makale ile ilgisi yok ama bir şey sormak istiyorum ,asp.net core bir site aidat takip otomasyonu geliştirmeye çalışıyorum . ama aklımı karıştıran bir sorun var . Mvc ile mi devam etmeliyim yoksa en baştan reactjs öğrenip bu şekilde mi devam etmeliyim ? Mvc ile kod ortamında daha rahat çalışıyorum bir çok kütüphaneye aşinayım , react js gözlerimi kanatıyor =) Teşekkür ederim
Selam Yunus. bence projeni mvc ile tamamla. React’ı kısa sürede öğrenip uygulama zor olur. Naçizane tavsiyem bildiğin konuda ilerle ve pratik yap, React’ı öğrenmeye de başla ama kendine fazla yüklenme, zamana yay öğrenirkende tam öğren.