كيفيّة استخدام FileSystemWatcher بأمان - الأحداث المفقودة والإشعارات المكرّرة وفخاخ كشف الاكتمال
· 小村 豪 · FileSystemWatcher, C#, .NET, تطوير Windows, تكامل الملفّات, التصميم
FileSystemWatcher هو من أوائل الـ APIs التي يلجأ إليها الناس لمراقبة تغييرات الملفّات على Windows من .NET.
لكن إذا استخدمت Created أو Changed كما لو كانت إشارة اكتمال مباشرة، فمن السهل جدّاً أن تواجه أحداثاً مفقودة وإشعارات مكرّرة والمشكلة الكلاسيكيّة المتمثّلة في قراءة ملفّ بينما لا يزال نصفه مكتوباً.
ينظّم هذا المقال كيفيّة استخدام FileSystemWatcher بأمان، خاصّةً في سياق تكامل الملفّات على Windows باستخدام .NET.
كما يرتبط بالتفكير المطروح في التحكّم الآمن بالقفل في تكامل الملفّات - أفضل الممارسات لقفل الملفّات والمطالبة الذرّيّة والمعالجة الـ idempotent.
FileSystemWatcher مفيد.
يمكنه أن يعطيك أحداثاً للإنشاء والتغيير والحذف وإعادة التسمية.
لكن إذا اعتبرت تلك الأحداث «الحقيقة» أو «الاكتمال»، فإنّ المتاعب تصل بسرعة كبيرة.
على سبيل المثال:
- قد يُطلَق
Createdقبل انتهاء نسخ الملفّ - قد يأتي
Changedأكثر من مرّة - يمكن لدفعة من التغييرات أن تتجاوز سعة المخزن المؤقّت الداخليّ وتفقد تفاصيل الأحداث
لذا فإنّ فكرة التصميم الجوهريّة هي:
- الإشعار هو محفّز
- الحقيقة هي إعادة مسح الدليل
- الملكيّة تأتي من مطالبة ذرّيّة
- شبكة الأمان النهائيّة هي idempotency
ينظّم هذا المقال الفخاخ الشائعة حول FileSystemWatcher من هذا المنظور.
المحتويات
- النسخة المختصرة
- أنماط سوء الفهم الشائعة عند استخدام
FileSystemWatcher - الأنماط المعاكسة
- أفضل الممارسات
- مقتطفات شيفرة افتراضيّة
- دليل قواعد عامّة تقريبيّة
- الخلاصة
- المراجع
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. المراجع
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
مزالق تطبيقات serial communication - framing وtimeouts وflow control وreconnects ومحوّلات USB وتجمّد الـ UI
ملخّص عمليّ لمزالق تطبيقات serial communication على Windows: framing وtimeouts وflow control وreconnects ومحوّلات USB-to-serial وتجمّد ال...
ما الذي يجب التحقّق منه قبل ترحيل .NET Framework إلى .NET - قائمة تحقّق عمليّة لما قبل الترحيل
قائمة عمليّة لما قبل ترحيل .NET Framework إلى .NET: جرد المشاريع وفحص WCF و Web Forms و EF6 و NuGet لتجنّب المفاجآت قبل بدء التنفيذ.
ما هو .NET Generic Host - شرح DI والإعدادات والـ logging و BackgroundService
مقدمة عمليّة إلى .NET Generic Host وكيف يجمع DI والإعدادات والـ logging و BackgroundService في تطبيقات console و worker، مع متى يفيد فعلا...
ما هو Native AOT في .NET - الفروق عن JIT و ReadyToRun و trimming
شرح Native AOT في .NET كنموذج نشر يقدّم الترجمة الأصليّة، مع الفروق عن JIT و ReadyToRun و trimming، والأنسب أن يكون أدوات CLI و workers و...
كيف نُحوِّل C# إلى native DLL باستخدام Native AOT - استدعاء exports من نوع UnmanagedCallersOnly من C/C++
يوضِّح هذا المقال كيف نُصدر مكتبة C# بوصفها native DLL عبر Native AOT، ونكشف نقاط دخول UnmanagedCallersOnly تُستدعى مباشرةً من C أو C++ ب...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.
روابط عامة