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:

Friday, November 3, 2023

WPF, MVVM (CommunityTookit.Mvvm) and Dependency Injection

Projelerim için genel olarak bir template oluşturmak için bu yazıyı yazıyorum. Daha sonrasında da kontrol eder ve burayı temel alırım diye düşünüyorum. Bu yazının amacı teorik olarak ilerlemeyecek daha çok pratik ve sonuç odaklı bir şekilde ele alınacaktır. 

Öncelikle .NET 8 üzerinden bir WPF oluşturuldu. Daha sonrasında MVVM mimarisini projeye uygulayacağız. Proje dizininde üç temel klasörü şimdiden oluşturalım:
  • Models
  • ViewModels
  • Views
Views klasöründe TestView adında basit bir şekilde hazırlamış olduğum bir UserControl .xaml dosya oluşturdum:
<UserControl x:Class="LDG_WPF.Views.TestView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:LDG_WPF.Views"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>

        <Grid Grid.Row="0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="auto"></ColumnDefinition>
            </Grid.ColumnDefinitions>


            <TextBox Grid.Column="0" Width="200" MaxLength="100">

            </TextBox>
            <Button Grid.Column="1" Content="Export" />
        </Grid>

        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="2*"></ColumnDefinition>
            </Grid.ColumnDefinitions>


            <TextBlock Grid.Column="0"></TextBlock>
        </Grid>
    </Grid>
</UserControl>
 
Şimdi sıra logic katmanı görevi görecek olan ViewModel oluşturmada. Normalde ViewModel'ler için bir base class oluşturup onun üzerinden property değişikliklerini handle etmemizi sağlayan bir yapı kullanılırdı. Ancak bu bir nokta zahmet olmaya başladığı için ve bu gibi işleri bizim yerimize yapan paketler çıktığı için artık bu tarz bir yönelime girmeye gerek yok. Microsoft tarafından geliştirilen CommunityToolkit.Mvvm bizim işimizi fazlasıyla görecektir. Nuget Package Manager üzerinden indirebilirsiniz.  Bu sayede ICommand veya INotifyPropertyChanged gibi interface'leri custom olarak  kendimiz uygulamak zorunda kalmadık. 
public partial class TestViewModel: ObservableObject
{
    [ObservableProperty]
    string? displayText;
    [ObservableProperty]
    string? inputText;

    [RelayCommand]
    public void SetText()
    {
        DisplayText = InputText;
    }
}

Şimdi sıra bu ViewModel'i yazdığımız TestView'e bağlamaya geldi. İlk olarak .xaml dosyasından ViewModel'leri bulabilmek için namespace tanımlamak zorundayız, daha sonrasında bu ViewModel'i View datacontext'ine bağlamak için UserControl.DataContext kullanmaktayız:
<UserControl x:Class="LDG_WPF.Views.TestView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:viewmodels="clr-namespace:LDG_WPF.ViewModels"
             xmlns:local="clr-namespace:LDG_WPF.Views"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">

    <UserControl.DataContext>
        <viewmodels:TestViewModel x:Name="test"/>
    </UserControl.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>

        <Grid Grid.Row="0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="auto"></ColumnDefinition>
            </Grid.ColumnDefinitions>

            <TextBox Grid.Column="0" Width="200" MaxLength="100" Text="{Binding InputText}">

            </TextBox>
            <Button Grid.Column="1" Content="Export" Command="{Binding SetTextCommand}" />
        </Grid>

        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="2*"></ColumnDefinition>
            </Grid.ColumnDefinitions>

            <TextBlock Grid.Column="0" Text="{Binding DisplayText}"></TextBlock>
        </Grid>
    </Grid>
</UserControl>

Not: Ayrıca ViewModel bulunamadı gibi bir hata alırsanız muhtemelen projeyi rebuild yaparak sorunu ortadan kaldırabilirsiniz.

Test etmek için TestView'i MainView üzerinden çağırmalıyız:

<Window x:Class="LDG_WPF.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        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"
        xmlns:local="clr-namespace:LDG_WPF"
        xmlns:views="clr-namespace:LDG_WPF.Views"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <views:TestView/>
    </Grid>
</Window>
Textbox'a bir şeyler yazarsak ve export dersek, yazdığımız yazı TextBlock üzerinde görünecektir. Hey hey hey. CommunityToolkit.Mvvm işi bayağı kolaylaştırdı:

Artık sıra geldi bir Dependecy Injection olayını WPF projesi üzerinde uygulamaya. Dependecy Injection'u IoC container teknoloji olan Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Configuration.Abstractions ve Microsoft.Extensions.Options.ConfigurationExtensions  nuget paketi kullanarak uygulayacağız. App.xaml.cs dosyasında aşağıdaki kodları yazalım:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Configuration;
using System.Data;
using System.Windows;

namespace LDG_WPF
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public IServiceProvider ServiceProvider { get; private set; }

        public  IConfiguration Configuration { get; private set; }

        protected override void OnStartup(StartupEventArgs e)
        {
            var builder = new ConfigurationBuilder();

            Configuration = builder.Build();

            var serviceCollection = new ServiceCollection();
            ConfigureServices(serviceCollection);

            ServiceProvider = serviceCollection.BuildServiceProvider();
            
            var mainWindow = ServiceProvider.GetRequiredService<MainWindow>();
            mainWindow.Show();
        }

        private void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient(typeof(MainWindow));
        }
    }
}
Ayrıca App.xaml dosyasından StartupUri="MainWindow.xaml" opsiyonunu kaldırdım çünkü aynı pencereden tane açılmasına sebep oluyordu:
<Application x:Class="LDG_WPF.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:LDG_WPF"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>

Tuesday, October 11, 2022

Handling Response of REST API

Let's say we need to use some REST api in our application. This api give an response object like below:

  • Object:
    • isSuccess
    • message
    • result
For example, we use a blogger service for our blog, and let's say this service provides some features like recent posts, statistics of post, etc. This features has fields that defined by publisher of the api. We should know these fields of the features, so we can implement this our project. We need to create a response class and response objects according to the fields of the features:
public class Response<T>
{
    public bool isSuccess { get; set; }
    public string message { get; set; }
    public T result { get; set; }
}

// we created response object classes according to the api
public class RecentPostResponse 
{
    public int postId { get; set; }
    public string title { get; set;}
    public string author { get; set; }
    public string publishedDate { get; set; }
}

public class PostStatisticResponse
{
    public int postId { get; set; }
    public int viewCounter { get; set; }
}
We have to create request method to use the api. Probably, the publisher will give an username and password or token.
private Response<T> SendRequest<T>()
{
	...
    
    string responseContent = ...
    
    Response<T> response = Json.DeserializeObject<Response<T>>(responseContent);
    
    return response;
}
I can use the api. I want to get recent post list via the api:
...

Response<List<RecentPostResponse>> response = SendRequest<List<RecentPostResponse>>();

List<RecentPostResponse> recentPostList = response.result;

...
That's how we can handle it.😟

Saturday, October 8, 2022

Making To-do App with .NET MAUI

First note as an information, the blogger of this blog is not profession about .NET MAUI. There is going to be long post for a making to-do app. Because this is not about just to-do application itself. MVVM will be used for project structure, and also the application can save to local database. We are going use SQLite database engine. Because it is simple, and the project is not complex. 

# MVVM is software architectural pattern. 
  • Model
  • View 
  • Viewmodel
Basically, this pattern makes more manageable, readable, and reusable a code project. You have to use it, but it recommended for .NET MAUI apps. Model is about data. A models represent a table in database. If we are going to make a todo application, then we need to create model that called to-do within models' folder in the project directory. Yes, we are going to create three folders that called models, views, viewmodels in the project. The first lights of this decoupling.

View is about user interface of the application. This side just shows necessary animation, other stuffs and data to user. For example, I'm going to add checkbox for to-do app. So actually I will do it in views folder. If I make changes on UI then I have to go Views as part of the project.

Viewmodels are logical structures that provide a relationship between the view and the model. This is the main part of programming (for me👶). We are going add and update database from here.

# Generic Repository Pattern

But I don't want to use sql connection in viewmodels. I want to reduce my work with the database quite a bit here. So let's code our generic repository class for the project. I created interface of the repository:
The folder struct will be like that:
  • +DBManager
    • +Repositories
    • IRepository.cs
    • Repository.cs
using SQLite;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;

namespace Todo.Business
{
    public interface IRepository<T> where T : class, new()
    {
        Task<List<T>> GetAllAsync();
        Task<T> GetAsync(int id);
        Task<List<T>> GetList<TValue>(Expression<Func<T, bool>> predicate = null, Expression<Func<T, TValue>> orderBy = null);
        Task<T> GetEntity(Expression<Func<T, bool>> predicate);
        AsyncTableQuery<T> AsQueryable();
        Task Add(T entity);
        Task Update(T entity);
        Task Delete(T entity);
    }
}
Before implement of this interface, I need to create connection class for database.

Firstly, We need to install sqlite-net-pcl(1.8.116) from nuget package manager. You don't have install sqlite database engine to your computer. But I think this package is too useful for users of the application. Because they don't have to install any database engine to use application because this is provided by this package. But for testing, you can install DB Browser for SQLite. So you can see what's going on the database. Now we can create DBConnection class:
using Todo.Models;
using SQLite;

namespace Todo.Business
{
    public class DBConnection
    {
        string _path = @"C:\Users\gurbu\Desktop\maui-projects\Todo\DB\Database.db3";

        SQLiteAsyncConnection _connection;
        public SQLiteAsyncConnection Connection { get { return _connection; } }

        public DBConnection()
        {
            _connection = new SQLiteAsyncConnection(_path);

            CreateTables();
        }

        private void CreateTables()
        {
            // tables is created here
        }
    }
}
Let's implement the interface to the repository class:
using SQLite;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;

namespace Todo.Business
{
    public class Repository<T> : IRepository<T> where T : class, new()
    {
        private SQLiteAsyncConnection _connection;

        public Repository(DBConnection dBConnection)
        {
            _connection = dBConnection.Connection;
        }

        public AsyncTableQuery<T> AsQueryable()
        {
            return _connection.Table<T>();
        }

        public Task<List<T>> GetAllAsync()
        {
            return _connection.Table<T>().ToListAsync();
        }

        public Task<T> GetAsync(int id)
        {
            return _connection.FindAsync<T>(id);
        }

        public Task<T> GetEntity(Expression<Func<T, bool>> predicate)
        {
            return _connection.FindAsync(predicate);
        }

        public async Task<List<T>> GetList<TValue>(Expression<Func<T, bool>> predicate = null, Expression<Func<T, TValue>> orderBy = null)
        {
            AsyncTableQuery<T> table = _connection.Table<T>();

            if(predicate != null)
            {
                table = table.Where(predicate);
            }
            if(orderBy != null)
            {
                table = table.OrderBy<TValue>(orderBy);
            }

            return await table.ToListAsync();

        }

        public async Task Add(T entity)
        {
            await _connection.InsertAsync(entity);
        }

        public async Task Update(T entity)
        {
            await _connection.UpdateAsync(entity);
        }

        public async Task Delete(T entity)
        {
            await _connection.DeleteAsync(entity);
        }
    }
}
Probably this code causes some errors because of the constructor. We need to make dependency injection for it. Open MauiProgram.cs file from the project, and add DBConnection:
using Todo.Business;
using Todo.Business.Repositories;
using Todo.ViewModels;

namespace Todo;

public static class MauiProgram
{
	public static MauiApp CreateMauiApp()
	{
		var builder = MauiApp.CreateBuilder();
		builder
			.UseMauiApp<App>()
			.ConfigureFonts(fonts =>
			{
				fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
				fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
			});

		builder.Services.AddSingleton<DBConnection>();
		
		return builder.Build();
	}
}
Now we can reach the instance of DBConnection from the constructor. 

Let's create our first model which is called Entity:
using SQLite;

namespace Todo.Models
{
    public class Entity
    {
        [PrimaryKey, AutoIncrement]
        public int Id { get; set; }
        public DateTime CreatedTime { get; set; } = DateTime.Now;
        public DateTime UpdatedTime { get; set; }
    }
}
The entity class is inherited by the sub-classes. We don't have to define Id, CreatedTime, UpdatedTime properties for each class. We can just inherit Entity class for them:
using SQLite;

namespace Todo.Models
{
    public class Todo: Entity
    {
        public bool IsChecked { get; set; }

        [MaxLength(250)]
        public string Text{ get; set; }
    }
}
Let's create specific repository class. But why? Because, we will make dependency injection for that specific repository. So that means less lines of code and maintainable.
using Todo.Business.Repositories;
using Todo.Business;
using Todo.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Todo.DBManager.Repositories
{
    public interface ITodoRepository : IRepository<Todo>
    {

    }

    public class TodoRepository : Repository<Todo>, ITodoRepository
    {
        private readonly DBConnection _connection;
        public TodoRepository(DBConnection dBConnection) :
            base(dBConnection)
        {
            _connection = dBConnection;
        }
    }
}
Let's go to the MauiProgram.cs file and add these of lines code like below:
		builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
		builder.Services.AddScoped<ITodoRepository, TodoRepository>();
Now we can reach that the instance of the repository from viewmodels constructor. But not now. Before something do that, do not forget to add the necessary function to create our table in the database:
		...
	public DBConnection()
        {
            _connection = new SQLiteAsyncConnection(_path);

            CreateTables();
        }

        private void CreateTables()
        {
            _connection.CreateTableAsync<Todo>();
        }
        ...
Let's run the project and check the file with extension .db3 is created. If the file is existed in the specified folder, so that's good progress. Let's open it using DB Browser for SQlite program:
SQLite overview

# Viewmodels

Now we can create viewmodel that called MainViewModel.cs, it is optional. I defined with that name because I'm going to use MainPage.xaml. 

We need to install CommunityToolkit.Mvvm(8.0.0) package by Microsoft. This package provides easy usage for MVVM pattern. So we just create properties for attributes that are provided by this package to show in view side.

What should we do in this application?
  • Add to-do
  • List to-dos with checkbox
  • Show loading bar with percentage value if checked.
I created folder called ViewModels, and created new file named MainViewModel.cs in this folder. 
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Todo.DBManager.Repositories;
using Todo.Models;

namespace Todo.ViewModels
{

    [ObservableObject]
    public partial class MainViewModel
    {
        private readonly ITodoRepository todoRepository;

        public MainViewModel(ITodoRepository todoRepository)
        {
            this.todoRepository = todoRepository;
        }

        [ObservableProperty]
        private string text;

        [RelayCommand]
        public void AddTodo()
        {
            Todo todo = new Todo();
            todo.Text = text;
            this.todoRepository.Add(todo);
        }
    }
}
I created MainViewModel as partial class, and give an attribute called ObservableObject. Also defined text property like field and, I gave ObservableProperty attribute. The package we installed makes mvvm operations for us. We can use it command in view side if we give RelayCommand attribute to the function. So what did I the code above? Firstly, I made a dependency injection using TodoRepository. After that, I'm able to reach the database and make changes on it. I defined a property called text. I will bind it to a textbox or similar component of .NET MAUI in view side, and also I created a method as a command for adding todo the database. I will bind it as a command to the add button in view side.

Open the MauiProgram.cs and also add singletons of MainPage and MainViewModel:
        builder.Services.AddTransient<MainPage>();
        builder.Services.AddTransient<MainViewModel>();

        return builder.Build();
	}
}
After that open App.xaml.cs and make these changes like below:

namespace Todo;

public partial class App : Application
{
	public App(MainPage mainPage)
	{
		InitializeComponent();

		//MainPage = new AppShell();
		MainPage = new NavigationPage(mainPage);
	}
}
Open the MainPage.xaml.cs and assign mainViewModel to BindingContext:
using Todo.Business.Repositories;
using Todo.ViewModels;

namespace Todo;

public partial class MainPage : ContentPage
{

	public MainPage(MainViewModel mainViewModel)
	{
		InitializeComponent();
		BindingContext = mainViewModel;
	}
    ...
Let's code our page view in MainPage.xaml:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Todo.MainPage"
             xmlns:viewmodel="clr-namespace:Todo.ViewModels"
             x:DataType="viewmodel:MainViewModel">

    <ScrollView>
        <VerticalStackLayout
            Spacing="25"
            Padding="30,0"
            VerticalOptions="StartAndExpand">

            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"></RowDefinition>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="7*"/>
                    <ColumnDefinition Width="2*"/>
                </Grid.ColumnDefinitions>
                <Entry Grid.Column="0" MaxLength="250" Text="{Binding Text}"></Entry>
                <Button Grid.Column="1" Text="add to-do" Command="{Binding AddTodoCommand}"/>
            </Grid>
            
        </VerticalStackLayout>
        
    </ScrollView>

</ContentPage>
Let's check the result:
Let's test it:

Okay, that's fine. But CreatedTime value is not look good. But it is actually SQLite date format. It's not problem (for me 👴).
So how to list these data in the bottom of the entry, and the button. Firstly I have to define class as ObservableObject. But I think I cannot do it for models. However, I can use DTO(data transfer object) as solution for this problem. I created a folder called DTOs in the project, and also created file named TodoDto:
using CommunityToolkit.Mvvm.ComponentModel;

namespace Todo.DTOs
{
    [ObservableObject]
    public partial class TodoDto
    {
        [ObservableProperty]
        public bool isChecked;

        [ObservableProperty]
        public string text;
    }
}
Open the MainViewModel.cs and create a list method:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Todo.DBManager.Repositories;
using Todo.DTOs;
using Todo.Models;
using System.Collections.ObjectModel;

namespace Todo.ViewModels
{

    [ObservableObject]
    public partial class MainViewModel
    {
        private readonly ITodoRepository todoRepository;

        [ObservableProperty]
        ObservableCollection<TodoDto> todos = new();

        public MainViewModel(ITodoRepository todoRepository)
        {
            this.todoRepository = todoRepository;
            this.ListTodos();
        }

        [ObservableProperty]
        private string text;

        [RelayCommand]
        public void AddTodo()
        {
            ...
        }

        [RelayCommand]
        public void ListTodos()
        {
            todos = new ObservableCollection<TodoDto>(
                todoRepository.GetAllAsync().Result
                .Select(x => new TodoDto { 
                    Text = x.Text, 
                    IsChecked = x.IsChecked 
                }).ToList()
                );
        }
    }
}
Now let's code the list on the MainPage.xaml:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Todo.MainPage"
             xmlns:viewmodel="clr-namespace:Todo.ViewModels"
             xmlns:dtos="clr-namespace:Todo.DTOs"
             x:DataType="viewmodel:MainViewModel">

    <ScrollView>
        <VerticalStackLayout
            Spacing="25"
            Padding="30,0"
            VerticalOptions="StartAndExpand">

            <Grid>
                ...
            </Grid>

            <ListView ItemsSource="{Binding Todos}">
                <ListView.ItemTemplate>
                    <DataTemplate x:DataType="dtos:TodoDto">
                        <ViewCell>
                            <Grid>
                                <Grid.RowDefinitions>
                                    <RowDefinition></RowDefinition>
                                </Grid.RowDefinitions>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="Auto"/>
                                    <ColumnDefinition Width="Auto"/>
                                </Grid.ColumnDefinitions>

                                <CheckBox IsChecked="{Binding IsChecked}"/>

                                <Label Grid.Column="1" Text="{Binding Text}"/>
                            </Grid>
                        </ViewCell>
                    </DataTemplate>
                </ListView.ItemTemplate>

            </ListView>

        </VerticalStackLayout>

    </ScrollView>
    
</ContentPage>
Let's test it:
DTO usage as ObservableObject
It works. Of course, I'm not sure my solution(dto usage as ObservableObject) is great idea or not bad. Maybe it can be standard but I have no idea if it is. 

Now the big part is done. From here it is entirely up to you. I'm tired, see you.