You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
// server-http.jsconsthttp=require('http')constserver=http.createServer((req,res)=>{res.writeHead(200,{'Content-Type': 'text/html'})res.end('<h1>Hello World</h1>')})server.listen(3000,()=>{console.log('server is running on http://localhost:3000')})
现在使用对 http 进行了封装的 Koa2 起一个类似的本地服务,当然了,这需要我们先安装一下这个包,控制台执行下 npm i koa ,然后在新建的文件中写入以下代码:
// server-koa.jsconstKoa=require('koa')constapp=newKoa()app.use(asyncctx=>{ctx.response.res.writeHead(200,{'Content-Type': 'text/html'})ctx.body='<h1>Hello World</h1>'})app.listen(3001,()=>{console.log('server is running on http://localhost:3001')})
上面的代码就是对 http 的一个简单封装,利用 app.use 注册回调函数,通过 app.listen 监听 server 并传入参数。
值得注意的是 handleRequestCallback 返回的是一个箭头函数,这里是为了让 this 指向的是实例,毕竟 fn 就是挂在实例上的。如果这里不这样写,而是直接执行 this.fn(req, res) ,其中 this 将会指向我们创建的 server ,显然是不正确的。
此时在同目录新建一个 test.js ,写入以下代码:
constKoa=require('./koa')constapp=newKoa()app.use((req,res)=>{res.writeHead(200,{'Content-Type': 'text/html'})res.end('<h1>Hello World</h1>')})app.listen(8888,()=>{console.log('server is running on http://localhost:8888')})
控制台使用 node 命令执行该文件,随后打开 http://localhost:8888 ,会发现 Hello World 被正确地返回。
// test-koa.jsconstKoa=require('koa')constapp=newKoa()app.use((ctx,next)=>{console.log(1)next()console.log(2)})app.use((ctx,next)=>{console.log(3)next()console.log(4)})app.use((ctx,next)=>{console.log(5)next()console.log(6)})app.listen(7777,()=>{console.log('server is running on http://localhost:7777')})
constKoa=require('koa')constapp=newKoa()constsleep=(time)=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log('sleeping')resolve()},time)})}app.use((ctx,next)=>{console.log(1)ctx.body='1'next()console.log(2)ctx.body='2'})app.use(async(ctx,next)=>{console.log(3)ctx.body='3'awaitsleep(2000)next()console.log(4)ctx.body='4'})app.use((ctx,next)=>{console.log(5)ctx.body='5'next()console.log(6)ctx.body='6'})app.listen(7777,()=>{console.log('server is running on http://localhost:7777')})
constKoa=require('./koa')constapp=newKoa()app.use((ctx)=>{ctx.response.res.writeHead(200,{'Content-Type': 'text/html'})str+='<h1>Hello World</h1>'// 变量未声明,应该报错ctx.body=str});app.on('error',(err,ctx)=>{console.error('[Outer Error]',err)});app.listen(8888,()=>{console.log('server is running on http://localhost:8888')})
引言
作为一个前端,工作大部分时间都是在和页面打交道,但如果有一天你有一个很好的产品 idea,前后端都需要,就尴尬了,一般这个时候有 3 条路走:
我的建议还是首先尝试一下第三条路(如果你觉得这个想法再不快点实现就亏了一个亿,你也可以首先选第二条路),先别想着放弃,一方面哪怕最后不成功没人用,但是从技术角度讲,这不是刚好扩展了自己的知识面吗?而且万一咱的产品🔥了呢?
想搞服务端,前端最好的选择无非就是 NodeJS 了,语法和我们写前端时的 JavaScript 是一样的,只需要了解相关的 node 模块即可上手编写服务端代码。而我们最先应该了解的就是
http
模块,它能让我们快速启动一个服务。但是因为历史包袱以及考虑到广泛的适用性,原生的模块多少是需要二次封装一下才能很好地服务于我们开发者。Koa2 原理实现
Koa2
就是这么个封装了原始http
模块,拥有更好的心智模型的框架,接下来我会从原理实现入手,讲清楚如何实现一个基本的Koa2
,再到后面介绍如何基于Koa2
开始我们的服务端开发!一个快乐的 SQL Boy!🎉koa2 与 http 简单对比
我们先使用 node 原生模块
http
起一个本地服务:执行一下
node server-http.js
,在浏览器打开http://localhost:3000
,即可看到效果。现在使用对
http
进行了封装的Koa2
起一个类似的本地服务,当然了,这需要我们先安装一下这个包,控制台执行下npm i koa
,然后在新建的文件中写入以下代码:执行一下
node server-koa.js
,在浏览器打开http://localhost:3001
,即可看到一样的效果。我们对比下两者书写上的区别,可以很直观地发现:
koa
中导出的是一个类class
,没有暴露创建服务的方法http.createServer
,可以猜想到是在app.listen
中执行了此方法。app.use
的第一个参数是一个回调函数,与http.createServer
类似,不过,回调参数req
和res
被封装到了一个参数ctx
中。http
中将内容响应到客户端是使用res.end
,在koa2
中却是ctx.body
,咋回事呢?现在我们对两者先有直观上的区别感受,接下来大家逐步跟着我的思路阅读,会对这种区别产生的原因理解地透透的,大家记住一句话,本质上
koa2
就是对http
的扩展,使其有更多的常用功能和更好用而已,我们学习的就是koa2
的封装思路。koa2 源码文件的结构
koa2
的源码文件就只有 4 个,很简洁明了。各个文件的名字很直接展示了其主要作用:
application.js
为导出Koa
类的主入口文件,内部实现将其它模块串联起来的逻辑。context.js
主要作用是代理request.js
和response.js
中的方法。request.js
封装了http
的请求,扩展了一些功能。response.js
封装了http
的响应,扩展了一些功能。根据
koa2
的简单 demo 和上面的文件结构,我们就可以顺着思路一步步实现基本的koa2
了,这里的实现不是完全照搬koa2
的源码,而是利用其实现思路,写一个相对来说更容易理解的版本。封装 http 服务
新建
application.js
,直接创建Application
类,并实现对 node 中http
的封装:上面的代码就是对
http
的一个简单封装,利用app.use
注册回调函数,通过app.listen
监听server
并传入参数。值得注意的是
handleRequestCallback
返回的是一个箭头函数,这里是为了让this
指向的是实例,毕竟fn
就是挂在实例上的。如果这里不这样写,而是直接执行this.fn(req, res)
,其中this
将会指向我们创建的server
,显然是不正确的。此时在同目录新建一个
test.js
,写入以下代码:控制台使用 node 命令执行该文件,随后打开
http://localhost:8888
,会发现 Hello World 被正确地返回。但是我们使用
koa2
时,app.use
中回调函数的第一个参数是ctx
,而不是现在的 node 原生的request
和response
对象,所以我们要将其封装成如下这样:接下来就需要我们编写
context.js
、request.js
和response.js
中的内容了,并将它们在application.js
中串联起来。创建上下文 context
使用
koa
时,在上下文中能访问到封装的请求对象ctx.request
,原生模块的请求对象ctx.req
,封装的响应对象ctx.response
,原生模块的响应对象ctx.res
,以及原生的请求、响应对象都被附加到了封装的请求、响应对象中,即ctx.request.req
和ctx.response.res
。先在各个文件写入最简单的代码,先实现这些对象存储结构。
context.js
:request.js
:response.js
:然后在
application.js
中导入这些模块:接下来我们思考以下几个问题:
context
、request
和response
对象?new Koa
,如何保持各个应用中对于这 3 个模块的独立性?app.use
注册回调函数时,这些回调函数内的上下文都是独立的?koa
通过在构造函数内分别创建三个对应的对象,并将原型分别指向context
、request
和response
,解决前两个问题:因为
http
请求无状态,使用app.use
注册回调函数时,其上下文也要保持独立,所以需要再进行一次类似上面的原型操作:再然后就是将各个原生对象挂在我们自己封装的对象,以下是这一步骤
application.js
完整代码:自定义 request 和 response 扩展
我们上面说到过,
ctx
上挂载的request
和response
是自定义的请求和响应对象的扩展,接下来举个例子说明这两个自定义对象的目的。第一种情况,代理原生请求或响应对象本来就有的能力:
通过
getter/setter
函数对原生的url
进行代理,可方便进行赋值和取值操作。我们在
test.js
中通过以下写法来获取url
,这样写:控制台使用 node 命令重新执行该文件,随后打开
http://localhost:8888/a/b
,在控制台会发现打印了/a/b
,说明我们自定义的request
扩展对象代理url
成功。现在考虑下,为什么在执行
this.req.url
时能够正确访问原生req
对象?因为在context
中我们将原生req
对象挂载到了自定义扩展的request
对象上了,即以下这行代码:于是访问
this.req.url
时,实际上这里的this
就是ctx.request
。第二种情况,新增原生对象上没有的能力:
到目前为止,我们的服务并没有返回任何东西,如果你开着浏览器访问,会一直转圈圈,因为我们实际上并没有返回任何东西,原生的
http
服务通过res.end('xxx')
来返回数据并关闭连接。而现在我们是通过ctx.body
来模拟这个操作。有了
response
模块这部分代码,当我们执行ctx.body = '<h1>Hello World</h1>'
时,相当于给_body
赋值存了起来。诶,不对,给
ctx.body
赋值,关我response
什么事?还记得一开始我们说过,context.js
主要作用是代理request.js
和response.js
中的方法。比如
ctx.body
其实就相当于ctx.response.body
,回到context
模块,以下代码就是实现这种代理的方式:实际上
__defineGetter__
和__defineSetter
一直都是非标准方法,但是其兼容性却特别好。koa2
中使用的delegates
模块也一直是用的这两个非标准方法。或许大家可以考虑下用Object.defineProperty
来实现。回到
application.js
,在handleRequestCallback
函数中添加以下代码:重启服务,再看看浏览器,即可看到正确返回了
Hello World
。中间件机制 - 洋葱模型
目前在我们的测试文件
test.js
中只使用了一次app.use
,注册了一个回调函数,然而在实际应用时,我们必然会使用多次的。现在我们使用
koa2
做个测试,新建一个test-koa.js
写入以下代码:控制台执行
node test-koa.js
后,打开http://localhost:7777
�,再回到控制台会看到打印的顺序如下:在
koa2
中注册函数的第一个参数ctx
大家很熟悉了,第二个参数next
代表下一个要被执行的注册函数。其实上面的代码可以这样来理解:每一次执行
next()
函数就相当于将下一个注册函数执行,也就是说执行下一个中间件函数,这就是所谓的洋葱模型,用一张图来解释:上面的代码中全是同步逻辑,如果我们的中间件函数里有异步逻辑,也就是我们使用
koa2
时经常用到的async/await
,我们考虑下面一段代码会在浏览器显示什么,以及控制台的打印顺序:结果是浏览器访问
http://localhost:7777
显示的是2
,控制台打印的是顺序是:在
koa2
中代表所有中间件函数执行完毕的标志是最外圈的“洋葱皮”被“刀”都切到了,也就是说最外层的代码都被执行完毕了,知道这个我们再来分析上面代码的输出结果。在第一个(也就是最外层)中间件函数中,执行到的
next
中有异步逻辑 但我们没有等待next
执行完毕,只是进入了第二个中间件函数中开始执行代码,所以直接打印出了1 3 2
,至此其实已经判定为中间件函数执行完毕了,开始响应逻辑,所以在浏览器页面上看到的是2
。但后续的代码还在执行,在第二个中间件函数中使用了
await
,于是2000 ms
后继续走后续逻辑,所以在控制台的打印顺序如上。所以咱们在
koa2
的使用中,一定要在next()
前加上await
,不然大概率结果会不如预期,大多数中间件都是有异步逻辑的。实现中间件机制
在
koa2
中是如何实现上述的中间件机制的呢?通过上述代码和结果演示,能够看出每一个中间件函数的执行顺序是和app.use
的使用顺序一致的,这不难想到也许koa2
中使用了一个数组来保存每一个中间件函数,并依次执行。回到我们的
application.js
模块,接下来就是重头戏了,也是koa2
中最核心最精华的部分,我将演示如何一步步实现中间件机制。初始化一个 middlewares 数组
在构造函数
constructor
中把我们原来定义的this.fn
删掉,定义一个数组this.middlewares = []
,其目的是保存所有app.use
注册的中间件函数。添加中间件函数
在
use
函数内部,删除this.fn = fn
,而是将fn
添加至this.middlewares
数组中。新建组合函数 compose
之前只注册一个函数时,我们直接执行
this.fn(ctx)
,但现在我们需要一个新的组合函数compose
来执行所有注册的有异步逻辑的中间件函数,并且返回一个Promise
。koa2
源码中使用了koa-compose
模块,该模块导出的compose
为一个函数,该函数执行后返回一个新的内联函数,我们上述的实现相当于直接把这个内联函数抽出来执行了,相信大家看源码的时候会理解的。实现 compose 逻辑
我们在
compose
内部的代码主要实现以下逻辑:Promise.resolve()
。next
就意味着要执行第二个中间件函数,相当于递归调用。Promise
。于是我们根据上面思路,就可以写出以下代码:
大家思考一下,为什么我在一个中间件函数里执行两次或以上
next
,就会导致报错?原因在于我们执行无论多少次,i + 1
的i
都是同一个值,但是第一次执行dispatch(i + 1)
时,index
已经赋值为i + 1
了,这样第二次执行时,i + 1 <= index
就会成立,反之就代表只执行了一次。就是那么简单的几行代码就实现了
koa2
的核心功能,不过我们只是阅读别人的代码时候觉得简单,真的要自己想出来估计也是需要不少脑细胞的。接下来你可以拿刚才写好的
test-koa
来测试这段逻辑了,别忘了引入的是我们自己的koa
哦~错误捕获与处理
一个优秀的框架或 SDK,良好的错误或异常捕获是很有必要的,不至于因为代码执行错误导致后续逻辑中断,这可以给开发者更多的信息,也能有更多选择,比如降级逻辑。
在
koa2
中某个中间件函数发生错误时,可以通过app.on('error', () => {})
拿到错误信息,这需要 node 的原生模块 events 默认导出的EventEmitter
支持,如果有用过 Vue 的同学,对这个一定很熟悉了。不熟悉的同学可以搜索下发布订阅模式~回到
application.js
,我们引入并继承这个类,构造函数中要加上super()
:在执行
compose
方法的地方,我们已经写了then
,把catch
也补上:在上面代码我们总共做了两件事:
this.on('error', this.onerror)
并创建了一个方法onerror
,该方法用于处理捕获到的错误,并处理后在控制台输出(我这里为了演示简便,只是很简单处理)。const onerror = err => ctx.onerror(err)
,并在catch
中将err
传递给onerror
。上面的两个
onerror
作用是完全不一样的,第一个是用于koa2
内部错误打印,如果用了社区的koa-logger
还能用于收集错误日志。第二个是用于返回给用户的原始错误。但是给用户的错误是通过
ctx.onerror
去做的,所以我们要来到context
模块,为其添加一个onerror
方法:同样地,我为了演示,在该方法只是将错误抛出去,可以看到是通过
this.app.emit
进行抛出的,那么问题来了,context
上面怎么会有一个app
属性,并且它上面还有继承了EventEmitter
才有的方法emit
,不难想到,其实我们只需要在application
模块中createContext
时,将this
赋值给ctx.app
就可以了:接下来新建一个
test-koa-error.js
文件,输入以下故意有错误的代码,执行后看看控制台是不是正确打印了错误:启动服务后,打开
http://localhost:8888
再回到控制台,出现[Outer Error]
和[Inner Error]
,说明我们的错误捕获成功了!当然,这两个提示只是我为了区分才故意这样写的哦~
参考代码
以上就是
koa2
框架实现的基本原理,因为是文章的展现形式,可能做不到每行代码都解释清清楚楚,也不可能每一行代码都演示如何去写,我已经尽量将整段代码切割成一块一块,如果大家还是有不理解的地方,可以参考下本文的所有演示代码:koa2 实现思路源码
结语
写这篇文章的目的一方面在于强制驱动自己去学习了解
koa2
的源码,以便于在使用koa2
进行开发时遇到问题能快速定位问题,也能学习到其封装思路,之后自己写代码时能借鉴其思想;另一方面希望能帮助和我有一样想法的同学建立起源码阅读思路。总而言之,我认为源码的阅读不是为了读而读,而是阅读理解它之后或许能让我们在实践时有指导思路。另外,如果对大家有所帮助,给我的 blog 赏个 star🌟 哦~
The text was updated successfully, but these errors were encountered: