koa和koa-router的使用及源码实现

孟英光
2023-12-01

1. 前言

鉴于之前使用expresskoa的经验,最近想尝试构建出一个koa精简版,利用最少的代码实现koa和koa-router,同时也梳理一下Node.js网络框架开发的核心内容。

实现的源代码将会放在文末,配有详细的注释。

2. 核心设计

2.1 API调用

mini-koa的API设计中,参考koa和koa-routerAPI调用方式。

Node.js的网络框架封装其实并不复杂,其核心点在于http/httpscreateServer方法上,这个方法是http请求的入口。

首先,我们先回顾一下用Node.js来启动一个简单服务。

const http = require('http')
const app = http.createServer((request, response) => {
  response.end('hello Node.js')
})
app.listen(3333, () => {
  console.log('App is listening at port 3333...')
})

2.2 路由原理

既然我们知道Node.js的请求入口在createServer方法上,那么我们可以在这个方法中找出请求的地址,然后根据地址映射出监听函数(通过get/post等方法添加的路由函数)即可。

其中,路由列表的格式设计如下:

// binding的格式
{
'/': [fn1, fn2, ...],
'/user': [fn, ...],
...
}
// fn/fn1/fn2的格式
{
  method: 'get/post/use/all',
  fn: '路由处理函数'
}

3. 难点分析

3.1 next()方法设计

我们知道在koa中是可以添加多个url监听函数的,其中决定是否传递到下一个监听函数的关键在于是否调用了next()函数。如果调用了next()函数则先把路由权转移到下一个监听函数中,处理完毕再返回当前路由函数。

mini-koa中,我把next()方法设计成了一个返回Promise fullfilled的函数(这里简单设计,不考虑next()传参的情况),用户如果调用了该函数,那么就可以根据它的值来决定是否转移路由函数处理权。

判断是否转移路由函数处理权的代码如下:

let isNext = false
const next = () => {
  isNext = true
  return Promise.resolve()
}
await router.fn(ctx, next)
if (isNext) {
  continue
} else {
  // 没有调用next,直接中止请求处理函数
  return
}

3.2 use()方法设计

mini-koa提供use方法,可供扩展日志记录/session/cookie处理等功能。

use方法执行的原理是根据请求地址在执行特定路由函数之前先执行mini-koa调用use监听的函数

所以这里的关键点在于怎么找出use监听的函数列表,假设现有监听情况如下:

app.use('/', fn1)
app.use('/user', fn2)

如果访问的url/user/add,那么fn1和fn2都必须要依次执行。

我采取的做法是先根据/字符来分割请求url,然后循环拼接,查看路由绑定列表(binding)中有没有要use的函数,如果发现有,添加进要use的函数列表中,没有则继续下一次循环。

详细代码如下:

// 默认use函数前缀
let prefix = '/'
// 要预先调用的use函数列表
let useFnList = []

// 分割url,使用use函数
// 比如item为/user/a/b映射成[('user', 'a', 'b')]
const filterUrl = url.split('/').filter(item => item !== '')
// 该reduce的作用是找出本请求要use的函数列表
filterUrl.reduce((cal, item) => {
  prefix = cal
  if (this.binding[prefix] && this.binding[prefix].length) {
    const filters = this.binding[prefix].filter(router => {
      return router.method === 'use'
    })
    useFnList.push(...filters)
  }
  return (
    '/' +
    [cal, item]
      .join('/')
      .split('/')
      .filter(item => item !== '')
      .join('/')
  )
}, prefix)

3.3 ctx.body响应

通过ctx.body = '响应内容'的方式可以响应http请求。它的实现原理是利用了ES6Object.defineProperty函数,通过设置它的setter/getter函数来达到数据追踪的目的。

详细代码如下:

// 追踪ctx.body赋值
Object.defineProperty(ctx, 'body', {
  set(val) {
    // set()里面的this是ctx
    response.end(val)
  },
  get() {
    throw new Error(`ctx.body can't read, only support assign value.`)
  }
})

3.4 子路由mini-koa-router设计

子路由mini-koa-router设计这个比较简单,每个子路由维护一个路由监听列表,然后通过调用mini-koaaddRoutes函数添加到主路由列表上。

mini-koaaddRoutes实现如下:

addRoutes(router) {
  if (!this.binding[router.prefix]) {
    this.binding[router.prefix] = []
  }
  // 路由拷贝
  Object.keys(router.binding).forEach(url => {
    if (!this.binding[url]) {
      this.binding[url] = []
    }
    this.binding[url].push(...router.binding[url])
  })
}

4. 用法

使用示例如下:

// examples_server.js
const { Koa, KoaRouter } = require('../index')
const app = new Koa()
// 路由用法
const userRouter = new KoaRouter({
  prefix: '/user'
})

// 中间件函数
app.use(async (ctx, next) => {
  console.log(`请求url, 请求method: `, ctx.req.url, ctx.req.method)
  await next()
})

// 方法示例
app.get('/get', async ctx => {
  ctx.body = 'hello ,app get'
})

app.post('/post', async ctx => {
  ctx.body = 'hello ,app post'
})

app.all('/all', async ctx => {
  ctx.body = 'hello ,/all 支持所有方法'
})

// 子路由使用示例
userRouter.post('/login', async ctx => {
  ctx.body = 'user login success'
})

userRouter.get('/logout', async ctx => {
  ctx.body = 'user logout success'
})

userRouter.get('/:id', async ctx => {
  ctx.body = '用户id: ' + ctx.params.id
})

// 添加路由
app.addRoutes(userRouter)

// 监听端口
app.listen(3000, () => {
  console.log('> App is listening at port 3000...')
})

5. 总结

本次实现的精简版mini-koa,虽然跟常用的koa框架有很大区别,但是也实现了最基本的API调用和原理。

造轮子是一件难能可贵的事,程序员在学习过程中不应该一直崇尚拿来主义,学习到一定程度后,在自己的个人项目中,可以秉持能造就造的态度,去尝试理解和挖掘源码背后的原理和思想。

当然,通常来说,自己造的轮子本身不具备多大的实用性,没有经历过社区大量的测试和实际应用场景的打磨,但是能加深自己的理解和提高自己的能力也是一件值得坚持的事。

附录:源代码

mini-koa-router.js:

/**
 * koa-router精简版
 * @author Mask
 */
class KoaRouter {
  /**
   * 构造函数
   * @param {object} props 路由参数配置
   */
  constructor(props) {
    // 路由前缀
    this.prefix = props.prefix || '/'
    // 路由监听列表
    this.binding = {}
  }

  /**
   *
   * @param {string} method 请求方法
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  request(method, url, callback) {
    if (typeof url === 'function') {
      // 简单判断没有传入url
      callback = url
      url = '/'
    }

    if (this.prefix) {
      // 添加路由实例有前缀
      url =
        '/' +
        [this.prefix, url]
          .join('/')
          .split('/')
          .filter(item => item)
          .join('/')
    }

    if (!this.binding[url]) {
      this.binding[url] = []
    }

    this.binding[url].push({
      method: method,
      fn: callback
    })
  }

  /**
   * 中间件函数,可用作日志记录等等
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  use(url, callback) {
    this.request('use', url, callback)
  }

  /**
   * 监听所有的请求方法,包括get/post等等
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  all(url, callback) {
    this.request('all', url, callback)
  }

  /**
   * 监听get请求
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  get(url, callback) {
    this.request('get', url, callback)
  }

  /**
   * 监听post请求
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  post(url, callback) {
    this.request('post', url, callback)
  }
}

module.exports = KoaRouter

mini-koa.js:

const http = require('http')

/**
 * 解析url的查询参数,比如/a?name=123&pwd=456 解析成 {name: 123, pwd: 456}
 * @param {string} url 请求路径
 */
const parseUrlParams = url => {
  const query = {}
  const index = url.indexOf('?')
  if (index < 0) {
    return query
  }
  url = url.substring(index + 1)
  url.split('&').forEach(function(item) {
    let obj = item.split('=')
    query[obj[0]] = obj[1] || undefined
  })
  return query
}

/**
 * Koa精简版
 * @author Mask
 */
class Koa {
  /**
   * 构造函数
   */
  constructor() {
    // 路由监听列表
    this.binding = {}
    // 监听实例
    this.httpApp = null
    // 初始化
    this.init()
  }

  /**
   * 初始化
   */
  init() {
    // 这里要绑定this,不然requestServer里面的this是Server实例
    this.httpApp = http.createServer(this._requestServer.bind(this))
  }

  /**
   * http请求函数
   * @param {*} request
   * @param {*} response
   */
  async _requestServer(request, response) {
    // 本次请求的环境
    const ctx = {}
    request.query = {}
    request.params = {}
    ctx.req = request
    ctx.request = request
    ctx.res = response
    ctx.response = response
    ctx.query = request.query
    ctx.params = request.params

    // 设置一些默认响应头
    response.statusCode = 200
    response.setHeader('Content-Type', 'text/plain;charset=utf-8')
    response.setHeader('Access-Control-Allow-Origin', '*')
    response.setHeader(
      'Access-Control-Allow-Methods',
      'PUT,POST,GET,DELETE,OPTIONS'
    )

    // 追踪ctx.body赋值
    Object.defineProperty(ctx, 'body', {
      set(val) {
        // set()里面的this是ctx
        response.end(val)
      },
      get() {
        throw new Error(`ctx.body can't read, only support assign value.`)
      }
    })

    // 解析url,获取查询参数,类似/a?name=123&pwd=456
    const method = request.method
    const rawUrl = request.url
    const resUrl = rawUrl.match(/(\/[^?&=]*)/i)
    let url = rawUrl
    if (resUrl) {
      url = resUrl[1]
    }
    // 解析参数,需要重新指向ctx.query,不然追踪会断掉
    request.query = parseUrlParams(rawUrl)
    ctx.query = request.query

    // 默认use函数前缀
    let prefix = '/'
    // 要预先调用的use函数列表
    let useFnList = []

    // 分割url,使用use函数
    // 比如item为/user/a/b映射成[('user', 'a', 'b')]
    const filterUrl = url.split('/').filter(item => item !== '')
    // 该reduce的作用是找出本请求要use的函数列表
    filterUrl.reduce((cal, item) => {
      prefix = cal
      if (this.binding[prefix] && this.binding[prefix].length) {
        const filters = this.binding[prefix].filter(router => {
          return router.method === 'use'
        })
        useFnList.push(...filters)
      }
      return (
        '/' +
        [cal, item]
          .join('/')
          .split('/')
          .filter(item => item !== '')
          .join('/')
      )
    }, prefix)

    // 1 调用use函数列表,可以做日志记录等等
    if (useFnList.length) {
      for (let i = 0, length = useFnList.length; i < length; i++) {
        let router = useFnList[i]
        let isNext = false
        const next = () => {
          isNext = true
          return Promise.resolve()
        }
        await router.fn(ctx, next)
        if (isNext) {
          continue
        } else {
          // 没有调用next,直接中止请求处理函数
          return
        }
      }
    }

    // 2 遍历特定的路由监听函数
    const routerList = []

    // 2.1 添加具体匹配路由函数
    if (this.binding[url] && this.binding[url].length) {
      routerList.push(...this.binding[url])
    }

    // 2.2 添加模糊路由监听函数,比如请求的url为'/post/123',可以映射到'/post/:id'监听上
    let bindingUrlList = Object.keys(this.binding).map(item => {
      // 比如item为/user/a/b映射成['user', 'a', 'b']
      return item.split('/').filter(i => i !== '')
    })

    // 模糊判断,过滤路由参数长度不同的项
    bindingUrlList = bindingUrlList.filter(item => {
      return item.length === filterUrl.length
    })

    // 具体过滤,存在的路由监听函数是否匹配
    filterUrl.forEach((key, index) => {
      bindingUrlList = bindingUrlList.filter(item => {
        if (item[index].startsWith(':')) {
          // 这一项参数是查询参数(类似:id),挂载到request.params上
          let variableName = item[index].replace(':', '')
          request.params[variableName] = key
          return true
        } else if (item[index] === key) {
          // 值相等,不是查询参数
          return true
        } else {
          // 只有长度一致
          return false
        }
      })
    })

    // 根据过滤后的模糊路由来添加路由监听函数
    bindingUrlList.forEach(item => {
      let url = '/' + item.join('/')
      if (this.binding[url] && this.binding[url].length) {
        routerList.push(...this.binding[url])
      }
      routerList.push(...this.binding[url])
    })

    // 3 执行匹配路由
    if (routerList.length) {
      // 执行
      for (let i = 0, length = routerList.length; i < length; i++) {
        let router = routerList[i]
        if (router.method === method.toLowerCase() || router.method === 'all') {
          // 新的ctx
          let isNext = false
          const next = () => {
            isNext = true
            return Promise.resolve()
          }
          await router.fn(ctx, next)

          // 如果调用了next,则传递到下一个
          if (isNext) {
            continue
          } else {
            // 没有调用next,直接中止请求处理函数
            return
          }
        }
      }
      // 函数没有中断,不支持的方法
      response.statusCode = 404
      ctx.body = `不支持的方法 - ${method}`
    } else {
      // 没有监听
      response.statusCode = 404
      ctx.body = `${url}不存在`
    }
  }

  /**
   *
   * @param {string} method 请求方法
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  request(method, url, callback) {
    if (typeof url === 'function') {
      // 简单判断没有传入url
      callback = url
      url = '/'
    }

    if (!this.binding[url]) {
      this.binding[url] = []
    }

    this.binding[url].push({
      method: method,
      fn: callback
    })
  }

  /**
   * 中间件函数,可用作日志记录等等
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  use(url, callback) {
    this.request('use', url, callback)
  }

  /**
   * 监听所有的请求方法,包括get/post等等
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  all(url, callback) {
    this.request('all', url, callback)
  }

  /**
   * 监听get请求
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  get(url, callback) {
    this.request('get', url, callback)
  }

  /**
   * 监听post请求
   * @param {string} url 请求的路由
   * @param {function} callback 请求回调函数
   */
  post(url, callback) {
    this.request('post', url, callback)
  }

  /**
   * 监听端口
   * @param  {...any} args
   */
  listen(...args) {
    this.httpApp.listen(...args)
  }

  /**
   * 添加子路由
   * @param {MoaRouter} router
   */
  addRoutes(router) {
    if (!this.binding[router.prefix]) {
      this.binding[router.prefix] = []
    }
    // 路由拷贝
    Object.keys(router.binding).forEach(url => {
      if (!this.binding[url]) {
        this.binding[url] = []
      }
      this.binding[url].push(...router.binding[url])
    })
  }
}

module.exports = Koa

作者:mask.qi

 类似资料: