كيفيّة استخدام FileSystemWatcher بأمان - الأحداث المفقودة والإشعارات المكرّرة وفخاخ كشف الاكتمال

· · FileSystemWatcher, C#, .NET, تطوير Windows, تكامل الملفّات, التصميم

FileSystemWatcher هو من أوائل الـ APIs التي يلجأ إليها الناس لمراقبة تغييرات الملفّات على Windows من .NET. لكن إذا استخدمت Created أو Changed كما لو كانت إشارة اكتمال مباشرة، فمن السهل جدّاً أن تواجه أحداثاً مفقودة وإشعارات مكرّرة والمشكلة الكلاسيكيّة المتمثّلة في قراءة ملفّ بينما لا يزال نصفه مكتوباً.

ينظّم هذا المقال كيفيّة استخدام FileSystemWatcher بأمان، خاصّةً في سياق تكامل الملفّات على Windows باستخدام .NET. كما يرتبط بالتفكير المطروح في التحكّم الآمن بالقفل في تكامل الملفّات - أفضل الممارسات لقفل الملفّات والمطالبة الذرّيّة والمعالجة الـ idempotent.

FileSystemWatcher مفيد. يمكنه أن يعطيك أحداثاً للإنشاء والتغيير والحذف وإعادة التسمية. لكن إذا اعتبرت تلك الأحداث «الحقيقة» أو «الاكتمال»، فإنّ المتاعب تصل بسرعة كبيرة.

على سبيل المثال:

  • قد يُطلَق Created قبل انتهاء نسخ الملفّ
  • قد يأتي Changed أكثر من مرّة
  • يمكن لدفعة من التغييرات أن تتجاوز سعة المخزن المؤقّت الداخليّ وتفقد تفاصيل الأحداث

لذا فإنّ فكرة التصميم الجوهريّة هي:

  • الإشعار هو محفّز
  • الحقيقة هي إعادة مسح الدليل
  • الملكيّة تأتي من مطالبة ذرّيّة
  • شبكة الأمان النهائيّة هي idempotency

ينظّم هذا المقال الفخاخ الشائعة حول FileSystemWatcher من هذا المنظور.

المحتويات

  1. النسخة المختصرة
  2. أنماط سوء الفهم الشائعة عند استخدام FileSystemWatcher
  3. الأنماط المعاكسة
  4. أفضل الممارسات
  5. مقتطفات شيفرة افتراضيّة
  6. دليل قواعد عامّة تقريبيّة
  7. الخلاصة
  8. المراجع

1. النسخة المختصرة

  • أحداث FileSystemWatcher ليست إشعارات اكتمال. إنّها فقط علامات على أنّ شيئاً قد تغيّر.
  • يمكن لـ Created و Changed و Renamed أن تتكرّر، وأن تصل بترتيبات مفاجئة، وأن تُفقد جزئيّاً أثناء الفيضان
  • يصبح الأمر أكثر استقراراً إذا تجنّب معالجو الأحداث العمل الثقيل واكتفوا بطلب إعادة مسح
  • يجب أن يصبح الاكتمال صريحاً بأنماط على جانب المرسل مثل temp -> close -> rename / replace أو ملفّات done / manifest
  • إذا كان هناك عدّة workers، فأنت بحاجة إلى مطالبة ذرّيّة قبل القراءة
  • ضبط InternalBufferSize ليس سوى إجراء داعم. في النهاية، إعادة المسح الكاملة و idempotency أهمّ

بعبارة أخرى، أكثر طريقة آمنة للتعامل مع FileSystemWatcher هي عدم اعتباره مصدر الحقيقة، بل اعتباره مصدراً لـ «حدث شيء، اذهب وانظر مجدّداً».

2. أنماط سوء الفهم الشائعة عند استخدام FileSystemWatcher

2.1. اعتبار Created إشارة اكتمال

هذا هو الفخّ الأوضح. عند نسخ ملفّ أو نقله، يمكن أن يُطلَق Created لحظة أن يصبح اسم الملفّ مرئيّاً، حتّى لو كان المحتوى لا يزال يُكتب.

sequenceDiagram
    participant Sender
    participant Dir as watched directory
    participant Watcher as FileSystemWatcher
    participant Receiver

    Sender->>Dir: create orders.csv
    Dir-->>Watcher: Created
    Watcher-->>Receiver: OnCreated
    Receiver->>Dir: open orders.csv and start reading
    Note over Receiver: The copy is still in progress
    Sender->>Dir: continue writing the rest

قد يعني Created «الاسم مرئيّ»، لكنّه لا يعني «الملفّ جاهز للقراءة».

2.2. الوثوق بعدد وترتيب أحداث Changed

ليس مضموناً أن يُطلَق Changed بالضبط مرّة واحدة. حتّى عمليّة حفظ عاديّة يمكن أن تظهر كعدّة أحداث. يمكن لبرامج مكافحة الفيروسات أو الفهرسة أيضاً أن تولّد نشاطاً إضافيّاً تراه على شكل تغييرات أكثر.

لذا فإنّ افتراضات مثل:

  • «Changed مرّة واحدة يعني الاكتمال»
  • «Renamed يعني أنّ لا شيء آخر سيلمس الملفّ»

هشّة جدّاً.

2.3. فقدان التغييرات بسبب فيضان المخزن المؤقّت الداخليّ

يستخدم FileSystemWatcher مخزناً مؤقّتاً داخليّاً. عندما تحدث تغييرات كثيرة في دفعة قصيرة، يمكن لذلك المخزن أن يفيض وتفقد تفاصيل الأحداث الفرديّة.

flowchart LR
    A["Many changes in a short time"] --> B["Notifications pile up in the internal buffer"]
    B --> C{"Can processing keep up?"}
    C -- "Yes" --> D["Process event details one by one"]
    C -- "No" --> E["Overflow"]
    E --> F["Error event"]
    F --> G["Do not trust the event history as complete"]
    G --> H["Do a full directory rescan"]

النقطة المهمّة هي أنّ هذا ليس مجرّد «حدث واحد مفقود». بمجرّد حدوث الفيضان، تصبح سلامة تدفّق الأحداث نفسه موضع تساؤل.

3. الأنماط المعاكسة

3.1. القيام بالعمل الفعليّ مباشرةً داخل معالج الحدث

هذا يجعل الحدث مسؤولاً عن الحكم بالاكتمال والملكيّة في الوقت نفسه.

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // the file may still be incomplete
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // only logs
};

المشكلتان هنا هما:

  • قد لا يكون الملفّ مكتملاً بعد
  • لا يوجد مسار للتعافي عند حدوث الفيضان أو فشل الـ watcher

3.2. محاولة إعادة بناء الحقيقة من تدفّق الأحداث نفسه

تصميم مثل «أضف إلى dictionary عند Created، حدّثه عند Changed، احذفه عند Deleted، استبدل المفاتيح عند Renamed» يبدو أنيقاً للوهلة الأولى. لكن بمجرّد ظهور التكرار وإعادة الترتيب والفيضان والتداخل الخارجيّ، يصبح نموذج الحالة غير موثوق به بسرعة.

في تكامل الملفّات، ما يهمّ ليس إعادة إنشاء سجلّ أحداث مثاليّ. ما يهمّ هو العثور بشكل صحيح على العناصر التي يكون من الآمن معالجتها الآن.

3.3. اعتبار «لا مزيد من Changed» اكتمالاً

هذا يحمل نفس رائحة «إذا توقّف حجم الملفّ عن التغيّر، فلا بدّ أنّه انتهى». لا يزال هذا تخميناً.

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

يقع هذا في مشاكل عندما:

  • يتوقّف نسخ ملفّ كبير مؤقّتاً
  • يكتب المرسل في عدّة مراحل
  • يؤخّر سلوك network share الإشعارات
  • تغيّر عمليّة أخرى الخصائص أو الطوابع الزمنيّة لاحقاً

الاكتمال أقوى بكثير عندما يكون صريحاً بدلاً من أن يكون مخمّناً.

3.4. الاعتقاد بأنّ المشكلة تُحلّ بمجرّد زيادة InternalBufferSize

ضبط InternalBufferSize مهمّ، لكنّه ليس جوهر التصميم.

  • القيمة الافتراضيّة هي 8192 بايت
  • لا يمكن أن تكون أصغر من 4096
  • لا يمكن أن تتجاوز 64 KB
  • تستهلك non-paged memory

لذا حتّى لو رفعتها إلى 64 KB، يمكن لدفعة كبيرة بما يكفي أن تجعلها تفيض. وهي لا تفعل أيّ شيء على الإطلاق لحلّ مشكلة «إشارة الاكتمال مقابل تلميح التغيير».

قبل رفعها، غالباً ما يكون من الأكثر قيمة:

  • تضييق Filter / Filters
  • تقليل NotifyFilter إلى الحدّ الأدنى
  • تجنّب تشغيل IncludeSubdirectories بشكل عشوائيّ
  • إبقاء معالجي الأحداث خفيفين
  • بناء إعادة مسح كاملة و idempotency

3.5. تسجيل Error ثمّ تجاهله

Error ليس من نوع الأحداث التي ينبغي تجاهلها. هنا يظهر الفيضان والمتاعب على مستوى الـ watcher.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // If it ends here, you detected trouble but never recovered
};

في الحدّ الأدنى، تحتاج عادةً إلى:

  • طلب إعادة مسح كاملة
  • إعادة إنشاء الـ watcher إذا لم تعد المراقبة الأساسيّة موثوقة
  • سلوك idempotent لكي تكون المعالجة المتكرّرة آمنة

4. أفضل الممارسات

4.1. طيّ جميع الإشعارات إلى «طلب إعادة مسح»

عادةً ما يكون من الأسهل عدم ربط Created و Changed و Deleted و Renamed و Error مباشرةً بسلوكيّات أعمال منفصلة. بدلاً من ذلك، اطوها جميعاً إلى نوع واحد من الإشارة:

«اذهب وانظر مجدّداً»

flowchart LR
    A[Created / Changed / Deleted / Renamed] --> Q[scan request]
    B[Error / overflow] --> Q
    C[startup] --> Q
    Q --> D[directory rescan]
    D --> E[list ready candidates]
    E --> F[attempt claim]

نقاط التنفيذ العمليّة:

  • معالجو الأحداث عادةً لا يفعلون أكثر من ضبط dirty = true وإرسال signal
  • ضع المسح الفعليّ في worker واحد
  • إذا انفجرت الأحداث، اجمعها لمدّة 100-300 ms وامسح مرّة واحدة
  • إذا وصل إشعار آخر أثناء المسح، فقم بمسح إضافيّ بعد ذلك

بهذه الطريقة، سواء وصلت خمسة أحداث أو خمسون حدثاً، يبقى السلوك النهائيّ:

امسح حالة الدليل الفعليّة وابحث عن العناصر الجاهزة

4.2. اجعل الاكتمال صريحاً على جانب المرسل

إذا كنت تتحكّم بجانب الإرسال أيضاً، فمن الأفضل بكثير تحسين بروتوكول النشر بدلاً من اختراع استدلالات اكتمال على جانب FileSystemWatcher.

النهج القياسيّ لا يزال:

  • اكتب المحتوى الكامل إلى اسم temp
  • أغلقه
  • أعد تسميته / استبدله إلى الاسم النهائيّ على نفس نظام الملفّات
  • اختياريّاً ضع ملفّ done / manifest في النهاية
flowchart TD
    A["write full content to data.tmp"] --> B["flush / close"]
    B --> C["rename / replace to data.csv"]
    C --> D["place data.done / manifest.json"]
    D --> E["receiver watches only final names or done markers"]

هذا فعّال جدّاً. ينبغي اعتبار FileSystemWatcher طريقة لاكتشاف الاكتمال الصريح بالفعل في وقت أبكر، وليس أداة لاختراع قواعد اكتمال بمفرده.

4.3. خذ مطالبات ذرّيّة على جانب المستقبل

حتّى لو وجدت إعادة المسح مرشّحاً جاهزاً، فإنّ الذهاب مباشرةً إلى «افتحه وعالجه» يسمح لعدّة workers بأخذ نفس الملفّ. لذا ينبغي للمستقبل أن يأخذ مطالبة ذرّيّة أوّلاً.

sequenceDiagram
    participant Scan as scanner
    participant IN as incoming
    participant P1 as processing/worker1
    participant P2 as processing/worker2

    Scan->>IN: detect order-123
    Scan->>P1: rename order-123
    Scan->>P2: rename order-123
    Note over P1,P2: only the first successful claimant owns it

كما في مقال تكامل الملفّات السابق، فإنّ إعادة تسمية incoming -> processing/<worker>/ نمط مطالبة واضح جدّاً. وهو لطيف بشكل خاصّ عندما يُجمَّع payload والبيانات الوصفيّة كدليل bundle:

incoming/
  order-123/
    payload.csv
    manifest.json

ثمّ يمكن المطالبة بالـ bundle بأكمله بإعادة تسمية واحدة.

4.4. قم بإعادة مسح كاملة عند البدء والفيضان وإعادة الاتّصال

هذا مهمّ للغاية.

  • الملفّات التي كانت موجودة بالفعل قبل البدء لن تولّد أحداثاً جديدة
  • بعد الفيضان، لم يعد سجلّ الأحداث موثوقاً
  • في سيناريوهات network-share أو فصل/إعادة اتّصال، ينبغي افتراض «حدث شيء بينما كانت المراقبة معطّلة»

لذا ينبغي أن تحدث إعادة المسح الكاملة على الأقلّ:

  • عند البدء
  • عند حدوث Error
  • بعد إعادة إنشاء الـ watcher
  • اختياريّاً على فترات أمان دوريّة

فكرة التصميم هي:

الـ watcher يعطي تلميحات حول التغيير
إعادة المسح تستعيد الصحّة

4.5. افترض idempotency من البداية

إذا استخدمت FileSystemWatcher بأمان، فقد يُكتشف نفس العنصر المنطقيّ أكثر من مرّة. هذا ليس خطأ. إنّه التصميم وهو يصبح متيناً.

تشمل الإجراءات النموذجيّة:

  • وضع IdempotencyKey في الـ manifest
  • قمع التأثيرات الجانبيّة إذا تمّت معالجة العنصر بالفعل
  • التحقّق مقابل الحالة المؤرشفة / سجلّات DB / علامات الإرسال
  • اعتبار إعادة المسح الكاملة «من الآمن النظر مجدّداً»

محاولة فرض سلوك exactly-once من تدفّق الأحداث وحده عادةً ما تكون مؤلمة. at-least-once بالإضافة إلى idempotency أكثر عمليّة بكثير.

5. مقتطفات شيفرة افتراضيّة

5.1. نمط فشل نموذجيّ

using var watcher = new FileSystemWatcher(incomingDir)
{
    Filter = "*.csv",
    IncludeSubdirectories = false,
    EnableRaisingEvents = true,
    InternalBufferSize = 64 * 1024
};

watcher.Created += (_, e) =>
{
    // Incorrectly treating Created as completion
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // Another change event? Just process again
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // No recovery
};

هذا يحتوي على أربع مشاكل واضحة:

  • يرتبط تدفّق الأحداث مباشرةً بمعالجة الأعمال
  • لا توجد قاعدة اكتمال حقيقيّة
  • الفيضان لا يطلق إعادة مسح كاملة
  • لا يوجد أمان ضدّ المعالجة المتكرّرة

5.2. اتّجاه أكثر صحّة (تقريباً)

private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;

void OnAnyChange(object? sender, FileSystemEventArgs e)
{
    RequestScan(full: false);
}

void OnRenamed(object? sender, RenamedEventArgs e)
{
    RequestScan(full: false);
}

void OnError(object? sender, ErrorEventArgs e)
{
    Log(e.GetException());
    RequestScan(full: true);
}

void RequestScan(bool full)
{
    if (full)
    {
        Interlocked.Exchange(ref _fullRescanRequested, 1);
    }

    if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
    {
        _scanSignal.Release();
    }
}

async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
    RequestScan(full: true); // startup scan

    while (!cancellationToken.IsCancellationRequested)
    {
        await _scanSignal.WaitAsync(cancellationToken);

        await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); // coalesce bursts

        Interlocked.Exchange(ref _scanRequested, 0);
        bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;

        foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
        {
            var claimedPath = Path.Combine(processingDir, bundle.Name);

            if (!TryClaimByRename(bundle.Path, claimedPath))
            {
                continue; // another worker claimed it first
            }

            var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));

            if (AlreadyProcessed(manifest.IdempotencyKey))
            {
                MoveToArchive(claimedPath, archiveDir);
                continue;
            }

            ProcessBundle(claimedPath);
            RecordProcessed(manifest.IdempotencyKey);
            MoveToArchive(claimedPath, archiveDir);
        }

        if (Volatile.Read(ref _scanRequested) == 1)
        {
            _scanSignal.Release(); // do not lose a notification that arrived during scanning
        }
    }
}

الشيء المهمّ في هذا المثال ليس تفاصيل الـ API، بل التدفّق:

  • اطوِ الإشعارات إلى طلبات مسح
  • امسح حالة نظام الملفّات الفعليّة
  • طالب ذرّيّاً
  • تحقّق من idempotency
  • عالج وسجّل وأرشف

حدث الـ watcher نفسه ليس سوى محفّز.

6. دليل قواعد عامّة تقريبيّة

  • مستقبل واحد، جانب المرسل تحت تحكّمك
    ابدأ بـ temp -> close -> rename ومسح عند البدء. هذا يثبّت الكثير بالفعل.

  • عدّة workers مستقبلين
    أضف إعادة تسمية مطالبة ذرّيّة مثل incoming -> processing.

  • دفعات إشعارات عالية التردّد
    ضيّق Filter و NotifyFilter و IncludeSubdirectories، وأبقِ المعالجين خفيفين للغاية. اضبط InternalBufferSize فقط بعد ذلك.

  • الفيضان غير مقبول
    صمّم حول إعادة مسح كاملة، وإذا لم يكن ذلك كافياً أيضاً، فلا تراهن على FileSystemWatcher وحده. على Windows، قد يصبح USN change journal ذا صلة.

  • لا يمكنك التحكّم بكيفيّة نشر المرسل للملفّات
    قبل اختراع استدلالات، انظر إن كان بإمكانك التفاوض على بروتوكول نشر أفضل. إذا لم يكن ذلك ممكناً، اخفض مستوى الضمان وصمّم المستقبل ليتحمّل الملاحظة المتكرّرة بأمان.

7. الخلاصة

FileSystemWatcher ليس سجلّ معاملات. إنّه آليّة إشعار مفيدة، لكنّه يصبح متيناً فقط عند اقترانه بـ:

  • إعادة المسح
  • قواعد اكتمال صريحة
  • مطالبات ذرّيّة
  • idempotency

إذا اعتبرته «الحقيقة»، فستتأذّى عادةً. إذا اعتبرته «تلميحاً للذهاب والتحقّق من حالة نظام الملفّات الفعليّة مجدّداً»، فإنّه يصبح كتلة بناء أقوى بكثير.

8. المراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة