likes
comments
collection
share

结合源码了解 Express 的基本使用

作者站长头像
站长
· 阅读数 18

之前我们已经学会了如何使用 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 的基本使用

部分源码探究

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.prototypeproto 的属性和属性描述符,比如用于注册中间件的 app.use()use 方法,或者 app.listen()listen 方法就是通过 proto 得到的:

// createApplication 函数内部
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);

之后给 app 添加了 requestresponse 属性,并进行了初始化后将 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()

上面说到 applisten 方法是通过 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 应用程序则可以看成是一系列中间件函数的调用。官方文档里有如下一张图来解释中间件: 结合源码了解 Express 的基本使用

几种不同的写法

像代码片段一中在 app.get('/list', 中间件) 里注册的中间件,就是只匹配对应请求方法和路径的请求,get 也可以是 postputdeletepatch 等其它常见的请求方法。 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。然后将 pathfn 做了层封装,生成 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() 会去匹配之前由 pathfn 封装而来的 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 的基本使用

这是因为 express.urlencoded() 默认使用的解析方法所依赖的 node 里的 querystring 模块已经不建议使用了,查看 node 官网可以看到其已被标记为是 Legacy:

结合源码了解 Express 的基本使用

所以我们需要给 express.urlencoded() 传入 option,指定 extendedtrue,这样就会去使用 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 的基本使用

路由

当需要处理的请求比较多时,最好是按照功能区分不同的模块,比如用于处理文章的增删改查的接口,我们希望把它们抽离到单独的文件内进行维护,此时就可以使用 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 的文章详情,其响应结果如下图:

结合源码了解 Express 的基本使用

结合源码了解 Express 的基本使用 结合源码了解 Express 的基本使用