การใช้เส้นทาง Express.js สำหรับการจัดการข้อผิดพลาดตามสัญญา

เผยแพร่แล้ว: 2022-03-11

สโลแกน Express.js นั้นเป็นจริง: เป็น “เฟรมเวิร์กเว็บที่เรียบง่าย รวดเร็ว ไม่ได้รับความเห็นชอบสำหรับ Node.js” ไม่มีความคิดเห็นมากนัก ถึงแม้ว่าแนวทางปฏิบัติที่ดีที่สุดของ JavaScript ในปัจจุบันจะกำหนดการใช้คำสัญญา แต่ Express.js ก็ไม่รองรับตัวจัดการเส้นทางตามสัญญาโดยค่าเริ่มต้น

ด้วยบทช่วยสอน Express.js จำนวนมากที่ทิ้งรายละเอียดนั้นไว้ นักพัฒนามักจะคุ้นเคยกับการคัดลอกและวางโค้ดการส่งผลลัพธ์และการจัดการข้อผิดพลาดสำหรับแต่ละเส้นทาง ทำให้เกิดหนี้ทางเทคนิคตามมา เราสามารถหลีกเลี่ยง antipattern นี้ (และผลเสียของมัน) ด้วยเทคนิคที่เราจะพูดถึงในวันนี้ ซึ่งฉันเคยใช้อย่างประสบความสำเร็จในแอปที่มีหลายร้อยเส้นทาง

สถาปัตยกรรมทั่วไปสำหรับเส้นทาง 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 สำหรับ /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( .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 ) และส่งข้อผิดพลาดแบบ async โดยใช้การโทรกลับ 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() Function

มาสร้างสัญญามิดเดิลแวร์ของเรา ซึ่งจะทำให้เรามีความยืดหยุ่นในการจัดโครงสร้างเส้นทาง 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 ! นี่เป็นเพราะพฤติกรรมของอ็อบเจ็กต์การตอบสนองที่ไปป์ไลน์แล้วซึ่งส่งผ่านข้อผิดพลาดจะแตกต่างไปจากข้อผิดพลาดที่เกิดขึ้นตรงกลางสตรีม

นักพัฒนา Express.js ต้องให้ความสนใจเมื่อทำงานกับสตรีมในแอปพลิเคชัน Express.js การจัดการข้อผิดพลาดจะแตกต่างกันไปขึ้นอยู่กับว่าเกิดขึ้นเมื่อใด เราจำเป็นต้องจัดการกับข้อผิดพลาดก่อนที่จะส่ง res.promise() สามารถช่วยเราได้) เช่นเดียวกับมิดสตรีม (ตามตัวจัดการ .on .on('error') ) แต่รายละเอียดเพิ่มเติมอยู่นอกเหนือขอบเขตของบทความนี้

ปรับปรุง res.promise()

เช่นเดียวกับ การเรียก res.promise() เราไม่ได้ถูกล็อคให้ ใช้งาน อย่างที่เรามี promiseMiddleware.js สามารถเพิ่มเพื่อยอมรับตัวเลือกบางอย่างใน res.promise() เพื่อให้ผู้โทรระบุรหัสสถานะการตอบสนอง ประเภทเนื้อหา หรือสิ่งอื่นใดที่โครงการอาจต้องการ นักพัฒนาซอฟต์แวร์ต้องกำหนดรูปแบบเครื่องมือและจัดระเบียบโค้ดเพื่อให้เหมาะกับความต้องการมากที่สุด

การจัดการข้อผิดพลาด Express.js ตรงกับการเข้ารหัสตามสัญญาสมัยใหม่

วิธีการที่นำเสนอนี้ช่วยให้ มีตัวจัดการเส้นทางที่สวยงาม กว่าที่เราเริ่มต้น และ ผลลัพธ์การประมวลผลและข้อผิดพลาดเพียงจุดเดียว แม้จะเกิดขึ้นนอก res.promise(...) ด้วยการจัดการข้อผิดพลาดใน app.js อย่างไรก็ตาม เรา ไม่ได้บังคับ ให้ใช้และสามารถประมวลผล edge case ได้ตามต้องการ

รหัสเต็มจากตัวอย่างเหล่านี้มีอยู่ใน GitHub จากจุดนั้น นักพัฒนาสามารถเพิ่มตรรกะที่กำหนดเองได้ตามต้องการใน handleResponse() เช่น เปลี่ยนสถานะการตอบสนองเป็น 204 แทนที่จะเป็น 200 หากไม่มีข้อมูล

อย่างไรก็ตาม การควบคุมข้อผิดพลาดเพิ่มเติมมีประโยชน์มากกว่ามาก แนวทางนี้ช่วยให้ฉันนำคุณลักษณะเหล่านี้ไปใช้จริงอย่างกระชับ:

  • จัดรูปแบบข้อผิดพลาดทั้งหมดอย่างสม่ำเสมอเป็น {error: {message}}
  • ส่งข้อความทั่วไปหากไม่มีสถานะให้หรือส่งต่อข้อความที่กำหนดเป็นอย่างอื่น
  • หากสภาพแวดล้อมเป็น dev (หรือ test ฯลฯ ) ให้เติม error.stack field
  • จัดการข้อผิดพลาดของดัชนีฐานข้อมูล (เช่น มีบางเอนทิตีที่มีฟิลด์ที่จัดทำดัชนีเฉพาะอยู่แล้ว) และตอบสนองอย่างสวยงามด้วยข้อผิดพลาดของผู้ใช้ที่มีความหมาย

ลอจิกเส้นทาง Express.js นี้รวมอยู่ในที่เดียวโดยไม่ต้องแตะต้องบริการใดๆ ซึ่งเป็นการแยกส่วนที่ทำให้โค้ดดูแลรักษาและขยายได้ง่ายขึ้นมาก วิธีนี้เป็นวิธีที่เรียบง่าย—แต่สง่างาม—สามารถปรับปรุงโครงสร้างโครงการได้อย่างมาก


อ่านเพิ่มเติมในบล็อก Toptal Engineering:

  • วิธีสร้างระบบจัดการข้อผิดพลาด Node.js