إدارة الموارد الصريحة في TypeScript وNode.js 24: كيف نستخدم using وawait using وDisposableStack
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أهم النقاط
تستدعي التعليمتان using وawait using الدالة Symbol.dispose أو Symbol.asyncDispose عند الخروج من الكتلة البرمجية المحيطة، سواء بالإرجاع أو بالقطع أو برمي خطأ، وتحرر الموارد المتعددة بعكس ترتيب تعريفها.
يفهم Node.js 24 صياغة using بشكل أصلي، ويحوّلها TypeScript 5.2 وما بعده إلى دوال مساعدة مبنية على try/finally عند استهداف إصدارات أقدم.
تطبّق واجهات Node.js المدمجة بروتوكول التحرير مسبقًا: FileHandle وhttp.Server وnet.Server والمؤقتات وChildProcess وfs.promises.mkdtempDisposable().
لا تتلقى دوال التحرير أي وسيط يخبرها بوقوع خطأ، لذلك يجب أن تتراجع المعاملة (Transaction) القابلة للتحرير تلقائيًا ما لم يُستدعَ commit() صراحة.
تربط using المورد بنطاق معجمي (Lexical Scope)، لذلك تحرره مبكرًا جدًا في حالة البث (Stream) الذي يُرجعه Route Handler، ولا مكان لها داخل جسم مكوّن React.
لماذا ننقل كود التنظيف إلى using؟
في Devya ننقل تنظيف الموارد من كتل try/finally المكتوبة يدويًا إلى إدارة الموارد الصريحة (Explicit Resource Management)، لأن تسرب الاتصالات يأتي غالبًا من مسار خطأ واحد تخطّى استدعاء release(). تضع إدارة الموارد الصريحة كود التنظيف داخل المورد نفسه، فيُنفَّذ في كل مسار خروج. ومنذ أن أصبح Node.js 24 خط الدعم طويل الأمد (LTS) النشط، تعمل هذه الصياغة دون مترجم في خدمات Node.js لدينا، ونستخدمها الآن مع عملاء قواعد البيانات والمعاملات والمجلدات المؤقتة وتجهيزات الاختبارات.
ماذا تفعل using وawait using فعلًا؟
يعرّف تصريح using ثابتًا محصورًا في الكتلة البرمجية، ويستدعي دالته Symbol.dispose عند الخروج من الكتلة بأي طريق. إدارة الموارد الصريحة هي ميزة اللغة من TC39 التي تقف خلف ذلك: المورد هو أي كائن يملك دالة Symbol.dispose (مورد قابل للتحرير) أو دالة Symbol.asyncDispose (مورد قابل للتحرير غير المتزامن). تستدعي await using الدالة Symbol.asyncDispose وتنتظرها، لذلك لا يُسمح بها إلا داخل الدوال غير المتزامنة وفي المستوى الأعلى من وحدات ES.
مثال نموذجي هو دالة تحوّل ملفًا مرفوعًا. تعرّف await using على مجلد مؤقت من mkdtempDisposable()، ثم await using على مقبض الملف الذي تُرجعه open(). عند إرجاع الدالة أو رميها خطأ، يُغلق الملف أولًا ثم يُحذف المجلد المؤقت ثانيًا، دون أي كتلة finally.
ثلاث قواعد مهمة عمليًا. تُحرَّر الموارد بعكس ترتيب تعريفها. والقيمة التي لا تملك دالة تحرير ترمي TypeError في سطر التعريف نفسه، بينما يُسمح بـ null وundefined ويتم تجاوزهما. وداخل نطاق using يكتب فريقنا return await، لأن إرجاع وعد (Promise) مباشرة يُنهي النطاق ويحرر الموارد قبل أن يكتمل ذلك الوعد.
يأتي Node.js 24 بمحرك V8 13.6 الذي يفهم الصياغة بشكل أصلي، بما في ذلك ملفات .ts التي تعمل عبر خاصية حذف الأنواع المدمجة في Node. ويدعم TypeScript صياغة using منذ الإصدار 5.2: مع الهدف esnext يُخرجها كما هي، ومع هدف أقدم يعيد كتابة كل كتلة إلى استدعاءات مساعدة مبنية على try/finally. وتأتي تعريفات الأنواع من مكتبة esnext أو من مكتبة esnext.disposable الأضيق.
ما واجهات Node.js القابلة للتحرير مسبقًا؟
اكتسبت واجهات Node.js المدمجة دوال التحرير قبل وصول الصياغة، لذلك تعمل using معها دون أغلفة إضافية. يعرّف Node.js 20 و22 الرمزين Symbol.dispose وSymbol.asyncDispose ومعظم هذه الدوال لكنهما لا يفهمان صياغة using، وNode.js 24 هو أول خط LTS يجمع الأمرين.
FileHandle من fs/promises: تُغلق await using الملف (أضيفت في v20.4.0).
fs.promises.mkdtempDisposable(): تحذف await using المجلد المؤقت ومحتوياته (أضيفت في v24.4.0).
fs.Dir من opendir(): يُغلق أي من الشكلين مقبض المجلد (أضيفت في v24.1.0).
http.Server وnet.Server: تستدعي await using الدالة server.close() (أضيفت في v20.4.0 وv20.5.0).
Timeout وImmediate: تلغي using المؤقت (أضيفت في v20.5.0).
ChildProcess: تستدعي using الدالة kill بالإشارة SIGTERM (أضيفت في v20.5.0).
توقف server.close() قبول الاتصالات الجديدة ولا تكتمل إلا بعد انتهاء الاتصالات النشطة، لذلك نستخدم await using مع الخوادم في الاختبارات فقط، لا في إيقاف الإنتاج الذي يحتاج مهلة تصريف صريحة. ويصنع المؤقت القابل للتحرير حارسًا أنيقًا: تعريف using على setTimeout يسجّل تحذير بطء العملية يلغي التحذير تلقائيًا إذا انتهى العمل أولًا.
كيف نجعل عميل قاعدة البيانات المجمّع والمعاملة قابلين للتحرير؟
نغلّف العميل الذي تُرجعه pool.connect() في دالة مصنع صغيرة تضيف دالة تحرير. تستخدم أمثلتنا node-postgres، لكن الشكل نفسه يصلح لأي مشغّل قاعدة بيانات يملك زوج حجز وتحرير.
تُرجع الدالة المساعدة connect(pool) العميل ومعه دالة Symbol.dispose تستدعي client.release(). وتحجز الدالة beginTransaction(pool) عميلًا وتنفّذ BEGIN، ثم تُرجع العميل ودالة commit() تنفّذ COMMIT وتضبط علامة الالتزام، ودالة Symbol.asyncDispose. تحرر دالة التحرير هذه العميل بعد الالتزام، وإلا فإنها تنفّذ ROLLBACK ثم تحرر العميل، وإذا فشل التراجع نفسه تحرر العميل مع الخطأ حتى يدمّر المجمّع ذلك الاتصال.
القاعدة التصميمية التي نتبعها: التزم صراحة، وتراجع افتراضيًا. تُستدعى دالة التحرير دون وسائط، فلا تعرف إن كانت الكتلة قد رمت خطأ. لذلك يتتبع كائن المعاملة علامة الالتزام بنفسه، وكل خروج لم يصل إلى commit() ينتهي بالتراجع: استثناء، أو return مبكر، أو commit() منسي. وهكذا تصبح عملية تحويل رصيد تصريح await using واحدًا وجملتي UPDATE واستدعاء commit() واحدًا.
متى تكون using أفضل من try/finally أو دالة رد النداء المساعدة أو DisposableStack؟
تتفوق using عندما تحجز الدالة أكثر من مورد واحد، لأن كل try/finally تضيف مستوى تداخل جديدًا بينما تبقى تصريحات using مسطّحة.
try/finally: الأنسب لمورد واحد أو لبيئات تشغيل لا تدعم الصياغة، لكنها تتداخل مع كل مورد ويسهل نسيانها في أحد المسارات.
دوال رد النداء المساعدة مثل withClient(fn): الأنسب للمكتبات التي يجب أن تتحكم في دورة الحياة كاملة، لكنها تضيف تداخل ردود النداء وتجعل الإرجاع المبكر غير مريح.
using وawait using: الأنسب لمجموعة ثابتة من الموارد في نطاق واحد، لكنها تنتهي بانتهاء الكتلة ولا تستطيع تسليم المورد للمستدعي.
DisposableStack وAsyncDisposableStack: الأنسب لعدد متغير من الموارد ولدوال المصنع التي تنقل الملكية، مقابل كائن إضافي يجب فهمه.
DisposableStack وAsyncDisposableStack حاويات مدمجة تجمع عمليات التنظيف عبر use() وadopt() وdefer() وتنفّذها بترتيب عكسي. وتنقل دالتها move() كل عمليات التنظيف المسجلة إلى مكدس جديد. يستخدم مصنع تطبيق الاختبار لدينا ذلك: يسجل مجلدًا مؤقتًا، ويشغّل خادم HTTP وينتظر حدث الاستماع، ولا يسجل الخادم إلا بعد أن يبدأ الاستماع، ثم يستدعي move() ليمتلك المستدعي الموردين معًا. وإذا فشل الاستماع، يحذف المكدس المجلد قبل انتشار الخطأ.
ماذا يحدث عندما يرمي الكود ودالة التحرير خطأً معًا؟
عندما ترمي الكتلة خطأ وترمي دالة التحرير خطأً أيضًا، ترمي JavaScript خطأ SuppressedError: تحمل خاصيته error خطأ دالة التحرير، وتحمل خاصيته suppressed الخطأ الأصلي من الكتلة. ومع فشل عدة دوال تحرير تتداخل الأخطاء، بخطأ SuppressedError لكل فشل.
تجمّع أدوات تتبع الأخطاء البلاغات حسب الخطأ الأعلى، لذلك قد يخفي فشل ROLLBACK على اتصال مقطوع السبب الحقيقي. يمرّ مسجّل الأخطاء لدينا على السلسلة عبر الخاصية suppressed ويبلّغ عن السبب الأصلي أولًا ثم عن كل خطأ تحرير. ونبقي دوال التحرير هادئة أيضًا: تعود مبكرًا إذا كان المورد مغلقًا بالفعل، ولا ترمي خطأً إلا إذا بقي شيء مسرّبًا أو في حالة غير معروفة.
أين تفشل using في كود Next.js وReact؟
تحرر using المورد عند انتهاء كتلة الدالة، لا عند انتهاء العمل الذي بدأته الدالة. وأوضح مثال هو Route Handler في Next.js يُرجع Response مبثوثًا: يعود المعالج قبل قراءة جسم الاستجابة، فيعود العميل المعرّف بـ using في المعالج إلى المجمّع بينما ما زال البث يحتاجه.
الحل هو نقل التصريح إلى المولّد غير المتزامن (Async Generator) الذي ينتج البث. يبقى نطاق المولّد مفتوحًا حتى ينتهي، وتستدعي ReadableStream.from() دالة return() الخاصة بالمولّد عندما يلغي المستهلك القراءة، فتعمل دالة التحرير.
ولـ React الحد نفسه. تصريح using داخل جسم مكوّن يحرر المورد عند نهاية تلك العملية من التصيير، لذلك تبقى الاشتراكات والاتصالات التي تعيش بعمر المكوّن في دالة التنظيف الخاصة بـ useEffect. وفي مكوّن خادم غير متزامن (Async Server Component)، تكون using آمنة فقط عندما ينتهي كل استعلام قبل أن يُرجع المكوّن JSX.
وفي حزم المتصفح نترك المترجم يحوّل using إلى صياغة أقدم بدل شحن الصياغة الأصلية، لأن دعم المتصفحات لم يكتمل بعد. ما زالت الدوال المساعدة المترجمة تحتاج Symbol.dispose وSymbol.asyncDispose وقت التشغيل، وDisposableStack صنف وقت تشغيل لا يستطيع أي مترجم تحويله، لذلك تحتاج المتصفحات الأقدم إلى polyfill مثل core-js.
الأسئلة الشائعة
س: هل يمكنني استخدام الكلمة المفتاحية using في Node.js 20 أو 22؟
ج: ليس بشكل أصلي. يعرّف Node.js 20 و22 الرمزين Symbol.dispose وSymbol.asyncDispose ودوال التحرير في الواجهات المدمجة، لكنهما لا يفهمان صياغة using؛ ترجم الكود بـ TypeScript 5.2 أو أحدث إلى هدف أقدم وسيعمل الناتج على الإصدارين.
س: هل تعرف دالة التحرير إن كانت الكتلة قد رمت خطأ؟
ج: لا. تُستدعى دالتا Symbol.dispose وSymbol.asyncDispose دون وسائط، لذلك يجب أن يكون العمل المشروط بالنجاح، مثل الالتزام بالمعاملة، استدعاءً صريحًا قبل نهاية الكتلة، مع التراجع كسلوك افتراضي.
س: ماذا يحدث إذا عرّفت using على كائن لا يملك دالة تحرير؟
ج: يرمي التصريح TypeError فورًا في سطر using نفسه، لا عند الخروج من النطاق. ويُقبل null وundefined ويتم تجاوزهما، مما يسهّل تعريف الموارد الاختيارية.
س: هل يجب أن تحل using محل الإيقاف المنظّم (Graceful Shutdown) لمجمّع الاتصالات وخادم HTTP؟
ج: لا. يعيش مجمّع الاتصالات وخادم الإنتاج بعمر العملية كلها لا بعمر نطاق معجمي، لذلك يظل إغلاقهما من معالج SIGTERM مع مهلة تصريف؛ وتناسب using الموارد الخاصة بكل طلب وكل مهمة وكل اختبار.
قراءات إضافية
هندسة الأنظمة الخلفية
قراءة EXPLAIN ANALYZE في PostgreSQL: كيف نجد الفهرس الناقص وراء نقطة API بطيئة في Node.js
توقّفنا عن إضافة فهارس PostgreSQL بالتخمين. طريقتنا: نحدّد الاستعلام الأعلى تكلفة عبر pg_stat_statements، ونقرأ خطة EXPLAIN (ANALYZE, BUFFERS) بحثًا عن الصفوف التي تُقرأ ثم تُهمَل، ثم نضيف الفهرس المركّب أو الجزئي أو فهرس التغطية الذي تطلبه الخطة، ونبنيه باستخدام CREATE INDEX CONCURRENTLY.
هندسة الأنظمة الخلفية
أمان مستوى الصفوف في Postgres لتطبيقات Node.js متعددة المستأجرين: الأدوار والاتصالات المجمّعة والنقاط العمياء — ملاحظات ميدانية
ننقل عزل المستأجرين في تطبيقات Node.js ذات المخطط المشترك إلى ميزة Row-Level Security في PostgreSQL، فيُرجع الشرط المنسي WHERE tenant_id صفر صفوف بدلًا من بيانات عميل آخر. تغطي ملاحظاتنا شكل السياسة، وفصل الأدوار الذي يمنع تجاوز RLS بصمت، ونمط set_config الصامد أمام تجميع الاتصالات، والنقاط العمياء.