Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Koa源码学习 #35

Open
flytam opened this issue Mar 11, 2023 · 0 comments
Open

Koa源码学习 #35

flytam opened this issue Mar 11, 2023 · 0 comments
Labels
node This issue or pull request already exists

Comments

@flytam
Copy link
Owner

flytam commented Mar 11, 2023

前言

WX20230311-104750@2x

koa是一个非常流行的Node.js http框架。本文我们来学习下它的使用和相关源码

来自官网的介绍:
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序

为什么使用koa

使用koa而不直接使用Node.js的http模块

  1. 高度可定制性:koa中实现了一套中间件机制以及在koa中万物皆中间件,我们通过中间件处理请求和响应并可以按需自由添加和修改中间件,并且koa的中间件生态非常丰富。而使用http需要自己编写全部的请求处理逻辑

  2. 异步编程:koa基于async/await语法,可以让异步编程变得更加简单和优雅。而直接使用http模块,则需要使用回调函数或事件监听的方式进行异步编程,不够直观

  3. 错误处理:koa内置的错误处理机制可以很好的捕获和处理错误,让代码更加健壮和可靠。而使用http模块,则需要自己编写错误处理逻辑,容易出现漏洞

  4. 扩展性:koa内置的扩展机制可以让开发者在不改变核心代码的情况下,轻松地扩展和定制koa的功能。而使用http模块,则需要自己编写全部的扩展逻辑,不够便捷

使用

koa的使用非常简单,引入koa后只需要6行代码即可访问3000端口的http服务返回一个Hello koa

const Koa = require('koa');
const app = new Koa();

app.use(ctx => {
  ctx.body = 'Hello Koa';
});

app.listen(3000);

中间件

koa本身几乎没有封装任何进一步处理http请求的能力,而是实现了一套中间件的机制,所有的逻辑均由相关的中间件进行实现,中间件可以说是koa的灵魂

koa的中间件本质是一个函数,接收一个上下文对象(context)和一个next函数作为参数,然后对请求和响应进行处理,并将控制权传递给下一个中间件。中间件可以实现各种功能,例如路由、请求处理、错误处理等

const myMiddleware = async (ctx, next) => {
  // 处理请求
  // ...
  // 调用下一个中间件
  await next();
  // 处理响应
  // ...
}

例如我们实现一个错误处理中间件,在服务端发生任何错误时给客户端返回一个500的状态码,可以以下实现即可

const errorHandler = async (ctx, next) => {
  try {
    // 处理请求
    // ...
    // 调用下一个中间件
    await next();
    // 处理响应
    // ...
  } catch (err) {
    // 处理错误
    ctx.status = 500;
    ctx.body = err.message;
  }
}

app.use(errorHandler)

以两个最常用的中间件为例

  • koa-router

koa默认也是没有封装对于特定的请求方法进行处理的功能,像很多http中处理路由相关的逻辑则需要引入koa-router 进行使用。koa router提供了基础的路由路径处理、嵌套路由等一些基础路由能力

var Koa = require('koa');
var Router = require('koa-router');

var app = new Koa();
var router = new Router();

router.get('/', (ctx, next) => {
  // ctx.router available
});

app
  .use(router.routes())
  .use(router.allowedMethods());

koa-router的源码就不展开了,原理基本上在中间件中读取req.urlreq.method 和相关req上的一些属性进行分发到相应的路由注册的回调返回中进行处理

  • koa-body

另一个常用的功能就是将请求的请求体数据解析成js对象,方便代码进行消费
对于node原生的http服务,我们需要监听请求对象的dataend事件,在data 事件中接收二进制buffer数据,在end事件中将buffer转成字符串再序列化成js对象

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');

const app = new Koa();
app.use(bodyParser());

app.use(async ctx => {
  // the parsed body will store in ctx.request.body
  // if nothing was parsed, body will be an empty object {}
  ctx.body = ctx.request.body;
});

这样对于这类请求我们通过ctx.request.body就能获取到json请求的数据,无需关心从请求流关心如何获取请求体。koa-body不止处理json类型,它还会对form、text、xml等类型做相应的处理

源码实现

koa的源码非常简洁,一共只有4个文件

application

application.js定义了Koa类,用于创建koa app对象,下面是koa类的构造函数

// ...
const Emitter = require('events')
const compose = require('koa-compose');
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');
// ...

class Koa extends Emitter {
  constructor() {
    super();
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    // constructor中其它的逻辑忽略
  }
  // ...
}

Koa类继承了Emitter类,用于实现事件的发布和订阅。还定义了一些属性,主要包括middlewarecontextrequestresponse。其中,middleware是中间件函数数组,用于存储所有的中间件函数;context是koa的请求上下文对象、request是请求对象实例、response是响应对象实例

koa实例上也暴露了几个对外使用的方法

  • app.listen

上面的使用demo,可以看到调用listen后就是监听指定端口运行起我们的http服务

通过查看app.listen 的实现本质是调用了app.callback获取到回调函数处理逻辑,再传给http.createSerever。所以也等价于以下调用

const http = require('http');
const Koa = require('koa');
const app = new Koa();
http.createServer(app.callback()).listen(3000);
  • app.callback

返回可以直接传递给 http.createServer() 方法的回调函数来处理请求

  callback () {
    const fn = this.compose(this.middleware)

    if (!this.listenerCount('error')) this.on('error', this.onerror)

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      return this.handleRequest(ctx, fn)
    }

    return handleRequest
  }
  
    handleRequest (ctx, fnMiddleware) {
    const res = ctx.res
    res.statusCode = 404
    const onerror = err => ctx.onerror(err)
    const handleResponse = () => respond(ctx)
    onFinished(res, onerror)
    return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  }

主要有以下几个逻辑

  • 判断我们是否有监听错误事件进行处理(this.listenerCount是继承的EventEmiter上用于获取某个事件监听次数的方法),如果没有则使用koa自带的默认错误处理

  • 使用回调入参的request对象和response对象构造请求上下文对象并传递给this.handleRequest函数进行处理

  • handleRequest中,就是调用了被compose完成后的中间件函数,在处理完成后调用respond进行结束整个请求的流程

  • 在koa中我们无需像Node.js中http需要显式调用res.end或者res.pipe进行响应的结束发送,因为在handleResponserespond函数中处理了。它会根据我们在业务逻辑设置的不同的body的类型进行相关调用,例如如果是一个流则调用pipe进行流式返回、特定状态码不返回body、非buffer和string的body序列化成字符串等

  • 洋葱模型

koa的洋葱模型是一种中间件处理机制其核心是将请求和响应对象传递给一系列中间件函数,每个中间件函数都可以对请求和响应进行处理,并将控制权传递给下一个中间件函数,最终将响应返回给客户端。中间件函数在请求处理过程中像是一个个套在一起的“洋葱”,请求从外层中间件函数开始处理,逐层深入,直到最内层中间件函数,然后逐层返回,最终响应从最外层中间件函数返回给客户端

在洋葱模型中,每个中间件函数都是一个异步async函数。在处理请求时,每个中间件函数都接收一个context对象和一个next函数作为参数,context对象包含了请求和响应的信息,next函数可以调用下一个中间件函数

处理顺序如下

  1. 请求从外层中间件函数开始处理,先经过第一个中间件函数

  2. 第一个中间件函数处理请求,然后调用next函数,将控制权传递给下一个中间件函数

  3. 下一个中间件函数也处理请求,然后调用next函数,将控制权传递给下一个中间件函数,直到最内层中间件函数

  4. 最内层中间件函数处理请求完成后逐层返回每个中间件函数在返回时可以对响应进行处理

  5. 最后,响应从最外层中间件函数返回给客户端

洋葱模型的优点是可以将请求和响应的处理逻辑分解成多个模块,每个模块只需关注自己的逻辑,提高了代码的可维护性。由于每个中间件函数都可以对请求和响应进行处理,因此可以实现一些复杂的功能例如身份验证、日志记录、错误处理等

主要是koa-compose包的实现将中间件函数组合在一起,compoose实现代码如下

function compose (middleware) {
  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 {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

// 使用
 const fn = this.compose(this.middleware)

compose函数接收一个中间件函数数组作为参数,返回一个新的中间件。新的中间件函数接收contextnext对应于常规中间件的入参

函数内部实现了dispatch,用于递归调用中间件数组中的每个函数。
dispatch函数接收一个参数i,表示当前调用的中间件函数在数组中的索引。如果i小于等于上一次调用的索引index,则表示next函数被多次调用,koa中间件中next只能被调用一次,调用多次会抛出一个错误。然后,dispatchindex赋值为i,表示当前调用的中间件函数已经被执行。然后dispatch函数会从中间件数组中取出当前索引对应的函数fn,如果当前索引i等于数组长度则说明已经到达中间件函数数组的末尾然后将fn设置为next函数。如果fn不存在则直接返回一个已经resolvePromise。最后dispatch函数通过Promise.resolve调用当前中间件函数,并将dispatch.bind(null, i + 1)作为下一个中间件函数的next参数传入,以便递归调用下一个中间件函数。如果当前中间件函数抛出了一个错误则通过Promise.reject将错误传递给下一个中间件函数

总结原理是通过递归调用中间件函数数组中的每个函数,并将next函数作为参数传入,实现洋葱模型中间件的处理顺序。在递归调用的过程中,如果某个中间件函数抛出了错误则通过Promise.reject将错误逐层传递给下一个中间件函数,直到最终返回错误响应或者成功响应

context

请求上下文对象,对应中间件的ctx入参
context.js文件主要是对外导出了一个对象,以及执行了一系列delegate操作

简单来说就是将对context对象上的操作代理到koa封装的requestresponse对象中去

// proto这里是context
delegate(proto, 'response').method('append').access('body')

这个执行后的结果就是

  • context.append方法调用实际调用的是context.response.append

  • context.body的读写实际调的是context.response.body的读写
    context.response则在下面的createContext时将koaresponse对象设置在context对象中去

application中通过createContext方法构造后传入请求处理回调函数

class Koa extends Emitter {
  constructor() {
    //....
    this.context = Object.create(context);
    //....
  }
  // ...
    callback () {
    const fn = this.compose(this.middleware)

    if (!this.listenerCount('error')) this.on('error', this.onerror)

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      return this.handleRequest(ctx, fn)
    }

    return handleRequest
  }
  
    createContext (req, res) {
    /** @type {Context} */
    const context = Object.create(this.context)
    /** @type {KoaRequest} */
    const request = context.request = Object.create(this.request)
    /** @type {KoaResponse} */
    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
  }
}

主要是将我们koa中常用的几个对象挂载到相应的地方,经过createContext的操作,我们可以得到可以通过以下方式获取相关对象

  • koa app实例

    • app === context.app === context.request.app === context.response.app
  • koa 请求context对象

    • context === context.request.ctx === context.response.ctx
  • koa request对象

    • ctx.request === ctx.response.request
  • koa response对象

    • ctx.response === ctx.request.response
  • 原生req对象

    • context.req === context.request.req === context.response.req
  • 原生res对象

    • context.res === context.response.res === context.request.res

request

koa中的请求对象封装。基本上都是基于Node.js的http请求的request做一些便捷使用的二次封装的属性和方法,并挂载在ctx.request

一个例子就是Node.js 的http server回调函数入参的req对象http.ImcomingMessage 是没有提供便捷的获取query参数信息,它只有一个url属性

而koa的request对象则实现了query的解析、获取、设置等

// request.js
get query () {
    const str = this.querystring
    const c = this._querycache = this._querycache || {}
    return c[str] || (c[str] = qs.parse(str))
  }
  
get querystring () {
    if (!this.req) return ''
    return parse(this.req).query || ''
  }

response

koa中的响应对象封装,基于Node.js的http请求的response做一些封装的属性和方法,挂载在ctx.response

一个比较常用到的就是会有根据我们的ctx.body设置的值(会delegate到ctx.response.body中)帮我们去设置response的Content-Type的值,例如给ctx.body设置一个普通js对象的话,会将Content-Type设置为json类型并将js对象json序列化(序列化逻辑在上面提到的respond函数中)

最近更新

作为一个代码实现非常精简且已经非常稳定的广泛使用的框架,一般来说不会有什么更新了,2.x也已经稳定了很久。但是在1/2却更新了3.0.0-alpha.0版本,翻看更新记录这个大版本目前只更新了一个功能

可以直接使用app.currentContext来获取当前的请求上下文对象,这个功能可以方便不少我们的代码开发

通过上面我们知道,koa的contxt对象是每次请求维度的一个新对象,如果我们想在一些封装的方法中获拿到当前请求的context对象,必须层层传递context对象会比较麻烦

//  fn.js
const fn = (ctx) => {
    console.log(ctx.url)
}
exports.fn = fn

// app.js
const app = new Koa();
app.use(ctx => {
  ctx.body = 'Hello Koa';
  fn(ctx)
}).listen(3000);

而支持了app.currentContext后,我们在任意地方想获取当前的请求上下文对象直接app.currentContext即可,无需再多层透传context对象

//  fn.js
const fn = () => {
    console.log(app.currentContext.url)
}

这个功能的实现利用了Node.js的async_hooks模块提供的AsyncLocalStorageAsyncLocalStorage 是 Node.js 在v14.8.0 版本中引入的一个模块,是官方推荐的在异步代码中管理数据的方式之一,会将我们保存的数据与异步操作所在的上下文关联起来,确保在异步操作中访问到相应正确的数据

AsyncLocalStorage 有两个主要的方法

  • run():用于在异步操作中保存数据。接收一个回调函数作为参数,该回调函数会在异步操作执行期间被调用,并且在该回调函数中保存的数据会与异步操作所在的上下文关联起来

  • getStore():用于在异步操作中获取数据。它会返回与异步操作所在的上下文关联的数据

所以在koa中实现app.currentContext功能主要就是以下代码

// application.js
class Application extends Emitter {
   constructor (options) {
    //....
    if (options.asyncLocalStorage) {
      const { AsyncLocalStorage } = require('async_hooks')
      this.ctxStorage = new AsyncLocalStorage()
      this.use(this.createAsyncCtxStorageMiddleware())
        }
    }
   // ...
  createAsyncCtxStorageMiddleware () {
    const app = this
    return async function asyncCtxStorage (ctx, next) {
      await app.ctxStorage.run(ctx, async () => {
        return await next()
      })
    }
  }
  // ....
   get currentContext () {
    if (this.ctxStorage) return this.ctxStorage.getStore()
  }
}
  1. 如果初始化时配置了option.asyncLocalStorage,就注册一个放在第一位的koa中间件

  2. 在请求进入中间件时会执行ctxStorage.run 存入当前的context对象并马上在回调函数中执行next(即请求后续所有的操作)

  3. 在后续获取即可通过getStore()获取到当前请求的context对象

总结

通过本文的学习我们了解到了koa的一些使用和实现,koa的源码是非常精简的没有太多耦合功能,但是设计了巧妙的中间件机制设计来方便让我们开发各种功能

@flytam flytam added the node This issue or pull request already exists label Mar 11, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
node This issue or pull request already exists
Projects
None yet
Development

No branches or pull requests

1 participant