Express与Koa的比较及源码解读

2022/11/5 Node

# 前瞻

Express框架和Koa框架都出自于同一个团队,可以说Koa框架是Express框架的衍生但有别于Express框架,因为Koa框架的核心代码仅有1600+行,且由TJ大佬在维护。从架构设计上来说,Express是完整和强大的,在其中帮助我们内置了很多好用的功能;而Koa则是简洁和自由的,毕竟核心代码只有1600+行,完全称得上轻量级,它只包含最核心的功能,并不会对我们使用其他中间件进行任何的限制。两个框架的核心其实都是中间件,但两者的中间件执行机制是不同的,特别是针对某个中间件包含异步操作时,接下来会详细说说~

# Express与Koa的比较

# 创建服务器

// Express框架
const express = require('express')
const app = express()
app.listen(1234, (req, res, next) => {
  console.log('Express server has running at http://localhost:1234')
})

// Koa框架
const Koa = require('koa')
const app = new Koa()
app.listen(1235, (ctx, next) => {
  console.log('Koa server has running at http://localhost:1235')
})

# 静态资源服务器

  • Express框架

    Express内置了static方法实现托管静态资源

    // 通过添加路径前缀,统一访问public目录中的静态文件
    app.use('/public', express.static('public'))
    
  • Koa框架

    Koa实现托管静态资源需使用第三方库:koa-static

    const Koa = require('koa')
    const static = require('koa-static')
    const app = new Koa()
    
    // 处理静态资源
    app.use(static('./build'))
    

# 创建中间件

中间件的本质就是一个回调函数

  • Express框架

    • 请求对象(request对象)

    • 响应对象(response对象)

    • next函数(用于执行下一个中间件的函数)

      app.use((req, res, next) => {
        console.log('这是Express中的中间件~')
      })
      
  • Koa框架

    • 上下文对象

      • Koa将请求对象和响应对象封装在了上下文对象中了
      • 获取请求对象:ctx.request
      • 获取响应对象:ctx.response
    • next函数

      • 在Koa中,next本质上是dispatch函数(源码中体现),其作用与Express框架中的next函数类似
      app.use((ctx, next) => {
        console.log('这是Koa中的中间件~')
      })
      
  • Koa没有提供methods方式来注册中间件,也没有提供path中间件来匹配路径,同样也没有连续注册中间件的方式;而Express拥有这些方式

    // Express所独有的特点
    // methods方式注册中间件
    app.get('url', (req, res, next) => {
      res.end('methods方式注册中间件')
    })
    
    // path中间件匹配路径
    app.post('/user', (req, res, next) => {
      res.end('path中间件匹配路径')
    })
    
    // 连续注册中间件
    app.get("/home", (req, res, next) => {
      console.log("home path and method middleware 01")
      next()
    }, (req, res, next) => {
      console.log("home path and method middleware 02")
      next()
    }, (req, res, next) => {
      console.log("home path and method middleware 03")
      next()
    }, (req, res, next) => {
      console.log("home path and method middleware 04")
      res.end("home middleware end")
    })
    

# 路由

  • Express框架

    Express可以自动处理路径和method的匹配问题,如果两者同时匹配成功,则Express会将这次请求,转交给对应的中间件处理

    // 匹配GET请求,且请求url为 /
    app.get('/', (req, res, next) => {
      res.end('Hello Express~')
    })
    

    模块化路由

    const express = require('express')
    const userRouter = express.Router()
    
    userRouter.get('/', (req, res, next) => {
      res.end('获取用户列表成功~')
    })
    
    userRouter.post('/', (req, res, next) => {
      res.end('创建用户成功~')
    })
    
    userRouter.delete('/', (req, res, next) => {
      res.end('删除用户成功~')
    })
    
    app.use('/users', userRouter)
    
  • Koa框架

    Koa不能自动处理路径和method的匹配问题,但可以根据request自己来判断或使用第三方路由中间件(koa-router)

    • 根据request自己来判断

      app.use((ctx, next) => {
        if (ctx.request.url === '/users') {
          if (ctx.response.method === 'POST') {
            ctx.response.body = 'Create User Success'
          } else {
            ctx.response.body = 'Users List'
          }
        } else {
          ctx.response.body = 'Other Request'
        }
      })
      
    • 使用第三方路由中间件

      模块化路由

      const Router = require('koa-router')
      // 创建路由实例,定义当前路由统一前缀
      const router = new Router({ prefix: '/users' })
      
      router.put('/', (ctx, next) => {
        ctx.response.body = 'put request'
      })
      
      module.exports = router
      
      // 主文件中
      const userRouter = require('./router/user')
      app.use(userRouter.routes())
      app.use(userRouter.allowedMethods())
      

      注意:allowedMethods方法用于判断一个method是否支持,如果我们请求一些未实现的请求,就会自动报错:Method Not Allowed

# 参数解析

  • Express框架

    • 获取URL中携带的查询参数

      请求地址:http://localhost:8000/user?name=zs&age=20

      app.get('/user', (req, res, next) => {
        // req.query 默认是一个空对象
        console.log(req.query)
      })
      
    • 获取URL中的动态参数

      请求地址:http://localhost:8000/user/216

      app.get('/user/:id', (req, res, next) => {
        // req.params 默认是一个空对象,里面存放着通过冒号动态匹配到的参数值
        console.log(req.params.id)
      })
      
    • 解析JSON格式的数据

      内置中间件 express.json()

      app.use(express.json())
      
      app.use((req, res, next) => {
        if (req.headers['content-type'] === 'application/json') {
          req.on('data', data => {
            const info = JSON.parse(data.toString())
            // 通过req.body将数据传递给下一个中间件
            req.body = info
          })
          req.on('end', () => {
            next()
          })
        } else {
          next()
        }
      })
      
    • 解析URL-encoded格式的数据

      • true使用第三方库(qs)进行解析
      • false使用Node内置模块(querystring)进行解析
      // 配置解析 application/x-www-form-urlencoded 格式数据的内置中间件
      // 解析urlencoded格式的数据时,需要添加extended属性表明使用哪个模块进行解析
      app.use(express.urlencoded({ extended: true }))
      
    • 解析form-data格式的数据

      需要用到multer这个第三方库

      const multer = require('multer')
      
      =========== 解析非文件类型 ===========
      const upload = multer()
      // any()解析非文件类型数据
      app.use(upload.any())
      
      =========== 解析文件类型 ===========
      const upload = multer({
        // 文件存放位置
        dest: './uploads/'
      })
      // upload.single('key') -> 上传单个文件,并将数据的key传递给single函数
      app.post('/upload', upload.single('file'), (req, res, next) => {
        res.end("文件上传成功~")
      })
      
      ======== 使用diskStorage自定义文件名 ========
      const path = require('path')
      const storage = multer.diskStorage({
        destination: (req, res, cb) => {
          cb(null, './uploads/')
        },
        filename: (req, res, cb) => {
          cb(null, Date.now() + path.extname(file.originalname))
        }
      })
      const upload = multer({
        storage
      })
      app.post('/upload', upload.single('file'), (req, res, next) => {
        res.end("文件上传成功~")
      })
      
  • Koa框架

    • 获取URL中携带的查询参数

      请求地址:http://locahost:8000/login?username=licodeao&password=123

      app.use((ctx, next) => {
        console.log(ctx.request.query)
        ctx.body = "Hello Koa"
      })
      
    • 获取URL中的动态参数

      请求地址:http://localhost:8000/users/123

      const userRouter = new Router({ prefix: '/users' })
      
      // 获取params,使用路由可以自动解析
      userRouter.get("/:id", (ctx, next) => {
        console.log(ctx.params.id)
        ctx.body = "Hello Koa"
      })
      
    • 解析JSON格式的数据

      需要使用第三方库 koa-bodyparser

      请求地址:http://localhost:8000/login

      // body是json格式
      {
        "username": "licodeao",
        "password": "123"
      }
      
      const bodyParser = require('koa-bodyparser')
      
      app.use(bodyParser())
      app.use((ctx, next) => {
        // 解析后的数据被放在了request中
        console.log(ctx.request.body)
        ctx.body = "Hello koa"
      })
      
    • 解析URL-encoded格式的数据

      请求地址:http://localhost:8000/login

      body是x-www-form-urlencoded格式

      获取数据和json一样,安装koa-bodyparser

    • 解析form-data格式的数据

      需要使用koa-multer第三方库

      const multer = require('koa-multer')
      const upload = multer()
      app.use(upload.any())
      
      app.use((ctx, next) => {
        // 注意解析的数据被放在了req中,而不是request(与json不一样)!!!
        console.log(ctx.req.body)
        ctx.body = "Hello Koa"
      })
      
    • 文件上传

      const Koa = require('Koa')
      const path = require('path')
      const multer = require('koa-multer')
      const Router = require('koa-router')
      
      const storage = multer.diskStorage({
        destination: (req, file, cb) => {
          cb(null, "./uploads/")
        },
        filename: (req, file, cb) => {
          cb(null, Date.now() + path.extname(file.originalname))
        }
      })
      
      const upload = multer({
        storage
      })
      
      const fileRouter = new Router({ prefix: '/upload'});
      fileRouter.post('/', upload.single('avatar'), (ctx, next) => {
         // 解析数据在req中
         console.log(ctx.req.file) 
         ctx.response.body = "上传成功~"
      })
      

# 错误处理

  • Express框架

    // 错误级别的中间件必须注册在所有路由之后!
    app.use((err, req, res, next) => {
        // 在服务器打印错误消息
        console.log('发生了错误:' + err.message);
        // 向客户端响应错误相关的内容
        res.send('Error!' + err.message);
    })
    
  • Koa框架

    const Koa = require('koa')
    
    const app = new Koa()
    
    app.use((ctx, next) => {
      // 通过ctx.app.emit()发出错误事件
      ctx.app.emit('error', new Error("还没有登录嗷~"), ctx)
    })
    
    // 监听错误事件
    app.on('error', (err, next) => {
      // 错误信息在err.message中
      console.log(err.message)
      ctx.response.body = err.message
    })
    
    app.listen(8000, () => {
      console.log("错误处理服务器启动成功~")
    })
    

# 源码分析

# Express框架

  • 调用express()到底创建的是什么

    调用express()实际上是调用了createApplication函数

    // 源码
    exports = module.exports = createApplication
    
    // express()实际上是调用了该函数
    function createApplication() {
      var app = function(req, res, next) {
        app.handle(req, res, next)
      }
      
      mixin(app, EventEmitter.prototype, false)
      mixin(app, proto, false)
      
      // 封装了request
      app.request = Object.create(req, {
        app: { configurable: true, enumerable: true, writable: true, value: app }
      })
      // 封装了response
      app.response = Object.create(res, {
        app: { configurable: true, enumerable: true, writable: true, value: app }
      })
      
      app.init()
      return app
    }
    
    // 启动服务器
    app.listen = function listen() {
      // this就是app对象
      var server = http.createServer(this)
      return server.listen.apply(server, arguments)
    }
    
  • use注册一个中间件

    无论是app.use还是app.methods都会注册一个主路由

    app本质上会将所有的函数交给整个主路由来处理

    // 源码
    app.use = function use(fn) {
      var offset = 0
      var path = '/'
      ......
      // 扁平化处理
      var fns = flatten(slice.call(arguments, offset))
      // 注册一个主路由
      this.lazyrouter()
      var router = this._router
      // 不断去查找中间件
      fns.forEach(function(fn) {
        // 无app被创建时
        if (!fn || !fn.handle || !fn.set) {
          return router.use(path, fn)
        }
        ......
        router.use(path, function mounted_app(req, res, next){ ... })
      })
    }
                  
    // use函数中
    var layer = new Layer(path, {
        sensitive: this.caseSensitive,
        strict: false,
        end: false
    }, fn)
    layer.route = undefined
    // 加入调用栈中
    this.stack.push(layer)
    
  • 一个请求过来,从哪里开始处理

    从app函数被调用开始

    // 上面观察到createApplication函数中有app.handle方法
    
    app.handle = function(req, res, callback) {
      var router = this._router
      // 最终handle
      var done = callback || finalhandler(req, res, {
        env: this.get('env'),
        onerror: logger.bind(this)
      })
      // 没有路由时
      if (!router) {
        debug('no routes defined on app')
        done()
        return
      }
      // 开始匹配路由和方法,并处理请求
      router.handle(req, res, done)
    }
    
  • router.handle中做了什么事情

    proto.handle = function handle(req, res, out) {
      var self = this
      ......
      // 取出stack
      var stack = self.stack;
      ......
    }
    
    // 在handle的next方法中,不断查询是否匹配,如果匹配,就离开调用栈并执行该next方法所对应的中间件
    while(match !== true && idx < stack.length) {
      ......
      // 查看是否匹配
      if (match !== true) {
        continue;
      }
      ......
    }
    

# Koa框架

  • require('koa'),导出的是什么

    导出的是Application这个类

    const Koa = require('koa')

    这也是为什么在创建实例时需要大写

    // 源码
    module.exports = class Application extends Emitter {
      constructor(options) {
        super();
        options = options || {};
        this.proxy = options.proxy || false;
        this.subdomainOffset = options.subdomainOffset || 2;
        this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
        this.maxIpsCount = options.maxIpsCount || 0;
        this.env = options.env || process.env.NODE_ENV || 'development';
        if (options.keys) {
          this.keys = options.keys
        }
        // 中间件数据
        this.middleware = []
        // 创建请求上下文
        this.context = Object.create(context)
        this.request = Object.create(request)
        this.response = Object.create(response)
        ...
      }
    }
    
  • Koa中如何开启监听?

    // 同样是封装了listen方法
    listen(...args) {
      debug('listen');
      const server = http.createServer(this.callback())
      return server.listen(...args);
    }
    
  • Koa如何注册中间件?

    // use函数注册中间件
    use(fn) {
      // 判断是否为函数
      if (typeof fn !== 'function') {
        throw new TypeError('middleware muse be a function!')
      }
      if (isGeneratorFunction(fn)) {
        deprecate('Support for generators will be removed in v3...')
        fn = convert(fn)
      }
      debug('use %s', fn._name || fn.name || '-');
      // 推入中间件数组中,处于待执行状态
      this.middleware.push(fn);
      return this;
    }
    
  • 监听回调

    // const server = http.createServer(this.callback())callback() {
      // 处理后的中间件,返回的是个Promise
      const fn = 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方法

    // 处理请求
    handleRequest(ctx, fnMiddleware) {
      const res = ctx.res;
      const onerror = err => ctx.onerror(err);
      const handleResponse = () => respond(ctx);
      onFinished(res, onerror);
      // 这里意味着: 等所有中间件运行完后,才会响应结果
      // 同样注意:这里的结果是个Promise
      return fnMiddleware(ctx).then(handleResponse).catch(onerror);
    }
    
  • compose方法

    function compose(middleware) {
      // 判断middleware是否为数组,如果不是则抛出异常
      if (!Array.isArray(middleware)) {
        throw new TypeError('Middleware stack must be an array!')
      }
      // middleware的原生如果不是函数,则抛出异常
      for (const fn of middleware) {
        if (typeof fn !== 'function') {
          throw new TypeError('Middleware must be composed of functions!')
        }
      }
      // 返回值
      return function(context, next) {
        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 {
            // dispatch.bind(null, i + 1)相当于是next函数
            return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
            
            // 相当于是以下代码
    				Promise.resolve((async (ctx, next) => {
              console.log("执行中间件");
              await Promise.resolve(fn(context, dispatch.bind(null, i + 1)))();
              next();
              console.log("中间件next之后代码")
            })(context, dispatch.bind(null, i + 1)))
          } catch (err) {
            return Promise.reject(err)
          }
        }
      }
    }
    

# 中间件的执行顺序

对于某个中间件包含异步操作时,Express框架和Koa框架的机制是不一样的

假设有三个中间件会在一次请求中匹配到,并且按照顺序执行

希望实现的结果是:

  • 在middleware1中,在req.message中添加一个字符串aaa
  • 在middleware2中,在req.message中添加一个字符串bbb
  • 在middleware3中,在req.message中添加一个字符串ccc
  • 当所有的内容添加结束后,在middleware1中,通过res返回最终的结果

Express同步数据的实现

image-20221031183802223

const express = require('express');

const app = express();

const middleware1 = (req, res, next) => {
  req.message = 'aaa';
  // 这里去执行下一个中间件,只有当所有中间件执行完成时,才会执行下一步,这里返回的结果将会是所有中间件累加的结果(对于这个需求来说)
  next(); 
  // 中间件都执行完毕后,才会向服务器返回完整的数据
  res.end(req.message);
}

const middleware2 = (req, res, next) => {
  req.message += 'bbb';
  next();
}

const middleware3 = (req, res, next) => {
  req.message += 'ccc';
}

app.use(middleware1, middleware2, middleware3);

app.listen(8000, () => {
  console.log("服务器启动成功~");
})

Express异步数据的实现

Express异步数据的实现相较于Koa比较麻烦

const express = require('express');
const axios = require('axios');

const app = express();

const middleware1 = async (req, res, next) => {
  req.message = "aaa";
  await next();
  res.end(req.message);
}

const middleware2 = async (req, res, next) => {
  req.message += "bbb";
  await next();
}

const middleware3 = async (req, res, next) => {
  const result = await axios.get('http://123.207.32.32:9001/lyric?id=167876');
  req.message += result.data.lrc.lyric;
}

app.use(middleware1, middleware2, middleware3);

app.listen(8000, () => {
  console.log("服务器启动成功~");
})

// 结果:aaabbb

Koa同步数据的实现

实现原理图与Express同步数据的实现一致

const Koa = require('koa');

const app = new Koa();

const middleware1 = (ctx, next) => {
  ctx.message = "aaa";
  next();
  ctx.body = ctx.message;
}

const middleware2 = (ctx, next) => {
  ctx.message += "bbb";
  next();
}

const middleware3 = (ctx, next) => {
  ctx.message += "ccc";
}

app.use(middleware1);
app.use(middleware2);
app.use(middleware3);

app.listen(8000, () => {
  console.log("服务器启动成功~");
})

Koa异步数据的实现

Koa异步数据的实现较为方便,是因为其内部返回了一个Promise(详细可看上方源码)

const Koa = require('koa');
const axios = require('axios');

const app = new Koa();

const middleware1 = async (ctx, next) => {
  ctx.message = "aaa";
  // 由于内部返回的是一个Promise,所以不管同步还是异步,都会等到有结果后才执行下一步
  await next();
  ctx.body = ctx.message;
}

const middleware2 = async (ctx, next) => {
  ctx.message += "bbb";
  await next();
}

const middleware3 = async (ctx, next) => {
  const result = await axios.get('http://123.207.32.32:9001/lyric?id=167876');
  ctx.message += result.data.lrc.lyric;
}

app.use(middleware1);
app.use(middleware2);
app.use(middleware3);

app.listen(8000, () => {
  console.log("服务器启动成功~");
})

// 结果:aaabbb+axios得到的数据

值得注意的是:虽然上方的Express与Koa的异步实现看起来一样,但运行结果却大相径庭。

Express异步得到的结果:aaabbb

Koa异步得到的结果:aaabbb+axios返回的数据

造成这样结果的原因,显然是因为Express框架和Koa框架的机制是不一样的,Koa处理中间件时返回的是Promise,所以一定会得到一个完整的结果。 而Express处理中间件是同步执行的有异步操作时会得不到数据。所以上方Express异步数据的实现中的async、await相当于白加

# Koa洋葱模型

来自Koa社区针对于中间件的盛行的说法

两层理解

  • 中间件处理代码的过程

    直至中间件所有代码执行完毕后,才会返回response结果

    image-20221031223910473

  • response返回body执行

    res.body = "结果"

  • Express框架有洋葱模型吗?

    其实算有的,上方代码《Express同步数据的实现》就是一个洋葱模型,当然在Express框架中必须得是同步数据才行,而在Koa中,不管同步还是异步,都有洋葱模型存在。至于为什么,上方已经解释的很清楚啦。