结合源码了解 Express 的基本使用
之前我们已经学会了如何使用 node 的内置 http 模块开发 web 服务器,但使用起来比较繁琐,本篇文章则结合源码介绍如何使用 web 服务器框架 express 来快速地搭建服务器,如何通过中间件处理请求与响应数据以及如何使用路由。
快速搭建
首先是通过 npm i express
对 express 进行安装。之后就可以通过如下几行代码实现对 method 为 GET,path 为 /list
的请求的响应:
// 代码片段一
const express = require('express')
const app = express()
app.get('/list', (req, res) => {
res.end('hello, juejin')
})
app.listen(4396, () => {
console.log('服务器开启')
})
在浏览器输入 localhost:4396/list,结果如下:
部分源码探究
express()
我们可以在 const app = express()
处打上断点,然后通过 debugger 模式运行代码片段一,去查看 express 的源码。执行 express()
,执行的其实是 createApplication
函数:
// node_modules\express\lib\express.js
function createApplication() {
// ...
}
在 createApplication
函数内,首先是创建了一个 app
对象,值为一个函数:
// createApplication 函数内部
var app = function(req, res, next) {
app.handle(req, res, next);
};
然后使用 mixin()
(由 merge-descriptors 这个包导入)让创建的 app
继承 EventEmitter.prototype
和 proto
的属性和属性描述符,比如用于注册中间件的 app.use()
的 use
方法,或者 app.listen()
的 listen
方法就是通过 proto
得到的:
// createApplication 函数内部
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);
之后给 app
添加了 request
和 response
属性,并进行了初始化后将 app
返回出去,也就是代码片段一中 const app = express()
得到的 app
:
// createApplication 函数内部
// expose the prototype that will get set on requests
app.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
// expose the prototype that will get set on responses
app.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
app.init();
return app;
注意,虽然 app
是个函数,但归根结底也是个对象,所以可以添加属性
app.listen()
上面说到 app
的 listen
方法是通过 proto
继承得到的,proto
从 application.js 导入,其中对 listen
方法定义如下:
// node_modules\express\lib\application.js
var app = exports = module.exports = {};
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
可以看到当我们在代码片段一执行 app.listen()
时,在 express 内部其实使用的还是 node 内置的 http 模块,是通过 http.createServer(this)
的方式创建了服务,传入的 this,指向的就是代码片段一的 app
,由前文对 createApplication
的阅读得知它就是个函数:
function(req, res, next) {
app.handle(req, res, next);
};
每当接收到请求时都会执行。之后再通过 server.listen.apply(server, arguments)
将我们执行 app.listen()
时传入的端口号和回调等参数传入并执行。
中间件(middleware)
只有当请求的方法和路径都匹配时,才会触发代码片段一第 4 ~ 6 行的回调,而这个回调函数,就是在 express 中一个十分重要的概念 —— 中间件。中间件就是一个传递给 express 的回调函数,它接收 3 个参数:
- 请求对象 requset;
- 响应对象 response;
- 用于调用 stack 中下个中间件的 next 函数。
而 Express 应用程序则可以看成是一系列中间件函数的调用。官方文档里有如下一张图来解释中间件:
几种不同的写法
像代码片段一中在 app.get('/list', 中间件)
里注册的中间件,就是只匹配对应请求方法和路径的请求,get 也可以是 post
、put
、delete
、patch
等其它常见的请求方法。
app.use()
也可以注册中间件,第一个参数可以传路径,则匹配对应路径的任何方法的请求,如果不传入路径而是直接传入回调函数,则会匹配任意的请求:
// 代码片段二
// 匹配任意请求
app.use((req, res, next) => {
console.log(1)
next()
})
// 匹配路径为 '/list' 的请求
app.use('/list', (req, res, next) => {
console.log(2)
next()
})
// 匹配路径为 '/list' 的 GET 请求,注册多个中间件
app.get(
'/list',
(req, res, next) => {
console.log(3)
next()
},
(req, res) => {
res.end('hello, juejin')
}
)
当我们向浏览器输入 localhost:4396/list,首先会匹配到第 3 行的中间件,由于其调用了 next()
,则会继续匹配下一个中间件,最终命令行会依次打印 1、2、3,浏览器页面则会打印 hello, juejin。
中间件还能像代码片段二中,在 app.get('/list',)
里那样注册多个,但总归需要向客户端返回信息(res.end('hello, juejin')
)以结束请求-响应周期(request-response cycle),否则在不设置请求超时时间的情况下请求会一直持续。
app.use() 源码探究
当我们编写的代码首次执行时,传入 app.use()
的回调,也就是中间件函数,都会被放入到 stack
数组中。我们可以去 application.js 查看关于 app.use()
的定义:
// node_modules\express\lib\application.js
app.use = function use(fn) {
// ...
};
因为传入 app.use()
的第 1 个参数也可能是请求路径,所以在 use
函数内部,先是做了些判断来将所有回调参数收集到 fns
内:
// use 函数内部
var offset = 0;
var path = '/';
// default path to '/'
// disambiguate app.use([fn])
if (typeof fn !== 'function') {
var arg = fn;
while (Array.isArray(arg) && arg.length !== 0) {
arg = arg[0];
}
// first arg is the path
if (typeof arg !== 'function') {
offset = 1;
path = fn;
}
}
var fns = flatten(slice.call(arguments, offset));
然后调用:
// use 函数内部
this.lazyrouter()
目的是在我们没有定义路由时帮我们创建路由:
// node_modules\express\lib\application.js
app.lazyrouter = function lazyrouter() {
if (!this._router) {
this._router = new Router({
caseSensitive: this.enabled('case sensitive routing'),
strict: this.enabled('strict routing')
});
// ...
}
};
之后定义 router
变量,并循环遍历 fns
:
// use 函数内部
var router = this._router;
fns.forEach(function (fn) {
// non-express app
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}
// ...
}, this);
将传入 app.use()
的回调 fn
都传给 router.use()
处理,也就是下面的 proto.use
。开头也是做了些去除可能传入的请求路径从而得到所有 fn
的工作,并将所有 fn
放入 callbacks
。然后将 path
和 fn
做了层封装,生成 layer
放入到 stack
数组中:
// node_modules\express\lib\router\index.js
proto.use = function use(fn) {
// ...
var callbacks = flatten(slice.call(arguments, offset));
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);
this.stack.push(layer);
}
};
简单地说,app.use()
执行的结果就是将我们传入的回调都收集到了 stack
,等待合适的时机调用。
前面在探究 app.listen()
源码的时候,我们已经发现,当接收到请求时,会调用 app.handle(req, res, next)
:
// node_modules\express\lib\application.js
app.handle = function handle(req, res, callback) {
var router = this._router;
// ...
router.handle(req, res, done);
};
在 handle
方法中,获取到了路由对象 router
并调用路由的 handle
方法,也就是 proto.handle
,这个方法写了近 200 行,下面主要展示的是取到 stack
数组,然后调用 next()
,也就是说首个 app.use()
里的回调之所以不用我们调用 next()
就会执行,是因为其内部会调用:
// node_modules\express\lib\router\index.js
proto.handle = function handle(req, res, out) {
// ...
var self = this;
var idx = 0;
// middleware and routes
var stack = self.stack;
next();
};
执行 next()
会去匹配之前由 path
和 fn
封装而来的 layer
,匹配成功再交由 router 的 process_params
方法处理,并最终执行我们传入 app.use()
的回调,如果回调中调用了 next()
,那么又会执行下面的 next()
方法:
// next 函数 部分代码
function next(err) {
// find next matching layer
var layer;
var match;
var route;
while (match !== true && idx < stack.length) {
layer = stack[idx++];
match = matchLayer(layer, path);
route = layer.route;
}
// this should be done for the layer
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
next(layerError || err)
} else if (route) {
layer.handle_request(req, res, next)
} else {
trim_prefix(layer, layerError, layerPath, path)
}
sync = 0
});
}
响应数据的方法
响应数据的方法除了代码片段一中使用的 res.end()
,也可以是 res.json()
,参数可以是对象、数组、字符串、数字、布尔、null 等类型,它们都会被转成 json 格式的数据返回给客户端。另外,还可以通过 res.status()
传入响应状态码:
app.get('/article/:id', (req, res) => {
res.status(201)
res.json({ title: '文章名', content: '文章内容' })
})
响应数据的方法还有很多,比如 res.send()
等,具体可以参见 express 的中文文档。
错误的处理
如果请求有错误,我们可以根据不同情况使用 res.status()
返回对应的 http 响应状态码,或者都返回 200,然后在返回的数据里自己定义不同的 code 来代表不同的错误。但无论使用哪种处理逻辑,如果每个请求都单独处理错误就比较麻烦,我们可以统一在一个 app.use()
处理,传给它的中间件函数有 4 个参数,第一个为 err
,它的值由上一个中间件的 next()
传入的值决定:
// 获取文章详情
app.get('/article/:id', (req, res, next) => {
if (req.params.id !== '1234') {
// 查询不到文章,传递错误码 9999
next(9999)
} else {
res.json({ title: '文章名', content: '文章内容' })
}
})
// 处理错误
app.use((err, req, res, next) => {
res.json({
code: err, // 9999
msg: '请求出错'
})
})
请求数据的处理
前端在发送请求时可能会携带些参数,接下来就看看在 express 中如何处理它们吧~
body 里的 json 数据
对于请求传递的 json 数据,我们可以通过中间件传入的 req
对象获取,其本质上是个可读流,所以我们可以通过监听 'data'
事件获取请求参数,因为 data
默认为 buffer 对象,所以需要 toString()
转换成字符串再使用 JSON.parse
解析:
// 代码片段三
app.post('/list', (req, res) => {
req.on('data', data => {
const dataObj = JSON.parse(data.toString())
// 在这里处理请求参数
console.log(dataObj)
})
res.end('hello, juejin')
})
我们其实可以将对请求参数的解析放在一个普通的 app.use()
中间件内,这样任意请求都会被处理,可以减少重复代码。先对请求的参数类型做个判断,如果为 application/json
,则将请求携带的参数转换为 json 对象。由于在中间件函数中我们是可以更改请求(req
)和响应(res
)对象的,所以将转换后的值赋给 req.body
,然后在读取数据完毕的 'end'
事件中调用 next()
:
// 代码片段四
app.use((req, res, next) => {
if (req.headers['content-type'] === 'application/json') {
req.on('data', data => {
const dataObj = JSON.parse(data.toString())
req.body = dataObj
})
req.on('end', () => next())
} else {
next()
}
})
实际上,在 express 中,已经帮我们实现好了对请求携带的 json 数据进行解析功能,我们只需要给 app.use()
传入内建的返回中间件的函数调用 express.json()
:
app.use(express.json())
就可以替代代码片段四,之后在具体匹配请求方法和路径的中间件函数中,直接通过 req.body
即可获取请求参数对象:
app.post('/list', (req, res) => {
console.log(req.body)
res.end('hello, juejin')
})
body 里的 x-www-form-urlencoded 数据
当请求携带的参数类型为 x-www-form-urlencoded 时,我们可以使用 express.urlencoded()
进行解析,之后也是通过 req.body
获取结果:
app.use(express.urlencoded())
app.post('/list', (req, res) => {
console.log(req.body)
res.end('hello, juejin')
})
但是控制台会有下面红线标识的这样一句话,说是让我们在 option 中提供 extended:
这是因为 express.urlencoded()
默认使用的解析方法所依赖的 node 里的 querystring 模块已经不建议使用了,查看 node 官网可以看到其已被标记为是 Legacy:
所以我们需要给 express.urlencoded()
传入 option,指定 extended
为 true
,这样就会去使用 qs 库(安装 express 已默认安装)来解析了:
app.use(express.urlencoded({ extended: true }))
body 里的 form-data 数据
可以使用 express 官方出品的第三方中间件 multer,多数情况用于文件上传,详细使用方法我会在下一篇文章中介绍。
url 里的 query 数据
比如有个 GET 请求为 localhost:4396/search?name=Jay&age=20,我们可以直接通过 req.query
获取到 query 数据,而不需要额外处理:
app.get('/search', (req, res) => {
console.log(req.query) // { name: 'Jay', age: '20' }
res.end('查询成功')
})
注意,得到的 age
的值为字符串类型的 20
。
url 里的 params 数据
同 query 数据一样,express 也帮我们处理好了,只需要通过 req.params
即可获取。比如请求为 localhost:4396/article/123,其中 123 是文章的 id:
app.get('/article/:id', (req, res) => {
console.log(req.params) // { id: '123' }
res.end('查询成功')
})
第三方中间件
除了上面介绍的 express.json()
和 express.urlencoded()
这种 express 内建的中间件,也有许多第三方中间件可以帮我们完成不同的功能,比如上文提到的 multer。下面以同样是 express 官方团队提供的记录请求日志的 morgan 为例。
首先需要安装:npm i morgan
,之后即可引入使用,它是个函数,将其调用传入 app.use()
, morgan()
的第一个参数用于定义记录日志的格式(format),这里传'combined'
,意为使用标准apache 的行内输出,也可以传 'short'
等,第二个参数可以传入配置对象,{ stream: ws }
是对日志的输出流进行配置:
const fs = require('fs')
const morgan = require('morgan')
const ws = fs.createWriteStream('logs.log')
app.use(morgan('combined', { stream: ws }))
现在,当请求发送时,就能看到生成了日志记录:
路由
当需要处理的请求比较多时,最好是按照功能区分不同的模块,比如用于处理文章的增删改查的接口,我们希望把它们抽离到单独的文件内进行维护,此时就可以使用 express.Router()
,执行后会创建可安装的模块化路由处理程序,按照文档的说法:
Router 实例是完整的中间件和路由系统;因此,常常将其称为“微型应用程序”。
我们可以创建 router\articleRouter.js 文件如下:
// 代码片段五
const express = require('express')
const articleRouter = express.Router()
// 创建文章的请求
articleRouter.post('/', (req, res) => {
res.end('文章创建成功')
})
// 查询文章的请
articleRouter.get('/:id', (req, res) => {
res.status(201)
res.json({ title: '文章名', content: '文章内容' })
})
module.exports = articleRouter
然后在 index.js 引入 articleRouter
,并使用 app.use('/article', articleRouter)
安装,因为我们指定了匹配的路径为 '/article'
,所以在代码片段五中,articleRouter.post()
或 articleRouter.get()
传入的路径就不需要写 '/article'
了:
const express = require('express')
const articleRouter = require('./router/articleRouter')
const app = express()
app.use('/article', articleRouter)
app.listen(4396, () => {
console.log('服务器开启')
})
此时如果我们发送 GET 请求获取 id 为 123 的文章详情,其响应结果如下图:
转载自:https://juejin.cn/post/7211506841692373029