tomoya06 / web-developer-guidance

Actually it's just a notebook for keeping down some working experience.
4 stars 0 forks source link

Node.js - Express vs Koa vs Egg #34

Open tomoya06 opened 4 years ago

tomoya06 commented 4 years ago

Node.js主流框架比较

本文主要参考小专栏 - Node主流框架 Express Koa2 Egg 对比,文章价值¥9.9,很清晰,建议课一笔支持一下。

一、 Express vs Koa

因为Egg是基于Koa开发的(Egg 1.x 基于Koa 1.x,Egg 2.x 基于Koa 2.x),所以Egg会继承Koa2对比Express的所有优点。

1. 架构设计

Express是一个集合式的框架,自身集成了router,static,views,bodyparse等等常用中间件。让开发者能非常快速的搭建一个node后端服务,但是使用Express就必须引入全部的中间件和功能。

Koa2的设计思想就是小而美,轻量,插件化设计。只提供最基础的框架,所有功能都通过中间件引入。Koa将Express集成的中间件进行拆分,开发者按需引入即可使用,也可以选择社区或第三方开发的中间件,甚至自己写一个都可以。

2. ECMAScript版本

Express是基于ES5开发的,受限制于当时的JavaScript标准,很多功能的实现方式不是很好。比如异步执行只能采用回调函数的方式。

Koa2是基于ES6(ES2015)开发的,原生支持异步函数 async/await,使用Promise作为异步处理的标准。Promise的异步操作使用async/await的写法更简单整洁。

3. 中间件执行机制

中间件的执行机制可以说是Koa相较Express最大的改进。

Express的中间件时线性执行的,每一个中间件处理完后只有两个选择:交给下一个中间件或者返回Response。只要请求离开了,中间件就无法再次获得这个请求,更不能再对它进行处理。

微信图片_20201006212928

Koa的中间件机制是洋葱模型,中间件像一层层的洋葱。请求要穿过洋葱,每个中间件也会被穿过两次。

微信图片_20201006213022

以统计一个请求耗时时间的需求为例,koa的洋葱模型实现起来就非常方便,express需要开发者在每一个中间件上都添加回调函数,非常复杂。

4. 异常处理

由于中间件执行机制的差异,导致两者异常处理的方式有很大不同。

Express的异常处理是有一个error处理中间件完成的,由于Express的中间件时线性流程,所以要处理错误信息就必须把error中间件放到最后。并且只要有错误,就需要手动调用error中间件。

微信图片_20201006213311

Koa的异步处理是基于Promise的,而且Koa总是将next()包装在Promise中,所以我们不用关心是同步错误还是异步错误。另外最重要的是:Koa的错误处理中间件是在最顶端的。

微信图片_20201006213340

5. context封装

express没有context封装,每层中间件都以传参方式接收 req 和 res。

Koa对context(上下文)进行了封装,使用 ctx.request 和 ctx.response 来代理 req 和 res。一个ctx同时封装了 req 和 res,在中间件中也能随时操作request 和 response 。

二、Egg VS Koa2

Egg是基于Koa开发的,底层基本上都是相同的。但是Egg对Koa做了大量的改进以使用企业级应用。

1. 设计理念

Koa是简洁轻量的框架,功能都是通过中间件引入。Egg没有遵循Koa的设计,内置了很多常用模块,比如路由,Config,Logger,定时任务等。

2. 约定

对文件目录结构有约定。比如中间件要放到middleware目录下,配置都放到config下等。

实际开发时发现,Egg会对middleware/service/controller等文件自动生成typescript的d.ts声明文件,在vscode中有自动补全提示,所以对文件路径有约定要求。

3. 新概念和改动

Egg引入了Controller,Service 概念,很明显借鉴了Java的设计思想。

另外Egg还对Koa做了很多小改动。比如获取运行环境要从app.config.env获取;使用egg-bin启动本地测试环境,使用 egg-scripts 启动线上环境。

4. 内置功能

安全

内置对XXS,SSRF,CSRF等攻击的防御。提供IP白名单机制,钓鱼攻击的防御方案。

其他

内置单元测试、日志、多进程、异常处理等中间件。也提供了可选的mysql、session、redis中间件。

总结

框架 类型 开发语言 中间件机制 错误处理 上下文封装 优点 缺点
Express 集成式,开箱即用 ES5 connect线性模型 手动调用Error中间件 无封装,传参使用req/res 1. 使用简单,开箱即用
2. 生态稳定,第三方库较多
1. 体积大,内置功能多
2. 事件处理和中间件机制都基于回调,开发不方便
Koa2 轻量级,功能按需引入 ES6 洋葱模型 顶部统一处理 ctx封装,且提供更多API 1. 支持ES6新特性,尤其Promise和async/await解决回调地狱的问题
2. 按需引用,自由选择中间件
1. 需要学习ES6的新特性
Egg 集成式,开箱即用 同上 同上 同上 同上 1. 强调约定规则,方便团队协作,提高可维护性
2. 内置常用插件,满足安全、日志、多进程、多配置等要求
3. 引入了controller/service等Java开发概念,配合TypeScript可以实现类Java的开发体验,提高项目稳定性
1. 对约定规则的学习曲线较长
2. 内置模块较多,更适用于企业级项目
3. 需要理解强类型语言的设计思想
tomoya06 commented 4 years ago

Koa2实现原理

本节参考掘金博客 - KOA2框架原理解析和实现

本文所用的框架是koa2,它跟koa1不同,koa1使用的是generator+co.js的执行方式,而koa2中使用了async/await,因此本文的代码和demo需要运行在node 8版本及其以上。

源码GitHub链接在这,代码量不大,主要包括application.js/context.js/request.js/response.js四个文件。

application, request & response

实现koa的服务器应用和端口监听,其实就是基于node的原生代码进行了封装:

class Application extends Emitter {
// ....
  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
// ....
}

request、response两个功能模块分别对node的原生request、response进行了一个功能的封装,使用了getter和setter属性,基于node的对象req/res对象封装koa的request/response对象;context的作用就是将request、response对象挂载到ctx的上面,让koa实例和代码能方便的使用到request、response对象中的方法。

当前版本的Koa2使用的delegates模块完成request/response中属性的读取设置。

然后在application.js中使用createContext(req, res)方法把req/res挂载到ctx。

中间件机制和洋葱模型的实现

首先是中间件的语法:

let Koa = require('../src/application');

let app = new Koa();

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    console.log(6);
});

app.use(async (ctx, next) => {
    console.log(2);
    await next();
    console.log(5);
});

app.use(async (ctx, next) => {
    console.log(3);
    ctx.body = "hello world";
    console.log(4);
});

app.listen(3000, () => {
    console.log('listenning on 3000');
});

首先是use的实现,在application.js中可以看到,use方法就只是把所有的中间件方法丢到middleware队列中而已.

定义Koa类的时候在http.createServer(this.callback())中调用了callback方法。定义如下:

const compose = require('koa-compose');
//...
class Application extends Emitter {
  callback() {
    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(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);
  }
}

看到compose方法对middleware队列做了一些处理。参考koa/compose库的定义:

function compose (middleware) {
 // ...检查middleware是否为队列,以及是否每一项都是function

  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)
      }
    }
  }
}

参考这里的分析:比较关键的就是这个dispatch函数了,它将遍历整个middleware,然后将contextdispatch(i + 1)传给middleware中的方法:

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

这段代码就很巧妙的实现了两点:

  1. context一路传下去给中间件
  2. middleware中的下一个中间件fn作为未来next的返回值

因此在中间件定义中,await next()这一方法就会触发下一个中间件的调用,递归执行完之后再返回本中间件。