前言
在大学那会学习了express,简单的用express搭建了一个服务器。现在公司的项目中写BFF项目,底层框架用的是koa2,那么我就在想express和koa有啥不同。这篇文章将从express到koa、koa2进行深入的学习以及如何使用和理解。
express是一个简洁的node.js的web应用框架,express的核心特性:
- 可以设置中间件来响应http请求。
- 主要基于Connect中间件框架,封装了大量便利的功能
- connect的中间件模型是线性的,一个个的往下执行
- 定义了路由表用于执行不同的http请求。
- 可以通过向模版传递参数来动态渲染HTML页面。
- 弊端是callback回调方式,不可组合、异常不可捕获;
koa同样基于node.js的web开发框架
- 主要基于co中间件,框架本身没有集成太多的功能,没有捆绑任何的中间件。大部分的功能需要用户自己require中间件去解决。
- co中间件模型是洋葱模型,呈一个U型
- 基于ES6 generator特性的中间件,通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。
koa2使用的是es7,koa2完全使用Promise并配合async来实现异步
代码比较
可以看出来,以上他们的大部分语法是一样的,但是最大的区别就在于express路由处理是自身就有做处理的,而koa2是需要单独去require中间件。所以证实了官网中说明的,koa框架是更加简洁纯粹的,没有捆绑任何的中间件。
koa2的中间件:
- 通过async await实现的,中间件执行的顺序是“洋葱圈”模型。
- 中间件之间通过next函数联系,当一个中间件调用next()后,会将控制权交给下一个中间件,直到下一个中间件不再执行next()后,会沿路返回,将控制权交给前一个中间件。
express中间件
- 一个接一个顺序执行,response响应写在最后一个中间件中。线性的模型
- app.use用来注册中间件
- 遇到http请求,根据path和method判断触发哪些中间件;
- 实现next机制,即上一个中间件会通过next触发下一个中间件;
深入解读koa
Get请求数据
-
查看get请求数据源的格式
- 从ctx上直接拿query、querystring
- 从ctx.request的对象中拿query、querystring
// koa2 var Koa = require('koa'); var Route = require('koa-router'); //koa默认没有集成route功能,引入中间件 var app = new Koa(); var home = new Route() home.get('/', async (ctx) => { // 直接从ctx对象上拿取query、queryString const { request , query, querystring } = ctx // 从ctx.request上拿query、queryString const ctxQuery = request.query const ctxQueryString = request.querystring ctx.body = { request, query, querystring, ctxQuery, ctxQueryString } }) app.use(home.routes()); //监听端口,启动服务 app.listen(3000);
Post请求数据
对于POST请求的处理,koa2没有封装获取参数的方法,需要通过解析上下文context中的原生node.js请求对象req,将POST表单数据解析成query string
以上暂时介绍了get和post的数据来源格式,那么koa原生去获取的话,需要大量的代码,这无疑是给我们的开发制造了工作量。那么这么轻巧的框架,再加上中间件,为我们解决了很多问题。
- koa-router 装载路由
- koa-bodyparse 可以把koa2上下文的formData数据解析到ctx.request.body中
- koa-static 静态资源中间件
- koa-json中间件
其实很多中间件的开发,让我们的工作更加快速。
看过官网的例子之后,不经引发起思考
- koa是怎么样去封装的这些api呢
- 中间件又是怎么生成的、中间件的原理以及洋葱模型如何理解
- 每次代码中的app.use是怎么去处理中间件的
那么接下来通过看源码来深入一下了解koa,本文依赖koa2
源码地址koa2源码
看完源码结合资料,自己清理了一波结构图:
源码解析
源码目录结构:一共分为4个文件
application.js
其实网上的说法都差不多达成一致,我们学习的主要目的是自己能够理解koa的原理,能够掌握它的核心思想。接下来就开始从入口文件,开始来解析源码。
可以看到文件中require很多包,因为koa本着自己需要简洁的目的。不知道的我也去网上一一查了一下资料,咱们大概知道这些包的引入的目的以及作用,如果有时间可以再去深入理解其每个包的原理。
咱们简单的截取文件中的部分核心方法的实现来进行分析:
'use strict'
/**
* Module dependencies.
*/
// 所依赖的核心模块
const debug = require('debug')('koa:application')
const onFinished = require('on-finished') // on-finished 可以监听 response 的结束事件,
const response = require('./response') // 引入response文件
const compose = require('koa-compose') //包装所有的中间件组件,返回一个可以执行的函数,这里是实现了洋葱模型
const context = require('./context') // 引入context文件
const request = require('./request') // 引入request文件
const statuses = require('statuses') // statuses 可以向这个文件传递状态码或者文案,返回对应的文案或者状态码
const Emitter = require('events') // Node.js自带了对核心events模块中的events的内置支持,用于对事件的监听
const util = require('util') // util 是一个Node.js 核心模块,提供常用函数的集合,用于弥补核心 JavaScript 的功能 过于精简的不足
const Stream = require('stream') // stream 是Node.js提供的又一个仅在服务区端可用的模块
const http = require('http') // 同样是Node.js模块中的,用来操作http模块提供的request和response对象
const only = require('only') // only 做的事情就是从对象上取部分属性组成新的对象
const { HttpError } = require('http-errors') // 主要用于处理HTTP Error,在express框架中都有用到
/**
* 此文件暴露了一些公用的api,两个常见的,一个是listen,一个是use
* 继承emitter事件是表示具有异步事件的处理能力
* Emitter是事件,那么继承了事件,就被赋予了框架事件监听和事件触发的能力
*/
module.exports = class Application extends Emitter {
constructor (options) {
super()
options = options || {}
this.proxy = options.proxy || false
//当访问sudomain的时候,会返回第几级的域名
this.subdomainOffset = options.subdomainOffset || 2
// 自定义IpHeader,用来接收消息头中的ip,默认"X-Forwarded-For"
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'
this.maxIpsCount = options.maxIpsCount || 0
this.env = options.env || process.env.NODE_ENV || 'development'
if (options.keys) this.keys = options.keys
this.middleware = [] // 该数组存放所有通过use函数的引入的中间件函数
this.context = Object.create(context) //根据context创建一个新对象,并且将context的属性和方法作为新的对象的proto
this.request = Object.create(request) // 同上理
this.response = Object.create(response)
// util.inspect.custom support for node 6+
/* istanbul ignore else */
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect
}
}
/**
* Shorthand for:
*
* http.createServer(app.callback()).listen(...)
*
* @param {Mixed} ...
* @return {Server}
* @api public
*/
listen (...args) {
debug('listen')
// 建立http的server,创建一个新的访问,拥有http实例就可以访问其他方法
const server = http.createServer(this.callback())
// 其实对应了http.createServer的参数(req, res)=> {}
// 返回的是listen方法,启动 HTTP 服务器并监听连接
return server.listen(...args)
}
/*
通过调用koa应用实例的use函数,形如:
app.use(async (ctx, next) => {
await next();
});
来加入中间件
*/
use (fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
debug('use %s', fn._name || fn.name || '-')
// 把传入的函数存放到middlewear数组中
this.middleware.push(fn)
return this
}
/**
* Return a request handler callback
* for node's native http server.
*
* @return {Function}
* @api public
*/
callback () {
// 包装所有的中间件,通过koa-compose来组合一下use中传入的中间件
const fn = compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
// 基于req、res封装出更强大的ctx
const ctx = this.createContext(req, res)
// 调用app实例上的handleRequest,注意区分本函数handleRequest
return this.handleRequest(ctx, fn)
}
return handleRequest
}
/**
* Handle request in callback.
*
* @api private
*/
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
// 注意此处调用的是ctx上的onerror
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
// 监听 res 提前结束的异常场景, 确保一个流在关闭、完成和报错时都会执行响应的回调函数
onFinished(res, onerror)
// 这里是中间件的执行,也是统一处理错误的关键
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
/**
* Initialize a new context.
*
* @api private
*/
/*
高聚合的context从这里就可以看出来。
看到这里还不懂为什么要这样去赋值,去这样做。可以等我们看context文件的内容的时候,就理解了
*/
createContext (req, res) {
// 其实是创建一个新对象,使用context对象来提供新创建对象的proto,并且将这个对象赋值给this.context,实现了类继承的作用
const context = Object.create(this.context)
const request = context.request = Object.create(this.request)
const response = context.response = Object.create(this.response)
context.app = request.app = response.app = this
context.req = request.req = response.req = req
context.res = request.res = response.res = res
request.ctx = response.ctx = context
request.response = response
response.request = request
context.originalUrl = request.originalUrl = req.url
context.state = {}
return context
}
// 其他源码可以自己去github上去查看……
module.exports.HttpError = HttpError
从上面代码中,我们可以总结出application.js核心其实处理了这4个事情:
-
创建并且启动服务来达到启动框架,利用原生的node中的
http.createServer((req, res)=> {}) -
实现洋葱模型的中间件的机制,
koa-compose中间件组合了use方法中传入的方法 -
封装高内聚的
context(具体怎么封装、以及为什么这么做下面再看) -
实现异步函数统一的处理错误的机制
context.js
在下图中可以看到require的delegates这个库的作用是十分核心的。
首先看核心方法:
onerror (err) {
……
// delegate
// 触发application实例的error事件
this.app.emit('error', err, this)
……
}
/*
在application.createContext函数中,
被创建的context对象会挂载基于request.js实现的request对象和基于response.js实现的response对象。
下面2个delegate的作用是让context对象代理request和response的部分属性和方法
*/
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable')
/**
* Request delegation.
*/
delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
.access('socket')
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
.access('accept')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('URL')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip')
从context.js文件中,我们得知了它做了两件事:
- 错误事件的处理
- 代理response和request的事件属性和方法。
request.js
module.exports = {
// 在application.js的createContext函数中,会把node原生的req作为request对象(即request.js封装的对象)的属性
// request对象会基于req封装很多便利的属性和方法
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
// 省略了大量类似的工具属性和方法
};
request对象基于node原生req封装了一系列便利属性和方法,供处理请求时调用。
所以当你访问ctx.request.xxx的时候,实际上是在访问request对象上的赋值器(setter)和取值器(getter)。
response.js
module.exports = {
// 在application.js的createContext函数中,会把node原生的res作为response对象(即response.js封装的对象)的属性
// response对象与request对象类似,基于res封装了一系列便利的属性和方法
get body() {
return this._body;
},
set body(val) {
// 支持string
if ('string' == typeof val) {
}
// 支持buffer
if (Buffer.isBuffer(val)) {
}
// 支持stream
if ('function' == typeof val.pipe) {
}
// 支持json
this.remove('Content-Length');
this.type = 'json';
},
}
response对象与request对象类似,就不再赘述。
值得注意的是,返回的body支持Buffer、Stream、String以及最常见的json,如上示例所示。
中间件的源码解读
koa2 中间件和我们之前了解到的 express 有所不同,将他形容成一个洋葱模型。
'use strict'
/**
* Expose compositor.
*/
module.exports = compose
/**
在 application.js文件中有一个 callback 函数,其中有一个 compose 函数,里面传递的参数是 middleman 中间件。那么 componse 其实就是引用的 koa-componse 库。
简单的说明一下 koa-compose 库做了什么。
1. 那我们的 koa2 本身是基于 async/await 异步的方式来进行编程的,在 compose 函数中,传递的参数middleman 首先判读是否是一个数组,同时,这个数组中的每一个元素是否是一个函数。
2.
*/
function compose (middleware) {
// 判断 middleman 参数的类型
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
// 其实就是相当于一层层的执行函数,函数遇到 await next(),其实就是一个函数,那么就相当于一个 promise
// 传递 context 和 next()很重要,一个是上下文,一个是 next()下一个中间件其实。
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
总结
compose() 函数的参数是一个中间件数组,它包含了要组合的中间件函数。首先,代码会检查中间件数组是否是一个数组,并检查数组中的每个元素是否都是函数。如果中间件数组不合法,就会抛出一个错误。
然后,compose() 函数会返回一个新的函数,该函数接受两个参数:context 和 next。context 对象包含了请求的上下文信息,例如请求路径、请求参数等。next 函数是一个回调函数,用于在当前中间件函数完成工作后调用下一个中间件函数。
变量 index,用于记录最后一个被调用的中间件函数的编号。
在每次调用中间件函数之前,都会检查当前中间件函数的编号是否小于等于 index 变量。如果是,就意味着 next() 函数被调用了多次,会返回一个错误。然后会更新 index 变量,并获取下一个中间件函数。
如果当前中间件函数是最后一个中间件函数,就会将 next 函数赋值给当前中间件函数。如果没有更多的中间件函数,就会返回一个已完成的 Promise 对象。
最后,调用当前中间件函数,并返回一个 Promise 对象。如果在调用过程中发生错误则会抛出一个异常。
koa-compose 使用递归和Promise来实现多个中间件的链式调用,Promise 很好的简化了异步流程,并且能够让你使用 try-catch 语句捕获异步错误。
Q&A
1. Koa 中,如果一个中间件没有调用 await next(),后续的中间件还会执行吗?
不会执行;
因为当一个中间件函数执行完成并且没有调用 await next() 时,它不会将控制权交给下一个中间件,而是直接返回或抛出异常。
在 koa 中,中间件函数通常会使用await next()来调用下一个中间件函数,并且等待下一个中间件执行完毕并且返回结果后再执行自己的逻辑。如果一个中间件没有调用await next(),那么下一个中间件就不会被执行。当前中间件也不能得到后续中间件的处理结果,从而可能导致请求无法得到正确的响应或者程序出现错误。
2. 在 koa 没有 async/await 的时候,如何实现的洋葱模型
利用 generate 的语法糖,使用function*() yied来实现。
具体来说每一个中间件都是一个生成器函数,会接收两个参数,一个ctx(请求上下文对象),一个是next(将控制权交给下一个中间件)。洋葱模型的执行顺序是依次进入和推出各个中间件函数。
注意⚠️:koa 现已不支持 generate的写法,支持 async/await,这里只是为了实现该题目。
const Koa = require('koa');
const app = new Koa();
// x-response-time 中间件
app.use( function*(ctx, next){
console.log('\n开始 x-response-time');
const start = Date.now();
// await next(); // 调用下一个中间件:logger(等待下一个异步函数返回)
yield next;
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`); // 设置响应头
console.log('结束 x-response-time\n');
});
// logger 中间件
app.use(function* (ctx, next) {
console.log('开始 logger');
const start = Date.now();
// await next(); // 调用下一个中间件:response(等待下一个异步函数返回)
yield next;
const ms = Date.now() - start;
console.log(`\u0020\u0020\u0020\u0020\u0020${ctx.method} ${ctx.url} - ${ms} ms`); // \u0020 为空格
console.log('结束 logger');
});
// response 中间件
app.use(function* (ctx) {
console.log('开始 response');
ctx.body = 'Hello World';
console.log('结束 response');
// 没有更多的中间件执行,堆栈将展开并且每个中间件恢复执行其上游行为
});
app.listen(3000);
console.log('app started at port 3000...');
3. koa 中如何处理的中间件的异常
- 在 koa 源码中,会注册一个错误处理函数,这个
oneror函数其实是交给了 ctx.onerror进行处理。其中在源码中,有一个 listen 函数中,通过中间件执行响应后捕获。 - 在外围,可以通过 this.app.on('error', err => , err, this)进行捕获中间件练中的错误,因为 koa 集成了
events模块。
那么在我们使用过程中,可以在中间件中使用 try catch 到错误,并且通过编写一个错误 code 中间件处理我们业务中的错误。异常的对象需要包装成Events进行返回。
4. koa 中间件理解
koa 中间件可以理解成一个洋葱模型。在我们 bff 中使用中间件可以实现对业务逻辑的分层和复用。核心思想就是将 http 请求和响应对象依次传给各个中间件函数,行成一个类似洋葱模型的通道。由外向内依次执行中间件,由内向里依次执响应中间件的执行。先顺序执行next前面的逻辑,在逆序执行next后面的逻辑。不过通常我们把next前面的逻辑用来处理Request,next后面的逻辑用来处理Response。
koa 是通过koa-compose 库进行实现的中间件。
- compose 函数中会传入中间件数组,判断是否是一个数组。
- 其次,每个中间件会有两个参数,一个是 ctx一个上下文请求响应的对象,还有一个是 next,作用于将控制权给接下来的中间件。
- 在源码中会首先声明一个 index 标明是从最开始的中间件进行的,为
-1,为了保证每次只能调用依次 next() - 使用递归的函数驱动执行,dispatch 每次都从 middleware 数组中去的下一个中间件函数来自行,并且把并把
dispatch.bind(null, i + 1)作为next参数传入其中。一旦当前中间件调用了next(),就相当于主动调用了下一个中间件函数,如果不调用那后面的中间件永远得不到执行。
5. koa 和 express 有啥区别
相同点
都是对 http 进行封装,相关 api 都差不多, 毕竟 koa 也是 express 原班人马打造的
不同点
- koa 是轻量级的框架,express 内置了很多中间件可以提供使用,但是 koa 没有。
- express 有路由,视图渲染模块,但是 koa 只有 http 模块。
- express 的中间件成线形。koa 的中间件成 U 型,洋葱模型。
- express 通过回调的方式来处理异步callback 和 promise,容易嵌套过多,但是 koa 通过 async/await 来执行处理异步
6. koa 哪些中间件
- Koa-bodyparse解析 body 为 对象
- koa-router 对路由进行控制
- koa-statics 对静态文件的处理
- koa-jsonp 中间件
- ……