...
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSignalR();
var app = builder.Build();
...
Hub temelinde bir sınıf oluşturmamız lazım. Sınıfın ismi TrackHub olsun. Bu Hub sınıfı bir API olarak client'lar ile server arasında iletişim kurmamızı sağlayacak. Identity katmanında Hubs klasörü oluşturdum. Bu klasörde hub sınıflarım yer alacak. TrackHub.cs dosyası olacak şekilde ilk sınıfımı ekledim:using Application.Contracts.Hubs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Identity.Hubs
{
[Authorize]
public class TrackHub : Hub<ITrackHub>
{
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
if (httpContext != null)
{
var userName = httpContext.Request.Query["username"];
}
await base.OnConnectedAsync();
}
}
}
Authorize ile yetki gerekliliği sağladık bu sınıf için, sonuçta herkes bu hub'a bağlanamaz. Aynı zamanda Hub sınıfının kendisi strongly typed olmadığı için kendi strongly typed Hub'ımızı oluşturmak için bize interface kullanma imkanı sağlıyor. Bu sayede mesaj gönderirken hangi parametrelerin kullanılacağına dair bir kural oluşturmuş oluyoruz. Diğer türlü belli bir kurala bağlı olmayacağımız için eğer hata yaparsak mesaj gönderirken bunu runtime esnasında anlayabilmemiz mümkün oluyor.
namespace Application.Contracts.Hubs
{
public interface ITrackHub
{
Task ReceiveActiveUserCounter(int counter);
}
}
httpContext'i ise hangi kullanıcının hub'a bağlandığını anlamak amacıyla kullandım. Yazının devamında blazor wasm tarafında hub'a bağlanırken username'i parametre olarak göndereceğiz.
Ayrıca bir tane TrackBackgroundService adında bir Background Service oluşturdum. IHostedService olan background servisimiz arkaplanda concurrent olarak bizim belirlediğimiz süreye göre SignalR kullanarak mesaj gönderecek. Ama burada şöyle bir durum olacak. Eğer kullanıcı yönetim panelinden girdiyse bu kullanıcı veya kullanıcılara mesaj gönderilecek. Mesaj olarak göndereceğimiz şey ise Blazor Wasm uygulamasından giriş yapan kullanıcı bilgileri olacak. Background Service sayesinde anlık bir kontrol yapmış olacağız. Ancak background service kullanmamız gerekmiyor olabilir bu senaryoda ama nasıl kullanılacağını görmek amacıyla güzel bir pratik olur dedim.
using Application.Contracts.Hubs;
using Identity.Hubs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
namespace Identity.Services.Hubs
{
public class TrackBackgroundService : BackgroundService
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly IHubContext<TrackHub, ITrackHub> _context;
public TrackBackgroundService(IHubContext<TrackHub, ITrackHub> context)
{
_context = context;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Timeout);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
//await _context.Clients.Client(userService.GetUserId()).ReceiveActiveUserCounter(12);
// mesaj gönder
}
}
}
}
Hosted service olduğu için kayıt işlemini aşağıdaki gibi yapıyoruz:
});
services.AddHostedService<TrackBackgroundService>();
return services;
}
}
Oluşturduğumuz TrackHub'a gelecek olan request'leri alabilmek için kullanacağımız endpoint'i Program.cs dosyasında aşağıdaki gibi bildiriyorum:
app.UseHttpsRedirection();
app.MapHub<TrackHub>("track");
app.UseCors("all");
Tabi production'da CORS "all" falan diye kalmasın kendinize göre ayarlayın.namespace Application.Contracts.Identity
{
public interface IGarnetCacheService
{
Task<string> GetValueAsync(string key);
Task<bool> SetValueAsync(string key, string value);
Task AppendListAsync(string key, string element);
Task<bool> RemoveFromList(string key, string element);
Task Clear(string key);
void ClearAll();
}
}
Bu interface'in implementasyonunu yazmadan önce bir Garnet'in kurulumunu yapın. Aslında tam olarak kurulum denemez, sadece bir executable file. microsoft/garnet Buradan repo clone alıp kendiniz de build edebilirsiniz veya yine aynı linkten Garnet'in son release sürümünü indirebilirsiniz. İndirdikten sonra sadece garnet-server dosyasını çalıştırmanız yeterli. Sonrasında ekrana şöyle bir şey gelecek: "AllowedHosts": "*",
"Garnet": {
"Address": "127.0.0.1",
"Port": 6379,
"UseTLS": false
}
}
Sonrasında API tarafında bu değerleri alabilmek için uygun bir DTO oluşturdum infrastructure katmanında:
namespace Identity.Models.DTO
{
public class GarnetConfiguration
{
public int Port { get; set; }
public string Address { get; set; } = string.Empty;
public bool UseTLS { get; set; }
}
}
Service kayıt sınıfında aşağıdaki configurasyonu ve gerekli IGarnetCacheService'i singleton service olarak ekledim:
services.Configure<GarnetConfiguration>(configuration.GetSection("Garnet"));
services.AddSingleton<IGarnetCacheService, GarnetCacheService>();
GarnetCacheService'i GarnetClient'a göre ve oluşturduğumuz interface'e uygun şekilde implement edelim:
using Application.Contracts.Identity;
using Garnet.client;
using Identity.Models.DTO;
using Microsoft.Extensions.Options;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace Identity.Services
{
public class GarnetCacheService : IGarnetCacheService
{
private readonly GarnetConfiguration _g;
public GarnetCacheService(IOptions<GarnetConfiguration> garnetConfiguration)
{
_g = garnetConfiguration.Value;
}
public async Task Clear(string key)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
if (await Contains(db, key))
{
await db.KeyDeleteAsync(key);
}
}
public async void ClearAll()
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
await db.ExecuteForStringResultAsync("FLUSHDB");
}
public async Task<string> GetValueAsync(string key)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
return await db.StringGetAsync(key);
}
public async Task<bool> SetValueAsync(string key, string value)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
await db.StringSetAsync(key, value);
string check = await db.StringGetAsync(key);
if (check == value)
{
return true;
}
return false;
}
public async Task AppendListAsync(string key, string element)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
await db.ListRightPushAsync(key, element);
}
public async Task<bool> RemoveFromList(string key, string element)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
bool isRemove = int.Parse(await db.ExecuteForStringResultAsync("LREM", new string[] { key, "0", element })) == 1 ? true : false;
return isRemove;
}
private async Task<bool> Contains(GarnetClient db, string key)
{
bool exist = int.Parse(await db.ExecuteForStringResultAsync("EXISTS", new string[] { key })) == 1 ? true : false;
return exist;
}
SslClientAuthenticationOptions GetSslOpts() => _g.UseTLS ? new()
{
ClientCertificates = [new X509Certificate2("certfile.pfx", "temp")],
TargetHost = "GarnetTest",
RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true,
} : null;
}
}
Yaptığım implementasyon çalışacak mı emin olmamakla beraber çalışacağını umuyorum. Bu arada Garnet kullanımı çok muhtemel kötü bir kullanım yöntemi o yüzden sonrasında iyileştirme yapmak gerekecektir.Garnet yeni olduğu için ve ben de ilk defa kullandığım için bu şekilde yazdım daha iyi kavramak amacıyla.
using Application.Contracts.Hubs;
using Application.Contracts.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Identity.Hubs
{
[Authorize]
public class TrackHub : Hub<ITrackHub>
{
private readonly IGarnetCacheService _cache;
public TrackHub(IGarnetCacheService cache)
{
_cache = cache;
}
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
if (httpContext != null)
{
string connectionId = Context.ConnectionId;
string? userName = httpContext.Request.Query["username"];
if(userName != null)
{
await _cache.AppendListAsync("activeUsers", userName);
}
}
await base.OnConnectedAsync();
}
}
}
Şimdi sıra SignalR Hub'ımız düzgün çalışıyor mu ve kullanıcı adı Garnet Server'a ekleniyor mu test etmeye geldi. İlk olarak Blazor Wasm uygulaması olan yönetim paneli projesine öncelikle Microsoft.AspNetCore.SignalR.Client(8.0.6) paketini yükledim. Bu paket sayesinde HubConnection oluşturabileceğim client ile server arasında. HubConnection'ı sağlamak için Dashboard.razor'u seçtim. Bu seçim tamamen size bağlı istediğiniz yerde connection'u sağlayabilirsiniz:
@using AdminUI.Models.Stat
@using Microsoft.AspNetCore.SignalR.Client
@implements IAsyncDisposable
...
@code{
private HubConnection? hubConnection;
[Inject]
LocalStorageService localStorage { get; set; }
[Inject]
public IForumStatService forumStatService { get; set; }
...
int activeUserCounter = 0;
bool isLoaded;
[CascadingParameter] private Task<AuthenticationState> authenticationStateTask { get; set; }
protected async override Task OnInitializedAsync()
{
var authState = await authenticationStateTask;
var user = authState.User;
string username = string.Empty;
if (user.Identity.IsAuthenticated)
{
username = user.Identity.Name;
}
...
string token = await localStorage.GetItem("token");
hubConnection = new HubConnectionBuilder()
.WithUrl(
"https://localhost:7147/track",
o => {
o.AccessTokenProvider = () => Task.FromResult<string?>(token);
o.Url = new Uri($"https://localhost:7147/track?username={username}");
}
)
.Build();
hubConnection.On<int>("ReceiveActiveUserCounter", async message =>
{
activeUserCounter = message;
await InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
isLoaded = true;
}
public async ValueTask DisposeAsync()
{
if(hubConnection is not null)
{
await hubConnection.DisposeAsync();
}
}
}
API tarafında çalışan TrackHub ile bağlantıyı yetki almak üzere token ile sağlıyorum. Server tarafında ReceiveActiveUserCounter mesajı gönderildikçe client'da ReceiveActiveUserCounter içindeki kod bloğu çalıştırılmış olacak. Dikkat ederseniz Client burada sadece bağlanmak için Hub'a bir kez istek attı sonrasında Server'dan client içindeki metot request olmadan çalıştırılabilecek. public class GarnetCacheService : IGarnetCacheService
{
private readonly GarnetConfiguration _g;
public GarnetCacheService(IOptions<GarnetConfiguration> garnetConfiguration)
{
_g = garnetConfiguration.Value;
}
...
public async Task AddHashSet(string key, string field, string value)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
await db.ExecuteForStringResultAsync("HSET", new string[] { key, field, value });
}
HSET command'i key adında bir hashset yoksa oluşturmamızı ve ayrıca içerisinde field ve ona karşılık value'yi store etmemizi sağlıyor. Eğer önceden field mevcut ise sadece value değerini güncelliyor.
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
if (httpContext != null)
{
string connectionId = Context.ConnectionId;
string? userName = httpContext.Request.Query["username"];
if(userName != null)
{
await _cache.AddHashSet("activeUsers", userName, connectionId);
}
}
await base.OnConnectedAsync();
}
Şimdi ilk hub bağlantı sonrası redis-cli üzerinden kontrol edelim:
Bir sorun görülmüyor, tekrar hub bağlantısını yenileyelim:Tam olarak istediğimiz sonucu elde etmiş olduk. Sadece kullanıcı adını kontrol ederek varsa eğer son ConnectionId ile güncellemiş olduk. public class ActiveUser
{
public string UserName { get; set; } = string.Empty;
public string? ConnectionId { get; set; }
}
Sonrasında ITrackHub'a ReceiveActiveUsers signature'u ekledim:
public interface ITrackHub
{
Task ReceiveActiveUserCounter(int counter);
Task ReceiveActiveUsers(string jsonStr);
}
Test amaçlı BackgroundService'i aşağıda gibi güncelledim:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Timeout);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
var exampleList = new List<ActiveUser>() { new ActiveUser { UserName = "test", ConnectionId = "testcon" } };
var jsonStr = JsonConvert.SerializeObject(exampleList);
await _context.Clients.All.ReceiveActiveUsers(jsonStr);
}
}
Blazor WASM uygulaması tarafında yine aynı şekilde ActiveUserVM adı ile aynı sınıfı oluşturuyorum:
@using AdminUI.Models.Hub
@using AdminUI.Models.Stat
@using Microsoft.AspNetCore.SignalR.Client
@using System.Text.Json.Serialization
@using System.Text.Json
@implements IAsyncDisposable
...
@code{
private HubConnection? hubConnection;
[Inject]
LocalStorageService localStorage { get; set; }
...
int activeUserCounter = 0;
private List<ActiveUserVM>? activeUsers;
bool isLoaded;
[CascadingParameter] private Task<AuthenticationState> authenticationStateTask { get; set; }
protected async override Task OnInitializedAsync()
{
var authState = await authenticationStateTask;
var user = authState.User;
string username = string.Empty;
if (user.Identity.IsAuthenticated)
{
username = user.Identity.Name;
}
...
hubConnection.On<string>("ReceiveActiveUsers", async message =>
{
if(message != null)
{
activeUsers = JsonSerializer.Deserialize<List<ActiveUserVM>>(message);
}
await InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
isLoaded = true;
}
...
}
Ayrıca bu kullanıcı listesini arayüzde görmek için bir liste arayüzü oluşturalım geçici olarak:
@using AdminUI.Models.Hub
@using AdminUI.Models.Stat
@using Microsoft.AspNetCore.SignalR.Client
@using System.Text.Json.Serialization
@using System.Text.Json
@implements IAsyncDisposable
...
<div class="row">
@if(activeUsers != null)
{
<ul>
@foreach(var activeUser in activeUsers){
<li>@activeUser.UserName - @activeUser.ConnectionId</li>
}
</ul>
}
</div>
...
@code{
...
}
Şimdi projeyi çalıştıralım ve bakalım client tarafına mesajımız başarılı bir şekilde geliyor mu kontrol edelim:
public interface IGarnetCacheService
{
...
Task<Dictionary<string, string>?> GetAllHashSet(string key);
...
}
Implemantasyonunu aşağıdaki gibi HGETALL command'i kullanarak yaptım.
public async Task<Dictionary<string, string>?> GetAllHashSet (string key)
{
using var db = new GarnetClient(_g.Address, _g.Port, GetSslOpts());
await db.ConnectAsync();
string[] hashArray = await db.ExecuteForStringArrayResultAsync("HGETALL", new string[] { key });
if(hashArray.Length > 0)
{
Dictionary<string, string> hash = new Dictionary<string, string>();
for (int i = 0; i < hashArray.Length; i+=2)
{
hash.Add(hashArray[i], hashArray[(i + 1)]);
}
return hash;
}
return null;
}
Daha sonrasında Background Service'da bunu direkt mesaj olarak client'a gönderebiliriz. Ama biz şimdilik onu List<ActiveUser> yapısında gönderelim:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Timeout);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
List<ActiveUser> activeUsers = new List<ActiveUser>();
Dictionary<string, string>? activeUserHashSet = await _cache.GetAllHashSet("activeUsers");
if(activeUserHashSet != null)
{
foreach (var activeUser in activeUserHashSet)
{
activeUsers.Add(new ActiveUser { ConnectionId = activeUser.Value, UserName = activeUser.Key });
}
}
var jsonStr = JsonConvert.SerializeObject(activeUsers);
await _context.Clients.All.ReceiveActiveUsers(jsonStr);
}
}
Background servisi güncelledikten sonra tekrar uygulamamızı çalıştıralım:
using BlazorUI.Contracts;
using Microsoft.AspNetCore.Components;
using BlazorUI.Models.Authentication;
using Microsoft.AspNetCore.SignalR.Client;
using BlazorUI.Services.Common;
namespace BlazorUI.Pages;
public partial class Login
{
public LoginVM Model { get; set; }
[Inject]
public NavigationManager NavigationManager { get; set; }
public string Message { get; set; }
[Inject]
private IAuthenticationService AuthenticationService { get; set; }
private HubConnection? hubConnection;
[Inject]
LocalStorageService localStorage { get; set; }
public Login()
{
}
...
protected async Task HandleLogin()
{
if (await AuthenticationService.AuthenticateAsync(Model.Email, Model.Password))
{
NavigationManager.NavigateTo("/");
string token = await localStorage.GetItem("token");
hubConnection = new HubConnectionBuilder()
.WithUrl(
"https://localhost:7147/track",
o => {
o.AccessTokenProvider = () => Task.FromResult<string?>(token);
o.Url = new Uri($"https://localhost:7147/track?username={Model.Email}");
}
)
.Build();
await hubConnection.StartAsync();
}
Message = "Username/password combination unknown";
}
}
İlk olarak API ve Admin uygulamasını çalıştıracağım daha sonrasında kullanıcı uygulamasını çalıştıracağım, bu sayede aktif kullanıcılara ekleniyor mu görmüş olacağız:
Başarılı bir şekilde diğer kullanıcı yönetim panelinde aktif kullanıcı listesinde görünür oldu. Yazıyı daha fazla uzatmamak adına burada sonlandırıyorum. Muhtemel gözümden kaçan hatalar, yazım yanlışları olabilir.
No comments:
Post a Comment