Azure üzerinde Redis ve SignalR ile Tüm Clientlar İçin Data Consistency Sağlamak

Selamlar,

Giriş:

 

Bu makalede, yukarıdaki giriş videosunda da bahsedildiği gibi farklı bir çok teknoloji kullanılacaktır. Senaryo olarak, canlı maç scorelarının gösterileceği Html5, Mvc bir banner sayfa yapılacaktır. Score bilgileri RealTime Socket ile tüm clientlara anlık olarak gösterilecektir. Bu makalede yaklaşım olarak Cache First kullanılacaktır. Önce değişen maç score’u bilgisi SignalR ile Cache’de yani Redis’de güncellenecek, sonra tüm clientlara push edilecektir. Bir sonraki aşama olarak yazılmış olan Console Microservis bir application ile güncellenen maç score’u bilgisi SQL DB’de de asenkron olarak güncellenecektir. Sayfaya gelen tüm clientlar, gösterilecek olan datayı performans amaçlı bir DB’den değil de Redis cache’den alacaklardır.

Azure üzerinde kurulan bir Distributed In-Memory Redis Cache yapısını, yine yazacağımız Mvc bir web projesinin önyüzdeki datalar için kullanacağız. Redis ile ilgili daha detaylı bilgiye buradaki makalemden erişebilirsiniz. Redisin, Azure Cloude üzerinden kullanılmasından dolayı birçok felaket seneryasunun ve anlık aşırı yük durumlarında yapılması gereken otomatik scale işlemlerinin önüne geçilmesi ve bütün bu işlerin Azure tarafından otomotize edilmesi sağlanmıştır. Ayrıca tüm monitor işlemleri de yine cloude üzerinden yapılabilmektedir.

Uygulamalı Örnek Anlatım:

Part 1 :Azure Redis

İlk olarak gelin Azure üzerinde Redis’i kuralım:

1-) Database=> sekmesinden Redis Cache seçilir.

2-) Karşımıza gelen ekranda aşağıda görüldüğü gibi ayarlar yapılır.

3-) Oluşturulan redis configurasyon ekranı aşağıdaki gibidir.

4-) Test amaçlı Redis Console’a gelindiğinde “Ping” komutu yazıldığında “Pong” cevabı alınmalıdır. Yine test amaçlı “blog” key’i ne ait value olarak “www.borakasmer.com” aşağıdaki gibi redise atılmış ve tekrar “blog” key’i ile çekilmiştir.

Projede kullanılan redis versiyonu: “INFO” komutu kullanılarak aşağıdaki gibi öğrenilebilir. Bu proje için “3.2.7” versiyonu kullanılmıştır.

Şimdi gelin öncelikle clientlara gösterilecek sayfayı tasarlayalım.  Bu sayfa, aslında bir banner sayfası olacaktır. Sözde oynanan 4 karşılaşmanın maç scoreları, real time olarak konulduğu sayfalarda gösterilecektir. Bu makalede performans arttırma amaçlı her bir takımın bayrağının ayrı ayrı yüklenmesi yerine, takımların bayraklarının tek bir resimde toplanması ve  “Css Sprite” kullanılarak ilgili image üzerinden belli kordinatlar yardımı ile istenen takıma ait bayrak bilgisinin ekrana basılması sağlanmıştır. Böylece performansda büyük bir artış sağlamıştır.

Ben “Css Sprite” kodlarını çıkarmak için online uygulama http://www.spritecow.com/ ‘dan faydalandım. İlgili resim upload edilip, her bir bayrak seçilerek, ilgili Css Sprit code’u alınabilir. Örnek ekran görüntüsü yukarıdaki gibidir.

flag.css: Aşağıda yukarıda görülen tek bir resme ait 8 bayrak Sprite Css‘leri tanımlanmıştır. Top,Left kordinatları ve genişlik ile yükseklik değerleri her bir kart için tanımlanmıştır. Ayrıca isimlendirmeler de ilgili takımın adına göre verilmiştir.

Şimdi gelin hep beraber bir Mvc Empty Wep projesi yaratalım.

HomeController.cs: Aşağıda görüldüğü gibi  maçlar için ilgili dummy data “FillData()” methodu ile doldurulmuştur. Ve ilgili çekilen model “Index” view’a gönderilmiştir.

Index.cshtm ekran görüntüsü:

Index.cshtml:  Yukarıda görüldüğü gibi girilen dummy datalara göre takımlar, gönderilem “LiveScore.Models” içinde dönülerek ve css sprite kullanılarak bayrakları ve scoreları ile birlikte ekrana Razor ViewEngine ile basılmıştır. Aşağıda görüldüğü gibi tüm basılan score bilgisi “span” arasında ve “@data.Team1Flag” ve “@data.Team2Flag” id’leri ile basılmıştır. Ayrıca her takım bilgisinin basıldığı table row “td” id değeri “id=”tbl@(data.Team1Flag)” şeklinde tanımlanmıştır. Amaç ilerde ilgili alanlara erişebilmektir.

Sıra geldi projemize redis’i eklemeye. Öncelikle Nuget’den aşağıdaki paket indirilir. Bu projede redis için ServiceStack kullanılacaktır.

Öncelikle oluşturulan datalar Redis’e atılır. Aşağıda görüldüğü gibi “SaveRedis()” methodu “List of MatchData” tipinde bir değişken beklemektedir. Aşağıda Azure’da oluşturulan, Redis’e ait connection tanımlanmış ve bağlanılmıştır.

  1.  client.SearchKeys(“flag*”) ==> Redis’e flag ile başlıyan önceden kayıt eklenmiş mi diye kontrol edilir.
  2. MatchData data in datas ==> Gelen tüm kayıtlar tek tek gezilir.
  3. var matchClient = client.As<MatchData>() ==> Redis’e atılacak data tipi ile tanımlanır.
  4. matchClient.SetValue(“flag” + data.ID, data) ==> “flag” kelimesi ve gelen maç bilgisine ait “ID” değeri ile bir key oluşturulur ve MatchData bilgisi bu key’e value olarak atanır. Başında “flag” kelimisinin bulunma amacı ilgili keyleri redisde guruplamaktır. Yani istendiğinde “flag” kelimesi içeren tüm keyler toplu olarak çekilebilecektir.

Not : Redis’de “Key – Value” haricinde yukarıda görüldüğü gibi saklanabilecek farklı birçok data tip vardır. Mesela aklınıza neden bir List tipinde flagleri saklamadığımız gelebilir. Nedeni : İlerde maç scoreları değişecektir. Ve redis’de List tipinde bir datayı Update etmek epey maliyetlidir. Çünkü dataya key bilgisi ile direkt erişilemediği için, tüm list bilgisi çekilip ID’ye göre filter yapılması gerekmektedir. Uzun lafın kısası, bu seneryado key value tipinde data tutmak sonradan Update edilecek data tipi için daha performanslı durmaktadır.

Azure Redis Console’da görünen tüm keyler:

Sayfaya ilk gelindiğinde ilgili dummy kaydı Redis’e atılır. Redis’e atılan keyler, yukarıda görüldüğü gibi Azure tarafında “Redis Console’dan” “keys *” komutu ile listelenebilir. Sonradan gelen clientlar için ilgili kayıtlar Redis’de varmı diye  kontrol eden method aşağıdaki gibidir:

Sonradan gelen clientlar için ilgili data, aşağıda görüldüğü gibi performans amaçlı Redis üzerinden çekilmektedir.

  1. List<string> allKeys = client.SearchKeys(“flag*”) ==> Önceden de bahsedildiği gibi “flag” kelimesi ile guruplanan keyler, Liste olarak çekilir.
  2. foreach(string key in allKeys) ==> ilgili tüm keyler gezilir.
  3. dataList.Add(client.Get<MatchData>(key)) ==> Çekilen her bir key’e karşılık gelen “MatchData“‘ değeri “dataList“‘e eklenip geri dönülür.

Azure Redis Monitor Ekranı:

HomeController/Index(): Son hali aşağıdaki gibidir. Öncelikle Redis’de herhangi bir “flag” keyword’lü bir kayıt var mı diye kontrol edilir.(hasDataOnRedis()) Var ise “getDataFromRedis()” methodu ile ilgili kayıtlar çekilerek Index View’a model olarak gönderilir. Yok ise “FillData()” ile yaratılan dummy datalar Redis’e atanır. Böylece Redis Azure üzerinden çağrılan Distributed Cache olarak projemize dahil edilmiş olunur. Sonuçta büyük yükler altında, gelen her bir client için DB’ye gitmek ya da ilgili datayı, Local Sunuculardaki Session, Memory Cache gibi yapılarda saklamak yerine Cloude’da ve auto scalable şeklinde korunmuş olunur. Bu da bizi performans ve hazır kullanılabilir monitoring toollara kolayca erişim imkanı sağlar.

Gelin şimdi bir de bu ekranın admin sayfasını kodlayalım.

Admin(): Aşağıda görüldüğü gibi aynı Index sayfasında olduğu gibi ilgili data redisde yok ise, önce yaratılıp sonra çekilir.

Örnek Admin.cshtml sayfası:

Admin.cshtml: Yukarıda görüldüğü gibi takımlara ait scoreların güncellenmesi için input alanlar ve sonucun gönderilemsi için bir gönder buttonu konulmuştur. Input alanların id değeri css sprite ile gelen bayrak css’ini başına “txt” konarak ==> “txt@(data.Team1Flag)” tanımlanmıştır. Not: İlerde bu sayfa değişecektir.

 PART 2: SignalR Data Consistency 

Şimdi sıra geldi SignalR’ı projemize dahil edip, ilgili maç scoreları değiştiği zaman, bunu tüm clientlara bildirmeye. SignalR ile alakalı daha detaylı bilgiye buradaki makalemden erişebilirsiniz. Öncelikle aşağıdaki paket nugetden indirilir.

App_Start/Startup.cs: SignalR paketi indirilince, karşımıza çıkan readme.txt’de de belirtildiği gibi ilgili startup sınıfı aşağıdaki gibi oluşturulur.

Admin.cshtml : Admin sayfasına SignalR eklendikten sonraki son hali aşağıdaki gibidir.

  1. Amaç sadece değişen score bilgisi olan input alanları “id” ve “value” değerleri ile SignalR Hub methoduna gönderip, tüm clientlara push etmektir.
  2. İlgili script dosyaları (signalr/hub) => magic script ve “jquery.signalR” sayfanın başında tanımlanır. “jquery-3.1.1” dosyası sayfanın en başında tanımlanmalıdır. Bootstrap ve SignalR bu dosyayı kullanmaktadır.
  3. “var hubProxy = $.connection.match” i=> Backend’de tanımlanacak Match hub classına bağlanılır.
  4. UpdateData()” function’ı => Match:Hub sınıfının “NotifyClients()” methoduna sadece değişen datalar [updatedFlags] gönderilir.
  5. $(‘[id^=txt]’).attr(“isChange”, false)” Daha sonra değişti olarak işaretlenen tüm “txt” ile başlıyan input alanlar tekrardan eski haline yani değişmedi haline getirilir. Böylece yeni bir kayıt değiştiğinde yani maç score’u, eski değişen kayıtlar için tekrardan farklı bir işlem yapılmaz.
  6. MarkedChangeText(id)” => Bu function aslında input alanlar değiştiği zaman çağrılan functiondır. Amaç sadece değişen inputları üç property ile “ID“,”Score“,”No” ile =>”updatedFlags[]”  dizisine atamaktır. ID css değeri yani css sprite ile atanmış bayrak bilgisidir, Score maç score’u ve No ise kaçıncı kayıt yani redis’de karşılığı olan, tek bir satırda 2 takım bilgisinin tutulduğu tabiri caizse maçın ID’sini gösteren kolonlardır.
  7. onchange=”MarkedChangeText(‘txt@(data.Team2Flag)’)”” => Input alanların değiştiğini “onchange” eventi ile algılayıp değişen txt input’un ID’si “MarkedChangeText()” function’ına gönderilmiştir.

Not: Admin sayfasında değişen datayı yakalamaya benzer durumları, iş hayatında nasıl yanlış yaklaşıldığına çokça şahit oldum. Ya tüm data gönderiliyor ve sonradan backend tarafında sadece değişenler algoritma ile ayrılıyordu. Ya da  datanın tamamı tekrardan gönderiliyordu. Bu socket teknolojisinde, gönderilen paketin size’ının yanlış codelama yüzünden artması affedilecek bir durum değildir. Aynı anda 1 milyon kişiye değişen maç sonuçları yerine tüm datanın göndermesi çok büyük bir maliyet ve zaman(delay) kaybıdır. Bu örnekde aynı modern javascript frameworklernin yaptığı gibi dinleme yerine, değişen input elementinin kendisini marked yani değişti diye işaretlemesi, esas dikkat edilmesi gereken konudur. Daha sonra gönder buttonuna basıldığında sadece değişen datalar, bir dizide toplanıp gönderilmekte ve işaretlenen tüm input alanlar tekrardan unmarked yani ilk haline döndürülmektedir. Sırf bu algoritmanın düzgünce anlaşılması için modern javascript frameworkleri(angular,react,vue gibi..) yerine Jquery kullanılmıştır. 

HomeController.cs(Match):  Öncelikle gelen dataya göre redis güncellenmiştir. Gelen “NotifyData” listesi içinde gezilerek

  1. (“flag” + dt.No)” ==> ilgili keye ait “MatchData” bilgisi alınır.
  2. changeData.Team1Flag.Contains(dt.ID.Replace(“txt”, “”))” ==> NotifyData içindeki CssSprite’a göre, bulunan “MatchData” içinde hangi takımın Score’unun güncelleneceği belirlenmiştir. Not: Bu işlem sırasında Cssler içinde “txt” geçmediğinden gelen “ID” değeri içinde “txt” değeri temizlenmiştir.
  3.  “client.Set<MatchData>(“flag” + dt.No,changeData)” İ==> İlgili takımın Team1Score veya Team2Score’nin güncel yeni değeri atanıp redis’e ilgili key ile setlenir. Böylece Redis’de güncellenmiş ve bir sonra gelen Client’a en güncel doğru bilgi redis üzerinden gösterilmiş olunur.
  4. NotifyData sınıfı, en altta MatchData sınıfına göre çok daha lightweight bir şekilde tanımlanmıştır. ihtiyaç duyulmuyan kolonlar çıkarılmıştır. Amaç tüm clientlara gönderilen paketin boyutunun azaltılmasıdır.

Aşağıda görüldüğü gibi ilgili “Match” Hub class’ında asenkron olarak NotifyClients() adlı bir method tanımlanmıştır. Parametre olarak “List of NotifyData” beklemektedir. İlgili data, bağlı olan tüm clientlardaki “updateScore()” methodu tetiklenerek gönderilmektedir. Amaç Data Consistency’i sağlamak ve değişen data bilgisini tüm clientlara bildirmektir.

Index.cshtml: Aşağıda görüldüğü gibi aynı adminde olduğu gibi “Match:Hub” sınıfına bağlanılmıştır.

” hubProxy.client.updateScore()”: Öncelikle “$(‘[id^=txt]’)” id’si txt ile başlıyan tüm nesnelerin arkası beyaz renge atanır. Bir çeşit sıfırlama. Yani önceden değişen mavi renkli olan alanlar var ise temizlenir. Daha sonra “List<NotifyData>” tipinde alınan “datas” parametresi “for” ile gezilerek, ilgili id’ye ait “datas[i].ID” maç score’u bulunup, yeni maç sonucu “datas[i].Score” “window.setTimeout()” ile 4sn beklendikten sonra atanır ve değişen textlerin arka rengi animasyonla “fadeIn(2000)” mavi yapılır. Böylece “Data Consistency” tüm clientlar için socket kullanılarak sağlanmış olunur.

Part 3: MicroService üzerinde Redis ile Sql arasında Pub/Sub ile Migration Oluşturma

Bu zamana kadar, data yani maç sonuçları güncellendiği zaman, Redis de güncellenmiş ve daha sonra bu yeni data ekranı açık yani connect olan tüm clientlara real time olarak SignalR ile Pushlanmıştır. Şimdi sıra geldi, var olan sistemden bağımsız olarak sadece değişen datayı Sql üzerinde güncellemeye. Öncelikle gelin isterseniz Databasede tutulacak bu veri için, uygun bir DB oluşturalım: Aşağıda görüldüğü gibi MsSql üzerinde “LiveScore” adında bir DB ve aynı isimde bir tablo oluşturulmuştur.

Local’de yaratılan DB’ye manuel olarak aşağıdaki datalar girilir. Aynı data Redis’e de en başta dummy data olarak manuel atılmıştır. Tek fark MsSql’e her takım bilgisi tek bir kayıt olarak atanırken, Redis’e yapılan maçlar yani her bir satıra 2 takım bilgisi (flag,name,score) girilerek tek bir “ID” ile atanmıştır.

Örnek Redis Kaydı: “new MatchData(){ID=1,Team1Flag=”spritesFB”, Team1Name=”FENERBAHÇE”,Team2Flag=”spritesGS”,Team2Name=”GALATASARAY”,Team1Score=0,Team2Score=0 }

Solution’a aşağıdaki gibi bir DAL projesi eklenir. Bu projede DatabaseFirst kullanılmıştır. Yaratılan DB’ye göre ilgili DBContext ve Pocolar create edilir.

DAL/LiveScoreContext.cs: İlgili DBContext aşağıdaki gibidir.

DAL/LiveScore.cs: İlgili maç sonucu dataları, aşağıda görülen “liveScore” sınıfında tutulacaktır.

SqlSyc/Program.cs: Var olan sistemden yani CanlıScore’un yayımlandığı sunucunun dışındaki bir yapıda DB migration’ın yapılması, başta da söylendiği gibi performans ve var olan sisteme dokunulmadan istenen değişikliğin rahatça yapılabilmesine olanak sağlamıştır. Ayrıca genişletilebilir bir yapı sunması, unit test ve hata kontrolündeki kolaylıklar bize sunduğu diğer ayrıcalıklardır. Bu türk yapılara en güzel çözüm son zamanlarda MicroServisler olarak gözükmektedir.

Microservislerin hiç mi eksik bir yanı yok: Olmaz mı? MicroServiceslerin tek bir görevi olduğundan dolay, bunların sayısındaki artış yönetilebilirliği ciddi oranda düşürmektedir.

Azure Cloude Service Projesi Yaratılma Adımları:

Aşağıda görüldüğü gibi bu makale için, bir console application oluşturulmuştur. Aynı işi yapacak ama Cloude yani Azure’da çalışacak bir proje için yukarıda görüldüğü gibi”Azure Cloude Service” projesi de oluşturulabilirdi. Ama bu örnekde console application kullanılmıştır. Dikkat edilmesi gereken bir konuda ilgili Cloude Service’e atanacak Role’ün, yukarıda görüldüğü gibi bir “Worker Role” olmasıdır.

Main():

  1. using (IRedisClient client = new RedisClient(conf))“==> Azure üzerindeki redis’e bağlanılmıştır.
  2. using (sub = client.CreateSubscription())“==> Subscription oluşturulmuştur.
  3. sub.OnMessage += (channel, message) =>” ==> “OnMessage” ile ilerde belirtilicek kanal dinlenmeye başlanmıştır
  4. İlgili kanaldan herhangi bir message yani “NotfiyData” geldiği zaman gelen json string data “Newtonsoft” ile igili sınıfa deserialize edilir.
  5.  “UpdateData(data)“==> Gelen List Data içinde gezilerek her bir güncel score bilgisi SqlDb’ye kaydedilir.
  6. sub.SubscribeToChannels(new string[] { “SqlSync” })” ==> MicroServices tarafında dinlenecek kanal ismi “SqlSync” olarak belirlenmiştir.

UpdateData():

  1. CodeFirst yaklaşımı ile sadece score’u değişen ve database’de var olan takımın kaydı “CSS” değeri ile yani bayrak bilgisi ile bulunup, yeni score bilgisi ile güncellenmiştir. Böylece ilk etapta In-Memory olarak redis üzerinde güncellenen kayıt bilgisi, SqlDb ile de match edilmiştir.

DataModel: SignalR Hub Class’ından gönderilen, değişen maç score’u json string data şablonu, aşaığda tanımlanan “NotifyData” sınıfına karşılık gelmektedir.

MicrosService Gönderme Pub/Sub: Son olarak score’u değişen takımların bilgisinin SqlDB’de de değiştirilmesi için, SignalR Hub Match sınıfındaki “NotifyClients()” methodundan,  var olan sistemden bağımsız çalışan (ConsoleApp)Microservise aşağıdaki gibi NewtonSoft ile json string’e Serialize edilip gönderilir.

HomeController(Match:Hub): SignalR Hub sınıfının tamamı aşağıdaki gibidir.

  1. List<NotifyData> data“==> Admin.cshtml’den gönderilen sadece değişen data bilgisi.
  2. using (IRedisClient client = new RedisClient(conf))“==>Redis microservis’e değişen data bilgisinin gönderilebilmesi için, Azure üzerindeki redis’e bağlanılır.
  3. MatchData changeData = client.Get<MatchData>(“flag” + dt.No)”=> Her bir değişen kayıt gezilerek, redis’de karşılığı olan ilgili keye (“flag1”) ait data yani value çekilerek changeData değişkenine atanır.
  4. Önemli :if(changeData.Team1Flag.Contains(dt.ID.Replace(“txt”, “”)))“: Burada amaç hatırlarsanız redisde kayıtlar takım bazında değil maç bazında tutulmakta idi. Yani iki takıma ait data tek bir satırda saklanmaktadır. İşte bu nedenden dolayı değişen score bilgisinin hangi takıma ait olduğunun belirlenebilmesi için, çekilen data içinde takıma ait bayrak bilgisinin, “Team1Flag” ve “Team2Flag” alanlarındaki değeler karşılaştırılarak bulunması sağlanmıştır. “txt” ekinin temizlenmesindeki neden, Admin.cshtml sayfasında ilgili input alanların css bilgisinin başına ekstradan “txt” ekinin konmasıdır. Örnek: “id=”txt@(data.Team1Flag)”“.
  5. Hangi takımın score bilgisi güncellenecek ise ==>” changeData.Team1Score = dt.Score” şeklinde güncellenen score, Redis tarafına ==>”client.Set<MatchData>(“flag” + dt.No,changeData)” şeklinde atanır. Yani başta çekilen “Matchdata”‘sının hangi takım adına güncellenildiği bulunup, değiştirilmiş ve tekrardan redise atanması sağlanmıştır.
  6. client.PublishMessage(“SqlSync“, jsonData)” ==> Redis’de değişen data Sql’de de sistemden bağımsız olarak değişmesi için, “Pub/Sub” ile MicroServis olan Console Application’a gönderilir.
  7. await Clients.Others.updateScore(data)” ==> Son olarak sadece değişen data, diğer connect olan tüm clientların “PublishMessage()” function’ına signalR ile push edilir.

Böylece üçtan uca uzun bir projeyi 3 adımda kodladık. Gelen clientları In-Memory bir yapı ile karşılamak belki yeni birşey değil. Ama değişen datayı önce In-Memory’de değiştirip, tüm clientlara SignalR ile Socket teknolojisi kullanarak push etmek ve sonra asenkron olarak SqlDB’yi bir MicroServices ile güncellemek kesinlikle standart bir yaklaşım değil :) Performans anlamında kazanç büyük olmasının yanında, kıritik olmayan veriler için kesinlikle tavsiye edilebilir. Hayati önem taşıyan, özellikle banka işlemleri için kullanılması pek de doğru değildir. Örneğin bir alışveriş sitesinde sepete ekleme adımların için biçilmiş kaftandır rahatlıkla denilebilir.

Cloude’ın hayatımıza girişi ile aşırı yükler durumunda auto scale olması, Redis, SignalR, RabbitMQ gibi teknolojilerin tek bir slider’ın sağ çekilmesi ile scale olması, biz developerlar için bulunmaz bir nimettir. “Serverless” mantığı sadece rahatlık değil aynı zamanda bir güvenlik timsalidir. In-Memory Cache olarak Redis, inanılmaz özellikleri, hızlı ve kararlı çalışması ile günümüzün parlayan yıldızıdır.

Bu makalede karşımıza çıkan farklı sorunlara, tek bir teknoloji ile değil de, benim kendimce harmanladığı birkaç teknoloji katarı ile çözüm aradık. Bu katarlar birleşerek büyük bir tireni oluşturdu. Ve üzerine binen koca “milyon requestlik” yükü tek başına taşımaya çalıştı. Umarım siz de benim kadar bu yazıdan zevk alırsınız. Geldik bir maklenin daha sonun :) Yeni bir makalede görüşmek üzere Hoşçakalın, Esen kalın.

Source Code : https://github.com/borakasmer/livescore

Herkes Görsün:

Bunlar da hoşunuza gidebilir...

4 Cevaplar

  1. Kerim dedi ki:

    Çok başarılı teşekkürler.

  2. Samet dedi ki:

    Hocam elinize sağlık, çok yararlı bir makale olmuş. Ufuk açıcı

Bir Cevap Yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir