استخدام مسارات Express.js لمعالجة الأخطاء المستندة إلى الوعد

نشرت: 2022-03-11

يبدو شعار Express.js صحيحًا: إنه "إطار عمل ويب بسيط وسريع وغير معلن لـ Node.js." من غير المعلوم أنه على الرغم من أفضل ممارسات JavaScript الحالية التي تنص على استخدام الوعود ، فإن Express.js لا يدعم معالجات التوجيه القائمة على الوعد افتراضيًا.

مع استبعاد العديد من دروس Express.js لهذه التفاصيل ، غالبًا ما يعتاد المطورون نسخ ولصق رمز إرسال النتائج ومعالجة الأخطاء لكل مسار ، مما يؤدي إلى إنشاء دين فني أثناء ذهابهم. يمكننا تجنب هذا النمط المضاد (وتداعياته) بالتقنية التي سنغطيها اليوم - أحد الأساليب التي استخدمتها بنجاح في التطبيقات ذات المئات من المسارات.

العمارة النموذجية لمسارات Express.js

لنبدأ بتطبيق Express.js التعليمي مع بعض المسارات لنموذج المستخدم.

في المشاريع الحقيقية ، نقوم بتخزين البيانات ذات الصلة في بعض قواعد البيانات مثل MongoDB. ولكن لأغراضنا ، فإن تفاصيل تخزين البيانات ليست مهمة ، لذلك سنستهزئ بها من أجل البساطة. ما لن نبسطه هو هيكل مشروع جيد ، مفتاح نصف نجاح أي مشروع.

يمكن أن ينتج Yeoman هياكل عظمية أفضل بكثير للمشروع بشكل عام ، ولكن لما نحتاج إليه ، سنقوم ببساطة بإنشاء هيكل مشروع باستخدام المولد السريع وإزالة الأجزاء غير الضرورية ، حتى نحصل على هذا:

 bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json

لقد قمنا بتقليص سطور الملفات المتبقية التي لا تتعلق بأهدافنا.

هذا هو ملف تطبيق Express.js الرئيسي ، ./app.js :

 const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app;

نقوم هنا بإنشاء تطبيق Express.js وإضافة بعض البرامج الوسيطة الأساسية لدعم استخدام JSON وترميز URL وتحليل ملفات تعريف الارتباط. نضيف بعد ذلك usersRouter for /users . أخيرًا ، نحدد ما يجب فعله إذا لم يتم العثور على مسار ، وكيفية التعامل مع الأخطاء ، والتي سنقوم بتغييرها لاحقًا.

البرنامج النصي لبدء الخادم نفسه هو /bin/start.js :

 const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port);

لدينا /package.json مجردة أيضًا:

 { "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } }

دعنا نستخدم تطبيقًا نموذجيًا لجهاز توجيه المستخدم في /routes/users.js :

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router;

له مساران: / للحصول على جميع المستخدمين و /:id للحصول على مستخدم واحد بواسطة ID. كما أنها تستخدم /services/userService.js ، والتي لها طرق قائمة على الوعد للحصول على هذه البيانات:

 const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll };

هنا تجنبنا استخدام موصل DB فعلي أو ORM (على سبيل المثال ، Mongoose أو Sequelize) ، ببساطة محاكاة جلب البيانات باستخدام Promise.resolve(...) .

مشاكل توجيه Express.js

بالنظر إلى معالجات المسار الخاصة بنا ، نرى أن كل مكالمة خدمة تستخدم عمليات استدعاء مكررة .then(...) و .catch(...) لإرسال البيانات أو الأخطاء مرة أخرى إلى العميل.

للوهلة الأولى ، قد لا يبدو هذا الأمر خطيرًا. دعنا نضيف بعض المتطلبات الأساسية في العالم الحقيقي: سنحتاج فقط إلى عرض أخطاء معينة وحذف أخطاء مستوى 500 العامة ؛ أيضًا ، سواء طبقنا هذا المنطق أم لا ، يجب أن يعتمد على البيئة. مع ذلك ، كيف سيبدو عندما ينمو مشروع مثالنا من طريقين إلى مشروع حقيقي مع 200 طريق؟

النهج 1: وظائف المرافق

ربما ننشئ وظائف أداة مساعدة منفصلة للتعامل مع resolve reject ، وتطبيقها في كل مكان في مسارات Express.js الخاصة بنا:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); });

تبدو أفضل: نحن لا نكرر تنفيذنا لإرسال البيانات والأخطاء. لكننا ما زلنا بحاجة إلى استيراد هذه المعالجات في كل مسار وإضافتها إلى كل وعد تم تمريره إلى then() catch() .

النهج 2: الوسيطة

يمكن أن يكون الحل الآخر هو استخدام أفضل ممارسات Express.js حول الوعود: انقل منطق إرسال الأخطاء إلى البرنامج الوسيط للخطأ Express.js (مضاف في app.js ) وتمرير أخطاء غير متزامنة إليه باستخدام رد الاتصال next . سيستخدم إعداد البرامج الوسيطة للخطأ الأساسي وظيفة مجهولة بسيطة:

 app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); });

يتفهم Express.js أن هذا يتعلق بالأخطاء لأن توقيع الوظيفة يحتوي على أربع وسائط إدخال. (إنها تستفيد من حقيقة أن كل كائن دالة له خاصية .length التي تصف عدد المعلمات التي تتوقعها الوظيفة.)

سيبدو تمرير الأخطاء عبر next بالشكل التالي:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); });

حتى باستخدام دليل أفضل الممارسات الرسمي ، ما زلنا بحاجة إلى وعود JS في كل معالج مسار لحل استخدام وظيفة handleResponse() ورفضها عن طريق تمرير الوظيفة next .

دعنا نحاول تبسيط ذلك بنهج أفضل.

المقاربة 3: البرامج الوسيطة المستندة إلى الوعد

واحدة من أعظم ميزات JavaScript هي طبيعتها الديناميكية. يمكننا إضافة أي حقل إلى أي كائن في وقت التشغيل. سنستخدم ذلك لتوسيع كائنات نتيجة Express.js ؛ تعتبر وظائف البرنامج الوسيط Express.js مكانًا مناسبًا للقيام بذلك.

promiseMiddleware() الوظيفة

لنقم بإنشاء برمجية وسيطة وعدنا ، والتي ستمنحنا المرونة في هيكلة مسارات Express.js الخاصة بنا بشكل أكثر أناقة. سنحتاج إلى ملف جديد ، /middleware/promise.js :

 const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; }

في app.js ، دعنا نطبق برمجتنا الوسيطة على كائن app Express.js العام ونحدِّث سلوك الخطأ الافتراضي:

 const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); });

لاحظ أننا لا نحذف البرمجيات الوسيطة للأخطاء . لا يزال معالج الأخطاء مهمًا لجميع الأخطاء المتزامنة التي قد تكون موجودة في التعليمات البرمجية الخاصة بنا. ولكن بدلاً من تكرار منطق إرسال الخطأ ، تمرر البرامج الوسيطة للخطأ الآن أي أخطاء متزامنة إلى نفس وظيفة handleError() المركزية عبر استدعاء Promise.reject() يُرسل إلى res.promise() .

يساعدنا هذا في التعامل مع الأخطاء المتزامنة مثل هذا الخطأ:

 router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); });

أخيرًا ، دعنا نستخدم res.promise() في /routes/users.js :

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router;

لاحظ الاستخدامات المختلفة لـ .promise() : يمكننا منحها وظيفة أو وعدًا. يمكن أن تساعدك وظائف التمرير في الأساليب التي ليس لديها وعود ؛ .promise() يرى أنها وظيفة ويلفها بوعد.

أين من الأفضل إرسال الأخطاء بالفعل إلى العميل؟ إنه سؤال جيد حول تنظيم الكود. يمكننا فعل ذلك في برمجتنا الوسيطة للخطأ (لأنه من المفترض أن تعمل مع الأخطاء) أو في البرامج الوسيطة التي نعد بها (لأنها تحتوي بالفعل على تفاعلات مع كائن ردنا). قررت الاحتفاظ بجميع عمليات الاستجابة في مكان واحد في البرامج الوسيطة التي نعد بها ، ولكن الأمر متروك لكل مطور لتنظيم الكود الخاص به.

من الناحية الفنية ، res.promise() اختياري

لقد أضفنا res.promise() ، لكننا لسنا مقيدين باستخدامه: نحن أحرار في التعامل مع كائن الاستجابة مباشرة عندما نحتاج إلى ذلك. دعونا نلقي نظرة على حالتين حيث سيكون ذلك مفيدًا: إعادة التوجيه وتدفق الأنابيب.

الحالة الخاصة 1: إعادة التوجيه

لنفترض أننا نريد إعادة توجيه المستخدمين إلى عنوان URL آخر. دعنا نضيف دالة getUserProfilePicUrl() في userService.js :

 const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`);

والآن دعونا نستخدمه في جهاز توجيه مستخدمينا بأسلوب غير async / await مع معالجة الاستجابة المباشرة:

 router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } });

لاحظ كيف نستخدم حالة عدم async / await ، ونقوم بإعادة التوجيه ، و (الأهم من ذلك) لا يزال لدينا مكان مركزي واحد لتمرير أي خطأ لأننا استخدمنا res.promise() لمعالجة الأخطاء.

الحالة الخاصة 2: تدفق الأنابيب

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

للتعامل مع الطلبات إلى عنوان URL الذي نعيد التوجيه إليه الآن ، دعنا نضيف مسارًا يُرجع بعض الصور العامة.

أولاً ، يجب أن نضيف profilePic.jpg في مجلد فرعي جديد /assets/img . (في مشروع حقيقي ، سنستخدم التخزين السحابي مثل AWS S3 ، لكن آلية الأنابيب ستكون هي نفسها.)

دعنا نوجه هذه الصورة استجابةً لطلبات /img/profilePic/:id . نحتاج إلى إنشاء موجه جديد لذلك في /routes/img.js :

 const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router;

ثم نضيف جهاز التوجيه /img الجديد في app.js :

 app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img'));

من المحتمل أن يبرز اختلاف واحد مقارنة بحالة إعادة التوجيه: لم نستخدم res.promise() في /img router! ويرجع ذلك إلى أن سلوك كائن الاستجابة الموجه بالفعل والذي يتم تمريره خطأ سيكون مختلفًا عما إذا حدث الخطأ في منتصف التدفق.

يحتاج مطورو Express.js إلى الانتباه عند العمل مع التدفقات في تطبيقات Express.js ، ومعالجة الأخطاء بشكل مختلف اعتمادًا على وقت حدوثها. نحتاج إلى معالجة الأخطاء قبل أن تساعدنا الأنابيب ( res.promise() في ذلك) وكذلك في منتصف الطريق (بناءً على .on('error') ) ، ولكن مزيدًا من التفاصيل خارج نطاق هذه المقالة.

تحسين res.promise()

كما هو الحال مع استدعاء res.promise() ، نحن لسنا مقيدون بتنفيذها بالطريقة التي نطبقها أيضًا. يمكن زيادة الوعدMiddleware.js لقبول بعض الخيارات في promiseMiddleware.js res.promise() للسماح للمتصلين بتحديد أكواد حالة الاستجابة أو نوع المحتوى أو أي شيء آخر قد يتطلبه المشروع. يعود الأمر للمطورين لتشكيل أدواتهم وتنظيم التعليمات البرمجية الخاصة بهم بحيث تناسب احتياجاتهم على أفضل وجه.

معالجة أخطاء Express.js تفي بالترميز الحديث المستند إلى الوعد

النهج المقدم هنا يسمح بمعالجات المسار الأكثر أناقة مما بدأنا به ونقطة واحدة من نتائج المعالجة والأخطاء - حتى تلك التي تم إطلاقها خارج res.promise(...) بفضل معالجة الأخطاء في app.js ومع ذلك ، فنحن لسنا مجبرين على استخدامه ويمكننا معالجة الحالات المتطورة كما نريد.

الكود الكامل من هذه الأمثلة متاح على GitHub. من هناك ، يمكن للمطورين إضافة منطق مخصص حسب الحاجة إلى وظيفة handleResponse() ، مثل تغيير حالة الاستجابة إلى 204 بدلاً من 200 في حالة عدم توفر بيانات.

ومع ذلك ، فإن التحكم الإضافي في الأخطاء يكون أكثر فائدة. ساعدني هذا النهج في تنفيذ هذه الميزات بإيجاز في الإنتاج:

  • تنسيق جميع الأخطاء بشكل متسق كـ {error: {message}}
  • أرسل رسالة عامة إذا لم يتم تقديم أي حالة أو قم بتمرير رسالة معينة بخلاف ذلك
  • إذا كانت البيئة dev (أو test ، وما إلى ذلك) ، فاملأ حقل error.stack
  • معالجة أخطاء فهرس قاعدة البيانات (على سبيل المثال ، يوجد بالفعل كيان به حقل مفهرس فريد) والاستجابة بأمان مع أخطاء المستخدم ذات المعنى

كان منطق مسار Express.js هذا في مكان واحد ، دون لمس أي خدمة - وهو الفصل الذي ترك الرمز أسهل كثيرًا في الحفاظ عليه وتوسيعه. هذه هي الطريقة التي يمكن للحلول البسيطة - ولكن الأنيقة - أن تحسن بشكل كبير هيكل المشروع.


مزيد من القراءة على مدونة Toptal Engineering:

  • كيفية بناء نظام معالجة الأخطاء Node.js