Thursday, June 27, 2024

Godot'da Avalonia ile UI Oluşturma

Bugün ilginç bir proje ile karşılaştım. Projenin adı Estragonia. GitHub reposuna Releases · MrJul/Estragonia (github.com) buradan ulaşabilirsiniz. Projenin başlığında ise Avalonia in Godot yazmakta yani Godot uygulamasında Avalonia kütüphanesini UI oluşturma imkanı sunma amacı taşıyan bir köprü.

Kulağa hiç fena gelmediği için denemeye karar verdim. Repository'de yer alan README kısmında yer alan yönergelere göre projeyi oluşturmaya çalışalım.

İlk olarak Godot Engine'in .NET sürümünü indirelim. Godot üzerinde bir tane proje oluşturalım. Renderer kısmında Forward+ veya Mobile kullanmalıyız. Anladığım kadarıyla Compatibility şu an desteklenmiyor. Forward+'ı seçtim. Proje adını örnek olması amacıyla GodotWithAvalonia olarak belirledim.

2D Scene oluşturdum ve bu sahneye de Control node ekledim. Node'un ismini de UserInterface yapalım. Daha sonrasında UserInterface node'u seçin ve Inspector kısmında yer alan Focus bölümünde Mode değerini None yerine All yapın. Bu sayede keyboard input'ları işleyebileceğiz.

Gerekli ayarları yaptıktan sonra bu control node'una C# script ekleyelim. Script dosya ismi yine UserInterface.cs olsun. Daha sonrasında Godot solution'u VS Code ile açtım. Avalonia kurulumuna başlayabiliriz.

JLeb.Estragonia NuGet paketini projeye ekleyelim:
dotnet add package JLeb.Estragonia --version 1.1.1
Bu komutu kullanarak paketi yükleyebiliriz. .csproj uzantılı dosyayı kontrol ettiğimde paketin yüklendiğini anlıyorum:
<Project Sdk="Godot.NET.Sdk/4.2.2">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net7.0</TargetFramework>
    <TargetFramework Condition=" '$(GodotTargetPlatform)' == 'ios' ">net8.0</TargetFramework>
    <EnableDynamicLoading>true</EnableDynamicLoading>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="JLeb.Estragonia" Version="1.1.1" />
  </ItemGroup>
</Project>
Ayrıca Avalonia Nuget theme paketini yüklememiz gerekiyor. Projenin geliştiricisi bunun sebebini, diğer türlü Avalonia'dan görüntü alamayız olarak belirtmiş.
dotnet add package Avalonia.Themes.Fluent --version 11.0.11
Gerekli paketleri yükledikten sonra UserInterface.cs script'ine dönelim ve sınıfın miras aldığı sınıfı JLeb.Estragonia.AvaloniaControl olarak değiştirelim. _Ready ve _Process metotları aşağıdaki gibi olsun:
using Godot;
using System;

public partial class UserInterface : JLeb.Estragonia.AvaloniaControl
{
    public override void _Ready()
    {
		
    }

    public override void _Process(double delta)
    {
        
    }
    // Called when the node enters the scene tree for the first time.

}
Avalonia projesinde olduğu gibi Estragonia'da Avalonia.Application'dan türetilmiş bir sınıfa ihtiyaç duyuyor. O sebeple App adında bir sınıf oluşturalım. Bunu XAML veya C# olarak oluşturmak size kalmış. Ben XAML üzerinden ilerleyeceğim:
App.axaml:
<Application
	xmlns="https://github.com/avaloniaui"
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	x:Class="GodotWithAvalonia.App">

	<Application.Styles>
		<FluentTheme />
	</Application.Styles>

<Application>
App.axaml.cs:
using Avalonia;
using Avalonia.Markup.Xaml;

namespace GodotWithAvalonia;

public class App : Application {

	public override void Initialize()
		=> AvaloniaXamlLoader.Load(this);
}
Avalonia'ı başlatmak için AppBuilder.Configure<App>.UseGodot().SetupWithoutStarting() kullanmamız gerek. Bunu tüm AvaloniaControl nesnelerinde tek tek kullanmamız gerekiyor _Ready metodunda ama bunun yerine Godot'da singleton anlamına gelen autoload ile tek bir yerden başlatabiliriz.Öncelikle AvaloniaLoader adında bir sınıf(AvaloniaLoader.cs) oluşturalım:
using Avalonia;
using Godot;
using JLeb.Estragonia;

namespace GodotWithAvalonia;

public partial class AvaloniaLoader : Node {
	public override void _Ready()
		=> AppBuilder
			.Configure<App>()
			.UseGodot()
			.SetupWithoutStarting();
}
Daha sonrasında ise project.godot dosyasına Autoload'u aşağıda gibi ekliyorum:
...

[application]

config/name="GodotWithAvalonia"
run/main_scene="res://main_scene.tscn"
config/features=PackedStringArray("4.2", "C#", "Forward Plus")
config/icon="res://icon.svg"

[autoload]

AvaloniaLoader="res://UI/AvaloniaLoader.cs"

[dotnet]

project/assembly_name="GodotWithAvalonia"
Test amaçlı bir TestView adında bir Avalonia View (Avalonia UserControl) oluşturalım:
TestView.axaml:
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="GodotWithAvalonia.TestView">
	<TextBlock Text="Merhaba Godot!" />
</UserControl>
TestView.axaml.cs:
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace GodotWithAvalonia;

public partial class TestView : UserControl
{
    public TestView()
    {
        InitializeComponent();
    }
}
Daha sonrasında oluşturduğumuz UserControl'u _Ready metotunda çağıralım:
using Godot;
using GodotWithAvalonia;
using System;

public partial class UserInterface : JLeb.Estragonia.AvaloniaControl
{
    public override void _Ready()
    {
		Control = new TestView();

        base._Ready();
    }

    public override void _Process(double delta)
    {
        base._Process(delta);
    }
}
Şimdi projeyi çalıştıralım:
Avalonia in Godot

Kaynakça:

Tuesday, June 25, 2024

SignalR ve WebSockets Transport Kullanımı

SignalR'ın client ve server durumuna göre transport metodlarından en uygun hangisi ise onu seçtiğini biliyoruz. Ancak geliştirme ortamında projemin tam olarak benim istediğim şekilde anlık sonuç vermemesi istediğim bir şey değil. Bu noktadan itibaren önceki yazıyı baz alarak devam edeceğim.

TrackHub sınıfında override ettiğim OnDisconnectedAsync metoduna breakpoint koyduğumda, client kapatıldığında bu metoda düşmesi. Ancak OnDisconnectedAsync metotu tetiklenmiyor. Bu sebeple anlık olarak bir değişme var mı, göremiyorum.

Geliştirme ortamında projemi geliştirirken farkettiğim şey SignalR'ın transport metotu olarak Long Polling kullanmasıydı.

public override async Task OnConnectedAsync()
{
    var transportType = Context.Features.Get<IHttpTransportFeature>().TransportType;
    ...
}
Bu durum beklenen bir şey, ancak beklentim tarayıcım modern bir tarayıcı olduğu için ve muhtemelen asp.net core api projemin çalışması için sunulan IIS Express'de WebSockets transport'u destekliyor diye, WebSockets'i ilk olarak tercih eder demiştim, öyle olmuyormuş.

Transport metodu olarak Long Polling kullanıldığı için yapılan işlemleri anlık olarak göremiyorum. WebSockets ile Long Polling iki transport metodu olarak aralarında ciddi çalışma farkı var. İlk olarak WebSockets server ile client arasında kesintisiz uzun süreli çalışacak olan bir TCP bağlantısı oluşturmamızı sağlıyor ve bu oluşturulan iletişim kanalı real-time full-duplex bir kanal. Full-duplex demek anlık olarak çift yönlü veri transferinin sağlanabilmesidir. 

Long Polling'de ise aslında WebSockets davranışı taklit edilmekle beraber aynı performansı vermiyor. WebSockets'in aksine request-response HTTP mantığında çalışmakta. Yani ilk olarak client tarafından server'a request gönderiliyor, server, kendisi ve client arasında oluşturulan TCP connection'u mesaj gönderene kadar kapatmıyor. Mesaj oluştuktan sonra server bu mesajı response olarak gönderiyor ve mesaj client'a ulaştıysa oluşan bağlantıyı kapatıyor. Daha sonrasında tekrar client server'a request yolluyor ve döngü bu şekilde devam ediyor. Yani sonuç olarak istemci üzerinden sürekli bir tetikleme durumu var.

Ayrıntılı bilgiyi şu bağlantılara bakarak öğrenebilirsiniz:
Benim yaptığım uygulamada ise online olan kullanıcıları sistemde anlık olarak görmem gerek, örneğin kullanıcı client browser'ı kapattığında sistem tarafında bu bilgi hemen güncellenmeli, ancak istemci tarafında ben bu tetiklemeyi yaptırmadığım için connection açıkmış gibi görünüyor. Normal şartlarda Blazor WASM uygulamasında dispose metotunda hubconnection'ı durdurup, dispose ediyorum ancak bir değişiklik olmuyor. Sunucu tarafında çalışan hub'daki OnDisconnectedAsync metotu tetiklenmiyor. Bunun sebebini tam olarak çözemedim, bir süre bu konu hakkında araştırma yaptım ancak net bir sonuç bulamadım. Bu bir bug da olabilir ya da kullanım hatası da olabilir, Long Polling'in çalışma şekli ile alakalı da olabilir.

Bu sebeple TrackHub ile iletişim kurarken WebSockets transport kullanımını zorunlu hale getirdim. İlk olarak API kısmında yapılan konfigürasyonlara bakalım:
builder.Services.AddWebSockets(o => { 
    o.AllowedOrigins.Add("https://localhost:7058");
    o.AllowedOrigins.Add("https://localhost:7212");
});
Sadece belli origin'ler için websockets bağlantısı yapılmasını istiyorum o yüzden AddWebSockets ile bu sınırlandırmayı yukarıdaki gibi yapabiliriz. Bu güvenlik açısından önemli bir ayarlama. Daha sonrasında cors policy'i aşağıdaki gibi ayarladım:
builder.Services.AddCors(options =>
{
    options.AddPolicy("all", builder => builder
        .WithOrigins("https://localhost:7058", "https://localhost:7212")
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials()  // Eğer credential (kimlik doğrulama) kullanılıyorsa ekleyin.
        .SetIsOriginAllowed((host) => true) // CORS doğrulamasını özelleştirmek için gerekebilir.
    );
});
Ayrıca SignalR Hub'ın sadece WebSockets bağlantılarını kabul etmesini istiyorum. Diğer transport'lar devredışı kalmış oluyor:
app.MapHub<TrackHub>("track", o => { 
    o.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
});
Bir önemli nokta ise geliştirme ortamında kullandığım sunucu olan IIS Express'in WebSockets'i desteklemesi gerekli. Bunun için Turn Windows features on or off Windows işletim sistemi arama kısmına yazıp:
  • Internet Information Services
    • World Wide Web Services
      • Application Development Features
        • WebSocket Protocol'u seçip aktif hale getirin
Daha sonrasında client tarafında çalışacak Blazor WASM'de HubConnection sağlarken aşağıdaki değişiklikleri uyguluyorum:
    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 group = "admin";
        ...

        string token = await localStorage.GetItem("token");


        hubConnection = new HubConnectionBuilder()
            .WithUrl(
                "https://localhost:44342/track",
                o => {
                    o.AccessTokenProvider = () => Task.FromResult<string?>(token);
                    o.Url = new Uri($"https://localhost:44342/track?username={username}<group={group}");
                    o.SkipNegotiation = true;
                    o.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
                }
            )
            .Build();

		...

        isLoaded = true;
    }
SkipNegotiation true yaparak direkt hangi transport'u seçtiysek doğrudan o transport'u kullanmasını müzakere yapmadan sağlamış oluyoruz. Projeyi daha sonrasında çalıştırırsak muhtemelen alacağımız hata client tarafında şu olacaktır:
WebSocket connection to 'wss://localhost:44342/.. Authentication failed; no valid credentials available
Bu hatanın çözümünü bulamamıştım ta ki dünyanın en iyi arama motoru ChatGPT gerekli çözümü verene kadar:
services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(o =>
{
    o.SaveToken = true;

    o.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero,
        ValidIssuer = configuration["JwtSettings:Issuer"],
        ValidAudience = configuration["JwtSettings:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtSettings:Key"]))
    };

    o.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            var accessToken = context.Request.Query["access_token"];
            var path = context.HttpContext.Request.Path;
            if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/track")))
            {
                context.Token = accessToken;
            }
            return Task.CompletedTask;
        }
    };
});
OnMessageReceived event'i her HTTP mesajı için tetiklenir, amaç JWT token gönderen durumları dinlemektir. accessToken boş değilse ve url track ile başlıyorsa context.Token'a atama token ataması gerçekleştirilir. Yani kısacası OnMessageReceived token'i alır, token doğrulanır ve 'Authorize' attribute'u eğer token doğrulandıysa erişimi sağlar.

Thursday, June 20, 2024

SignalR ve Garnet ile Anlık Kullanıcıları İzleme

Üzerinde çalıştığım kişisel bir projede sistem üzerindeki anlık kullanıcı sayısını yönetim panelinde görmek istiyorum. Normal olmayan şartlarda ve en cahil halimle bir endpoint yazıp sunucuya her bir dakikada request atarım ve sunucu da anlık kullanıcı sayısını bana response olarak dönerdi. Ama bu hiç mantıklı değil, sunucuya ciddi anlamda yük bindirmiş oluruz(olmaz ama öyle varsayalım). O zaman çözüm SignalR. En iyi çözüm mü bilmiyorum ama onunla problemimi çözebilirim diye düşünüyorum. 

SignalR, Microsoft tarafından geliştirilmiş real-time iletişim sağlayan bir kütüphane. Bu iletişim server ile client tarayıcısı arasında gerçekleşmekte. SignalR'ın yaptığı şey server ile client arasında kalıcı bir bağlantı oluşturmak. Yani bu bağlantı bir kere oluşuyor sonrasında sürekli olarak çalışmaya devam ediyor. Burada önemli husus server'in herhangi bir istek alma zorunluluğunun olmaması. Duruma göre server üzerinde yapılan bir işlem sonucu client'a veri gönderilebilir. Bu işleme server push denmekte. Kısacası tek bir bağlantı üzerinden server-client arasında iletişim kurabiliyoruz. SignalR, bu iletişimi sağlarken şartlar için en uygun olduğunu düşündüğü bir transport metodu kullanıyor. Performans açısından kullanılabilecek en uygun tranport metodu ise SignalR için Websockets diyebiliriz. Ancak SignalR bu seçimi server-client teknolojilerine bağlı olarak yapar. Örneğin, client oldukça eski yani 2010 öncesi bir tarayıcı sürümü kullanıyorsa bu durumda SignalR transport metodu olarak Long Polling kullanacaktır. HTML5 destekli tarayıcılar WebSockets ve Server-Sent Events kullanımına olanak sağlıyor. Bu noktada çok fazla detaya girmeyeceğim ama ayrıntılı olarak bilgi veren bazı yazılar buldum onları aşağıya ekliyorum:
SignalR'ı genel olarak kullanabileceğimiz uygulama alanları: chat, notifications, real-time oyunlar ve yapmak istediğim anlık kullanıcıları sistem üzerinde görme yani genellersek sistemi canlı izleme. 

SignalR bir protocol değil bir kütüphane, implementasyon hazır olarak yapılmış bu kütüphanede. Yani bizim WebSockets için bir geliştirme yapmamıza gerek kalmıyor. SignalR o kısımları bizim için hallediyor. Bize kalan kısım ise onun sunduğu metotları uygun şekilde kullanmak. 

Şimdi probleme dönelim. Anlık kullanıcı sayısını yönetim panelinde görmek istiyorum. Yazdığım projenin Asp.Net Web API kısmı sunucu tarafında çalışacak, admin paneli olarak kullanacağım uygulama ise Blazor WASM yani istemci tarafında çalışacak bir uygulama.

Ayrıca gerekli değerleri geçici olarak depolamak için Garnet kullanacağım. Garnet, Microsoft tarafından geliştirilmiş remote cache-store bir redis alternatifi. Ayrıntılı bilgiye buradan ulaşabilirsiniz Welcome to Garnet | Garnet (microsoft.github.io).

Bu uygulamalara SignalR'ı ekleyelim. Öncelikle API tarafında Program.cs tarafında SignalR'ı projeye dahil ediyorum:
...
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.

Şimdilik API tarafında SignalR için gerekli eklemeler yapıldı. Sıra Garnet'e geldi, zaten yukarıda bahsettim biraz. İlk olarak API tarafında Garnet için gerekli implementasyonu yapmaya başlayalım. Infrastructure'da Identity katmanında Microsoft.Garnet paketini indirdim. Application katmanında Contracts içine IGarnetCacheService adında bir interface oluşturalım:
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:
 
Ayrıca client tarafında Garnet Server'i test etmek için redis-cli indirebilirsiniz. Bu sayede pratik şekilde Garnet server'ı kullanabiliyorsunuz.  Garnet'in dokümantasyonunda gördüğüm kadarıyla redis-cli ile kullanabilmeniz mümkün.

Garnet server'a bağlanabilmek için 127.0.0.1 adresini ve 6379 portunu kullanmamız gerek. Bu bilgileri appsettings.json dosyasına ekleyelim:
	"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. 

Oluşturulan cache servisini dependency injection yaparak TrackHub ve oluşturulan background service'te kullanacağım. (Background service'in güncel halini birazdan ekleyeceğim):

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.

Kullanıcı adını username parametresi olarak gönderiyoruz ve  server tarafında OnConnectedAsync çalıştığında Garnet Server'a activeUsers listesi adı altında eklenmiş oluyor.

Şimdi API ve AdminUI projesini çalıştıralım sonrasında kullanıcı Garnet Server'a eklenmiş mi kontrol edelim. Kontrolü redis-cli üzerinden yaptım:

admin kullanıcısının sisteme bağlandığını görebiliyorum. Fakat burada ben sayfayı yenilersem doğal olarak admin username'i tekrar eklenecek, liste boyutu artacak ve de istediğimiz şekilde çalışmamış olacak.

Bu durumda HashSet kullanmak daha mantıklı olacak. Aynı zamanda performans noktasında en iyi çözümü bu yapıyla sağlayabiliriz çünkü sürekli olarak listeyi kontrol etmek ve tekillik sağlamak optimal bir çözüm değil. İlk olarak dokümantasyonu kontrol ettim. Buradaki bilgiye göre Data Structures | Garnet (microsoft.github.io) cache servise aşağıdaki eklemeyi yaptım. (Interface'i güncellemeyi unutmayın)
    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.

Bu yapıyı kullanacağız ama nasıl? Birincisi SignalR'da her bağlantı yaptığımız da o client ve server'a özel bir ConnectionId oluşuyor. Her yeni bir bağlantıda bu ConnectionId değişebilir yani kalıcı bir id değil bu. O sebeple bizim asıl belirleyici yani ConnectionId'leri tanıma imkanı veren belirleyicimiz kullanıcı adı. Bu sebeple Garnet üzerinde hash yapısında field tarafında depolanacak veri, username'in kendisi olacak. Value tarafında ise ConnectionId depolanmış olacak. Hub bağlantısı yenilendiğinde ise eğer username'a ait bir eski ConnectionId var ise onun yerine yeni oluşan ConnectionId değeri atanacak.

İlk olarak TrackHub'da OnConnectedAsync metodunu aşağıdaki gibi güncelleyelim:
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. 

Bu bilgileri yönetim paneli arayüzünde görüntülemek istiyorum. ITrackHub'da zaten sayı almak için gerekli metotu oluşturmuştuk. Ayrıca ben aktif kullanıcı listesini de almak istiyorum. O yüzden Application katmanında ActiveUser adında bir model oluşturdum:
    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:

Başarılı bir şekilde gelmiş. O zaman aktarımda bir problem yoksa asıl veriyi göndermeye başlayabiliriz. İlk olarak hashset'i uygun şekilde Garnet server'dan çekebilmem lazım:

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:

Sıra kullanıcı uygulamasını test etmeye geldi. Yapacağım tek şey server ile client'ı çalışan TrackHub üzerinden birbirine bağlamak. Onun için Nuget Package Manager üzerinden Microsoft.AspNetCore.SignalR.Client paketini kullanıcı uygulamasına öncelikli olarak kuralım. Sonrasında login işlemi yapılan Login.razor.cs dosyasında aşağıdaki eklemeleri yaptım HubConnection için:
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. 

Tuesday, June 18, 2024

.NET Günlük #1 - Late Binding, Early Binding, const, readonly

C# üzerine yazacağım notları bu blog üzerinde toplamaya karar verdim. Hem teorik anlam da hem de pratik olarak küçük notlar barındıracak bir yazı serisi olacak. Merak ettiğim veya öğrenmek istediğim konular hakkında araştırmalardan öğrendiklerimi bu yazı serisine ekleyeceğim. Tamamen birbirinden bağımsız konular bir yazı içeriğinde toplanabilir. Kesinlikle bir tutorial serisi amacı taşımamaktadır.

Early Binding ve Late Binding

Daha önce duymadığım kavramlar. Early binding derleme sürecinde metot, property ve değişkenlerin belirlenip, doğrulanmasıdır. Derleyici bir nesnenin tipinin ne olduğunu bilir. Early binding durumunda bir hata varsa compile-time yani derleme zamanında hata alınır. Örneklendirmek gerekirse, bir class oluşturduk diyelim. Bu class'tan bir nesne oluşturmuş olalım. Bu nesne üzerinden ben varolmayan bir metot çağırırsam, derleyici böyle bir metodun olmadığına dair derleme anında bir hata verecektir. Bizim buradaki nesnemiz early binding olarak tanımlandı, yani derleyici nesnenin tipi olan class'ı önceden biliyor.

Late binding ise tam tersi yani derleyici, derleme anında object'in tipine dair bir bilgisi olmaz. Örneğin dynamic olarak tanımlanan bir değişken ve buna atanan bir değer olsun. Çalışma anında o değerin tipi ne ise o zaman değişkenin tipi belirlenmiş olur.

Late binding ile alakalı önemli bir husus aslında polimorfizm ile alakalı. Bir base class tanımlamış olalım. Bu base class'ta ise Print adında virtual bir void metot tanımlanmış olsun. Bu base class'tan kalıtım alan bir subclass tanımlayalım ayrıca, yine bu subclass'ta Print adlı metotu tekrar override etmiş olalım. Base class tipinde bir nesne tanımlayıp, buna değer olarak Subclass tipinde bir nesne  değeri atarsak. Bu durumda Print metotlarından hangisinin çağırılacağı çalışma anında yani run-time'da belirlenmiş olur. Bu durumu ise late binding olarak adlandırabiliriz.

const ve readonly

Her ikisi de bir C# keyword'u. İlk başta ikiside aynı işi yapıyor gibi görünebilir ama bariz farklar var. Const keyword'u ile tanımlanan bir constant, declaration esnasında bir değer alır ve compile-time constant olarak daha sonrasında değiştirilmesi mümkün değildir. Readonly'de constant olarak tanımlar değişkeni  ama daha tanımlarken değer atanması zorunlu değildir. Yani kurucu metot üzerinde daha sonrasında readonly constant'ın değeri en başta atanabilir, yine daha sonrasında değiştirilemez. Ancak bu atanma işlemi runtime olarak oluyor const tanımlı bir constant'a nazaran. Tabi en başta deklare ederken değer atanırsa readonly constant'a o zaman atama işlemi compile-time olur.

Ayrıca const tanımlı değişmezlere değer ataması yapılırken atanan değerin bir literal ve sabit bir expression olması gerekir. Readonly'de böyle bir zaruriyet yok ilk değer ataması yapılırken.

Ayrıntılı bilgi içeren kaynaklar: