PageHits Middleware: خط أنابيب عدّ طلبات بأقل تخصيص في ASP.NET Core 10

0 تعليق 454 مشاهدة

الأمن السيبراني dotnet devops إيه إس بي دوت نت كور الأداء Middleware القياس عن بُعد

9 دقيقة قراءة 1734 كلمة

ملخص قصير

خذ middleware ASP.NET Core 10 الذي يكتب صف dbo.PageHits لكل طلب، وادفع ملف التخصيص الخاص به قرب الصفر: Span<char> لتحليل path/UA، IMemoryCache dedup لمدة 30 ثانية، fire-and-forget Task.Run INSERT. تحت حمل اصطناعي قدره 50K req/دقيقة، ينخفض Gen0 GC من %15 إلى %2، ويتراجع P99 latency من 12ms إلى 3ms. يلي ذلك الكود الكامل وقياسات dotnet-counters الحية من نظام bilalkose.com.tr مباشرة.

ملاحظة المؤلف: كل الكود والقياسات مأخوذة من نظام bilalkose.com.tr الحي. شُحن PageHits middleware إلى الإنتاج في 2026-05-10؛ وفي أول 24 ساعة قِسْت 246 hit فريدًا بنسبة 33%/67% حقيقي/مشبوه، فيما بقي P99 middleware overhead دون 3ms. كان Pillar 2 H2-2 قد رسم المعمار، أمّا هذه المقالة فتتعمّق في التنفيذ allocation-conscious مع benchmark و tuning.


جدول المحتويات

  1. تكلفة الطلب: من أين تأتي؟
  2. PageHitsMiddleware skeleton: middleware vs filter
  3. Dedup cache: نافذة IMemoryCache 30 ثانية
  4. Span<char> وMemoryExtensions: تحليل بصفر تخصيص
  5. Fire-and-forget INSERT: Task.Run مع IServiceScopeFactory
  6. Benchmark: dotnet-counters قبل/بعد
  7. الأسئلة المتكررة
  8. مقالات ذات صلة

خط أنابيب الطلب TrafficClassifierMiddleware: ShouldSkip ثم VisitorHash ثم IMemoryCache dedup 30 ثانية ثم fire-and-forget Task.Run INSERT (non-blocking)
خط أنابيب الطلب TrafficClassifierMiddleware: ShouldSkip ثم VisitorHash ثم IMemoryCache dedup 30 ثانية ثم fire-and-forget Task.Run INSERT (non-blocking)


تكلفة الطلب: من أين تأتي؟

تبدو كتابة صف في DB لكل طلب بسيطةً للوهلة الأولى: _db.PageHits.Add(new PageHit(...)); await db.SaveChangesAsync();. غير أنّ هذا التنفيذ الساذج عند 50K req/دقيقة يُنتج ضغط Gen0 GC، ونمو P99 latency، واستنفاد connection pool في آنٍ واحد.

مصادر التخصيص المرشحة كثيرة، وأبرزها ما يلي:

  • HttpContext.Request.Path.ToString() يُخصّص string جديد في كل استدعاء.
  • Request.Headers["User-Agent"].ToString() يحوّل StringValues struct إلى string.
  • Guid.NewGuid().ToString("N") يُنتج string جديدًا بطول 32 حرفًا لكل طلب.
  • JsonSerializer.Serialize(...) يستأجر buffer ثم يجمع string نهائي.
  • MD5.HashData(...) يُخصّص byte[] على heap.
  • db.PageHits.Add(...) + await SaveChangesAsync() يُضيف overhead من EF ChangeTracker و ExecutionStrategy و retry policy.

ليست كل التخصيصات مهمّة بالقدر نفسه. أمّا غير المهمة فهي: dedup cache hit على hot path قراءة فقط بلا تخصيص، إضافةً إلى 5-10 strings صغيرة لكل طلب تعيش في Gen0 ولا تثير مشكلة.

لقطة dotnet-counters مباشرة من إنتاج bilalkose.com.tr: System.Runtime ثم gc.collections (Gen0/1/2)، allocation-rate، jit.compilation، lock_contentions

أمّا المهمة فهي ثلاث مصادر تستحقّ المعالجة، إذ تتراكم بوضوح تحت الحمل:

  • 50K req/دقيقة في MD5 byte[] إلى جانب Guid string، ما يقارب 500KB/دقيقة Gen0، وهو ضغط GC مستمر.
  • EF Core overhead بين ChangeTracker و DbContext scope لكل طلب و retry policy، أي قرابة 2ms/طلب لكل طلب دون استثناء.
  • الإصلاح يكون عبر Dapper أو SqlCommand خام، أي INSERT بدون EF، وهو أسرع بنحو 70%.

تشرح هذه المقالة كيفية خفض هذه التكاليف إلى الحد الأدنى.


PageHitsMiddleware skeleton: middleware vs filter

السؤال الأول الذي يُطرح هنا: أين يجب أن يعيش منطق PageHits، في middleware أم في IActionFilter؟

IActionFilter لا يعمل إلا على إجراءات controller. فهو يفوّت الملفات الثابتة، و health check، وطلبات hubs/*. كما أنّ filter pipeline يعمل بعد routing، ومن ثمّ يُدفع overhead lookup المسار في كل طلب.

أمّا Middleware فيعمل عبر pipeline كامل، ويبقى منطق الاستثناء صريحًا فيه. في bilalkose.com.tr اخترتُ دمجه مع TrafficClassifierMiddleware في نقطة قرار واحدة (وهي مغطّاة في Pillar 2)، بحيث تُقرأ إشارات PageHits و AttackProbes و IsSuspectNetwork flag من المكان نفسه.

وأمّا ترتيب pipeline في Program.cs:

app.UseForwardedHeaders();
app.UseResponseCompression();
app.UseHostFiltering();
app.UseMiddleware<TrafficClassifierMiddleware>(); // ← PageHits هنا
app.UseAttackProbeBlock();
// ... static files, routing, controllers

وفي ما يلي كود skeleton:

public sealed class TrafficClassifierMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMemoryCache _dedupCache;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TrafficClassifierMiddleware> _logger;

    public async Task InvokeAsync(HttpContext context)
    {
        if (ShouldSkip(context.Request.Path))
        {
            await _next(context);
            return;
        }

        var visitorHash = ComputeVisitorHash(context);
        var dedupKey = $"vh:{visitorHash}";

        if (!_dedupCache.TryGetValue(dedupKey, out _))
        {
            _dedupCache.Set(dedupKey, true, TimeSpan.FromSeconds(30));
            EnqueueRecord(context, visitorHash); // fire-and-forget
        }

        await _next(context);
    }

    private static bool ShouldSkip(PathString path)
    {
        var span = path.HasValue ? path.Value.AsSpan() : ReadOnlySpan<char>.Empty;
        return span.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/dev/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/css/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/js/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/uploads/", StringComparison.OrdinalIgnoreCase);
    }
}

مسارات Skip تشمل: /health/*، /metrics، /dev/*، إضافةً إلى مسارات static asset مثل (/css/*، /js/*، /images/*، /lib/*، /uploads/*)، فيما تُعالَج مسارات نمط البوتات بشكل منفصل داخل TrafficClassifier.


Dedup cache: نافذة IMemoryCache 30 ثانية

قد يبدو اختيار 30 ثانية اعتباطيًا، غير أنّ خلفه تفكيرًا عمليًا بسيطًا:

  • المستخدم نفسه يقوم بنحو 5-10 نقرات على صفحة في 30 ثانية (scroll triggers، nav، modal)، ونعدّها hit واحد.
  • 60 ثانية مدّة طويلة جدًا، إذ يستطيع البوت في الجلسة الواحدة الإفلات من العدّ الثاني.
  • 10 ثوانٍ مدّة قصيرة جدًا، حيث تُسجَّل SPA navigation كـ 3-4 visits بدلًا من واحدة.

تكوين VisitorHash ليس تتبّع مستخدم، وإنّما هو إخفاء هوية بـ daily-rolling salt:

private string ComputeVisitorHash(HttpContext ctx)
{
    var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
    var ua = ctx.Request.Headers.UserAgent.ToString();
    var dailySalt = DateTime.UtcNow.ToString("yyyyMMdd");
    var secret = _vaultSecret; // VISITOR_HASH_SECRET

    var raw = $"{ip}|{ua}|{dailySalt}|{secret}";
    var bytes = MD5.HashData(Encoding.UTF8.GetBytes(raw));
    return Convert.ToHexString(bytes); // 32 char
}

الأساس القانوني هو KVKK المادة 5/2-(f) «المصلحة المشروعة»، إذ يكون IP مُجزّأ بحيث لا يصل IP الخام إلى DB. كما أنّ تدوير salt يوميًا يجعل تتبّع المستخدم طويل المدى أمرًا مستحيلًا (يرد تعمّق في Pillar 2 H2-11).

إخراج cache هو sliding 30 ثانية يُجدَّد على كل hit، بدون حدّ ذاكرة. ويمكن القول إنّ IMemoryCache LRU eviction يعالج ضغط الذاكرة تلقائيًا. وعند إعادة تشغيل pod يُمسَح cache، ما يُنتج hit spike قصيرًا (وهو أمر مقبول؛ لأنّ telemetry ليست audit-grade).


Span<char> وMemoryExtensions: تحليل بصفر تخصيص

تحت 95 نانوثانية لكل طلب: هذا هو الفارق الذي يصنعه التحوّل من string allocations إلى Span. وثمّة تحسينان حرجان داخل هذا الفارق: تحليل path، وتكوين hash.

تحليل Path، string في مقابل Span:

// ALLOC: Path.Value → string allocation لكل طلب
var path = context.Request.Path.Value ?? string.Empty;
bool isAdmin = path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase);

// ALLOC-FREE: Path string موجود بالفعل؛ AsSpan() لا يُخصّص
var pathSpan = context.Request.Path.HasValue
    ? context.Request.Path.Value.AsSpan()
    : ReadOnlySpan<char>.Empty;
bool isAdmin = pathSpan.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase);

PathString هو struct ثابت فوق string مُحتجَز، إذ إنّ استخراج Span هو stack-only بصفر alloc.

تكوين MD5 hash، stackalloc في مقابل byte[]:

// ALLOC: Encoding.GetBytes + new byte[16]
var bytes = Encoding.UTF8.GetBytes(raw);          // ~30-60 byte heap
var hash = MD5.HashData(bytes);                   // 16 byte heap

// ALLOC-FREE: stackalloc + UTF8 encode
Span<byte> buffer = stackalloc byte[256];         // stack
int written = Encoding.UTF8.GetBytes(raw, buffer);
Span<byte> hash = stackalloc byte[16];            // stack
MD5.HashData(buffer[..written], hash);
return Convert.ToHexString(hash);

يكفي buffer بحجم 256-byte للسلسلة الخام (IP + UA + salt + secret). كما أنّ overload الخاص بـ Encoding.UTF8.GetBytes(string, Span<byte>) هو بالفعل alloc-free.

Convert.ToHexString مُحسَّن بـ SIMD. أمّا حلقات StringBuilder مع b.ToString("x2") اليدوية فأبطأ بنحو 3-4 مرّات وكثيفة char allocation. لذا استخدم helper سطر واحد من .NET 5+.

أمّا قياسات BenchmarkDotNet فجاءت كالآتي: الساذج (string + byte[]) بنحو 340ns / 280B alloc، والمُحسَّن (Span + stackalloc) بنحو 95ns / 32B alloc (فقط hex string النهائي). يبدو الفارق صغيرًا لكل طلب على حدة، غير أنّه يكبر في scale؛ إذ يصل إلى -5MB Gen0/دقيقة عند 50K req/دقيقة.


Fire-and-forget INSERT: Task.Run مع IServiceScopeFactory

خلال 24 ساعة من حركة الإنتاج تفقد ربما 0.1-0.5% من السجلات بسبب transient timeouts و connection pool exhaustion. أرى أنّ هذا الفقد مقبول عمليًا، إذ يبقى السبب وراء عدم انتظار DB INSERT واضحًا:

  • INSERT blocking يربط latency middleware بـ DB. كما أنّ استنفاد pool سيوقف تدفّق الطلب.
  • إذا تعطّل DB، فالموقع يجب أن يستمرّ في الخدمة (resilience).
  • المقايضة هنا: فقدان hit نادر (DB transient timeout، نحو 0.1-0.5%)، وهو مقبول؛ لأنّ PageHits مقياس فريد للزائرين لا audit-grade.

وأمّا نمط التنفيذ فهو على النحو التالي:

private void EnqueueRecord(HttpContext ctx, string visitorHash)
{
    // HttpContext لا يمكن CAPTURE'a fire-and-forget Task، يُلغى بعد الطلب
    // التقط primitive values فقط
    var path = ctx.Request.Path.Value ?? "";
    var method = ctx.Request.Method;
    var ua = ctx.Request.Headers.UserAgent.ToString();
    var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";

    var hostEnv = ctx.RequestServices.GetRequiredService<IHostApplicationLifetime>();
    var cancellation = hostEnv.ApplicationStopping;

    _ = Task.Run(async () =>
    {
        try
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.PageHits.Add(new PageHit
            {
                VisitorHash = visitorHash,
                Path = path,
                Method = method,
                UserAgent = ua,
                // ... GeoIP enrichment (Pillar 2 H2-6)
                CreatedDate = DateTime.UtcNow
            });
            await db.SaveChangesAsync(cancellation);
        }
        catch (OperationCanceledException)
        {
            // App shutdown، تجاهل
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "PageHits INSERT failed for {Path}", path);
        }
    }, cancellation);
}

المزلق الأول: لا تلتقط HttpContext أبدًا. فعند انتهاء الطلب يُبطَل context، وأيّ مرجع مُلتقَط داخل fire-and-forget Task ينتج NullReferenceException. لذا انسخ snapshots primitive فقط.

المزلق الثاني: IServiceScopeFactory singleton. فكل سجلّ يفتح scope جديدًا (DbContext scoped). أمّا singleton DbContext فهو anti-pattern؛ لأنّه يولّد race conditions و ChangeTracker conflicts.

Graceful shutdown. مرّر IHostApplicationLifetime.ApplicationStopping token. ومع pod terminationGracePeriodSeconds 30ث، تُلغى INSERTs المعلّقة بسلاسة عبر ابتلاع OperationCanceledException.


Benchmark: dotnet-counters قبل/بعد

أمّا أداة قياس الإنتاج المختارة فهي:

dotnet-counters monitor -p $(pgrep dotnet)     System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,allocation-rate]

تحت حمل اصطناعي قدره 50K req/دقيقة (k6 load test، mini app container، 2 CPU + 1GB RAM) جاءت النتائج التالية:

المقياس ساذج مُحسَّن Δ
Gen0 GC/ث 8.2 1.1 -%87
معدل التخصيص (MB/s) 14.3 2.4 -%83
P50 middleware latency 4ms 1ms -%75
P99 middleware latency 12ms 3ms -%75
إنتاجية الطلب 825 req/ث 880 req/ث +%7

مفاجأة Profiling: أين يقع أكبر فوز؟ يبدو لي أنّ التوزيع التالي يفسّر معظم المكاسب:

  • MD5 stackalloc يخفض معدّل التخصيص بنحو 35%.
  • Path Span parse يخفّضه بنحو 20% إضافية.
  • تجاوز كلّ التخصيص في dedup cache hit يستحوذ على الباقي، أي ما يقارب 30%.

لا يصل bilalkose.com.tr الحي إلى 50K req/دقيقة (حيث الحركة الحقيقية تتراوح بين 50-150 req/دقيقة)، غير أنّ middleware overhead المُحسَّن في الإنتاج يبقى عند 0.3-0.8ms P99، وهو ميزانية CPU telemetry غير مرئية تقريبًا.

جدول نتائج BenchmarkDotNet، naive في مقابل optimized: Gen0 GC %-87، معدل التخصيص %-83، P99 latency من 12ms إلى 3ms، الإنتاجية %+7


الأسئلة المتكررة

IMemoryCache في مقابل IDistributedCache، أيّهما أنسب؟

MemoryCache يصلح للحالات pod-local. أمّا إذا كان اتساق multi-pod مطلوبًا، فيلزم Redis (IDistributedCache). أمّا في حالة dedup فإنّ MemoryCache كافٍ؛ إذ لو ضرب زائر مُشاهَد على pod-A أيضًا على pod-B خلال 30 ثانية، فسيهبط INSERT إضافي واحد، وهو معدّل false-positive قرب 5% يبقى مقبولاً.

هل fire-and-forget INSERT يُفقد hits؟

نعم، بنحو 0.1-0.5% (استنفاد connection pool، transient timeout). وبما أنّ PageHits مقياس فريد للزائرين، فالفقد محتمل ولا يكسر المعنى. أمّا إذا كان audit-grade مطلوبًا، فأضف IBackgroundTaskQueue (مستندًا إلى Channel، in-memory bounded) ك retry queue.

لماذا Span<char> مهمّ إلى هذه الدرجة؟

string ثابت ويعيش على heap Gen0/Gen1. وعند 50K req/دقيقة، تتحوّل مئات تخصيصات string الصغيرة إلى ضغط GC مستمر يقود إلى نمو P99 latency. أمّا Span فهو stack-only بصفر heap alloc.

سمعتُ أنّ MD5 مهجور، فلماذا لا نزال نستخدمه؟

hash زائر PageHits هو إخفاء هوية لا تكامل cryptographic. MD5 سريع وخرجه 16 بايت مستقرّ، وهذا كافٍ لتفرّد VisitorHash. أمّا SHA256 فأبطأ ويُضيف 32-byte alloc، وفي تقديري أنّه غير ضروري لهذا الاستخدام.

يعمل PageHits middleware في الإنتاج منذ 2026-05-10، ويبقى السؤال المفتوح: هل يستحقّ Channel-based bounded queue الـ 200ns الإضافية مقابل audit-grade fidelity؟ سيُحسم هذا في C3 (MERGE+OUTPUT pattern)؛ أمّا حتى ذلك الحين فيكفي fire-and-forget بقياسات 0.3-0.8ms P99 لتشغيل telemetry حقيقية بميزانية CPU شبه غير مرئية.


مقالات ذات صلة

التعليقات (0)

اترك تعليقاً وتقييماً

لا توجد تعليقات بعد. كن أول من يعلق.