ASP.NET Core Web API'de Filter'lar: Action Filter, Result Filter ve ServiceFilter
MVC'nin filter pipeline'ını gerçek loglarla söküyoruz: global, controller ve action filter'ların sırası, DI kullanan async filter için ServiceFilter, TypeFilter ile kısa devre, result filter ve middleware ile filter arasındaki seçim.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Aynı filter'ı üç farklı yere kaydettiğini düşün: bir kez Program.cs'te bütün controller'lar için, bir kez ProductsController class'ının üstüne, bir kez de GetAll action'ının üstüne. Filter, action çalışmadan önce ve çalıştıktan sonra adını log'a yazıyor. GET /api/products isteği geldiğinde altı log satırı hangi sırayla çıkar? "Action" mı önce yazar, "Global" mı? Action'dan sonraki üç satırın sırası ilk üçle aynı mı olur? Cevap ilk filter'ı yazdığımız bölümde.
Filter Pipeline: MVC'nin İçindeki İkinci Pipeline
Middleware'ler isteği uygulamanın kapısında karşılar. Routing bir controller action'ı seçtikten sonra MVC kendi küçük pipeline'ını çalıştırır; bu pipeline'ın halkalarına filter denir. Bir havalimanı düşün: binanın girişindeki güvenlik kontrolü middleware, uçağın kapısındaki biniş kartı kontrolü ise filter. Kapıdaki görevli hangi uçuşa, hangi koltuğa gittiğini bilir; girişteki görevli bilmez.
Filter'lar beş türdür ve şu sırayla çalışır. Authorization filter'lar en önce çalışır ve kullanıcının bu action'a erişip erişemeyeceğine karar verir; [Authorize] attribute'u MVC'de bu türden bir filter'a dönüşür. Resource filter'lar model binding'den önce ve sonuç yazıldıktan sonra çalışır; önbellekten yanıt döndürmek gibi işler için uygundur. Action filter'lar model binding'den sonra, action'ın hemen etrafında çalışır ve action'ın argümanlarını görüp değiştirebilir. Exception filter'lar action ve action filter'larda fırlayan, yakalanmamış exception'ları ele alır. Result filter'lar ise action'ın döndürdüğü sonucun yanıta yazılmasının hemen etrafında çalışır.
Bu yazıda en çok kullanılan üçünü kodla göreceğiz: action, result ve kısa devre yapan action filter. Authorization filter'ı authorization yazısında [Authorize] ile birlikte ele alacağız.
İlk Action Filter
public class TraceFilterAttribute : ActionFilterAttribute
{
private readonly string _name;
public TraceFilterAttribute(string name)
{
_name = name;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
GetLogger(context.HttpContext).LogInformation("{Name} OnActionExecuting", _name);
}
public override void OnActionExecuted(ActionExecutedContext context)
{
GetLogger(context.HttpContext).LogInformation("{Name} OnActionExecuted", _name);
}
// attribute'lar constructor injection alamaz; logger'ı istekten çekiyoruz
private static ILogger GetLogger(HttpContext httpContext) =>
httpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Trace");
}
ActionFilterAttribute hem IActionFilter'ı hem de attribute olmayı tek class'ta birleştiren hazır bir taban class. OnActionExecuting action'dan önce, OnActionExecuted sonra çalışır. Logger'ı neden constructor'dan almadığımıza birazdan döneceğiz. Şimdi üç yere kaydedelim:
builder.Services.AddControllers(options =>
{
options.Filters.Add(new TraceFilterAttribute("Global"));
options.Filters.Add<CatalogVersionHeaderFilter>();
});
[ApiController]
[Route("api/[controller]")]
[TraceFilter("Controller")]
public class ProductsController : ControllerBase
{
[HttpGet]
[TraceFilter("Action")]
[ServiceFilter(typeof(ExecutionTimeFilter))]
public async Task<ActionResult<List<ProductDto>>> GetAll(CancellationToken cancellationToken)
{
_logger.LogInformation("GetAll action is running");
return await _productService.GetAllAsync(cancellationToken);
}
}
Diğer iki satırı (CatalogVersionHeaderFilter ve ServiceFilter) şimdilik görmezden gel; sırası gelecek. GET /api/products isteğinin gerçek log'u:
info: Trace[0]
Global OnActionExecuting
info: Trace[0]
Controller OnActionExecuting
info: Trace[0]
Action OnActionExecuting
info: ProductCatalog.Controllers.ProductsController[0]
GetAll action is running
info: ProductCatalog.Filters.ExecutionTimeFilter[0]
ProductCatalog.Controllers.ProductsController.GetAll (ProductCatalog) took 80 ms (exception: False)
info: Trace[0]
Action OnActionExecuted
info: Trace[0]
Controller OnActionExecuted
info: Trace[0]
Global OnActionExecuted
Tahminin tuttu mu? Filter'lar iç içe geçmiş halkalar gibi çalışır: dıştan içe girer, içten dışa çıkar. Global en dıştaki halka, sonra controller, en içte action seviyesi. Çıkışta sıra tersine döner. Bu, middleware pipeline'ında gördüğümüz modelin aynısı. Sırayı değiştirmen gerekirse filter'ların Order property'si var: varsayılan değer 0, küçük değer daha dışta çalışır ve scope'tan önce gelir.
Async Filter ve Dependency Injection: ServiceFilter
TraceFilterAttribute'ta logger'ı RequestServices'ten çektik, çünkü bir attribute'un constructor argümanları derleme anında sabit olmak zorundadır; oraya bir ILogger ya da AppDbContext veremezsin. DI kullanan filter'ları attribute olmaktan çıkarıp sıradan bir class olarak yazarız:
public class ExecutionTimeFilter : IAsyncActionFilter
{
private readonly ILogger<ExecutionTimeFilter> _logger;
public ExecutionTimeFilter(ILogger<ExecutionTimeFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
Stopwatch stopwatch = Stopwatch.StartNew();
// next() çağrısı action'ı (ve sonraki filter'ları) çalıştırır
ActionExecutedContext executed = await next();
stopwatch.Stop();
_logger.LogInformation("{Action} took {Elapsed} ms (exception: {HasException})",
context.ActionDescriptor.DisplayName, stopwatch.ElapsedMilliseconds, executed.Exception is not null);
}
}
IAsyncActionFilter'da iki ayrı method yok; tek bir method var ve action'ı sen tetikliyorsun. await next() satırından öncesi "executing", sonrası "executed". Middleware'deki await _next(context) ile birebir aynı fikir.
Filter'ı kullanmak için önce DI'a kaydedip sonra [ServiceFilter] ile action'a bağlıyoruz:
builder.Services.AddScoped<ExecutionTimeFilter>();
[ServiceFilter(typeof(ExecutionTimeFilter))] filter instance'ını container'dan ister. Log'daki took 80 ms satırı bu filter'dan geliyordu.
TypeFilter ve Kısa Devre
Bazen filter'ın action'ı hiç çalıştırmamasını istersin. Silme isteği gelmeden önce ürünün var olup olmadığına bakan bir filter:
public class ProductExistsFilter : IAsyncActionFilter
{
private readonly AppDbContext _db;
public ProductExistsFilter(AppDbContext db)
{
_db = db;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("id", out object? value) && value is int id)
{
bool exists = await _db.Products.AnyAsync(p => p.Id == id, context.HttpContext.RequestAborted);
if (!exists)
{
// Result atamak pipeline'ı kısa devre yapar; action hiç çalışmaz
context.Result = new NotFoundObjectResult(new { message = $"Product {id} does not exist." });
return;
}
}
await next();
}
}
[HttpDelete("{id:int}")]
[TypeFilter(typeof(ProductExistsFilter))]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
_logger.LogInformation("Delete action is running for {Id}", id);
await _productService.DeleteAsync(id, cancellationToken);
return NoContent();
}
context.ActionArguments model binding'in ürettiği argümanları tutar; action filter'ın gücü burada. context.Result'a bir değer atayıp next()'i çağırmadan dönmek pipeline'ı kısa devre yapar. DELETE /api/products/99 gerçek yanıtı:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
X-Catalog-Version: 1
{"message":"Product 99 does not exist."}
Log'da "Delete action is running" satırı bu istek için hiç yok; action çalışmadı.
[TypeFilter] ile [ServiceFilter] arasındaki fark: TypeFilter filter'ı DI'a kaydetmeni istemez, constructor bağımlılıklarını container'dan çözerek her seferinde kendisi oluşturur. ServiceFilter ise filter'ın kendisini container'dan ister, bu yüzden kayıt şarttır ama lifetime'ı sen belirlersin. Kayıt yapmadan [ServiceFilter] kullanırsan istek anında şu exception'ı alırsın:
System.InvalidOperationException: No service for type 'ProductCatalog.Filters.ExecutionTimeFilter' has been registered.
Result Filter ile Her Yanıta Header
404 yanıtındaki X-Catalog-Version: 1 header'ı nereden geldi? Program.cs'te global kaydettiğimiz result filter'dan:
public class CatalogVersionHeaderFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
context.HttpContext.Response.Headers["X-Catalog-Version"] = "1";
}
public void OnResultExecuted(ResultExecutedContext context)
{
}
}
Header'ı OnResultExecuting'de ekliyoruz, çünkü sonuç yazılmaya başladığı anda header'lar istemciye gider ve OnResultExecuted'da artık değiştirilemez. options.Filters.Add<CatalogVersionHeaderFilter>() tip üzerinden kayıt yapar; filter'ın bağımlılıkları varsa onları da çözer.
Dikkat çekici bir ayrıntı: action filter kısa devre yaptığında bile result filter çalıştı. Çünkü kısa devre bir sonuç üretti ve o sonuç yine normal yoldan yanıta yazıldı.
Middleware mi Filter mı?
İkisi de "isteğin etrafında kod çalıştırmak" için var; seçim, neye ihtiyacın olduğuna bağlı. Middleware bütün istekleri görür: controller'a gitmeyenleri, statik dosyaları, health check'leri, 404 olanları. Ama hangi action'ın çalışacağını, model binding'in ne ürettiğini, action'ın hangi sonucu döndürdüğünü bilmez. Filter ise yalnızca MVC action'larında çalışır, karşılığında action argümanlarını, ModelState'i, action'ın attribute'larını ve IActionResult'ı görür.
Pratik kural: işin HTTP seviyesindeyse (bütün isteklerin loglanması, global hata yönetimi, CORS, sıkıştırma) middleware; işin action'ın anlamına bağlıysa (argümana göre kontrol, belirli action'lara özel davranış, sonucu değiştirmek) filter.
Exception filter bu ayrımın iyi bir örneği. IExceptionFilter yalnızca controller oluşturma, model binding, action filter'lar ve action içinde fırlayan exception'ları yakalar; middleware'lerden ya da sonucun yazılmasından gelen exception'ları görmez. Bu yüzden bir önceki yazıda global hata yönetimini middleware ile kurduk; exception filter'ı belirli bir controller'a özel bir eşleme gerektiğinde düşünebilirsin.
Bir not daha: bu filter'lar minimal API endpoint'lerinde çalışmaz. Minimal API'lerin kendi filter mekanizması .NET 7 ile geliyor; onu sürüm yazılarında göreceğiz.
Sık Düşülen Tuzaklar
Aynı class'ta hem IActionFilter hem IAsyncActionFilter'ı uygulamak. Denedik: framework yalnızca async olanı çağırdı, sync method'lar hiç çalışmadı. Birini seç.
await next()'i unutmak. Ne next()'i çağırıp ne de context.Result atayan bir filter ile istek 200 OK ve boş body ile döndü; action hiç çalışmadı. Hata yok, uyarı yok, sadece sessiz bir boşluk.
Attribute'a constructor'dan bağımlılık vermeye çalışmak. [MyFilter(logger)] yazamazsın. DI gerekiyorsa ServiceFilter, TypeFilter ya da IFilterFactory kullan.
Global bir instance'a scoped bağımlılık koymak. options.Filters.Add(new MyFilter(...)) ile eklediğin instance uygulama boyunca tektir; içine bir AppDbContext koyarsan dependency injection yazısında konuştuğumuz captive dependency'yi kendi elinle kurmuş olursun. Bağımlılığı olan global filter'ı Filters.Add<T>() ile tip üzerinden ekle.
Filter'da gereksiz veritabanı turu. ProductExistsFilter var olan bir ürün için önce AnyAsync, sonra service'te FindAsync çalıştırır; iki sorgu. Bir önceki yazıdaki NotFoundException yaklaşımı tek sorguyla aynı sonucu veriyor. Kısa devre filter'ı, kontrolün birçok action'da tekrar ettiği ve maliyetinin düşük olduğu yerlerde anlamlı.
Küçük Bir Deneme
★☆☆ Action'daki attribute'u [TraceFilter("Action", Order = -1)] yap ve log sırasının nasıl değiştiğine bak. Sonra onu geri alıp global filter'ı new TraceFilterAttribute("Global") { Order = 10 } ile kaydet ve tekrar dene.
★★☆ IResourceFilter uygulayan bir ReadOnlyModeFilter yaz: configuration'da Catalog:ReadOnly değeri true ise GET dışındaki bütün isteklere 503 dön. Model binding'in bu durumda hiç çalışmadığını bir log ile kanıtla.
★★★ ExecutionTimeFilter'ı bir result filter ile birleştirip action süresini ve sonucun yazılma süresini ayrı ayrı ölç; ikisini Server-Timing header'ı olarak yanıta ekle.
Filter'larla action'ın etrafına kod sarmayı öğrendik. Şimdi action'ın kendisine dönme zamanı: GET /api/products şu an veritabanındaki her ürünü tek seferde dönüyor. Bir sonraki yazıda pagination, filtering ve sorting ile büyük listeleri sayfa sayfa, güvenli dinamik sıralamayla döndüreceğiz.