web应用开发框架—koa和egg

151 阅读4分钟

为什么要有web应用开发框架

原生node 的痛点

  • 1:写法繁琐,复用性差
  • 2:令人费解的api,官方api都是callback形式的编程模式
  • 3:无法描写复杂业务逻辑,如 处理 日志 鉴权、 eggjs.org/zh-cn/intro…

什么是koa

简介

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。

通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

  • 下一代, 超前
  • 新的语法, es7
  • 优雅

中间件

1.logger

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。为了理解中间件,我们先看一下 Logger (打印日志)功能的实现。 最简单的写法就是在main函数里面增加一行。


const main = ctx => {

 console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);

 ctx.response.body = 'Hello World';

};

2. 中间件的概念

上一个例子里面的 Logger 功能,可以拆分成一个独立函数。


const logger = (ctx, next) => {

 console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);

 next();

}

app.use(logger);

3.中间件执行顺序


const koa = require('koa')

const app = new koa()

const one = async(ctx, next) => {
  console.log('>> one');
  await next();
  console.log('<< one');
 }

 

 const two = async(ctx, next) => {
  console.log('>> two');
  await next(); 
  console.log('<< two');
 }

 

 const three = async(ctx, next) => {
  console.log('>> three');
  await next();
  console.log('<< three');
 }

 

 app.use(one);
 app.use(two);
 app.use(three);
 app.listen(3001,()=>{
   console.log('3001');
 })

*洋葱模型

其实就是想向我们表达,调用next的时候,中间件的代码执行顺序是什么。

4.异步中间件

迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成 async 函数。请看

const fs = require('fs.promised');
const Koa = require('koa');
const app = new Koa();
const main = async function (ctx, next) {
 ctx.response.type = 'html';
 ctx.response.body = await fs.readFile('./demos/template.html', 'utf8');
};

app.use(main);
app.listen(3000);

5.koa 原理

一个基于nodejs的入门级http服务,类似下面代码:


const server = http.createServer((req, res)=>{ 			  			res.writeHead(200) res.end('hi 666')
})

server.listen(3000,()=>{ console.log('监听端口3000') })

代码分析,先写个大体框架

const http = require('http')
class Kob {
  constructor(){
  }
  use(callback){
    this.callback = callback
  };
  listen(...arg){
    const server = http.createServer((req,res)=>{
        this.callback(req,res)
      }
    )
    server.listen(...arg)
  };
}
module.exports = Kob

const kob = require('./kob')
const app = new kob()


app.use((req,res)=>{
  res.writeHead(200)
  res.end('hi 666')

 })

app.listen(30001,()=>{

  console.log('listen...30001');

})

目前为止,Kob只是个马甲,要真正实现目标还需要引入上下文(context)和中间件机制(middleware)

context

koa为了能够简化API,引入上下文context概念,将原始请求对象req和响应对象res封装并挂载到 context上,并且在context上设置getter和setter,从而简化操作

// index.js
app.use(ctx=>{ 
 ctx.body = 'hehe' 

})

koa将req,res对象分别进行包装成request和response 然后再合成到context中 知识储备:getter/setter方法 通过对get/set对对象进行处理封装

const test = {
  info :{
    name:'peiyi'
  },
  get name(){
    return `[${this.info.name}]`

  },
  set name(val){
    this.info.name = val
  }
}

源码:github.com/koajs/koa/t…

整合

//request.js
module.exports = {
  get url(){
    return this.req.url
  },
  get method(){
    return this.req.method.toLowerCase()
  }
}
//Response.js
module.exports = {
  get body(){
    return this._body
  },
  set body(val){
    return this._body = val
  }
}
//Context.js
module.exports = {
  get url(){
    return this.request.url
  },
  get body(){
    return this.response.body
  },
  set body(val){
    return this.response.body = val
  },
  get method(){
      return this.request.method
  },
}

实现洋葱圈:

中间件2

Compose.js


function compose(middlewarea){
  return function (ctx) {
    return dispatch(0)
    function dispatch(i) {
      let fn = middlewarea[i]
      if(!fn) return Promise.resolve()
      return Promise.resolve(
        fn(ctx,()=>{
          return dispatch(i+1)
        })
      )
    }
  }
}

async function fn1(ctx,next) {
  console.log('fn1');
  // await next()
  console.log('end fn1');
}

async function fn2(ctx,next) {
  console.log('fn2');
  // await next()
  console.log('end fn2');
}

async function fn3(ctx,next) {
  console.log('fn3');
  // await next()
  console.log('end fn3');
}

compose([fn1,fn2,fn3])('context')

最后kob.js

//kob.js
const http = require('http')
const request = require('./request')
const response = require('./response')
const context = require('./context')
const compose = require('./compose')

class Kob {
  constructor(){
    this.middlewares = []
  }

  use(middleware){
    this.middlewares.push(middleware)
    // this.callback = callback
  };
  listen(...arg){
        const server = http.createServer(async(req,res)=>{
        const ctx = this.createContext(req,res)
​        // 合成中间件
​        const fn = compose(this.middlewares)
​        // this.callback(ctx)
​        await fn(ctx)
​        //响应
​        res.end(ctx.body)
​      }
​    )
​    server.listen(...arg)
  };

  createContext(req,res){
​    const ctx = Object.create(context)
​    ctx.request = Object.create(request)
​    ctx.response = Object.create(response)
​    ctx.req = ctx.request.req = req
​    ctx.res = ctx.response.res = res
​    return ctx
  }
}

扩展内容 Object.create的理解 juejin.cn/post/684490…

Egg与Koa的关系

Koa 是一个非常优秀的框架,然而对于企业级应用来说,它还比较基础,并不是一个合适的企业级框架。 Egg 选择了 Koa 作为其基础框架,在它的模型基础上,进一步对它进行了一些增强。分层实现问题,约定优于定义

egg

由阿里巴巴团队开源的一套基于koa的应用框架,已经在集团内部服务了大量的nodejs系统。

Egg.js 为企业级框架和应用而生*,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。*

设计原则

Egg 的插件机制有很高的可扩展性,一个插件只做一件事

(比如 Nunjucks 模板封装成了 egg-view-nunjucks、MySQL 数据库封装成了 egg-mysql)。Egg 通过框架聚合这些插件,并根据自己的业务场景定制配置,这样应用的开发成本就变得很低。

Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,团队内部采用这种方式可以减少开发人员的学习成本,开发人员不再是『钉子』,可以流动起来。

没有约定的团队,沟通成本是非常高的,比如有人会按目录分栈而其他人按目录分功能,开发者认知不一致很容易犯错。但约定不等于扩展性差,相反 Egg 有很高的扩展性,可以按照团队的约定定制框架。使用 Loader 可以让框架根据不同环境定义默认配置,还可以覆盖 Egg 的默认约定。

特点

扩展

// app/extend/context.js
module.exports = {
 get isIOS() {
  const iosReg = /iphone|ipad|ipod/i;
  return iosReg.test(this.get('user-agent'));
 },
};

在 Controller 中,我们就可以使用到刚才定义的这个便捷属性了:

// app/controller/home.js
exports.handler = ctx => {
 ctx.body = ctx.isIOS
  ? 'Your operating system is iOS.'
  : 'Your operating system is not iOS.';
};

插件

作用:主要为程序提供一些工具类,当插件稳定后,还可以独立出来作为一个npm包提供其他业务或者社区使用 Koa 中,经常会引入许许多多的中间件来提供各种各样的功能,例如引入 koa-bodyparser 来解析请求 body。而 Egg 提供了一个更加强大的插件机制,让这些独立领域的功能模块可以更加容易编写。

一个插件可以包含

  • extend:扩展基础对象的上下文,提供各种工具类、属性。
  • middleware:增加一个或多个中间件,提供请求的前置、后置处理逻辑。
  • config:配置各个环境下插件自身的默认配置项。 插件机制egg一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。
  • Koa 已经有了中间件的机制,为啥还要插件呢?
  • 中间件、插件、应用它们之间是什么关系,有什么区别?
为什么要插件

使用 Koa 中间件过程中发现了下面一些问题:

  1. 中间件加载其实是有先后顺序的,但是中间件自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。
  2. 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。
  3. 有些功能包含非常复杂的初始化逻辑,需要在应用启动的时候完成。这显然也不适合放到中间件中去实现。
中间件、插件、应用的关系

一个插件其实就是一个『迷你的应用』,和应用(app)几乎一样:

  • 它包含了 Service、中间件、配置、框架扩展等等。
  • 它没有独立的 Router 和 Controller。(插件一般不写业务逻辑)
  • 它没有 plugin.js,只能声明跟其他插件的依赖,而不能决定其他插件的开启与否。

插件没有独立的 router 和 controller。这主要出于几点考虑:

  • 路由一般和应用强绑定的,不具备通用性。

  • 一个应用可能依赖很多个插件,如果插件支持路由可能导致路由冲突。

  • 如果确实有统一路由的需求,可以考虑在插件里通过中间件来实现。


// 创建项⽬

$ npm i egg-init -g

$ egg-init egg --type=simple

$ cd egg-example

$ npm i

// 启动项⽬

$ npm run dev

$ open localhost:7001

基础功能

目录结构的约定

egg的原则: 约定大于配置

egg-project
├── package.json
├── app.js (可选) //启动的初始化工作。监听生命周期事件
├── agent.js (可选) // 比较独特
├── app
|  ├── router.js //路由
│  ├── controller
│  |  └── home.js  
│  ├── service (可选)//处理数据逻辑
│  |  └── user.js 
│  ├── middleware (可选) //中间件
│  |  └── response_time.js
│  ├── schedule (可选) 
│  |  └── my_task.js
│  ├── public (可选)//静态资源
│  |  └── reset.css
│  ├── view (可选)//模板
│  |  └── home.tpl
│  └── extend (可选)//扩展
│    ├── helper.js (可选)//utils
│    ├── request.js (可选)
│    ├── response.js (可选)
│    ├── context.js (可选)//ctx
│    ├── application.js (可选)
│    └── agent.js (可选)
├── config //配置
|  ├── plugin.js
|  ├── config.default.js
│  ├── config.prod.js
|  ├── config.test.js (可选)
|  ├── config.local.js (可选)
|  └── config.unittest.js (可选)
└── test //测试
  ├── middleware
  |  └── response_time.test.js
  └── controller
​    └── home.test.js
框架规定的目录
  • app/router.js 用于配置 URL 路由规则。
  • app/controller/** 用于解析用户的输入,处理后返回相应的结果。
  • app/service/** 用于编写业务逻辑层,可选。
  • app/middleware/** 用于编写中间件,可选。
  • app/public/** 用于放置静态资源,可选。
  • app/extend/** 用于框架的扩展,可选。
  • config/config.{env}.js 用于编写配置文件。
  • config/plugin.js 用于配置需要加载的插件。
  • test/** 用于单元测试。
  • app.jsagent.js 用于自定义启动时的初始化工作,可选。
内置插件约定的目录
  • app/public/** 用于放置静态资源,可选。
  • app/schedule/** 用于定时任务,可选。
Application

Application 是全局应用对象,在一个应用中,只会实例化一个,它继承自 Koa.Application,在它上面我们可以挂载一些全局的方法和对象。我们可以轻松的在插件或者应用中扩展 Application 对象

事件

在框架运行时,会在 Application 实例上触发一些事件,应用开发者或者插件开发者可以监听这些事件做一些操作。作为应用开发者,我们一般会在启动自定义脚本中进行监听。

  • server: 该事件一个 worker 进程只会触发一次,在 HTTP 服务完成启动后,会将 HTTP server 通过这个事件暴露出来给开发者。

  • error: 运行时有任何的异常被 onerror 插件捕获后,都会触发 error 事件,将错误对象和关联的上下文(如果有)暴露给开发者,可以进行自定义的日志记录上报等处理。

  • requestresponse: 应用收到请求和响应请求时,分别会触发 requestresponse 事件,并将当前请求上下文暴露出来,开发者可以监听这两个事件来进行日志记录。

// app.js
module.exports = app => {
 app.once('server', server => {
  // websocket
 });

 app.on('error', (err, ctx) => {
  // report error
 });

 app.on('request', ctx => {
  // log receive request
 });

 app.on('response', ctx => {
  // ctx.starttime is set by framework
  const used = Date.now() - ctx.starttime;
  // log total cost
 });
};

获取方式

// app.js
module.exports = app => {
 app.xxxx = 'xxxx';
};

使用插件

插件一般通过 npm 模块的方式进行复用:


npm i egg-mysql --save

然后需要在应用或框架的 config/plugin.js 中声明:

// config/plugin.js
// 使用 mysql 插件
exports.mysql = {
 enable: true,
 package: 'egg-mysql',
};
根据环境配置

同时,我们还支持 plugin.{env}.js 这种模式,会根据运行环境加载插件配置。

// config/plugin.local.js
exports.dev = {
 enable: true,
 package: 'egg-dev',
};

引入
  • packagenpm 方式引入,也是最常见的引入方式

  • path 是绝对路径引入,如应用内部抽了一个插件,但还没达到开源发布独立 npm 的阶段,或者是应用自己覆盖了框架的一些插件


// config/plugin.js
const path = require('path');
exports.mysql = {
 enable: true,
 path: path.join(__dirname, '../lib/plugin/egg-mysql'),
};

如何写一个插件

你可以直接使用 egg-boilerplate-plugin 脚手架来快速上手。

$ mkdir egg-hello && cd egg-hello
$ npm init egg --type=plugin
$ npm i

一个插件其实就是一个『迷你的应用』,下面展示的是一个插件的目录结构,和应用(app)几乎一样。


. egg-hello
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
│  ├── extend (可选)
│  |  ├── helper.js (可选)
│  |  ├── request.js (可选)
│  |  ├── response.js (可选)
│  |  ├── context.js (可选)
│  |  ├── application.js (可选)
│  |  └── agent.js (可选)
│  ├── service (可选)
│  └── middleware (可选)
│    └── mw.js
├── config
|  ├── config.default.js
│  ├── config.prod.js
|  ├── config.test.js (可选)
|  ├── config.local.js (可选)
|  └── config.unittest.js (可选)
└── test
  └── middleware
​    └── mw.test.js
  1. 插件需要在 package.json 中的 eggPlugin 节点指定插件特有的信息:
  • {String} name - 插件名(必须配置),具有唯一性,配置依赖关系时会指定依赖插件的 name。

  • {Array} dependencies - 当前插件强依赖的插件列表(如果依赖的插件没找到,应用启动失败)。

  • {Array} optionalDependencies - 当前插件的可选依赖插件列表(如果依赖的插件未开启,只会 warning,不会影响应用启动)。

  • {Array} env - 只有在指定运行环境才能开启,具体有哪些环境可以参考运行环境。此配置是可选的,一般情况下都不需要配置。


{
 "name": "egg-rpc",
 "eggPlugin": {
  "name": "rpc",
  "dependencies": [ "registry" ],
  "optionalDependencies": [ "vip" ],
  "env": [ "local", "test", "unittest", "prod" ]
 }
}

插件能做什么?
  • 扩展内置对象的接口
  • app/extend/request.js - 扩展 Koa#Request 类
  • app/extend/response.js - 扩展 Koa#Response 类
  • app/extend/context.js - 扩展 Koa#Context 类
  • app/extend/helper.js - 扩展 Helper 类
  • app/extend/application.js - 扩展 Application 类
  • app/extend/agent.js - 扩展 Agent 类
  • 插入自定义中间件
  • 在应用启动时做一些初始化工作
  • 设置定时任务