作者 rocYoung

Koa是一个基于Node.js的Web开发框架,特点是小而精,对比大而全的Express(编者按:此处是相对来说,国内当然是有Egg.js和ThinkJS),两者虽然由同一团队开发,但各有其更适合的应用场景:Express适合开发较大的企业级应用,而Koa致力于成为Web开发中的基石,例如Egg.js就是基于Koa开发的。

关于两个框架的区别和联系,后期我会再写一篇Express源码解析,这里不赘述。本文的主要目的如下:

Koa官网上说:“Koa提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序”。这套优雅的方法是什么?是如何实现的?让我们一探究竟,并手写源码。

过去我不了解太阳,那时我过的是冬天——聂鲁达

傻瓜式用法

Koa的用法可以说非常傻瓜,我们快速过一下:

首先映入眼帘的不是假山,是hello world

const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {ctx.body = 'Hello World';
});
app.listen(3000);

不用框架时的写法

let http = require('http')
let server = http.createServer((req, res) => {res.end('hello world')
})
server.listen(4000)

对比发现,相对原生,Koa多了两个实例上的use、listen方法,和use回调中的ctx、next两个参数。这四个不同,几乎就是Koa的全部了,也是这四个不同让Koa如此强大。

listen

简单!http的语法糖,实际上还是用了http.createServer(),然后监听了一个端口。

ctx

比较简单!利用 上下文(context) 机制,将原来的req,res对象合二为一,并进行了大量拓展,使开发者可以方便的使用更多属性和方法,大大减少了处理字符串、提取信息的时间,免去了许多引入第三方包的过程。(例如ctx.query、ctx.path等)

use

重点!Koa的核心 —— 中间件(middleware)。解决了异步编程中回调地狱的问题,基于Promise,利用 洋葱模型 思想,使嵌套的、纠缠不清的代码变得清晰、明确,并且可拓展,可定制,借助许多第三方中间件,可以使精简的koa更加全能(例如koa-router,实现了路由)。其原理主要是一个极其精妙的 compose 函数。在使用时,用 next() 方法,从上一个中间件跳到下一个中间件。

注:以上加粗部分,下面都有详细介绍。

源码

Koa有多简单?简单到只有四个文件,算上大量的空行和注释,加起来不到1800行代码(有用的也就几百行)。

github.com/koajs/koa/t…(https://github.com/koajs/koa/tree/master/lib)

所以,学习Koa源码并不是一个痛苦的过程。豪不夸张的说,搞定这四个文件,手写下面的100多行代码,你就能完全理解Koa。为了防止大段代码的出现,我会讲的很详细。

准备工作

模仿官方,我们建立一个koa文件夹,并创建四个文件:application.js,context.js,request.js,response.js。 通过查看package.json可以发现,application.js为入口文件。

context.js是上下文对象相关,request.js是请求对象相关,response.js是响应对象相关。

  • 首先,梳理一下思路,原理无非就是use的时候拿到一个回调函数,listen的时候执行这个函数。

  • 此外,use回调函数的参数ctx拓展了很多功能,这个ctx其实就是原生的req、res经过一系列处理产生的。

  • 其实,第一句不准确,use可以多次,所以是多个回调函数,用户第二个参数next()跳到下一个,把多个use的回调函数按照规则顺序执行。

  • 那么,看起来就很简单了,难点只有两个:一个是如何将原生req和res加工成ctx,另一个是如何实现中间件。

  • 第一个,ctx其实就是一个上下文对象,request和response两个文件用来拓展属性,context文件实现代理,我们会手写相关源码。

  • 第二个,源码中的中间件由一个中间件执行模块koa-compose实现,这里我们会手写一个。

application.js

结合上面hello world,可以明确,Koa是一个类,实例上主要两个方法,use和listen。

上面说过,listen是http的语法糖,所以要引入http模块。

Koa有一套错误处理机制,需要监听实例的error事件。所以要引入events模块继承EventEmitter。再引入另外三个自定义模块。

let http = require('http')
let EventEmitter = require('events')
let context = require('./context')
let request = require('./request')
let response = require('./response')
class Koa extends EventEmitter {constructor () {super()}use () {}listen () {}
}
module.exports = Koa

这三个模块,其实都是一个对象,为了代码能跑通,这里先简单导出一下。

context.js

let proto = {} // proto同源码定义的变量名
module.exports = proto

request.js

let request = {}
module.exports = request

response.js

let request = {}
module.exports = request

开始写Koa类里面的代码,先实现创建服务的功能:1、listen方法创建一个http服务并监听一个端口。2、use方法把回调传入。

class Koa extends EventEmitter {constructor () {super()this.fn}use (fn) {this.fn = fn // 用户使用use方法时,回调赋给this.fn}listen (...args) {let server = http.createServer(this.fn) // 放入回调server.listen(...args) // 因为listen方法可能有多参数,所以这里直接解构所有参数就可以了}
}

这样就可以启动一个服务了,测试一下:

let Koa = require('./application')
let app = new Koa()
app.use((req, res) => { // 还没写中间件,所以这里还不是ctx和nextres.end('hello world')
})
app.listen(3000)

下面先解决ctx,ctx是一个上下文对象,里面绑定了很多请求和相应相关的数据和方法,例如ctx.path、ctx.query、ctx.body()等等等等,极大的为开发提供了便利。

思路是这样的:用户调用use方法时,把这个回调fn存起来,创建一个createContext函数用来创建上下文,创建一个handleRequest函数用来处理请求,用户listen时将handleRequest放进createServer回调中,在函数内调用fn并将上下文对象传入,用户就得到了ctx。

class Koa extends EventEmitter {constructor () {super()this.fnthis.context = context // 将三个模块保存,全局的放到实例上this.request = requestthis.response = response}use (fn) {this.fn = fn}createContext(req, res){ // 这是核心,创建ctx// 使用Object.create方法是为了继承this.context但在增加属性时不影响原对象const ctx = Object.create(this.context)const request = ctx.request = Object.create(this.request)const response = ctx.response = Object.create(this.response)// 请仔细阅读以下眼花缭乱的操作,后面是有用的ctx.req = request.req = response.req = reqctx.res = request.res = response.res = resrequest.ctx = response.ctx = ctxrequest.response = responseresponse.request = requestreturn ctx}handleRequest(req,res){ // 创建一个处理请求的函数let ctx = this.createContext(req, res) // 创建ctxthis.fn(ctx) // 调用用户给的回调,把ctx还给用户使用。res.end(ctx.body) // ctx.body用来输出到页面,后面会说如何绑定数据到ctx.body}listen (...args) {let server = http.createServer(this.handleRequest.bind(this))// 这里使用bind调用,以防this丢失server.listen(...args)}
}

如果不理解Object.create可以看这个例子:

let o1 = {a: 'hello'}
let o2 = Object.create(o1)
o2.b = 'world'
console.log('o1:', o1.b) // 创建出的对象不会影响原对象
console.log('o2:', o2.a) // 创建出的对象会继承原对象的属性

o1: undefined
o2: hello


经过上面的操作,用户在ctx上可以用各种姿势取到想要的值。

例如url,可以用ctx.req.url、ctx.request.req.url、ctx.response.req.url取到。

app.use((ctx) => {console.log(ctx.req.url)console.log(ctx.request.req.url)console.log(ctx.response.req.url)console.log(ctx.request.url)console.log(ctx.request.path)console.log(ctx.url)console.log(ctx.path)
})

访问localhost:3000/abc

/abc
/abc
/abc
/undefined
/undefined
/undefined
/undefined

姿势多,不一定爽,要想爽,我们希望能实现以下两点:

  • 从自定义的request上取值、拓展除了原生属性外的更多属性,例如query path等。

  • 能够直接通过ctx.url的方式取值,上面都不够方便。

1 修改request

request.js

let url = require('url')
let request = {get url() { // 这样就可以用ctx.request.url上取值了,不用通过原生的reqreturn this.req.url},get path() {return url.parse(this.req.url).pathname},get query() {return url.parse(this.req.url).query}// 。。。。。。
}
module.exports = request

非常简单,使用对象get访问器返回一个处理过的数据就可以将数据绑定到request上了,这里的问题是如何拿到数据,由于前面ctx.request这一步,所以this就是ctx,那this.req就是原生的req,再利用一些第三方模块对req进行处理就可以了,源码上拓展了非常多,这里只举例几个,看懂原理即可。

访问localhost:3000/abc?id=1

/abc?id=1
/abc?id=1
/abc?id=1
/abc?id=1
/abc
undefined
undefined

2 接下来要实现ctx直接取值,这里是通过一个代理来实现的

context.js

let proto = {
}
function defineGetter(prop, name){ // 创建一个defineGetter函数,参数分别是要代理的对象和对象上的属性proto.__defineGetter__(name, function(){ // 每个对象都有一个__defineGetter__方法,可以用这个方法实现代理,下面详解return this[prop][name] // 这里的this是ctx(原因下面解释),所以ctx.url得到的就是this.request.url})
}
defineGetter('request', 'url')
defineGetter('request', 'path')
// .......
module.exports = proto

访问localhost:3000/abc?id=1

/abc?id=1
/abc?id=1
/abc?id=1
/abc?id=1
/abc
/abc?id=1
/abc

__defineGetter__方法可以将一个函数绑定在当前对象的指定属性上,当那个属性的值被读取时,你所绑定的函数就会被调用,第一个参数是属性,第二个是函数,由于ctx继承了proto,所以当ctx.url时,触发了__defineGetter__方法,所以这里的this就是ctx。这样,当调用defineGetter方法,就可以将参数一的参数二属性代理到ctx上了。

有个问题,要代理多少个属性就要调用多少遍defineGetter函数么?是的,如果想优雅一点,可以模仿官方源码,提出一个delegates模块,批量代理(其实也没优雅到哪去),这里为了方便展示,还是看懂即可吧。

3 修改response。根据koa的api,输出数据到页面不是res.end('xx')也不是res.send('xx'),而是ctx.body = 'xx'。我们要实现设置ctx.body,还要实现获取ctx.body。

response.js

let response = {get body(){return this._body // get时返回出去},set body(value){this.res.statusCode = 200 // 只要设置了body,就应该把状态码设置为200this._body = value // set时先保存下来}
}
module.exports = response

这样得到的是ctx.response.body,并不是ctx.body,同样,通过context代理一下

修改context

let proto = {
}
function defineGetter (prop, name) {proto.__defineGetter__(name, function(){return this[prop][name]})
}
function defineSetter (prop, name) {proto.__defineSetter__(name, function(val){ // 用__defineSetter__方法设置值this[prop][name] = val})
}
defineGetter('request', 'url')
defineGetter('request', 'path')
defineGetter('response', 'body') // 同样代理response的body属性
defineSetter('response', 'body') // 同理
module.exports = proto

测试一下

app.use((ctx) => {ctx.body = 'hello world'console.log(ctx.body)
})

访问localhost:3000

node控制台输出:

hello world

网页显示:hello world

接下来解决一下body的问题,上面说了,一旦给body设置值,状态码就改成200,那么没设置值就应该是404。还有,用户不光会输出字符串,还有可能是文件、页面、json等,这里都要处理,所以改一下handleRequest函数:

let Stream = require('stream') // 引入stream
handleRequest(req,res){res.statusCode = 404 // 默认404let ctx = this.createContext(req, res)this.fn(ctx)if(typeof ctx.body == 'object'){ // 如果是个对象,按json形式输出res.setHeader('Content-Type', 'application/json;charset=utf8')res.end(JSON.stringify(ctx.body))} else if (ctx.body instanceof Stream){ // 如果是流ctx.body.pipe(res)}else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) { // 如果是字符串或bufferres.setHeader('Content-Type', 'text/htmlcharset=utf8')res.end(ctx.body)} else {res.end('Not found')}
}

这样上下文相关就实现了,接下来看重中之重:中间件

现在只能use一次,我们要实现use多次,并可以在use的回调函数中使用next方法跳到下一个中间件,在此之前,我们先了解一个概念:“洋葱模型”。

当我们多次使用use时

app.use((crx, next) => {console.log(1)next()console.log(2)})app.use((crx, next) => {console.log(3)next()console.log(4)})app.use((crx, next) => {console.log(5)next()console.log(6)})

它的执行顺序是这样的:

1
3
5
6
4
2

next方法会调用下一个use,next下面的代码会在下一个use执行完再执行,我们可以把上面的代码想象成这样:

app.use((ctx, next) => {console.log(1)// next() 被替换成下一个use里的代码console.log(3)// next() 又被替换成下一个use里的代码console.log(5)// next() 没有下一个use了,所以这个无效console.log(6)console.log(4)console.log(2)
})

这样的话,理所应当输出135642

这就是洋葱模型了,通过next把执行权交给下一个中间件。

这样,开发者手中的请求数据会像仪仗队一样,乖乖的经过每一层中间件的检阅,最后响应给用户。

既应付了复杂的操作,又避免了混乱的嵌套。

除此之外,koa的中间件还支持异步,可以使用async/await

app.use(async (ctx, next) => {console.log(1)await next()console.log(2)
})
app.use(async (ctx, next) => {console.log(3)let p = new Promise((resolve, roject) => {setTimeout(() => {console.log('3.5')resolve()}, 1000)})await p.then()await next()console.log(4)ctx.body = 'hello world'
})

1
3
// 一秒后
3.5
4
2

async函数返回的是一个promise,当上一个use的next前加上await关键字,会等待下一个use的回调resolve了再继续执行代码。

所有现在要做的事有两步:

第一步,让多个use的回调按照顺序排列成串。

这里用到了数组和递归,每次use将当前函数存到一个数组中,最后按顺序执行。执行这一步用到一个compose函数,这个函数是重中之重。

constructor () {super()// this.fn 改成:this.middlewares = [] // 需要一个数组将每个中间件按顺序存放起来this.context = contextthis.request = requestthis.response = response
}
use (fn) {// this.fn = fn 改成:this.middlewares.push(fn) // 每次use,把当前回调函数存进数组
}
compose(middlewares, ctx){ // 简化版的compose,接收中间件数组、ctx对象作为参数function dispatch(index){ // 利用递归函数将各中间件串联起来依次调用if(index === middlewares.length) return // 最后一次next不能执行,不然会报错let middleware = middlewares[index] // 取当前应该被调用的函数middleware(ctx, () => dispatch(index + 1)) // 调用并传入ctx和下一个将被调用的函数,用户next()时执行该函数}dispatch(0)
}
handleRequest(req,res){res.statusCode = 404let ctx = this.createContext(req, res)// this.fn(ctx) 改成:this.compose(this.middlewares, ctx) // 调用compose,传入参数if(typeof ctx.body == 'object'){res.setHeader('Content-Type', 'application/json;charset=utf8')res.end(JSON.stringify(ctx.body))} else if (ctx.body instanceof Stream){ctx.body.pipe(res)}else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {res.setHeader('Content-Type', 'text/htmlcharset=utf8')res.end(ctx.body)} else {res.end('Not found')}
}

再次测试上面打印123456的例子,可以正确的得到135642

第二步,把每个回调包装成Promise以实现异步。

最后一步,用Promise.resolve将每个回调包装成Promise,并在调用时then,不懂Promise的可以去看我的另一篇文章[juejin.im/post/5ab20c…]

compose(middlewares, ctx){function dispatch(index){if(index === middlewares.length) return Promise.resolve() // 若最后一个中间件,返回一个resolve的promiselet middleware = middlewares[index]return Promise.resolve(middleware(ctx, () => dispatch(index + 1))) // 用Promise.resolve把中间件包起来}return dispatch(0)
}
handleRequest(req,res){res.statusCode = 404let ctx = this.createContext(req, res)let fn = this.compose(this.middlewares, ctx)fn.then(() => { // then了之后再进行判断if(typeof ctx.body == 'object'){res.setHeader('Content-Type', 'application/json;charset=utf8')res.end(JSON.stringify(ctx.body))} else if (ctx.body instanceof Stream){ctx.body.pipe(res)}else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {res.setHeader('Content-Type', 'text/htmlcharset=utf8')res.end(ctx.body)} else {res.end('Not found')}}).catch(err => { // 监控错误发射error,用于app.on('error', (err) =>{})this.emit('error', err)res.statusCode = 500res.end('server error')})
}

完整application代码

let http = require('http')
let EventEmitter = require('events')
let context = require('./context')
let request = require('./request')
let response = require('./response')
let Stream = require('stream')
class Koa extends EventEmitter {
constructor () {super()this.middlewares = []this.context = contextthis.request = requestthis.response = response
}
use (fn) {this.middlewares.push(fn)
}
createContext(req, res){const ctx = Object.create(this.context)const request = ctx.request = Object.create(this.request)const response = ctx.response = Object.create(this.response)ctx.req = request.req = response.req = reqctx.res = request.res = response.res = resrequest.ctx = response.ctx = ctxrequest.response = responseresponse.request = requestreturn ctx
}
compose(middlewares, ctx){function dispatch (index) {if (index === middlewares.length) return Promise.resolve()let middleware = middlewares[index]return Promise.resolve(middleware(ctx, () => dispatch(index + 1)))}return dispatch(0)
}
handleRequest(req,res){res.statusCode = 404let ctx = this.createContext(req, res)let fn = this.compose(this.middlewares, ctx)fn.then(() => {if (typeof ctx.body == 'object') {res.setHeader('Content-Type', 'application/json;charset=utf8')res.end(JSON.stringify(ctx.body))} else if (ctx.body instanceof Stream) {ctx.body.pipe(res)} else if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {res.setHeader('Content-Type', 'text/htmlcharset=utf8')res.end(ctx.body)} else {res.end('Not found')}}).catch(err => {this.emit('error', err)res.statusCode = 500res.end('server error')})
}
listen (...args) {let server = http.createServer(this.handleRequest.bind(this))server.listen(...args)}
}
module.exports = Koa

总结

这样就完成了全部核心功能的编写,通过本文你就可以足够了解Koa了,如果对你有帮助,不妨点个赞。

❤️爱心三连击

1.看到这里了就点个在看支持下吧,你的在看是我创作的动力。

2.关注公众号程序员成长指北「带你一起学Node」

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信【ikoala520】,拉你进技术交流群一起学习。

“在看转发”是最大的支持

Node进阶——之事无巨细手写Koa源码相关推荐

  1. node进阶——之事无巨细手写koa源码(转)

    https://juejin.im/post/5ba48fc4e51d450e704277fa koa是一个基于nodejs的web开发框架,特点是小而精,对比大而全的express,两者虽然由同一团 ...

  2. Node进阶—事无巨细手写Koa源码

    作者 rocYoung Koa是一个基于Node.js的Web开发框架,特点是小而精,对比大而全的Express(编者按:此处是相对来说,国内当然是有Egg.js和ThinkJS),两者虽然由同一团队 ...

  3. java 手写签名,signature java html5+ 手写签名 源码 Develop 238万源代码下载- www.pudn.com...

    文件名称: signature下载 收藏√  [ 5  4  3  2  1 ] 开发工具: Java 文件大小: 491 KB 上传时间: 2013-08-03 下载次数: 17 提 供 者: 孙晨 ...

  4. 【手写 Promise 源码】第八篇 - 完善 Promise 并通过 promise-aplus-tests 测试

    一,前言 上一篇,实现 Promise 对返回值 x 各种情况的分析和处理,主要涉及以下几个点: 回顾了相关的 Promise A+ 规范内容: 根据 Promise A+ 规范描述和要求,实现了核心 ...

  5. 【Java进阶营】膜拜 13万字 腾讯高工手写JDK源码笔记带你飙向实战

    灵魂一问,我们为什么要学习JDK源码? 当然不是为了装,毕竟谁没事找事虐自己 - 1.面试跑不掉.现在只要面试Java相关的岗位,肯定或多或少会会涉及JDK源码相关的问题. 2.弄懂原理才不慌.我们作 ...

  6. 跨年巨作 13万字 腾讯高工手写JDK源码笔记 带你飙向实战

    灵魂一问,我们为什么要学习JDK源码? 当然不是为了装,毕竟谁没事找事虐自己 ... 1.面试跑不掉.现在只要面试Java相关的岗位,肯定或多或少会会涉及JDK源码相关的问题. 2.弄懂原理才不慌.我 ...

  7. 手写Mybatis源码(原来真的很简单!!!)

    目录 一.JDBC操作数据库_问题分析 二.自定义持久层框架_思路分析 三.自定义框架_编码 1.加载配置文件 2.创建两个配置类对象 3.解析配置文件,填充配置类对象 4.创建SqlSessionF ...

  8. react学习笔记 react-router-dom react-redux基础使用及手写基础源码 组件反射 react原理

    vdom diff 高效的diff算法 新老vdom树比较 更新只需要更新节点 数据变化检测 batch dom读写 组件多重继承 //parent components export default ...

  9. 【手写 Vuex 源码】第五篇 - Vuex 中 Mutations 和 Actions 的实现

    一,前言 上一篇,主要介绍了 Vuex 中 getters 的实现,主要涉及以下几个点: 将选项中的 getters 方法,保存到 store 实例中的 getters 对象中: 借助 Vue 原生 ...

最新文章

  1. 哪个厂家的监控平台用的云服务器_哪个品牌的云服务器最好用?
  2. 使用lvs搭建负载均衡集群
  3. Application Constants
  4. IBM打造云访问量子计算机 规模仅相当于D-Wave系统的四百分之一
  5. 在本地进行开发工作置chrome谷歌浏览器解决跨域问题
  6. 持续集成[代码流水线管理及Jenkins和gitlab集成]-自动化部署05
  7. vue路由加载页面时,数据返回慢的问题
  8. Windows Phone 7 MVVM模式的学习笔记
  9. hive replace_Hive新增字段(column)后,旧分区无法更新数据问题
  10. go有没有php的array,实现类似php的array_column方法
  11. 盛世zeepower远程距离隔空无线充投放商用 低频磁共振无线充电技术——充电有效距离 20-45mm
  12. 大数据之 Kafka API 从入门到放弃 (第四章)
  13. 介绍-Linux capability机制
  14. wannacry作者捉到了吗_WannaCry爆发的根源原来是它?
  15. Arduino平衡小车
  16. wordpress主题_这些顶级WordPress主题可以使2018年成为有史以来最好的一年
  17. JAVA打印300以内的质数
  18. 器件选型 贴片还是直插好?
  19. review设备管理
  20. JSP实用教程第二章-JSP语法

热门文章

  1. 使用python将图片格式转换为ico格式
  2. 【白板动画制作软件】万彩手影大师教程 | 视频比例
  3. 支持通话/音量加减/接听功能TypeC线控耳机方案开发
  4. steam linux 64位,Valve可能会很快为Linux发布原生64位Steam客户端
  5. 红绿灯的html代码,简易红绿灯的仿真设计与单片机源代码(注释很详细)
  6. Ambari+HDP+HDP-UTILS下载地址大全
  7. 路由器WAN口和LAN口的IP地址的区别【转】
  8. 2021-01-05云盒子校园云盘,东南大学、清华美院、清华设计院亲测:好用!
  9. linux socket_stream,Linux socket类,支持AF_INET/AF_UNIX STREAM/DGRAM
  10. 国外插画等形式美术网址