MediatR Kütüphanesi Eşliğinde Cross-Cutting Concerns(CCC) Manevraları
Merhaba,
Bu içeriğimizde bir yazılım sisteminin farklı parçaları arasında yaygın olarak bulunan ve birçok farklı işlevselliği etkileyen davranışlara karşın tarif olarak kullanılan Cross-Cutting Concerns kavramını değerlendiriyor olacak ve logging, validation, caching vs. gibi belli başlı çalışmaları MediatR kütüphanesiyle nasıl uygulayabileceğimizi değerlendiriyor olacağız. Haliyle vakit kaybetmeksizin hemen başlayalım…
Cross-Cutting Concerns Nedir?
Cross-Cutting Concerns (CCC), bir yazılımın birden fazla bileşeni veya modülü üzerinde yayılan, tekrarlayan ve böylece birden fazla noktayı etkileyen davranışları ifade eden bir kavramdır. Ee doğal olarak birden fazla noktayı etkileyen davranışların söz konusu olması, ister istemez kimi sorunları beraberinde getirmekte ve neticede bir endişe yaratmaktadırlar. İşte bu nedenden dolayı bu davranışlar concerns olarak nitelendirilmektedirler. Cross-Cutting denmesinin sebebi ise bu endişelerin, uygulamaların farklı parçaları arasında cereyan etmesidir. Yani farklı noktalarda kesişen, ortak olan kaygılar/endişeler mevzu bahistir.
Buna misal olarak logging’i örnek verebiliriz. Yazılımlarda logging; uygulamanın temel iş mantığından bağımsız olarak birçok noktasında tekrarlı bir şekilde uygulanan ve böylece uygulamaya yayılmış olan bir davranıştır. Haliyle logging; uygulamanın temel işlevselliğinin dışında olan bir işlev olmasından ve uygulamanın birçok noktasında kullanılmasından dolayı bir Cross-Cutting Concern’dir diyebiliriz.
Ee Ne Yapmalı O Halde?
Yazılımlardaki bu tür endişelerin, uygulamanın genişliği ve karmaşıklığı arttıkça yönetilmesi zorlaşabilir. Bu nedenle, yazılım geliştirme sürecinde Cross-Cutting Concerns’lerin iyi bir şekilde ele alınması ve yönetilmesi önem arz etmektedir. Bu amaçla, genellikle Aspect-Oriented Programming(AOP) gibi teklikler veya farklı tasarımlar kullanılarak bu endişelerin yönetilmesi gerekmektedir.
❔Peki nasıl yönetmeliyiz hoca❔
Bu tarz farklı yerlerde tekrar eden davranışları, temel işlevselliği etkilemeden uygulamaya entegre etmeyi sağlayacak bir yaklaşımla. Yani endişeli kodları tek bir yerde merkezileştirerek. Böylece hem kod tekrarı azaltılmış olacaktır hem de kodun bakımı kolaylaşacaktır.
Özellikle farklı bileşenlerden oluşan uygulamalarda, bu bileşenler arasındaki iletişimi sağlamak için MediatR kütüphanesi kullanılıyorsa buradaki her bir isteğe karşın pipeline’da merkezi bir çözüm noktası oluşturulmalı ve önceki satırlarda bahsedildiği üzere caching, validation ya da logging tarzı işlemlerin bu noktadan çözümlenmesi gerekmektedir. Yani bir nevi bunun için MediatR pipeline’ının genişletilmesi gerekmektedir. Bunun için MediatR’da IPipelineBehavior
interface’inden istifade ediyor olacağız. Bu interface ile MediatR üzerinden yapılan isteklerin öncesi ve sonrası süreçlerinde belirli davranışları gerçekleştirebilir ve bu sayede temel iş mantığından ayrı olarak bu şekilde Cross-Cutting Concern’leri ekleyip, yönetebiliriz.
Şimdi gelin MediatR kütüphanesini kullandığımız çalışmalarda birkaç senaryo üzerinden Cross-Cutting Concerns davranışlarının yönetimini tecrübe etmeye çalışalım.
Cross-Cutting Concerns #1 – Logging
Logging, uygulamalarımızın canlı ortamlardaki reflekslerini takip edebilmemizi, uygulamanın anlık ya da geriye dönük davranışlarına dair veriler sağlayan hayati öneme sahip bir davranıştır diyebiliriz.
public record MyRequest(string Text) : IRequest<MyResponse>; public record MyResponse(string Message); public sealed class MyHandler : IRequestHandler<MyRequest, MyResponse> { public Task<MyResponse> Handle(MyRequest request, CancellationToken cancellationToken) => Task.FromResult<MyResponse>(new("...")); }
Şimdi, yukarıdaki gibi MediatR konsepti eşliğinde bir isteği yürüttüğümüzü varsayarsak eğer bu süreçteki logging işleminin gerçekleştirilebilmesi için IPipelineBehavior
interface’ini şöyle kurgulayabilir;
public class LoggingPipelineBehavior<TRequest, TResponse>(ILogger<LoggingPipelineBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : class where TResponse : class { public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { logger.LogInformation($"Handling {typeof(TRequest).Name}"); TResponse response = await next(); logger.LogInformation($"Handled {typeof(TRequest).Name}"); return response; } }
ve aşağıdaki gibi uygulamaya dahil edebiliriz;
var builder = WebApplication.CreateBuilder(args); Log.Logger = new LoggerConfiguration() .WriteTo.File("logs.txt") .CreateLogger(); builder.Host.UseSerilog(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); builder.Services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblyContaining<Program>()); var app = builder.Build(); app.MapGet("/", async (IMediator mediator) => await mediator.Send(new MyRequest("MyRequest Sending..."))); app.Run();
Böylece tüm MediatR isteklerindeki loglama süreci, oluşturduğumuz LoggingPipelineBehavior
sınıfı üzerinden yürütülecek ve haliyle yazılıma yayılmaktan ziyade merkezi olarak bu sorumluluk gerçekleştirilecektir.
Cross-Cutting Concerns #2 – Validation
Validation, yazılım uygulamalarında sisteme girecek olan verilerin beklenen formatta olup olmadığını denetleyen ve böylece sistemin tutarlılığını korumak için ilk savunma hattı olarak kullanılan davranışlardır. Validation sayesinde yazılımlar, hem potansiyel güvenlik açıklarına karşı korunak sağlamakta hem de olası tutarsızlıklara karşı önlem almaktadırlar.
public record CreateUserCommandRequest(string Username, string Email, string Password) : IRequest<CreateUserCommandResponse>; public record CreateUserCommandResponse(string Message); public sealed class CreateUserCommandHandler : IRequestHandler<CreateUserCommandRequest, CreateUserCommandResponse> { public Task<CreateUserCommandResponse> Handle(CreateUserCommandRequest request, CancellationToken cancellationToken) => Task.FromResult<CreateUserCommandResponse>(new("...")); }
Eğer ki, yukarıdaki gibi bir kullanıcı oluşturma akışındaki istek yapılanmasını ele alırsak eğer burada CreateUserCommandRequest
nesnesine validation uygulamak gerekecektir. Bunun için FluentValidation kütüphanesinden destek alarak aşağıdaki gibi validasyon kurallarını oluşturabiliriz;
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommandRequest> { public CreateUserCommandValidator() { RuleFor(x => x.Username) .NotEmpty() .WithMessage("Username boş geçilemez!"); RuleFor(x => x.Email) .NotEmpty() .WithMessage("Email boş geçilemez!") .EmailAddress() .WithMessage("Yanlış e-mail formatı!"); RuleFor(x => x.Password) .NotEmpty() .WithMessage("Password boş geçilemez!") .MinimumLength(6) .WithMessage("Password minimum 6 karakter olmalıdır!"); } }
Ardından gelen verileri bu kurallar doğrultusunda kontrol edecek olan IPipelineBehavior
interface’ini implemente etmiş olan ValidationPipelineBehavior
sınıfını oluşturabiliriz;
public class ValidationPipelineBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse> where TRequest : class where TResponse : class { public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { var context = new ValidationContext<TRequest>(request); var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); var failures = validationResults .SelectMany(result => result.Errors) .Where(f => f != null) .ToList(); if (failures.Any()) throw new ValidationException(failures); return await next(); } }
Görüldüğü üzere bu sınıf sayesinde doğrulama kurallarını kontrol edebilmekteyiz. Bu adımdan sonra yapılması gereken aşağıdaki gibi gerekli tanımların ve yapılandırmanın sağlanmasıdır.
var builder = WebApplication.CreateBuilder(args); builder.Services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblyContaining<Program>()); builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)); var app = builder.Build(); app.MapPost("/create-user", async (IMediator mediator, CreateUserCommandRequest createUserCommandRequest) => await mediator.Send(createUserCommandRequest)); app.Run();
Böylece yüksek veri kalitesi standardını koruyarak, daha iyi bir kullanıcı deneyimi sağlayabilmekteyiz.
Cross-Cutting Concerns #3 – Caching
Caching; yazılımların kaynak tüketim maliyetlerini azaltarak, performans artışı amacıyla daha verimli kullanım sağlamak için sık kullanılan verilerin geçici olarak daha hızlı erişilebilir bir konuma kopyalanması davranışıdır. Haliyle bu verilere erişim, normal duruma nazaran kat be kat hızlı olabilmekte ve böylece kullanıcı deneyimi açısından da avantaj sağlanabilmektedir. İşte caching bu açıdan oldukça önemlidir.
MediatR kütüphanesiyle yapılan CQRS çalışmalarında caching davranışı için esasında Cache Aside Pattern‘ı uygulanmaktadır. Bu model ile istek işlemeden önce cache kontrol edilerek gerekiyorsa yeni verilerle cache’in güncellemesi gerçekleştirilmektedir ve böylece caching kullanımı basit ve etkili bir strateji ile optimize edilmektedir. Şimdi bunun için aşağıdaki istek sürecini ele alarak bir örneklendirmede bulunabiliriz;
public record GetProductsQueryRequest() : IRequest<List<GetProductsQueryResponse>>; public record GetProductsQueryResponse(string ProductName, int Quantity); public sealed class GetProductsQueryHandler : IRequestHandler<GetProductsQueryRequest, List<GetProductsQueryResponse>> { public Task<List<GetProductsQueryResponse>> Handle(GetProductsQueryRequest request, CancellationToken cancellationToken) => Task.FromResult<List<GetProductsQueryResponse>>(new() { new("Product1", 10), new("Product2", 20), new("Product3", 30), new("Product4", 40), new("Product5", 50), }); }
Yukarıdaki örnekte olduğu gibi GetProductsQueryRequest
isteği neticesinde geriye veritabanından sorgulanarak elde edilen ( mış gibi 🙂 ) GetProductsQueryResponse
koleksiyonunun döndürüldüğünü varsayarsak eğer bu isteğin her yapılışına karşın aynı neticeyi return etmektense bunu cache’lemek ve cache’de tutulan veri üzerinden isteğe sonuç dönmek en performanslı ve az maliyetli tercih olacaktır diyebiliriz. Bunun için Redis’ten aşağıdaki servis aracılığıyla istifade edebiliriz;
public class RedisService { ConnectionMultiplexer? connectionMultiplexer; public void Connect() => connectionMultiplexer = ConnectionMultiplexer.Connect("localhost:6379"); public IDatabase Database { get { if (!connectionMultiplexer?.IsConnected == null) Connect(); return connectionMultiplexer.GetDatabase(0); } } }
Devamında ise aşağıdaki gibi IPipelineBehavior
interface’ini implemente ederek oluşturduğumuz CachingBehavior
sınıfı üzerinden caching operasyonunu yürütebiliriz;
public class CachingBehavior<TRequest, TResponse>(RedisService redisService) : IPipelineBehavior<TRequest, TResponse> where TRequest : class where TResponse : class { public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { var redisDatabase = redisService.Database; var cacheKey = $"{request.GetType().FullName}"; TResponse cachedResponse = null; var cachedData = await redisDatabase.StringGetAsync(cacheKey); if (cachedData.HasValue) { var cachedResponseJson = Encoding.UTF8.GetString(cachedData); cachedResponse = JsonSerializer.Deserialize<TResponse>(cachedResponseJson); } //Cache'de varsa o veriyi döndürüyoruz. if (cachedResponse != null) return cachedResponse; //Cache'de yoksa gerçek sorguyu yürütüyoruz. var response = await next(); //Yeni veriyi cache'e alıyoruz. var responseJson = JsonSerializer.Serialize(response); var encodedData = Encoding.UTF8.GetBytes(responseJson); var result = await redisDatabase.StringSetAsync(cacheKey, encodedData, expiry: TimeSpan.FromSeconds(60)); return response; } }
Dikkat edilirse eğer her isteğe karşın önce cache’deki veri kontrol edilerek sonuç üretilmektedir. Eğer veri varsa geriye döndürülmekte, yoksa ya da süresi dolmuşsa(ki bu da yok anlamına gelir) o taktirde de yeni veri veritabanından elde edilerek hem cache’lenmekte hem de geriye döndürülmektedir. Mantık budur. Gerisi aşağıdaki gibi salt yapılandırmadan ibarettir.
var builder = WebApplication.CreateBuilder(args); builder.Services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblyContaining<Program>()); builder.Services.AddSingleton<RedisService>(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); var app = builder.Build(); app.MapGet("/", async (IMediator mediator) => await mediator.Send(new GetProductsQueryRequest())); app.Run();
Nihai olarak;
Geliştirdiğimiz yazılımların sağlam ve sürdürülebilir olması için Cross-Cutting Concerns‘lerin içeriğimizde örneklendirildikleri üzere hassas bir şekilde ele alınması gerekmektedir. Haliyle bizler bu ele alış şekillerinin ekalliyeti de olsa en çok kullanılan ve en kritik olanlarını MediatR kütüphanesi eşliğinde örneklendirmiş bulunuyoruz.
İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…
Not : Örnek çalışmaya aşağıdaki github adresinden erişebilirsiniz.
https://github.com/gncyyldz/Cross.Cutting.Concerns.With.MediatR.Example
Bu değerli bilgiler için çok sağolun hocam en kısa sürede yeni bir projede kullanıcam
Hocam Onion Architecture’da Cross-Cutting Concernsleri hangi katmanda tanımlamam daha doğru olur Application Katmanında mı