koa相信大家多多少少都有所接触,即使没用过koa的人肯定也会听过洋葱模型这个概念。今天的目标就是手把手带大家实现一个koa2。
前2章的思路是从使用层面和运行结果层面倒推源码,感觉这么做的代入感会更强一些。最后一章就完全按照官方文档和源码直接实现了。
老规矩,先提几个问题
- koa如何启一个node服务?
- 洋葱模型如何实现?
- 常用的ctx是怎么来的?
- 为什么给ctx.body赋值后会自动返回对应的数据类型?
接下来我们在实现的过程中会一个个解决这些问题!
如何启动一个服务
先观察一个Koa的最简demo
const Koa = require('koa')
const app = new Koa()
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
首先观察代码结构
- 第2行使用了new操作符,那么koa抛出的肯定是一个构造函数(function)或者类(class)
- 第3行Koa实例调用了listen方法,并且接收了几个参数,如果先不管参数,那么Koa实例内肯定包含一个listen方法
结合上述2个结论可以推断出koa内的结构大概是这样
class Koa {
listen(...args) {
}
}
然后看看运行结果
- 启动成功后会调起一个服务,那么肯定离不开node的http模块的createServer,createServer得到一个server后也拥有一个listen方法,并且完全对应demo里app.listen的参数。所以Koa的listen实现如下
const http = require('http')
class Koa {
listen(...args) {
const server = http.createServer()
return server.listen(...args)
}
}
验证一下
const Koa = require('./listen.js')
const app = new Koa()
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node app.js -> 启动成功8889
洋葱模型
单个中间件
首先我们先看看单个中间件的最简demo
const Koa = require('koa')
const app = new Koa()
app.use(() => {
console.log(1)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node koa.js - > 启动成功8889
// Postman请求 -> 1
观察一下上述代码结构
- 根据第3行app.use,可以推断出Koa类有一个use函数,并且接收一个函数参数
就这个结论我们可以很容易的写出如下代码
class Koa {
constructor() {
// 初始化函数
this.middleware = () => {}
}
use(cb) {
// 保存函数
this.middleware = cb
},
listen(...) {...}
}
然后再看看运行结果
- 启动成功后log了启动成功8889,也就是上一节我们实现的内容
- Postman请求后log了1,也就是执行了app.use中的函数参数。那么我们可以假设app.use是一个注册器,注册了一个函数(中间件),当服务接收到请求后执行这个函数,而http.createServer api的参数接收一个函数来监听请求,正好满足我们的需求
所以结合上述结论我们就可以实现刚刚写的最简demo了,代码如下
// 源码层
const http = require('http')
class Koa {
constructor() {
// 初始化函数
this.middleware = () => {}
}
// 一个注册器
use(cb) {
// 保存函数
this.middleware = cb
}
listen(...args) {
// http.createServer接收一个函数参数,用于接收请求
const server = http.createServer((req, res) => {
// 接收到请求后执行use中注册的函数
this.middleware()
// 这一段是为了正常结束请求,暂时加上,可以先忽略
res.end('1');
})
return server.listen(...args)
}
}
验证一下
const Koa = require('./use1.js')
const app = new Koa()
app.use(() => {
console.log(1)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node app.js - > 启动成功8889
// Postman请求0.0.0.0:8889 -> 1
多个中间件
最简demo的源码是完成了,但是正常大家写Koa的时候,都会有不少的插件,自定义的各种拦截器等各种不同类型的中间件。但是抛开这些中间件的具体逻辑而言,它们的执行顺序无非为同步和异步2种。 所以区分这2种情况,分别观察一下代码结构和运行结果,然后一步步分析,最后再去推导源码。
同步
const Koa = require('koa')
const app = new Koa()
app.use(function cb1(ctx, next){
console.log(1)
next()
console.log(5)
})
app.use(function cb2(ctx, next) {
console.log(2)
next()
console.log(4)
}).use(function cb3(ctx, next) {
console.log(3)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3 4 5
观察一下上述代码结构和运行结果(这一次需要结合运行结果来分析,所以不拆分了)
- 可以多次使用app.use注册中间件,中间件接收ctx,next两个参数,因为可以注册多个,所以我们需要用有一个地方存起来,比如用数组存储
- 可以使用链式写法(13行),可以推断出use内返回this。
- 请求后会先后打印1,2,3,4,5。也就是说next()会暂停执行当前中间件去执行下一个中间件,实际上就像js内的嵌套函数
首先,根据1,2结论,可以得出下方的代码
class Koa {
constructor() {
// 老代码 this.middleware = () => {}
// 保存中间件数组
this.middleware = []
}
// 注册器
use(cb) {
// 保存中间件
this.middleware.push(cb)
// 链式写法
return this
}
}
第3点就比较复杂了,为了更好的分析,我们可以把use这一段代码用js的写法来写一遍
这一整节暂时先不管ctx,后续会对其进行封装
function cb1(next){
console.log(1)
next(cb3)
console.log(5)
}
function cb2(next) {
console.log(2)
next(cb3)
console.log(4)
}
function cb3(next) {
console.log(3)
}
cb1(cb2)
// 放控制台执行 -> 1 2 3 4 5
// cb1的next -> cb2
// cb2的next -> cb3
// cb3的next -> () => {} // 兼容
结合js代码和上图,那么我们怎么去封装这个use函数呢?
唯一的困难点就是如何获取下一个中间件。 这时我们想一下中间件注册的时机,中间件的注册发生在node服务启动的时候,执行是在请求进来时,所以请求进来的时候,我们已经用数组保存了全部的中间件,那么我们是不是就可以利用数组的下标获取下一个中间件呢
const middleware = [cb1, cb2, cb3]
// 执行cb1的时候,我们可以获取到cb2,并传给cb1
middleware[0](middleware[1])
用上述的概念实现一下最简单的洋葱,看看是否可以和Koa一样运行
const http = require('http')
class Koa {
constructor() {
// 初始化中间件数组,因为可能是多个
this.middleware = []
}
// 注册器
use(cb) {
// 保存所有注册的中间件
this.middleware.push(cb)
return this
}
compose() {
const dispatch = (i) => {
// 从数组中取出中间件
const fn = this.middleware[i]
// 执行中间件,并传递执行下一个中间件的函数
// dispatch(i + 1)会立即执行下一个中间件,所以用一个函数包起来,何时执行交给用户自己选择
return fn(() => dispatch(i + 1))
}
// 执行第一个中间件
return dispatch(0)
}
callback() {
return (req, res) => {
// 接收请求后执行compose
this.compose()
// 这一段是为了让Postman正常结束请求,暂时加上,可以先忽略
res.end('111')
}
}
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}
}
验证一下
const Koa = require('./useSync.js')
const app = new Koa()
app.use(function cb1(next){
console.log(1)
next()
console.log(5)
})
app.use(function cb2(next) {
console.log(2)
next()
console.log(4)
}).use(function cb3(next) {
console.log(3)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3 4 5
校验
基于上述实现,有2个点需要注意
- middleware是一个数组
- middleware内每一项都是一个函数
之前也提到过,在server启动的时候,就已经收集到了全部的中间件,所以为了代码更健壮,最好在启动阶段就进行强制校验。让我们分析一下如何做到这一步。
- 运行时机:启动时,也就是执行callback的时候
- 需要compose提供的功能:校验 + 执行中间件。利用闭包,执行时校验并返回一个执行中间件的函数
基于上述2点,可以推导出,callback执行时立即执行compose,请求进来时执行中间件函数
const http = require('http')
class Koa {
constructor() {
this.middleware = []
}
use(cb) {
this.middleware.push(cb)
return this
}
compose(middleware) {
// 校验中间件是数组
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!')
}
// 返回执行中间件的函数
return () => {
const dispatch = (i) => {
// 从数组中取出中间件
const fn = middleware[i]
// 执行中间件,并传递执行下一个中间件的函数
// 这里注意,dispatch(i + 1)会立即执行下一个中间件,所以用一个函数包起来,何时执行交给用户自己选择
return fn(() => dispatch(i + 1))
}
// 执行第一个中间件
return dispatch(0)
}
}
callback() {
// 启动时校验
const fn = this.compose(this.middleware)
return (req, res) => {
// 请求进来时执行
fn()
res.end('111')
}
}
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}
}
校验一下
const Koa = require('./useSyncValidator.js')
const app = new Koa()
app.use(111)
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
边界
比较了解Koa的人可能会发现,我们目前实现的代码还有2个边界情况未处理
- Koa中规定每个中间件只能执行一次next,但是我们目前还没做到
- 最后一个中间件也存在next,执行next会报错。因为i >= middleware.length,用middleware[i]获取到的是undefined
先看看第1个边界
如图所示,虽然给出了警告,但是log了3和1。可以看出底层拦截了第2个next,第一个next正常执行,并且不会影响next后续逻辑的运行
那么我们怎么去判断代码里存在1个以上的next呢?
看看刚刚的核心代码compose
compose(middleware) {
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!')
}
return () => {
const dispatch = (i) => {
const fn = middleware[i]
return fn(() => dispatch(i + 1))
}
return dispatch(0)
}
}
每一个next都是一个dispatch(i + 1),也就是说每次执行next的时候i都是相同的。那么我们如果在dispatch外再维护一个索引,dispatch执行的时候index = i,再执行一次dispatch的时候判断i是不是小于等于index,就可以判断当前next是不是执行2+次了。
修改一下上面的compose函数
compose(middleware) {
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!')
}
return () => {
let index = -1
const dispatch = (i) => {
if (i <= index) {
return console.error(new Error('next() called multiple times'))
}
index = i
const fn = middleware[i]
return fn(() => dispatch(i + 1))
}
return dispatch(0)
}
}
然后第2个边界就好处理了,当最后一个next的时候,默认返回一个空函数就行
compose(middleware) {
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!')
}
return () => {
let index = -1
const dispatch = (i) => {
if (i <= index) {
return console.error(new Error('next() called multiple times'))
}
index = i
// 添加了这一段
if (i >= middleware.length) return () => {}
const fn = middleware[i]
return fn(() => dispatch(i + 1))
}
return dispatch(0)
}
}
然后整体验证一下
const Koa = require('./useSync.js')
const app = new Koa()
app.use(function cb1(next){
next()
next()
console.log(1)
})
app.use(function cb2(next) {
next()
})
app.use(function cb3(next) {
console.log(3)
next()
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node app.js
报错时机是因为底层封装不一样,并不影响。接下来我们看看异步的情况
异步
首先用上一步实现的代码试试看async await的运行结果
const Koa = require('./useSync.js')
const app = new Koa()
app.use(async (next) => {
console.log(1)
await next()
console.log(4)
})
app.use(async (next) => {
console.log(2)
await timeout()
console.log(3)
})
function timeout() {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, 2000)
})
}
// node app.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2(2s后) 3 4
到这里是不是很疑惑,和Koa源码的运行结果一致,难道封装已经完成了?
答案肯定是否定的!因为log正确完全是因为async await的底层实现。一句话概括底层原理就是基于Promise实现的自执行的Generator函数,async最后会返回一个Promise,await等于yield。 所以不用async await,直接用Promise的结构来看看demo
const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
console.log(1)
next().then(() => {
console.log(3)
})
})
app.use((ctx, next) => {
console.log(2)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node app.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3
从上述demo可以给看出,最后一个中间件是一个普通函数,但是上一个中间件却用了next().then(),也就是认为最后一个中间件是一个Promise。在Koa中这么写确实被允许的,没有报错,也就是Koa中兼容了普通函数和Promise。那么如何去兼容呢?
Promise.resolve
我们看看Promise.resolve的源码实现
// 函数是Promise直接返回,不是就包一层Promise
Promise.resolve = function (fn) {
if (fn instanceof Promise) {
return fn
} else {
return new Promise((resolve) => {
resolve(fn)
})
}
}
所以再次修改compose函数
compose(middleware) {
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!')
}
return () => {
let index = -1
const dispatch = (i) => {
if (i <= index) {
return console.error(new Error('next() called multiple times'))
}
index = i
if (i >= middleware.length) return () => {}
const fn = middleware[i]
// 修改了这
return Promise.resolve(fn(() => dispatch(i + 1)))
}
return dispatch(0)
}
}
再次验证一下刚刚的demo,正常log 1 2 3,然后再看看另一个情况
const Koa = require('./useAsync.js')
const app = new Koa()
app.use(next => {
console.log(1)
next().then(() => {
console.log(3)
})
})
app.use(next => {
return next().then(() => {
console.log(2)
})
})
从报错可以看出2都没有被log出来,也就是最后一个中间件的next().then报错了,回想一下之前保护机制里第2个边界的处理,直接返回一个Promise.resolve,就可以解决这个问题
compose(middleware) {
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!')
}
return () => {
let index = -1
const dispatch = (i) => {
if (i <= index) {
return console.error(new Error('next() called multiple times'))
}
index = i
// 就这里
if (i >= middleware.length) return Promise.resolve()
const fn = middleware[i]
return Promise.resolve(fn(() => dispatch(i + 1)))
}
return dispatch(0)
}
}
最后做一下错误情况的兼容
compose(middleware) {
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!')
}
return () => {
let index = -1
const dispatch = (i) => {
// Promise.reject
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
if (i === middleware.length) return Promise.resolve()
const fn = middleware[i]
// try catch
try{
return Promise.resolve(fn(() => dispatch(i + 1)))
} catch(err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
ctx封装
基础结构
这一章直接按文档来实现其原理。不再按之前的推断思路。
首先在封装前,我们先看看官方对ctx的定义,简单来说就是封装了node的response和request。
这一部分,只简单的封装一下ctx,request,response和body
首先,ctx是一个对象,request和response是ctx的一个属性并且也是一个对象,直接得出如下源码
const ctx = {
}
module.exports = ctx
module.exports = {
}
module.exports = {
}
然后上述对Context的描述里说到,每个请求都会创建一个新的Context,并在中间件中引用,也就是说每次server接收到请求后(callback内返回的函数),都会根据req和res创建一个Context。 Context中的内容根据文档内描述
- ctx.request:根据node的request封装而来
- ctx.response:根据node的response封装而来
- ctx.req:node的request对象
- ctx.res:node的response对象
中间件第一个参数为Context 首先根据上述描述,可以推导出源码如下(可以只看注释部分)
const http = require('http')
const context = require('./src/context')
const request = require('./src/request')
const response = require('./src/response')
class Koa {
constructor() {
this.middleware = []
// 初始化ctx等,引用类型,避免引用
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
use(cb) {
this.middleware.push(cb)
}
compose(middleware) {
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!')
}
// 接收ctx
return (ctx) => {
let index = -1
const dispatch = (i) => {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
if (i === middleware.length) return Promise.resolve()
const fn = middleware[i]
try{
// 增加第一个参数为ctx
return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
} catch(err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
createContext(req, res) {
// 避免引用
const context = Object.create(this.context)
const request = context.request = Object.create(this.request)
const response = context.response = Object.create(this.response)
// req,res为node原生request和response
// 给request和response也赋值req,res是为了利用this获取到原生req,res,然后做二次封装
context.req = request.req = response.req = req
context.res = request.res = response.res = res
return context
}
callback() {
const fn = this.compose(this.middleware)
return (req, res) => {
// 每个请求都创建一个新的context
const ctx = this.createContext(req, res)
// 传入ctx
fn(ctx)
res.end('111')
}
}
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}
}
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
next()
})
app.use(ctx => {
console.log(ctx)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
log的ctx大概如下结构
{
request: {
req: <node req>,
res: <node res>
},
response: {
req: <node req>,
res: <node res>
},
req: <node req>,
res: <node res>
}
response & request
上文已经给request和response赋值了,所以可以对req和res做一层有用的封装
module.exports = {
// req: <node req>,
// res: <node res>
get header() {
return this.req.headers
},
set header(val) {
this.req.headers = val
},
get url() {
return this.req.url
},
set url(val) {
this.req.url = val
}
... api内的方法
}
module.exports = {
// req: <node req>,
// res: <node res>
get header() {
const { res } = this;
return typeof res.getHeaders === 'function'
? res.getHeaders()
: res._headers || {}; // Node < 7.7
},
set header(val) {
console.log(val, 222)
},
get body() {
return this._body;
},
// 这里只是随便赋了个值,源码内做了很多判断为了适应不同的数据
set body(val) {
this._body = val
}
... api内的方法
}
验证一下
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
next()
})
app.use(ctx => {
console.log(ctx.request.header, 2)
console.log(ctx.request.url, 3)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
ctx别名
可以看看官方文档的别名具体都有哪些,这里就对request的header和url做一次实现,其它都同理。
源码内利用delegates包做了一层代理,其实也可以用proxy等,实现一下request的代理
const delegate = require('delegates');
const ctx = {
}
// 将ctx.request的header和url属性代理到ctx下
delegate(ctx, 'request')
.access('header')
.access('url')
// 代理response.body
delegate(ctx, 'response')
.access('body')
module.exports = ctx
验证一下
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
next()
})
app.use(ctx => {
console.log(ctx.request.url, 1)
// 可以直接访问
console.log(ctx.url, 2)
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
ctx.body
Koa中给ctx.body赋值后,请求结束会识别ctx.body的类型然后返回对应的数据
const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
console.log(1)
next()
console.log(3)
})
app.use((ctx, next) => {
console.log(2)
ctx.body = '<html>111</html>'
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3
所以我们只需要走完全部中间件的时候,判断body类型,然后使用res.end结束请求即可,粗略版本源码如下
class Koa {
callback() {
const fn = this.compose(this.middleware)
return (req, res) => {
const ctx = this.createContext(req, res)
// 中间件全部走完后执行
fn(ctx).then(() => respond(ctx))
}
}
}
function respond(ctx) {
const res = ctx.res
let body = ctx.body
// 判断body类型,自动设置Content-Type
if (typeof body === 'string') {
res.setHeader('Content-Type', /^\s*</.test(body) ? 'text/html' : 'text/plain')
}
if (typeof body === 'object' && ctx.body !== null) {
res.setHeader('Content-Type', 'application/json')
body = JSON.stringify(body)
}
// 结束请求并返回body
res.end(body)
}
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
next()
})
app.use(ctx => {
ctx.body = {
msg: '成功啦'
}
})
app.listen(8889, '0.0.0.0', () => {
console.log(`启动成功8889`)
})