Menggunakan Rute Express.js untuk Penanganan Kesalahan Berbasis Janji

Diterbitkan: 2022-03-11

Tagline Express.js benar: Ini adalah “kerangka web minimalis yang cepat, tidak beropini, untuk Node.js.” Sangat tidak masuk akal bahwa, meskipun praktik terbaik JavaScript saat ini meresepkan penggunaan janji, Express.js tidak mendukung penangan rute berbasis janji secara default.

Dengan banyaknya tutorial Express.js yang mengabaikan detail itu, pengembang sering kali memiliki kebiasaan menyalin dan menempelkan kode pengiriman hasil dan penanganan kesalahan untuk setiap rute, sehingga menimbulkan utang teknis saat berjalan. Kita dapat menghindari antipola ini (dan dampaknya) dengan teknik yang akan kita bahas hari ini—teknik yang telah saya gunakan dengan sukses di aplikasi dengan ratusan rute.

Arsitektur Khas untuk Rute Express.js

Mari kita mulai dengan aplikasi tutorial Express.js dengan beberapa rute untuk model pengguna.

Dalam proyek nyata, kami akan menyimpan data terkait di beberapa database seperti MongoDB. Tetapi untuk tujuan kami, spesifikasi penyimpanan data tidak penting, jadi kami akan mengejeknya demi kesederhanaan. Apa yang tidak akan kami sederhanakan adalah struktur proyek yang baik, kunci setengah dari keberhasilan proyek apa pun.

Yeoman dapat menghasilkan kerangka proyek yang jauh lebih baik secara umum, tetapi untuk apa yang kami butuhkan, kami hanya akan membuat kerangka proyek dengan generator ekspres dan menghapus bagian yang tidak perlu, sampai kami memiliki ini:

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

Kami telah mengurangi baris file yang tersisa yang tidak terkait dengan tujuan kami.

Berikut file aplikasi Express.js utama, ./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;

Di sini kita membuat aplikasi Express.js dan menambahkan beberapa middleware dasar untuk mendukung penggunaan JSON, encoding URL, dan penguraian cookie. Kami kemudian menambahkan usersRouter untuk /users . Terakhir, kami menentukan apa yang harus dilakukan jika tidak ada rute yang ditemukan, dan bagaimana menangani kesalahan, yang akan kami ubah nanti.

Script untuk memulai server itu sendiri adalah /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 kami juga barebone:

 { "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" } }

Mari kita gunakan implementasi router pengguna biasa di /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;

Ini memiliki dua rute: / untuk mendapatkan semua pengguna dan /:id untuk mendapatkan satu pengguna dengan ID. Itu juga menggunakan /services/userService.js , yang memiliki metode berbasis janji untuk mendapatkan data ini:

 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 };

Di sini kami menghindari penggunaan konektor DB atau ORM yang sebenarnya (misalnya, Mongoose atau Sequelize), hanya dengan meniru pengambilan data dengan Promise.resolve(...) .

Masalah Perutean Express.js

Melihat penangan rute kami, kami melihat bahwa setiap panggilan layanan menggunakan panggilan balik duplikat .then(...) dan .catch(...) untuk mengirim data atau kesalahan kembali ke klien.

Sekilas, ini mungkin tidak terlihat serius. Mari tambahkan beberapa persyaratan dasar dunia nyata: Kita hanya perlu menampilkan kesalahan tertentu dan menghilangkan kesalahan 500-level generik; juga, apakah kita menerapkan logika ini atau tidak harus didasarkan pada lingkungan. Dengan itu, seperti apa proyek contoh kita tumbuh dari dua rute menjadi proyek nyata dengan 200 rute?

Pendekatan 1: Fungsi Utilitas

Mungkin kita akan membuat fungsi utilitas terpisah untuk menangani resolve dan reject , dan menerapkannya di mana saja di rute Express.js kita:

 // 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)); });

Terlihat lebih baik: Kami tidak mengulangi implementasi pengiriman data dan kesalahan. Tetapi kita masih perlu mengimpor penangan ini di setiap rute dan menambahkannya ke setiap janji yang diteruskan ke then() dan catch() .

Pendekatan 2: Middleware

Solusi lain adalah dengan menggunakan praktik terbaik Express.js seputar janji: Pindahkan logika pengiriman kesalahan ke middleware kesalahan Express.js (ditambahkan di app.js ) dan berikan kesalahan asinkron ke dalamnya menggunakan panggilan balik next . Pengaturan middleware kesalahan dasar kami akan menggunakan fungsi anonim sederhana:

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

Express.js memahami bahwa ini untuk kesalahan karena tanda tangan fungsi memiliki empat argumen input. (Ini memanfaatkan fakta bahwa setiap objek fungsi memiliki properti .length yang menjelaskan berapa banyak parameter yang diharapkan fungsi.)

Melewati kesalahan melalui next akan terlihat seperti ini:

 // 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); });

Bahkan menggunakan panduan praktik terbaik resmi, kita masih memerlukan janji JS di setiap penangan rute untuk diselesaikan menggunakan fungsi handleResponse() dan menolak dengan meneruskan fungsi next .

Mari kita coba menyederhanakannya dengan pendekatan yang lebih baik.

Pendekatan 3: Middleware berbasis janji

Salah satu fitur terbesar JavaScript adalah sifatnya yang dinamis. Kami dapat menambahkan bidang apa pun ke objek apa pun saat runtime. Kami akan menggunakannya untuk memperluas objek hasil Express.js; Fungsi middleware Express.js adalah tempat yang nyaman untuk melakukannya.

Fungsi promiseMiddleware() kami

Mari kita buat middleware janji kita, yang akan memberi kita fleksibilitas untuk menyusun rute Express.js kita dengan lebih elegan. Kami membutuhkan file baru, /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(); }; }

Di app.js , mari terapkan middleware kita ke objek app Express.js secara keseluruhan dan perbarui perilaku kesalahan default:

 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)); });

Perhatikan bahwa kami tidak menghilangkan middleware kesalahan kami . Ini masih merupakan penangan kesalahan yang penting untuk semua kesalahan sinkron yang mungkin ada dalam kode kita. Tetapi alih-alih mengulangi logika pengiriman kesalahan, middleware kesalahan sekarang meneruskan kesalahan sinkron apa pun ke fungsi handleError() pusat yang sama melalui panggilan Promise.reject( Promise.reject() yang dikirim ke res.promise() .

Ini membantu kami menangani kesalahan sinkron seperti ini:

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

Terakhir, mari kita gunakan res.promise() baru kita di /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;

Perhatikan perbedaan penggunaan .promise() : Kita dapat memberikannya fungsi atau janji. Melewati fungsi dapat membantu Anda dengan metode yang tidak memiliki janji; .promise() melihat bahwa itu adalah sebuah fungsi dan membungkusnya dengan sebuah janji.

Di mana lebih baik mengirim kesalahan ke klien? Ini pertanyaan pengorganisasian kode yang bagus. Kita bisa melakukannya di middleware kesalahan kita (karena itu seharusnya bekerja dengan kesalahan) atau di middleware janji kita (karena sudah memiliki interaksi dengan objek respons kita). Saya memutuskan untuk menyimpan semua operasi respons di satu tempat di middleware janji kami, tetapi terserah masing-masing pengembang untuk mengatur kode mereka sendiri.

Secara teknis, res.promise() Adalah Opsional

Kami telah menambahkan res.promise() , tetapi kami tidak terkunci untuk menggunakannya: Kami bebas beroperasi dengan objek respons secara langsung saat kami membutuhkannya. Mari kita lihat dua kasus di mana ini akan berguna: mengarahkan dan mengalirkan pipa.

Kasus Khusus 1: Mengarahkan

Misalkan kita ingin mengarahkan pengguna ke URL lain. Mari tambahkan fungsi getUserProfilePicUrl() di userService.js :

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

Dan sekarang mari kita gunakan di router pengguna kami dalam gaya async / await dengan manipulasi respons langsung:

 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)); } });

Perhatikan bagaimana kami menggunakan async / await , melakukan pengalihan, dan (yang paling penting) masih memiliki satu tempat sentral untuk melewatkan kesalahan apa pun karena kami menggunakan res.promise() untuk penanganan kesalahan.

Kasus Khusus 2: Perpipaan Aliran

Seperti rute gambar profil kami, pemipaan aliran adalah situasi lain di mana kami perlu memanipulasi objek respons secara langsung.

Untuk menangani permintaan ke URL yang sekarang kita arahkan, mari tambahkan rute yang mengembalikan beberapa gambar umum.

Pertama kita harus menambahkan profilePic.jpg di subfolder /assets/img baru. (Dalam proyek nyata kami akan menggunakan penyimpanan cloud seperti AWS S3, tetapi mekanisme pemipaan akan sama.)

Mari kita menyalurkan gambar ini sebagai tanggapan atas permintaan /img/profilePic/:id . Kita perlu membuat router baru untuk itu di /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;

Kemudian kita tambahkan router /img baru kita di app.js :

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

Satu perbedaan mungkin menonjol dibandingkan dengan kasus pengalihan: Kami belum pernah menggunakan res.promise() di router /img ! Ini karena perilaku objek respons yang sudah disalurkan melalui kesalahan akan berbeda dari jika kesalahan terjadi di tengah aliran.

Pengembang Express.js perlu memperhatikan saat bekerja dengan aliran di aplikasi Express.js, menangani kesalahan secara berbeda tergantung pada saat terjadinya. Kami perlu menangani kesalahan sebelum pemipaan ( res.promise() dapat membantu kami di sana) serta midstream (berdasarkan pengendali .on('error') ), tetapi detail lebih lanjut berada di luar cakupan artikel ini.

Meningkatkan res.promise()

Seperti halnya pemanggilan res.promise() , kita juga tidak terkunci dalam mengimplementasikannya seperti yang kita miliki. promiseMiddleware.js dapat ditambah untuk menerima beberapa opsi di res.promise() untuk memungkinkan pemanggil menentukan kode status respons, tipe konten, atau apa pun yang mungkin diperlukan proyek. Terserah pengembang untuk membentuk alat mereka dan mengatur kode mereka sehingga paling sesuai dengan kebutuhan mereka.

Penanganan Kesalahan Express.js Memenuhi Pengodean Berbasis Janji Modern

Pendekatan yang disajikan di sini memungkinkan penangan rute yang lebih elegan daripada yang kita mulai dan satu titik pemrosesan hasil dan kesalahan —bahkan yang dipecat di luar res.promise(...) —berkat penanganan kesalahan di app.js . Namun, kami tidak dipaksa untuk menggunakannya dan dapat memproses kasus tepi seperti yang kami inginkan.

Kode lengkap dari contoh ini tersedia di GitHub. Dari sana, pengembang dapat menambahkan logika khusus sesuai kebutuhan ke fungsi handleResponse() , seperti mengubah status respons menjadi 204 alih-alih 200 jika tidak ada data yang tersedia.

Namun, kontrol tambahan atas kesalahan jauh lebih berguna. Pendekatan ini membantu saya mengimplementasikan fitur-fitur ini secara ringkas dalam produksi:

  • Format semua kesalahan secara konsisten sebagai {error: {message}}
  • Kirim pesan umum jika tidak ada status yang diberikan atau sampaikan pesan yang diberikan sebaliknya
  • Jika lingkungan adalah dev (atau test , dll.), isi bidang error.stack
  • Menangani kesalahan indeks basis data (yaitu, beberapa entitas dengan bidang terindeks unik sudah ada) dan merespons dengan baik dengan kesalahan pengguna yang berarti

Logika rute Express.js ini ada di satu tempat, tanpa menyentuh layanan apa pun—pemisahan yang membuat kode lebih mudah dipelihara dan diperluas. Inilah cara sederhana—namun elegan—solusi dapat secara drastis meningkatkan struktur proyek.


Bacaan Lebih Lanjut di Blog Teknik Toptal:

  • Cara Membangun Sistem Penanganan Kesalahan Node.js