Bir İçeriğin Görüntülenme Sayısını Redis, Signalr ve Bulk DB İşlemleri ile Bulma ve Anlık Raporlama
Selamlar,
Geçenlerde code review yaparken, her kayıt için DB’ye gidildiğini ve büyük bir performans kaybı yaşandığını gördüm. Ben de bu konuya eğlenceli ve farklı teknolojileri bir araya getirerek makaleleştirme kararı aldım. Ayrıca yeni neslin Sql üzerinde bussines çözümlerinden mümkün olduğunca kaçınıp herşeyi code ile çözmeye çalışması beni üzdü. Bunun üzerine bu makalede, peformans arttırma amaçlı Sql procedurelerden nasıl faydalanabileceğimize dair gerçekçi bir örnek yazmak istedim.
Seneryo: Bu makalede farklı teknolojiler kullanılarak, belirli bir portalda ilgili içeriğin impression yani, kullanıcı tarafından görüntülenme sayısı bulunacaktır. Kısaca amaç, son 1 dakika içerisinde okunan ya da izlenen içeriklerden, en yüksek impressiondan en azına doğru anlık bir raporun oluşturulmasıdır. Böylece ilgili içeriklerin önem sırasına göre portaldaki yeri değiştirilebilecek ve bunun sonucu olarak kullanıcı deneyimi daha da arttırılacaktır.
Kullanılacak Teknolojiler:
- Redis ilgili son 1 dakkalık içerik sayılacaktır. Yani ilgili içerik yok ise redis’e atılıp ilk değeri 1 verilecek, ya da var ise ilgili içerik bulunup değeri 1 arttırılacaktır. Yoğun trafikte en çok yük bu yapının üstünde olacağından Redis tercih edilmiştir.
- Dapper ile son 1 dakika tamamlandığında, ilgili data redisten çekilip bir DB’ye yazılacaktır. Yine performans amaçlı Dapper tercih edilmiştir.
- SignalR ile ilgili son 1 dakikalık data, monitor işlemi yapan admin ekranlarına real time olarak push edilecektir.
- AngularJS signalR ile gönderilen data front tarafda angularJs bir modele doldurulup, değişen datanın ayrıca bir kod yazılmadan ekrana basılması sağalanacaktır.
Şimdi gelin öncelikle Modellerimizi oluşturalım:
CounterItem: Herhangi bir içeriğe tıklandığında DB’ye yazılacak ilgili data aşağıdaki gibidir.
CounterViewItem: Front tarafda ilgili içeriğe ait gösterilecek data aşağıdaki gibidir. Yani bir çeşit view modeldir.
- ContentID: İlgili içeriğin ID’sidir.
- ViewCount: 1 dakikalık görüntülenme sayısıdır.
- PlatformID: Görüntülendiği ortamdır. Web mi yoksa Mobile mi gibi.
- PageNumber : Özellikle PhotoGallery’de görüntülenen resmi temsil etmektedir.
- ContentTypeID: İçerik tipidir. Makale mi, Resim mi yoksa Video mu.
- TotalViewCount: En başından beri sayılan toplam görüntülenme sayısıdır.
- Title: İlgili içeriğe ait başlık.
- Url: İlgili içeriğe ulaşılacak adresdir. Kısaca title’a tıklanıldığında gidilecek yoldur.
- ViewsDate: En son alınan 1 dakikalık zamanı gösterir.
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 |
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Counter.Model { public class CounterItem { public long Id { get; set; } public long ContentId { get; set; } public int ViewCount { get; set; } public int PlatformId { get; set; } public int PageNumber { get; set; } public int ContentTypeID { get; set; } public DateTime ViewsDate { get; set; } } public class CounterViewItem { public long ContentId { get; set; } public string Title { get; set; } public int ViewCount { get; set; } public int ContentTypeID { get; set; } public int TotalViewCount { get; set; } public string Url { get; set; } } } |
Enum ContentType:
1 2 3 4 5 6 |
enum ContentType { Article= 1, PhotoGallery = 2, Video = 3 }; |
Enum PlatformType:
1 2 3 4 5 |
enum PlatformType { Web= 1, Mobile= 2 }; |
Öncelikle gelin içerik sayfasına konucak olan script’i belirleyelim: Burada amaç portalda girilen içeriğin görülme değerini +1 arttırmaktır. Kısaca “Counter”, farklı bir Mvc projesindeki actiondır. Ve tek amacı kendisine gelen request’e göre ilgili içeriği saymaktır.
- platformID= platformID: İçeriğin bakıldığı mecra belirlenmiştir. Örneğin bu sayfada “Web” seçilmiştir.
- id= Gelen içeriğin “ID”‘si alınmıştır.
- ctID= contentTypeID Daha sonra içerik tipi seçilmiştir. [Article,PhotoGallery,VideoGallery]
- pg= Son olarak özellikle PhotoGallery için ihtiyaç olacak PageID belirlenmiştir. PhotoGallery’de page’in amacı, gallerydeki herbir resimin toplam görümtülenme sayısının belirlenmesidir. Böylece galery içinde en çok beyenilen resim ya da gallery de hangi resime kadar tıklanılıp kapatıldığı belirlenebilecektir.
1 2 3 4 5 |
@{ int platformID= (int)PlatformType.Web; int contentTypeID = (int)Model.ContentType; } <script type="text/javascript">document.write("<img src='http://localhost:1453/Counter?id=@Model.ContentId&platformID=" +@platformID +"&ctID=" +@contentTypeID +"&pg=1' style='display:none;' />"); </script> |
Şimdi ilgili içeriği sayacak yeni bir proje oluşturalım.
Adı da “Statistics” olsun: Yukarıda tanımlanan, haber detay sayfalarına konan script’in, request çektiği asenkron “Counter” action’ı aşağıda tanımlanmıştır.
- “int id, int platformID, int ctID, int? pg” ilgili parametreler içeride tanımlı değişkenlere atanır.
- Önceden yaratılan Redis bir sunucuya bağlanılır. İlgili connection dış bir sistemden alınır(Web.config veta DB gibi) “using (IRedisClient client = new RedisClient(new Uri(AppConfig.ListeningRedisConnectionString)))“
- “StatisticKey()” methodu ile ilgili content’e göre tekil bir key üretilir.
- Oluşturulan bu static key ile redis’e gidilip ServiceStack’in “IncrementValue()” methodu çağrılır ve içeriği yok ise 1 atanır, var ise +1 arttırılır. Ben bu projede redis için ServiceStack kütüpahanesinden faydalandım.
- “Last1MinutePeriod” property’si ile(hemen aşağıda anlatılmıştır.) belirlenen 1 dakikalık sürenin dolup dolmadığına bakılır.
- Eğer bir dakikalık süre dolmuş ise (var threeminsub = DateTime.Now.Subtract(Last1MinutePeriod)) Redis’den sayılan bu content’e ait tüm keyler çekilir. Burada önemli nokta, statick bir key oluşturulurken, bu sayım için tüm keylerin “Statistic:” anahtar kelimesi ile başlamasıdır. Buna göre yarıtılan tüm keyler “client.SearchKeys(“Statistic:*”)” methodu ile çeklir. Daha sonra ilgili tüm keyler gezilerek pars edilir ve content için gerekli alanlar [“platformID,contentID,contentTypeID”] ilgili değişkenlere atanır. Son olarak value yani toplam sayı(count) değerleri de alınarak “CounterItem“(aşağıda anlatılmıştır) modeli doldurulur ve List of CounterItem nesnesine eklenir. Kısaca son 1 dakika içinde sayılan tüm içerik bir liste altında toplanır.
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
namespace Statistics { public class HomeController : Controller { int count = 0; static object lockObject = new object(); // GET: Home public ActionResult Index() { return View(); } public async Task Counter(int id, int platformID, int ctID, int? pg) { ContentID = id; PlayformID = platformID; ContentTypeID = ctID; PageID = pg; if (PageID == null || PageID == 0) { PageID = 1; } var threeminsub = DateTime.Now.Subtract(Last1MinutePeriod); //1 DAKİKA SÜRESİNE BAKILIR. lock (lockObject) { using (IRedisClient client = new RedisClient(new Uri(AppConfig.ListeningRedisConnectionString))) { //Redis için tekil bir key oluşturulur. var cacheKey = StatisticKey((PlatformType)PlatformID, ContentID, (ContentType)ContentTypeID); client.IncrementValue(cacheKey); if (threeminsub.TotalSeconds >= 60) { lock (lockObject) { List<CounterItem> dataList = new List<CounterItem>(); //Get All Keys //Redisdeki son dakika içeriği tutan tüm keyler alınır ve pars edilip bir listeye toplanır. var allStatisticKeys = client.SearchKeys("Statistic:*"); //Fill Table foreach (string key in allStatisticKeys) { //Parametre olarak gelen içerik tip ve null kontrolü yapılabilir. Am try {} catch{} içinde yazıldığı için gerek duyulmamıştır. //Tek bir parametre bile yoksa zaten işlem yapılamamaktadır. string[] _params = key.Split(':'); int _platformID = int.Parse(_params[1]); int _contentID = int.Parse(_params[3]); int _contentTypeID = int.Parse(_params[2]); int _count = client.Get<int>(key); CounterItem data = new CounterItem(); data.ContentId = _contentID; data.ViewCount = _count; data.PlatformId = _platformID; data.ContentTypeID = _contentTypeID; data.PageNumber = 1; data.ViewDate = DateTime.Now; dataList.Add(data); } //DB Process //------------------------------------- //SignalR Process - Push Data To All Clients //-------------------------------------- //Redis Clean For All Statistic Keys... } } } } } //Local Variables public int ContentID { get; set; } public int PlatformID { get; set; } public int ContentTypeID { get; set; } public int? PageID { get; set; } } } |
StatisticKey: Gelen içeriğin ID’sine, tipine ve geldiği platforma bakılarak, redis’de tanımlanacak unique yani tekil bir key üretilir.
1 2 3 4 5 |
public static string StatisticKey(PlatformType platform, long contentParentId, ContentType contentTypeId) { var cacheKey = $"Statistic:{(int)platform}:{(int)contentTypeId}:{contentParentId}"; return cacheKey; } |
Last1MinutePeriod: Burada amaç son 1 dakikadaki sayılan içeriğin, admin ekranlarına signalR ile push edilmeden önceki 60sn’lik sürenin sayılıp, Global Application’a atılmasıdır. Bunu için ilk sayımın başladığı anın, tüm clientlar adına aynı olması ve Global olarak bir yerde saklanması adına Application’da saklanır. İstendiğinde ilgili zamanın kontrol edilip, var olan değer geri dönülür. Yani kısacası son 1 dakikalık süre içinde anlık olarak redisden bilgi çekilebilir. Sürenin dolması durumunda da, yeni zaman application’a tekrardan atılarak yenilenir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public DateTime Last1Minute1Period { get { if (System.Web.HttpContext.Current.Application["Last1Minute1Period"] == null) { System.Web.HttpContext.Current.Application.Add("Last1MinutePeriod", DateTime.Now); return DateTime.Now; } return (DateTime)System.Web.HttpContext.Current.Application["Last1MinutePeriod"]; } set { System.Web.HttpContext.Current.Application.Lock(); if (value == null) System.Web.HttpContext.Current.Application.Add("Last1MinutePeriod", DateTime.Now); else System.Web.HttpContext.Current.Application.Set("Last1MinutePeriod", value); System.Web.HttpContext.Current.Application.UnLock(); } } |
CounterItem:
1 2 3 4 5 6 7 8 9 10 |
public class CounterItem { public long Id { get; set; } public long ContentId { get; set; } public int ViewCount { get; set; } public int PlatformId { get; set; } public int PageNumber { get; set; } public int ContentTypeID { get; set; } public DateTime ViewDate { get; set; } } |
Şimdi sıra geldi Database İşlemlerine: Öncelik ilgili kayıtların tutulacağı 2 tablo oluşturulacaktır. Bunlardan biri Son 1 dakikanın kayıtlarını tutan CounterItem. Diğeri de o zamana kadar ilgili içeriğin toplam görüntülenme sayısını tutan CounterItemTotal tablolarıdır.
CounterItem Table: Son 1 dakika ile alakalı kaydın tutulduğu tablo.
1 2 3 4 5 6 7 8 |
CREATE TABLE [dbo].[CounterItem]( [Id] [bigint] IDENTITY(1,1) NOT NULL, [ContentID] [bigint] NOT NULL, [ViewCount] [int] NOT NULL, [PlatformID] [int] NOT NULL, [ContentTypeID] [int] NOT NULL, [PageNumber] [int] NOT NULL, [ViewDate] [datetime2](7) NOT NULL |
CounterItemTotal Table: Baştan sona içeriklerin toplam görüntülenme sayılarının tutulduğu tablo.
1 2 3 4 5 6 7 8 |
CREATE TABLE [dbo].[CounterItemTotal]( [Id] [bigint] IDENTITY(1,1) NOT NULL, [ContentId] [bigint] NOT NULL, [TotalViewCount] [int] NOT NULL, [PlatformID] [int] NOT NULL, [ContentTypeID] [int] NOT NULL, [PageNumber] [int] NULL, [UpdatedDate] [datetime2] |
Şimdi sıra geldi Database işlemlerine: Eğer beklenen 1 dakikalık süre dolar ise önce son 1 dakikalık data “CounterItem“‘a daha sonra da toplam görüntülenme sayıları “CounterItemTotal” tablosuna kaydedilir.
İlgili static dapper servisi using ile kullanılır. Redisden gelen son 1 dakikalık tüm data “dataList” listesine doldurulur. İlgili liste önce “InsertCounterItem” procedure ile “CounterItem” tablosuna Insert edilmiştir. Daha sonra “InsertCounterTotal” procedure ile “CounterItemTotal” tablosunda olmayan kayıtlar insert, olan kayıtlarda update edilmiştir.
1 2 3 4 5 6 7 8 9 10 |
using (ServiceDao sd = DAL.GetService<IServiceDal>()) { if (dataList != null && dataList.Count > 0) { if (sd.InsertCounterItem(dataList)) { sd.InsertCounterItemTotal(dataList); } } } |
Datebase’de Bulk İşlemler: Şimdi sıra geldi bu makaleyi yazmamdaki esas amaca :). Database işlemlerinde Insert veya Update yapılacak tüm kayıdın tek tek gönderilmesi demek, büyük bir performans kaybı ve işlem maliyeti demektir. 1 milyon haber son bir dakika içinde okunmuş olsa, herbir makalenin tek tek varsa update, yoksa insert amacı ile db’ye gönderilmesi, 1 milyon connection’ın açılıp her bir datanın işlem görmesi demektir. Bunun için öncelikle ilgili işlem yapılacak data kümesi “dataList“, yukarıda yapıldığı gibi bir liste içerisinde toplanır. İlerde yazacağımız procedure, bir table parametresi beklemektedir. Bu da bizim data kümemize karşılık gelmektedir.
InsertCounter ve InsertCounterTotal Procedure: Öncelikle bir “DataTable” oluşturulur. İlgili liste gezilerek, yeni oluşturulan datatable doldurulur. Ben database işlemlerinde Dapper kullandım. Dapper ile ilgili kütüpahanelere bu makalede deyinmeyeceğim. Sadece ilgili dapper nesnesi için kendi oluşturduğum “_redisConnectionFactory.GetConnection” sınıfıı kullanacağım.
“ExecNonQueryStoredProc” dapper ile ilgili procedure’ü çağran komuttur.
- İlgili procedureler “InsertCounterItem ve InsertCounterTotal”‘dir.
- Parametre olarak ilgili DataTable “dt” “new { Dt = dt.AsTableValuedParameter(“dbo.UDT_CounterItem”) }” şeklinde gönderilmiştir.
- MsSql’de bir procedure’ün parametre olarak data table alabilmesi için, önceden bir “User-Defind Table“‘ın yaratılması gerekmektedir. Bu bir çeşit Web tarafındaki ViewModel’e karşılık gelmektedir. Burada “dbo.UDT_CounterItem” adında bir table yaratılmıştır. İlgili tablo aşağıdaki gibi tanımlanmıştır.
- Önemli bir nokta Sql tarafında yaratılan tablonun kolonları ile Sql’e parametre olarak gönderilen DataTable’ın kolonlarının tip ve ad olarak birebir örtüşmesi gerekmektedir.
InsertCounter ve InsertCounterTotal:
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 46 47 48 49 50 51 52 53 54 55 |
public bool InsertCounter(List<CounterItem> data) { try { var dt = new System.Data.DataTable(); dt.Columns.Add("Id", typeof(long)); dt.Columns.Add("ContentId", typeof(long)); dt.Columns.Add("ViewCount", typeof(int)); dt.Columns.Add("PlatformId", typeof(int)); dt.Columns.Add("ContentTypeID", typeof(int)); dt.Columns.Add("PageNumber", typeof(long)); dt.Columns.Add("ViewDate", typeof(DateTime)); foreach (var i in data) { dt.Rows.Add(i.Id, i.ContentId, i.ViewCount, i.PlatformId, i.ContentTypeID, i.PageNumber, i.ViewDate); } _redisConnectionFactory.GetConnection.ExecNonQueryStoredProc("InsertCounterItem", new { Dt = dt.AsTableValuedParameter("dbo.UDT_CounterItem") }); return true; } catch (Exception ex) { return false; } } public bool InsertCounterTotal(List<CounterItem> data) { try { var dt = new System.Data.DataTable(); dt.Columns.Add("Id", typeof(long)); dt.Columns.Add("ContentId", typeof(long)); dt.Columns.Add("ViewCount", typeof(int)); dt.Columns.Add("PlatformId", typeof(int)); dt.Columns.Add("ContentTypeID", typeof(int)); dt.Columns.Add("PageNumber", typeof(long)); dt.Columns.Add("ViewDate", typeof(DateTime)); foreach (var i in data) { dt.Rows.Add(i.Id, i.ContenId, i.ViewCount, i.PlatformId, i.ContentTypeID, i.PageNumber, i.ViewDate); } _redisConnectionFactory.GetConnection.ExecNonQueryStoredProc("InsertCounterTotal", new { Dt = dt.AsTableValuedParameter("dbo.UDT_CounterItem") }); return true; } catch (Exception ex) { return false; } } |
dbo.UDT_CounterItem :
1 2 3 4 5 6 7 8 9 |
CREATE TYPE [dbo].[UDT_CounterItem] AS TABLE( [Id] [bigint] NOT NULL, [ContentId] [bigint] NOT NULL, [ViewCount] [int] NOT NULL, [PlatformId] [int] NOT NULL, [ContentTypeID] [int] NOT NULL, [PageNumber] [int] NOT NULL, [ViewDate] [datetime2](7) NOT NULL ) |
Şimdi sıra geldi ilgili procedurelerin yazılmasına:
[dbo].InsertCounterItem: Parametre olarak bir tablo beklemektedir. Bu tablo tipi Sql tarafından önceden “[dbo].[UDT_CounterItem]” adı ile tanımlanmıştır. Parametre olarak gelen bu data table toplu olarak “CounterItem” tablosuna insert edilmektedir. Ayrıca işlemler “Transaction” açılarak yapılmıştır. Böylece bir hata durumunda, tüm yapılan işlemler “ROLLBACK” ile geri alınabilmektedir. Bu işlem sonunda son 1 dakikalık tüm kayıt, redis memory cache’den MsSql bir DB’ye aktarılmış olunur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
CREATE PROC [dbo].[InsertCounterItem] @Dt [dbo].[UDT_CounterItem] READONLY AS DECLARE @TransactionName VARCHAR(20) = 'MergeCounter'; BEGIN TRY truncate table [dbo].[CounterItem] BEGIN TRAN @TransactionName INSERT INTO [dbo].[CounterItem](ContentId,ViewCount, PlatformID, ContentTypeID,PageNumber,ViewDate) select ContentId,ViewCount, PlatformId, ContentTypeID,PageNumber,ViewDate from @Dt COMMIT END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK END CATCH |
[dbo].InsertCounterTotal: Bu procedure’de, en başından beri ilgili içeriğe ait toplam okunma sayısının tutulduğu “CounterItemTotal” tablosuna, insert veya update işlemleri yapılmaktadır. Esasında işlem yapılan 2 tablo bulunmaktadır. İlki bir çeşit temp olan son 1 dakikayı tutan bir tablo. Bize bu procedure’de parametre olarak gelmektedir. İkincisi yukarıda bahsedilem content’in geçmişini tutulduğu, toplam görüntülenme sayısının kaydedildiği tablodur. Aşağıdaki procedurede iki tablo arasında “MERGE” işlemi yapılarak yani iki tablo birbiri ile kıyaslanarak var olan ve var olmayan kayıtlar belirlenmektedir. Var olan kayıtlar “MATCHED” tanımlaması ile güncellenmekte ve var olmayan kayıtlar “NOT MATCHED” tanımlaması ile Insert edilmektedir. Böylece toplu olarak gönderilen kayıtlar en performanslı şekilde Insert veya Update olmaktadır. Burada “SOURCE”olarak adlandırılan gelen parametrik @Dt tablosudur. “TARGET” ise işlem yapılacak CounterItemTotal tablosudur. Tüm işlemler Transaction altında yapılmakta ve herhangi bir hata anında “ROLLBACK” ile istendiğinde kolaylıkla geri alınabilmektedir.
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 |
CREATE PROC [dbo].[InsertConterTotal] @Dt [dbo].[UDT_CounterItem] READONLY AS DECLARE @TransactionName VARCHAR(20) = 'MergeCounter'; BEGIN TRY BEGIN TRAN @TransactionName MERGE CounterItemTotal AS TARGET USING @Dt AS SOURCE ON TARGET.ContenID = SOURCE.ContentID AND TARGET.PlatformId= SOURCE.PlatformId AND TARGET.ContentTypeID = SOURCE.ContentTypeID AND TARGET.PageNumber = SOURCE.PageNumber WHEN MATCHED THEN UPDATE SET TotalViewCount = TotalViewCount + SOURCE.ViewCount WHEN NOT MATCHED THEN INSERT (ContentId,PlatformID, ContentTypeID,PageNumber,TotalViewCount,UpdatedDate) VALUES (SOURCE.ContentId, SOURCE.PlatformId, SOURCE.ContentTypeID,SOURCE.PageNumber,SOURCE.ViewCount,SOURCE.ViewDate); COMMIT END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK END CATCH |
Bu ana kadar ilgili içeriğin içine konan script ile nasıl bir Mvc projesine request atıldığını, böylece ilgili içeriğin impression yani görüntülenme sayısının redis kullanılarak nasıl arttırıldığını, herkes için belirlenen global süre dolunca nasıl database’de bulk yani toplu kayıt işlemleri yapıldığını, “Sql Match” ile var olan içeriğin güncellenip olmayan içeriğin nasıl Insert edildiğini gördük.
Bu makalenin devamında ilgili içeriği AngularJs ile bootstrap kullanılmış bir admin ekranda göstereceğiz. Ayrıca ilgili içeriği her 1 dakikada bir signalR kullanarak push edeceğiz. Son olarak angularJs ile custom directive yazarak timer nesnesi oluşturup istenirse anlık sayım bilgisini redisten çeken ve diğer tüm clientlara push eden bir başka yapıyı da admin ekranına ekleyeceğiz. Bu işleri UI tarafında Bootstrap Tabs kullanarak gerçekleştireceğiz.
Bir sonraki makalede görüşmek üzere hoşçakalın.
Çok güzel bir makale hocam. Böyle değişik telkolojilerin bir arada kullanıldığı başka makalelerde yazın. Saygılar
Selamlar Hakan,
Teşekkürler. Zaten bu yazının devamı da var :) Çok tutarsa 2.sini de yayımlayacağım :)
İyi çalışmalar.
hocam cok teşkurler
Ben teşekkür ederim.