Node.js<十一>——express核心用法和源码解读

1,493 阅读11分钟

客户端发送请求的方式

客户端传递到服务器参数常见的5种方法

  • 通过get请求中的URLparams
  • 通过get请求中的URL的query
  • 通过post请求中的bodyjson格式
  • 通过post请求中的body的x-www-form-urlencoded格式
  • 通过post请求中的form-data格式

传递参数params和query

params

如果请求路径是http://localhost:3000/user/123132,那么我们就可以通过req.params的方式获取到用户传递过来的参数信息

const express = require('express')

const app = express()

app.get('/user/:id', (req, res, next) => {
  console.log(req.params); // { id: '123132' }
  res.end('Hello')
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

用户在传递的时候可能也会传递多个参数,但这种情况比较少;请求路径为http://localhost:3000/user/haha/18

app.get('/user/:name/:age', (req, res, next) => {
  console.log(req.params); // { name: 'haha', age: '18' }
  res.end('Hello')
})

query

如果请求路径是http://localhost:3000/user?name=haha&age=18,那么我们就可以通过req.params的方式获取到用户传递过来的参数信息

const express = require('express')

const app = express()

app.get('/user', (req, res, next) => {
  console.log(req.query); // { name: 'haha', age: '18' }
  res.end('Hello')
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

响应数据

end方法

类似于http中的response.end方法,用法是一致的;但是其只能返回字符串或者是buffer数据

const express = require('express')

const app = express()
app.post('/login', (req, res, next) => {
  // 下面这行代码会报错
  res.end({ name: 'chris' }) // The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received an instance of Object
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

如果我们想返回一个json数据,则需要先用res.type方法设置响应头中的content-typeapplication/json,然后再利用JSON.stringify将我们要返回的对象转换为json格式的数据之后再使用res.end方法返回

const express = require('express')

const app = express()
app.post('/login', (req, res, next) => {
  // 指定响应数据的数据类型,这样客户端才知道以什么形式的数据进行解析
  res.type('application/json')
  // 将返回的对象先转为json格式的字符串
  res.end(JSON.stringify({ name: 'chris' }))
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

json方法

json方法中可以传入很多的类型:objectarraystringbooleannumbernull等,它们最终都会被转换成json格式返回;json方法相当于帮我们做了两件事情,先设置好响应头中的content-type属性,再将数据转化为json格式响应回去

const express = require('express')

const app = express()
app.post('/login', (req, res, next) => {
  res.json({ name: 'chris' })
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

status方法

用于设置状态码

const express = require('express')

const app = express()
app.post('/login', (req, res, next) => {
  res.status(204)
  res.json([1, 2])
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

Express的路由

如果我们将所有的代码逻辑都写在app中,那么app会变得越来越复杂

  • 一方面完整的Web服务器包含非常多的处理逻辑

  • 另一方面有些处理逻辑其实是一个整体,我们应该将它们放在一起:比如对用户user相关的处理

    • 获取用户列表
    • 获取某一个用户信息
    • 创建一个新的用户
    • 删除一个用户
    • 更新一个用户

我们可以使用express.Router来创建一个路由处理程序

  • 一个Router实例拥有完整的中间件和路由系统
  • 因此,它也被称为迷你应用程序(mini-app
// app.js
const express = require('express')

const userRouter = require('./routers/user')

const app = express()

// 注册一个普通中间件来解析请求体中的json数据
app.use(express.json())

// 将有关用户的请求全部引入到注册好的用户路由中去执行
app.use('/user', userRouter)

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

一个路由实例就相当于是一个小的mini-app,其拥有完整的中间件,所以其也可以像app一样注册其它的中间件

// routers/user.js
const express = require('express')

// 使用express.Router函数注册一个路由
const router = express.Router()

// 一个路由实例拥有完整的中间件功能,所以其也可以像app一样注册其它的中间件
router.get('/:id', (req, res, next) => {
  res.json({nickname: 'chris', age: 18})
})

router.post('/', (req, res, next) => {
  console.log(req.body);
  res.end('注册了一个新用户')
})

router.delete('/:id', (req, res, next) => {
  res.end(`删除了用户${req.params.id}`)
})

// 将注册号的路由导出
module.exports = router

静态资源服务器

node里面,如果想把某一个文件夹作为静态的服务器让客户端可以进行访问的话是非常简单的

  • express内置了一个static方法,其返回值是一个函数,可以作为我们的中间件
  • 如果我们通过http来做的话,需要自己读取里面的文件然后再返回回去
  • 但是在express里面,我们只需要注册一个普通中间件就可以了,只指定文件夹作为我们的静态资源文件夹,这样当我们去请求资源的时候就会去该文件夹里面寻找

dist文件夹是之前自己做的后台管理系统项目的打包文件,我们可以通过express来部署项目到本机;express内置了一个static方法,其返回值是一个函数,可以作为我们的中间件,需要传递一个路径作为静态资源文件夹

const express = require('express')

const app = express()

// express内置了一个static方法,其返回值是一个函数,可以作为我们的中间件
app.use(express.static('./dist'))

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

express的错误处理

next函数中如果传入了参数,执行的并不是下一个匹配上的中间件,而是专门用来匹配错误的中间件;这样我们所有的错误处理就可以集中在一个中间件中进行管理,代码更加整洁且利于维护

const express = require('express')

const app = express()

// 一般错误都是用常量来进行定义,这样可以减少错误的发生率
const PWD_NOT_EXACT = 'PWD_NOT_EXACT'
const ACCOUNT_ALREADY_EXIST = 'ACCOUNT_ALREADY_EXIST'

app.post('/login', (req, res, next) => {
  // 假装这里有去数据库中查询
  const isPwdRight = false
  if (isPwdRight) {
    res.end('登录成功!')
  } else {
    next(new Error(PWD_NOT_EXACT))
  }
})

app.post('/register', (req, res, next) => {
  // 假装这里有去数据库中查询
  const isExist = true
  if (!isExist) {
    res.end('注册成功!')
  } else {
    next(new Error(ACCOUNT_ALREADY_EXIST))
  }
})

// 监听错误的中间件,错误处理都放在一个地方有利于代码的维护
app.use((err, req, res, next) => {
  let code = 400
  let message = ''
  switch (err.message) {
    case PWD_NOT_EXACT:
      message = 'Your pwd is not exact~'
      break;
    case ACCOUNT_ALREADY_EXIST:
      message = 'Your account already exist~'
      break;
    default:
      message = 'not found'
  }

  // 返回错误的状态码
  res.status(code)
  // 将错误状态码和错误信息返回
  res.json({
    code,
    message
  })
})

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

下面是自己用postman进行的测试

源码解读

调用express()到底创建的是什么?

express()创建的是一个名为app的函数,在这个函数上面集成了很多的方法,最经典的就是可以用app来创建中间件

  1. 我们从expressindex.js文件中发现其是从lib文件夹下面的express.js文件中导入的
// index.js
module.exports = require('./lib/express');
  1. 来到express.js中发现其导出的其实是一个函数createApplication,也就是说const express = require('express')其实就是将createApplication函数的引用赋值给了变量express而已
// lib/express.js
exports = module.exports = createApplication;
  1. 我们来到createApplication函数后发现,在其里面定义了一个名为app的函数并导出。我们现在应该明白const app = express()的时候函数的返回值是什么了吧?其实就是在createApplication函数里面声明的一个名为app的函数而已
// lib/express.js
function createApplication() {
  var app = function(req, res, next) {
    app.handle(req, res, next);
  };
  // ............省略部分代码
  return app;
}

app.listen()是如何启动服务器的?

app.listen()其实本质上用的还是http模块搭建服务器的方式,相当于在外面封装了一层而已

  1. 仔细查看createApplication函数之后,没有发现有给app函数添加过listen属性,那我们为什么可以直接调用app.listen方法呢?
// lib/express.js
function createApplication() {
  var app = function(req, res, next) {
    app.handle(req, res, next);
  };

  mixin(app, EventEmitter.prototype, false);
  // 将proto上的属性混合到app函数的原型上
  mixin(app, proto, false);

  // 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;
}
  1. 其实基础比较好的同学肯定已经想到了,一个对象上面没有对应的属性就会到其原型中寻找,我们能调用listen方法就说明该方法肯定是在app函数的原型上的,而mixin(app, proto, false)这行代码做的就是将proto对象上的属性混合到app上
  2. 我们去proto对象中寻找之后发现,其身上的确具有listen方法,所以我们调用app.listen时实际上调用的是下面的代码;不难看出app.listen实际上是使用了原生http模块的createServer方法来进行搭建服务器的,监听端口号的方法用的也是server.listen而已,相当于是做了一层封装。所以本质上express就是http模块的封装这句话一点也不假
// var app = function (req, res, next) {
//   app.handle(req, res, next);
// };

// lib/application.js
app.listen = function listen() {
  // 传入http.createServer函数的this就是app;这个函数会在客户端发送请求的时候执行,所以在express框架中,用户发送请求的时候执行的函数其实是app
  var server = http.createServer(this);
  // 将传递给app.listen的参数传递给server.listen
  return server.listen.apply(server, arguments);
};

传入http.createServer函数的this就是app;这个函数会在客户端发送请求的时候执行,所以在express框架中,用户发送请求的时候执行的函数其实是app

app.use(中间件)内部到底发生了什么?

其实app.use的本质是调用了router.use方法,在内部将我们传递进去的中间件函数都放入到了一个stack数组中,以这种方法帮我们注册了中间件

// lib/application.js
app.use = function use(fn) {
  // 设置偏移量默认为0,该偏移量用于后续合并中间件函数
  var offset = 0;
  // 中间件监听的请求路径默认为'/'
  var path = '/';

  // 这里的fn就是用户调用app.use时传递的第一个参数,一般来说是路径或者是函数
  if (typeof fn !== 'function') {
    var arg = fn;
    // 如果不是函数,则先判断其传递进来的是不是数组,因为app.use也只是以数组的形式传参
    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }

    // 总之现在的arg就是第一个参数了,如果其不是函数,那就代表用户有传递要监听的路径进来
    if (typeof arg !== 'function') {
      // offset设为1代表等等截取的时候忽略第一个参数
      offset = 1;
      // 将默认路径设为用户传递进来的
      path = fn;
    }
  }

  // 因为app.use可以连续注册多个中间件,所以可以接受多个参数
  // flatten结合slice方法将传递过来的所有函数放到了fns中
  var fns = flatten(slice.call(arguments, offset));

  // fns长度为0说明用户没有传递函数进来,直接报错
  if (fns.length === 0) {
    throw new TypeError('app.use() requires a middleware function')
  }

  // lazyrouter最主要做的就是创建了一个路由实例并赋值给了app._router,也就是下面的router;源代码请看下面
  this.lazyrouter();
  var router = this._router;

  // 循环遍历用户传入的每个中间件函数
  fns.forEach(function (fn) {
    // 最终调用的是router.use方法
    router.use(path, function mounted_app(req, res, next) {
      var orig = req.app;
      fn.handle(req, res, function (err) {
        setPrototypeOf(req, orig.request)
        setPrototypeOf(res, orig.response)
        next(err);
      });
    });
  }, this);

  return this;
};

Layer构造函数中可以知道,其在创建实例的时候将对应的中间件函数的引用赋值给了layer.handle属性

// lib/router/layer.js
function Layer(path, options, fn) {
  // ....
  this.handle = fn;
  // ....
}

下面是lazyrouter函数,主要做的事情就是注册了一个路由并赋值给了app._router

// lib/application.js
app.lazyrouter = function lazyrouter() {
  // 先判断app上有没有_router属性,如果没有则命中逻辑
  if (!this._router) {
    // 创建了一个路由赋值给了app._router,所以我们才说app其实相当于是个主路由
    this._router = new Router({
      caseSensitive: this.enabled('case sensitive routing'),
      strict: this.enabled('strict routing')
    });

    this._router.use(query(this.get('query parser fn')));
    this._router.use(middleware.init(this));
  }
};

下面是router.use函数,前面的操作跟app.use的操作很类似,所以现在大家应该可以理解之前在创建路由当中为什么可以用router.use来创建中间件了吧,并不是router.use模仿了app.use,而是app.use基于router.use

// lib/router/index.js
proto.use = function use(fn) {
  // 前面一部分和app.use的逻辑类似,因为用户也可以在对应的路由文件中通过router.use来创建路由
  var offset = 0;
  var path = '/';

  // default path to '/'
  // disambiguate router.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 callbacks = flatten(slice.call(arguments, offset));

  if (callbacks.length === 0) {
    throw new TypeError('Router.use() requires a middleware function')
  }

  // 对所有的回调做了一个遍历
  for (var i = 0; i < callbacks.length; i++) {
    var fn = callbacks[i];

    if (typeof fn !== 'function') {
      throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
    }

    // 通过Layer构造函数创建了一个layer实例,并且将中间件函数赋值给了layer.handle方法
    var layer = new Layer(path, {
      sensitive: this.caseSensitive,
      strict: false,
      end: false
    }, fn);

    layer.route = undefined;
    // router.stack = [];
    // 将创建好的layer对象压入了stack中,stack原先是一个空的数组
    this.stack.push(layer);
  }

  return this;
};

用户发送了网络请求,中间件是如何被回调的?

刚刚我们探索了app.use帮助我们注册中间件的本质其实就是把中间件函数和layer绑定到了一起放到了stack数组中去。其实中间件是如何被执行的也很简单,express会在用户发送请求的时候遍历stack数组,找到第一个匹配的layer之后,就去执行绑定在它身上的中间件函数layer.handle,这样就实现了用户发送请求回调中间件的功能

  1. 首先我们要知道,当用户发送了网络请求之后,传入http.createServer的参数会被执行,我们从app.listen的源码中可以看到,我们其实是把app传入给了http.createServer函数,所以当用户发送请求的时候,http模块会帮助我们执行app函数
// lib/application.js
app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};
  1. app函数里面到底有什么内容呢?不难发现,其本质上执行的是app.handle函数
// lib/express.js
var app = function(req, res, next) {
  app.handle(req, res, next);
};
  1. 于是我们又找到了app.handle函数,发现其调用的又是router实例上的handle方法
// lib/application.js
app.handle = function handle(req, res, callback) {
  var router = this._router;
  // ......省略部分代码
  router.handle(req, res, done);
};
  1. 沿着代码的轨迹,我们来到了router文件夹的index.js文件中寻找router.handle方法;为了让代码看起来更加简洁,我删除掉我们这次不讨论的源码
// lib/router/index.js
proto.handle = function handle(req, res, out) {
  // 将router实例赋值给self变量
  var self = this;
  // 定义了一个索引值
  var idx = 0;
  // 从router实例中获取到stack数组,这里面存储了我们通过app或者router注册的中间件(现在是以layer的形式保存)
  var stack = self.stack;
  // 先执行next函数,因为用户发送了请求之后需要执行第一个匹配上的中间件
  next();
  // next函数做的事情其实就是找到下一个匹配的中间件并执行
  function next(err) {
    // no more matching layers
    // 当前索引如果大于等于数组的长度,说明我们所有匹配的中间件都被执行过了,退出函数即可
    if (idx >= stack.length) {
      setImmediate(done, layerError);
      return;
    }
    
    var layer;
    var match;
    
    // match代表我们有没有匹配到对应的中间件,如果找到了或者已经遍历完了中间件,则退出循环
    while (match !== true && idx < stack.length) {
      // 获取存储在数组中的layer,让idx自增来遍历stack数组
      layer = stack[idx++];
      // 看看当前的layer(对应一个中间件函数)是否和路径匹配,如果匹配则将match改为true
      match = matchLayer(layer, path);

      // 如果match值不为true,说明当前中间件匹配失败,利用continue去到下一次循环中匹配下一个中间件
      if (match !== true) {
        continue;
      }

    }

    // this should be done for the layer
    // 代码执行到这里,说明已经匹配到了对应的中间件
    // 传入process_params函数的最后一个参数(也是一个函数)会被执行
    // 不出意外的会执行layer.handle_request函数
    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)
      }
    });
  }
};
  1. 为了知道最终执行的 layer.handle_request函数到底执行了哪些操作,我们来到了router文件夹下面的layer.js文件;仔细阅读后发现,最终调用的就是我们绑定在layer上面的中间件
// lib/router/layer.js
Layer.prototype.handle_request = function handle(req, res, next) {
  // layer.handle我们前面特意提到过,其就是绑定在layer上面的中间件函数,也就是我们这个问题中说的中间件
  var fn = this.handle;

  if (fn.length > 3) {
    // not a standard request handler
    return next();
  }

  try {
    // 最后执行的就是我们的中间件函数
    fn(req, res, next);
  } catch (err) {
    next(err);
  }
};

至此,用户发送网络请求是怎么执行中间件的这个过程我们就已经探索完了

调用next为什么会执行下一个中间件?

从上面的代码中,可以看到在执行fn函数的时候将reqres以及next作为参数传递了进去,传递进去的next其实就是router.handle中的next函数,所以我们在中间件函数中调用next,实际上调用的是router.handle中的next函数,这里涉及到了js的闭包——可以访问到其他函数作用域中的变量的函数

因为闭包特性,所以next在执行的时候是可以访问到作用域链中的idx变量的,这个变量记录了上一次执行的中间件函数在数组中的索引,所以当我们在中间件函数里面执行next函数的时候,会从idx开始寻找下一个匹配的中间件并执行

app.use((req, res, next) => {
  next()
})