.Net Core ve SignalR ile Online Bir Dükkanda Gerçek Zamanlı Ürün Düzenleme
Selamlar,
Bu makalede, online bir game shop’un ürün bilgisini gerçek zamanlı güncellemeyi ve bunu daha performanslı nasıl yapabileceğimizi tartışacağız. Backend WebApi servisi için, .Net 5.0 ve FrontEnd uygulaması için de Angular 12 kullanacağız.
Backend:
Gelin öncelikle WebApi projemizi, Visual Studio 2019 ile aşağıdaki gibi oluşturalım.
Aşağıda görüldüğü gibi, GamesController oluşturulmuş ve random 10 oyun içinden 5 tanesi, ilgili client’a gönderilmiştir. Ayrıca, herbir client için signalR socket ile oluşacak connectionID ve ilgili client’a gönderilen 5 oyun, static bir dictionary’ye key-value şeklinde saklanmıştır. connectionID, herbir client’ın signalR Hub sınıfına bağlandığı zaman otomatik aldığı unique bir keydir. Ve bu örnekde, client’a özel bir key olarak kullanılmaktadır.
- “GamesDB“: Herbir client’ın saklanacağı, static dictionary listesi.
- “Games“: Herbir client’a gönderilen 5’li oyun listesi.
- “GameList“: Test amaçlı DB rolunde olan, oyun havuzunun bulunduğu string dizi. Herbir client için rastgele seçilen beş oyun, bu liste içinden alınmaktadır.
- “Get()“: Client, signalR Hub’a connect olduğu zaman aldığı connectionID ile bu method çağrılır, ve havuzdan rastgele 5 oyun alınır.
- “Games.ForEach(g => g.ImgPath = g.Name + “.jpg”)“: Seçilen geriye dönülecek beş oyun tek tek gezilerek, ImgPathleri Name’e göre düzenlenir.
- “GamesDB.Add(connectionID, Games)“: İlgili client’ın connectionID’sine göre seçilen oyunlar, GamesDB dictionary’e key-value olarak atanır.
GamesController:
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 |
[ApiController] [Route("[controller]")] public class GamesController : ControllerBase { public static Dictionary<string, List<Games>> GamesDB = new(); public static List<Games> Games = new(); private static readonly string[] GameList = new[] { "ColdWar", "Returnal", "Village", "Sackboy", "Spiderman", "ValHalla", "Cyberpunk", "Horizon", "Control", "Fifa21" }; [HttpGet("{connectionID}")] public List<Games> Get(string connectionID) { var rng = new Random(); Games = Enumerable.Range(1, 10).Select(index => new Games { ID = index, Name = GameList[rng.Next(GameList.Length)], Price = rng.Next(1000) + 500, CreatedDate = DateTime.Now }).GroupBy(item => item.Name) .Select(grp => grp.First()).Take(5) .ToList(); Games.ForEach(g => g.ImgPath = g.Name + ".jpg"); GamesDB.Add(connectionID, Games); return Games; } |
Models/Games: Tanımlanan Games model, aşağıdaki gibidir.
1 2 3 4 5 6 7 8 |
public class Games { public int ID { get; set; } public string Name { get; set; } public double Price { get; set; } public DateTime CreatedDate { get; set; } public string ImgPath { get; set; } } |
SignalR Hub Sınıfı:
GamesHub(): Client’ın, connect ve disconnect olduğunda çeşitli işlemlerin yapıldığı ayrıca istenen bir ürünün güncellendiği Hub sınıfıdır.
- OnConnectedAsync(): Bir client connect olduğu zamani aldığı connectionID, kendisine gönderilir.
- OnDisconnectedAsync(): Bir client disconnect olduğu zaman, kendisi kayıtlı olduğu static GamesDB listesinden çıkarılır.
- ClearProduct(): Güncelenen oyun datasını, tüm clientlara asenkron gönderen methoddur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class GamesHub : Hub { public override async Task OnConnectedAsync() { await Clients.Caller.SendAsync("GetConnectionId", this.Context.ConnectionId); } public override async Task OnDisconnectedAsync(Exception exception) { GamesController.GamesDB.Remove(this.Context.ConnectionId); Console.WriteLine("DisconnectID:" + this.Context.ConnectionId); await base.OnDisconnectedAsync(exception); } public async Task ClearProduct(Games game) { await Clients.All.SendAsync("ChangeGame", game); } } |
Dependency Injection ile signalR Hub:
IHubGameDispatcher.cs: Dependency injection için ilgili sınıfa ait bir interface’in yaratılması gerekmektedir. Yaratılan signalR Hub sınıfı, ChangeGame() adında ilgili oyunun güncellenmesini sağlayacak, asenkron bir methoda sahiptir.
1 2 3 4 |
public interface IHubGameDispatcher { Task ChangeGame(Games game); } |
HubGameDispatcher.cs: Bu sınıf, backend’e Controller katmanında dependency injection ile projeye dahil edilip, ilgili signalR Hub sınıfına erişimi sağlar. Bu sınıf üzerinden ChangeGame() methodu çağrılıp, GamesHub sınıfı üzerindeki ChangeGame() methodu asenkron olarak çağrılmıştır. Constructorın’da, ilgili IHubContext<GamesHub> sınıfını dependency Injection ile alınmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 |
public class HubGameDispatcher : IHubGameDispatcher { private readonly IHubContext<GamesHub> _hubContext; public HubGameDispatcher(IHubContext<GamesHub> hubContext) { _hubContext = hubContext; } public async Task ChangeGame(Games game) { await this._hubContext.Clients.All.SendAsync("ChangeGame", game); } } |
GamesController/UpdateGame(): Aşağıda görüldüğü gibi, GamesController constructor’ında “IHubGameDispatcher” almakta ve böylece signalR GamesHub sınıfına erişilebilmektedir. UpdateGame() post methodu ile
- “foreach (var gamesList in GamesDB)”: Tüm kullanıcıların oyun Listesi gezilmiş.
- “var updateGame = gamesList.Value.Where(g => g.Name == game.Name).FirstOrDefault()” : İlgili liste içinde, güncellenecek oyun bulunmuş.
- “gamesList.Value.Remove(updateGame)” : İlgili liste içinden oyun çıkarılmış.
- “gamesList.Value.Add(game)” : Güncellenen oyunun son hali, tekrar listeye eklenmiş.
- “GamesDB[gamesList.Key] = gamesList.Value”: Son olarak güncel liste GameDB dictionary içinde, ilgili client’ın key’i bulunup değiştirilmiştir.
- “if (isChange) await this._dispatcher.ChangeGame(game)“: Gerçekden değişen bir liste var ise, tüm clientların “ChangeGame()” function’ı signalR ile gerçek zamanlı değişen oyun parametresi ile tetiklenmiştir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
IHubGameDispatcher _dispatcher; public GamesController(IHubGameDispatcher dispatcher) { _dispatcher = dispatcher; } [HttpPost("UpdateGame")] public async Task UpdateGame([FromBody] Games game) { bool isChange = false; foreach (var gamesList in GamesDB) { var updateGame = gamesList.Value.Where(g => g.Name == game.Name).FirstOrDefault(); if (updateGame != null) { isChange = true; gamesList.Value.Remove(updateGame); gamesList.Value.Add(game); GamesDB[gamesList.Key] = gamesList.Value; } } if (isChange) await this._dispatcher.ChangeGame(game); } |
Startup.cs (ConfigureServices): .Net 5.0 tarafındaki tüm tanımlamaların yapıldığı sayfadır.
- “services.AddCors(o => o.AddPolicy(“MyPolicy”, builder =>”: Client side taraftan, server side tarafdaki bir methodun tetiklenebilmesi için, güvenlik anlamında Cors’un açılması gerekmektedir.
- “services.AddSingleton<IHubGameDispatcher, HubGameDispatcher>()”: IHubGameDispatcher’ın Constructor’da Dependency Injection ile dahil edilebilmesi için bağlı olduğu sınıfın tanımlanması gerekmektedir.
- “services.AddSignalR()”: SignalR servis projeye bu şekilde eklenir.
- “options.PayloadSerializerOptions.PropertyNamingPolicy = null”: Tanımlaması ile signalR sınıfından ile ilgili clientlara gönderilen modellerin propertylerinin, büyük küçük harfe göre değişmemesi sağlanı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 |
public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddJsonOptions(opts => opts.JsonSerializerOptions.PropertyNamingPolicy = null); services.AddCors(o => o.AddPolicy("MyPolicy", builder => { builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); })); services.AddSingleton<IHubGameDispatcher, HubGameDispatcher>(); services.AddSignalR() .AddJsonProtocol(options => { options.PayloadSerializerOptions.PropertyNamingPolicy = null; }); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "GamesService", Version = "v1" }); }); } |
Startup.cs (Configure): SignalR sınıfının routing tanımlamasının yapıldığı, swagger’ın dosya yolunun gösterildiği, Cors için ilgili policy’nin seçildiği ve Authorization tanımlamasının yapıldığı methoddur.
- “app.UseCors(“MyPolicy”)”: Cors için yukarıda tanımlanan policy, buradan eklenir.
- “endpoints.MapHub<GamesHub>(“/gameHub”)”: Routing amaçlı, Client-Side’dan Server-Side’a gelinirken “/gameHub” şeklinde request çekilir ise, GamesHub signalR sınıfına yönlenilmesi sağlanmıştır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseCors("MyPolicy"); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "GamesService v1")); } app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapHub<GamesHub>("/gameHub"); endpoints.MapControllers(); }); } |
Frontend:
Aşağıdaki komut ile, Games adında Angular projesi ayağa kaldırılır.
1 |
ng new Games |
models/game.ts: Game model, aşağıdaki gibi oluşturulur.
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 |
export class Game { constructor(){ } private _Name: string; public get Name(): string { return this._Name; } public set Name(v: string) { this._Name = v; } private _ID: number; public get ID(): number { return this._ID; } public set ID(v: number) { this._ID = v; } private _Price: number; public get Price(): number { return this._Price; } public set Price(v: number) { this._Price = v; } private _ImgPath: string; public get ImgPath(): string { return this._ImgPath; } public set ImgPath(v: string) { this._ImgPath = v; } } |
app.component.ts: Sayfa yüklendiği zaman, ilgili oyun datasının servisden çekilip ekrana basıldığı ve bir oyun bilgisinin değiştiği zaman, client-side tarafda bu değişen oyun datasını real-time gösteren typescript kodları, aşağıdaki gibidir.
- “modelGames: Array<Game>”: Sayfaya yüklenecek tüm oyunların toplandığı dizidir.
-
“_hubConnection: HubConnection”: SignalR Games Hub sınıfına bağlanacak, connectiondır.
-
“_connectionId: string”: Client’ın signalR hub sınıfına bağlandığı zaman aldığı, unique connectionID’dir.
-
“signalRServiceIp: string = “http://localhost:42213/gameHub””: Server side tarafda, bağlanılacak Hub sınıfının yoludur.
-
Aşağıdaki kodda, ngOnInit() ile sayfa yüklendikten sonra, server-side tarafdaki GamesHub sınıfına, WebSocket ile bağlanılır.
- Aşağıda görüldüğü gibi, Server-Side tarafda, client GamesHub sınıfına connect olduktan sonra, alınan unique connectionID, server-side taraftan client-side tarafdaki “GetConnectionID()” function’ı trigger edilerek gönderilir. İlgili connectionID alındıktan sonra, bu sefer de client-side’dan server-side taraftaki GamesController sınıfına ait “Get” methodu çağrılarak, gelen oyun listesi başta tanımlanan modelGames[] değişkenine atanı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 |
import { Component } from '@angular/core'; import { Observable } from 'rxjs'; import { Game } from 'src/models/game'; import { GameService } from 'src/services/gameService'; import { HubConnection, HubConnectionBuilder, LogLevel } from '@aspnet/signalr'; import * as signalR from '@aspnet/signalr'; import { OnInit } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { title = 'Games'; modelGames: Array<Game>; _hubConnection: HubConnection; _connectionId: string; signalRServiceIp: string = "http://localhost:42213/gameHub"; public constructor(private service: GameService) { this.modelGames = new Array<Game>(); } public ngOnInit(): void { this._hubConnection = new HubConnectionBuilder() .withUrl(`${this.signalRServiceIp}`, { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets }) .build(); this._hubConnection.start().then( () => console.log("Hub Connection Start")) .catch(err => console.log(err)); this._hubConnection.on('GetConnectionId', (connectionId: string) => { this._connectionId = connectionId; console.log("ConnectionID :" + connectionId); this.service.GetGames(connectionId).then(res => { this.modelGames = res; }); }); this._hubConnection.on('ChangeGame', (game: Game) => { //console.log("Updated Game:" + JSON.stringify(game)); //console.log("Game Data Push:"+JSON.stringify(this.modelGames)); var item = this.modelGames.find(rd => rd.Name == game.Name); //console.log("Current Game:" + JSON.stringify(item)); this.modelGames = this.modelGames.filter(gam => gam != item); this.modelGames.push(game); //console.log("Row Data Push:" + JSON.stringify(this.modelGames)); }); } } |
service/gameService.ts: Aşağıda görüldüğü gibi webapi servisinden, ilgili oyun listesinin çekilmesi için GameService yazılmıştır. GetGames() methodu ile ilgili “/games” servisine request çekilip, “Game[ ]” dizisi şeklinde result model alınır ve ekrana basılır.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Game } from 'src/models/game'; @Injectable({ providedIn: 'root' }) export class GameService { gameUrl = "http://localhost:42213/games/"; constructor(private httpClient: HttpClient) { } GetGames(connectionId: string): Promise<any[]> { return this.httpClient.get<Game[]>(this.gameUrl+ `${connectionId}`).toPromise(); } } |
app.component.html: Yukarıda görüldüğü gibi, servisden çekilen oyunların listelendiği html sayfadır. Servisden dönen “Games[ ]” result, modelGames dizisine doldurulmaktadır. İlgili dizi gezilerek oyunun resmi ve fiyat bilgisi ekrana basılır.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<table> <tr> <td style="padding: 10px" *ngFor="let game of modelGames"> <div style=" text-align: center"><b>[{{game.ID}}] {{game.Name}}</b></div> <img src="/assets/images/{{game.ImgPath}}" height="220" /> <div style=" text-align: center"> <h4><b> <font color="red">{{game.Price | currency:'€'}}</font> </b></h4> </div> </td> </tr> </table> |
app.module.ts: İlgili kütüphanelerin tanımlandığı sayfadır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { GameService } from 'src/services/gameService'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, AppRoutingModule, HttpClientModule ], providers: [GameService], bootstrap: [AppComponent] }) export class AppModule { } |
Performans:
- Dikkat edilir ise, her client için random 5 oyun seçilmektedir. Ama fiyatı değişen oyun bilgisi, tüm clientlara gönderilmektedir. Bunun yerine, herbir client’a özel oluşturulan connectionID ile “GamesDB”‘ye eklenen oyunlar tek tek incelenip, değişen oyun o client’da var ise, o zaman push notify gönderilmelidir. Kısaca, sadece o oyuna sahip clientlara notify gönderilmelidir.
- Diğer bir durum, anlık kullanıcı sayısı eğer belli bir sayının üstüne çıkar ise, SignalR Hub sınıfı için Azure SignalR Services’inden faydalanılabilir.
- Üçüncü bir konu, sisteme bağlı binlerce oyun olduğunu farz edelim. İşte bu durumda GamesDB static dictionary listesi yerine client’a ait oyunları Redis’de tutmak çok daha performanslı olacaktır. Key olarak Client’a ait unique connectionID ve value olarak random seçilen 5 oyun listesi olacaktır.
- Son olarak, oyunun bilgilerinin değiştiğini duyuran trigger servisi, doğrudan DB’ye ya da bir başka microservices’e bağlanabilir. Bu durumda test , monitoring ve devops apayrı bir önem kazanmaya başlamaktadır.
Geldik bir makalenin daha sonuna. Yeni bir makalede görüşmek üzere hepinize hoşçakalın.
Source Code: https://github.com/borakasmer/GamesShop
abi ellerine sağlık harika ve çok faydalı bir anlatım olmuş ??????
Hocam oyun fiyatları manidar :D
Merhaba hocam,
İnternette sizden başka signalr bu kadar çok farklı patformda blog yazan olmadı.Bence signalr geliştirme takımının başında olmalısınız.
Emeğinize sağlık.
Hocam izin verirse bende aşağıdaki linki bırakayım. Olurda token ile güvenlik sağlarsanız bunları eklemeniz gerekecek
https://stackoverflow.com/a/57460785