Koa2超详细入门教程

Koa2超详细入门教程

 

Koa入门教程之示例应用

Koa范例

一个包含一些小示例的存储库,这些示例说明了如何使用Koa创建Web应用程序和其他HTTP服务器。

源码地址

https://github.com/koajs/examples[源码包含以下示例]

例子

示例存储库

  • coko -Koa 2的配置框架/样板间的最小约定。
  • kails-使用Koa v2,Webpack和Postgres构建的类似Rails的Web应用程序
  • 松饼 -在Koa v2之上构建的内容管理系统
  • 链接 -实验性内容共享和协作平台
  • 组件搜寻器 -使用component.jsons 搜寻用户和组织的存储库
  • bigpipe -Koa和组件中Facebook的BigPipe实现
  • webcam-mjpeg-stream-从Mac传输JPEG快照
  • cnpmjs.org-基于koa,MySQL和Simple Store Service的企业专用npm注册表和Web
  • blog- mongo-此仓库中的博客示例,但使用MongoDb数据库和测试
  • koa-rest-一个演示REST API的简单应用
  • koajs-rest-skeleton-一个简单的Koa REST骨架应用程序
  • koa- bookshelf-使用MongoDB和Heroku兼容性的带有CRUD的Koa示例
  • todo-用koa编写的todo示例并做出反应
  • koa-skeleton-一个简单的将要分叉的Koa应用程序,它使用Postgres并部署到Heroku。
  • nodejs-docs-samples -Koa应用示例和教程,用于部署到Google App Engine
  • koa-passport-mongoose-graphql-使用猫鼬,graphql设置和护照认证的Koa 2入门套件
  • hacknical-基于Koa v2,redis和mongoose的github用户网站,可以使简历更好。
  • koa-vue-notes- api-充实的SPA,在后端使用Koa 2.3,在前端使用Vue 2.4。包括功能齐全的用户身份验证组件,针对用户注释的CRUD操作以及异步/等待。
  • koa- typescript - node-用于构建nodejs和typescript服务的模板。功能:MySql,迁移,Docker,单元和集成测试,JWT身份验证,授权,正常关闭,更漂亮。

Template模板

  • koa2-boilerplate -koa v2开发的最小模板
  • api-boilerplate -API应用程序样板
  • component-koa-et-al- boilerplate-具有组件,livereload 等的服务器/客户端样板
  • koa- typescript - starter-使用TypeScript,ES6导入/导出,Travis,Coveralls,Jasmine,Chai,Istanbul / NYC,Lodash,Nodemon,Docker和Swagger的Koa2入门套件

脚手架

  • koa-rest-带有子生成器的RESTful API脚手架
  • koa -Web应用程序脚手架
  • k-具有中文自述文件的Web应用程序脚手架

文章

 

翻译来源:https://github.com/koajs/examples

注:下文是其他参考资料,非常详细

 


 

Koa2 快速开始

环境准备

安装koa2

# 初始化package.json
npm init

# 安装koa2 
npm install koa

Hello World 代码

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

app.use( async ( ctx ) => {
  ctx.body = 'hello koa2'
})

app.listen(3000)
console.log('[demo] start-quick is starting at port 3000')

启动demo

由于koa2是基于async/await操作中间件,目前node.js 7.x的harmony模式下才能使用,所以启动的时的脚本如下:

node index.js

访问http:localhost:3000,效果如下

 

 

Async/await使用

快速上手理解

先复制以下这段代码,在粘贴在chrome的控制台console中,按回车键执行

function getSyncTime() {
  return new Promise((resolve, reject) => {
    try {
      let startTime = new Date().getTime()
      setTimeout(() => {
        let endTime = new Date().getTime()
        let data = endTime - startTime
        resolve( data )
      }, 500)
    } catch ( err ) {
      reject( err )
    }
  })
}

async function getSyncData() {
  let time = await getSyncTime()
  let data = `endTime - startTime = ${time}`
  return data
}

async function getData() {
  let data = await getSyncData()
  console.log( data )
}

getData()

在chrome的console中执行结果如下

快速上手理解

先复制以下这段代码,在粘贴在chrome的控制台console中,按回车键执行

function getSyncTime() {
  return new Promise((resolve, reject) => {
    try {
      let startTime = new Date().getTime()
      setTimeout(() => {
        let endTime = new Date().getTime()
        let data = endTime - startTime
        resolve( data )
      }, 500)
    } catch ( err ) {
      reject( err )
    }
  })
}

async function getSyncData() {
  let time = await getSyncTime()
  let data = `endTime - startTime = ${time}`
  return data
}

async function getData() {
  let data = await getSyncData()
  console.log( data )
}

getData()

在chrome的console中执行结果如下

从上述例子可以看出 async/await 的特点:

  • 可以让异步逻辑用同步写法实现
  • 最底层的await返回需要是Promise对象
  • 可以通过多层 async function 的同步写法代替传统的callback嵌套

 

koa2简析结构

源码文件

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json

这个就是 GitHub https://github.com/koajs/koa上开源的koa2源码的源文件结构,核心代码就是lib目录下的四个文件

  • application.js 是整个koa2 的入口文件,封装了context,request,response,以及最核心的中间件处理流程。
  • context.js 处理应用上下文,里面直接封装部分request.js和response.js的方法
  • request.js 处理http请求
  • response.js 处理http响应

koa2特性

  • 只提供封装好http上下文、请求、响应,以及基于async/await的中间件容器。
  • 利用ES7的async/await的来处理传统回调嵌套问题和代替koa@1的generator,但是需要在node.js 7.x的harmony模式下才能支持async/await
  • 中间件只支持 async/await 封装的,如果要使用koa@1基于generator中间件,需要通过中间件koa-convert封装一下才能使用。
  • © 2020 GitHub, Inc.

koa2 中的 async/await 使用

举个栗子

  • Promise封装 fs 异步读取文件的方法
// code file:  util/render.js
// Promise封装 fs 异步读取文件的方法

const fs = require('fs')

function render( page ) {
  return new Promise(( resolve, reject ) => {
    let viewUrl = `./view/${page}`
    fs.readFile(viewUrl, "binary", ( err, data ) => {
      if ( err ) {
        reject( err )
      } else {
        resolve( data )
      }
    })
  })
}

module.exports = render
  • koa2 通过async/await 实现读取HTML文件并执行渲染
// code file : index.js
// koa2 通过async/await 实现读取HTML文件并执行渲染
const Koa = require('koa')
const render = require('./util/render')
const app = new Koa()

app.use( async ( ctx ) => {
  let html = await render('index.html')
  ctx.body = html
})

app.listen(3000)
console.log('[demo] start-async is starting at port 3000')

 

koa中间件开发和使用

注:原文地址在我的博客issue里https://github.com/ChenShenhai/blog/issues/15

  • koa v1和v2中使用到的中间件的开发和使用
  • generator 中间件开发在koa v1和v2中使用
  • async await 中间件开发和只能在koa v2中使用

 

generator中间件开发

generator中间件返回的应该是function * () 函数

/* ./middleware/logger-generator.js */
function log( ctx ) {
    console.log( ctx.method, ctx.header.host + ctx.url )
}

module.exports = function () {
    return function * ( next ) {

        // 执行中间件的操作
        log( this )

        if ( next ) {
            yield next
        }
    }
}

generator中间件在koa@1中的使用

generator 中间件在koa v1中可以直接use使用

const koa = require('koa')  // koa v1
const loggerGenerator  = require('./middleware/logger-generator')
const app = koa()

app.use(loggerGenerator())

app.use(function *( ) {
    this.body = 'hello world!'
})

app.listen(3000)
console.log('the server is starting at port 3000')

generator中间件在koa@2中的使用

generator 中间件在koa v2中需要用koa-convert封装一下才能使用

const Koa = require('koa') // koa v2
const convert = require('koa-convert')
const loggerGenerator  = require('./middleware/logger-generator')
const app = new Koa()

app.use(convert(loggerGenerator()))

app.use(( ctx ) => {
    ctx.body = 'hello world!'
})

app.listen(3000)
console.log('the server is starting at port 3000')

async 中间件开发

/* ./middleware/logger-async.js */

function log( ctx ) {
    console.log( ctx.method, ctx.header.host + ctx.url )
}

module.exports = function () {
  return async function ( ctx, next ) {
    log(ctx);
    await next()
  }
}

async 中间件在koa@2中使用

async 中间件只能在 koa v2中使用

const Koa = require('koa') // koa v2
const loggerAsync  = require('./middleware/logger-async')
const app = new Koa()

app.use(loggerAsync())

app.use(( ctx ) => {
    ctx.body = 'hello world!'
})

app.listen(3000)
console.log('the server is starting at port 3000')

 

koa2 原生路由实现

简单例子

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

app.use( async ( ctx ) => {
  let url = ctx.request.url
  ctx.body = url
})
app.listen(3000)

访问 http://localhost:3000/hello/world 页面会输出 /hello/world,也就是说上下文的请求request对象中url之就是当前访问的路径名称,可以根据ctx.request.url 通过一定的判断或者正则匹配就可以定制出所需要的路由。

定制化的路由

demo源码

https://github.com/ChenShenhai/koa2-note/tree/master/demo/route-simple

源码文件目录

.
├── index.js
├── package.json
└── view
    ├── 404.html
    ├── index.html
    └── todo.html

 

demo源码

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

/**
 * 用Promise封装异步读取文件方法
 * @param  {string} page html文件名称
 * @return {promise}      
 */
function render( page ) {
  return new Promise(( resolve, reject ) => {
    let viewUrl = `./view/${page}`
    fs.readFile(viewUrl, "binary", ( err, data ) => {
      if ( err ) {
        reject( err )
      } else {
        resolve( data )
      }
    })
  })
}

/**
 * 根据URL获取HTML内容
 * @param  {string} url koa2上下文的url,ctx.url
 * @return {string}     获取HTML文件内容
 */
async function route( url ) {
  let view = '404.html'
  switch ( url ) {
    case '/':
      view = 'index.html'
      break
    case '/index':
      view = 'index.html'
      break
    case '/todo':
      view = 'todo.html'
      break
    case '/404':
      view = '404.html'
      break
    default:
      break
  }
  let html = await render( view )
  return html
}

app.use( async ( ctx ) => {
  let url = ctx.request.url
  let html = await route( url )
  ctx.body = html
})

app.listen(3000)
console.log('[demo] route-simple is starting at port 3000')

 

运行demo

执行运行脚本

node -harmony index.js

 

运行效果如下

访问http://localhost:3000/index 

 

koa-router中间件

如果依靠ctx.request.url去手动处理路由,将会写很多处理代码,这时候就需要对应的路由的中间件对路由进行控制,这里介绍一个比较好用的路由中间件koa-router

 

安装koa-router中间件

# koa2 对应的版本是 7.x
npm install --save koa-router@7

 

快速使用koa-router

demo源码

https://github.com/ChenShenhai/koa2-note/tree/master/demo/route-use-middleware

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

const Router = require('koa-router')

let home = new Router()

// 子路由1
home.get('/', async ( ctx )=>{
  let html = `
    <ul>
      <li><a href="/page/helloworld">/page/helloworld</a></li>
      <li><a href="/page/404">/page/404</a></li>
    </ul>
  `
  ctx.body = html
})

// 子路由2
let page = new Router()
page.get('/404', async ( ctx )=>{
  ctx.body = '404 page!'
}).get('/helloworld', async ( ctx )=>{
  ctx.body = 'helloworld page!'
})

// 装载所有子路由
let router = new Router()
router.use('/', home.routes(), home.allowedMethods())
router.use('/page', page.routes(), page.allowedMethods())

// 加载路由中间件
app.use(router.routes()).use(router.allowedMethods())

app.listen(3000, () => {
  console.log('[demo] route-use-middleware is starting at port 3000')
})

 

GET请求数据获取

使用方法

在koa中,获取GET请求数据源头是koa中request对象中的query方法或querystring方法,query返回是格式化好的参数对象,querystring返回的是请求字符串,由于ctx对request的API有直接引用的方式,所以获取GET请求数据有两个途径。

  • 1.是从上下文中直接获取
    • 请求对象ctx.query,返回如 { a:1, b:2 }
    • 请求字符串 ctx.querystring,返回如 a=1&b=2
  • 2.是从上下文的request对象中获取
    • 请求对象ctx.request.query,返回如 { a:1, b:2 }
    • 请求字符串 ctx.request.querystring,返回如 a=1&b=2

 

举个例子

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/request/get.js

 

例子代码

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

app.use( async ( ctx ) => {
  let url = ctx.url
  // 从上下文的request对象中获取
  let request = ctx.request
  let req_query = request.query
  let req_querystring = request.querystring

  // 从上下文中直接获取
  let ctx_query = ctx.query
  let ctx_querystring = ctx.querystring
  
  ctx.body = {
    url,
    req_query,
    req_querystring,
    ctx_query,
    ctx_querystring
  }
})

app.listen(3000, () => {
  console.log('[demo] request get is starting at port 3000')
})

执行程序

node get.js

执行后程序后,用chrome访问 http://localhost:3000/page/user?a=1&b=2 会出现以下情况

注意:我是用了chrome的json格式化插件才会显示json的格式化

 

 

 

POST请求参数获取

原理

对于POST请求的处理,koa2没有封装获取参数的方法,需要通过解析上下文context中的原生node.js请求对象req,将POST表单数据解析成query string(例如:a=1&b=2&c=3),再将query string 解析成JSON格式(例如:{"a":"1", "b":"2", "c":"3"}

注意:ctx.request是context经过封装的请求对象,ctx.req是context提供的node.js原生HTTP请求对象,同理ctx.response是context经过封装的响应对象,ctx.res是context提供的node.js原生HTTP响应对象。

具体koa2 API文档可见 https://github.com/koajs/koa/blob/master/docs/api/context.md#ctxreq

 

解析出POST请求上下文中的表单数据

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/request/post.js

// 解析上下文里node原生请求的POST参数
function parsePostData( ctx ) {
  return new Promise((resolve, reject) => {
    try {
      let postdata = "";
      ctx.req.addListener('data', (data) => {
        postdata += data
      })
      ctx.req.addListener("end",function(){
        let parseData = parseQueryStr( postdata )
        resolve( parseData )
      })
    } catch ( err ) {
      reject(err)
    }
  })
}

// 将POST请求参数字符串解析成JSON
function parseQueryStr( queryStr ) {
  let queryData = {}
  let queryStrList = queryStr.split('&')
  console.log( queryStrList )
  for (  let [ index, queryStr ] of queryStrList.entries()  ) {
    let itemList = queryStr.split('=')
    queryData[ itemList[0] ] = decodeURIComponent(itemList[1])
  }
  return queryData
}

 

举个例子

源码在 /demos/request/post.js中

 

例子代码

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

app.use( async ( ctx ) => {

  if ( ctx.url === '/' && ctx.method === 'GET' ) {
    // 当GET请求时候返回表单页面
    let html = `
      <h1>koa2 request post demo</h1>
      <form method="POST" action="/">
        <p>userName</p>
        <input name="userName" /><br/>
        <p>nickName</p>
        <input name="nickName" /><br/>
        <p>email</p>
        <input name="email" /><br/>
        <button type="submit">submit</button>
      </form>
    `
    ctx.body = html
  } else if ( ctx.url === '/' && ctx.method === 'POST' ) {
    // 当POST请求的时候,解析POST表单里的数据,并显示出来
    let postData = await parsePostData( ctx )
    ctx.body = postData
  } else {
    // 其他请求显示404
    ctx.body = '<h1>404!!! o(╯□╰)o</h1>'
  }
})

// 解析上下文里node原生请求的POST参数
function parsePostData( ctx ) {
  return new Promise((resolve, reject) => {
    try {
      let postdata = "";
      ctx.req.addListener('data', (data) => {
        postdata += data
      })
      ctx.req.addListener("end",function(){
        let parseData = parseQueryStr( postdata )
        resolve( parseData )
      })
    } catch ( err ) {
      reject(err)
    }
  })
}

// 将POST请求参数字符串解析成JSON
function parseQueryStr( queryStr ) {
  let queryData = {}
  let queryStrList = queryStr.split('&')
  console.log( queryStrList )
  for (  let [ index, queryStr ] of queryStrList.entries()  ) {
    let itemList = queryStr.split('=')
    queryData[ itemList[0] ] = decodeURIComponent(itemList[1])
  }
  return queryData
}

app.listen(3000, () => {
  console.log('[demo] request post is starting at port 3000')
})

 

启动例子

node post.js

 

访问页面

 

提交表单发起POST请求结果显示

 

 

koa-bodyparser中间件

原理

对于POST请求的处理,koa-bodyparser中间件可以把koa2上下文的formData数据解析到ctx.request.body中

安装koa2版本的koa-bodyparser@3中间件

npm install --save koa-bodyparser@3

 

举个例子

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/request/post-middleware.js

const Koa = require('koa')
const app = new Koa()
const bodyParser = require('koa-bodyparser')

// 使用ctx.body解析中间件
app.use(bodyParser())

app.use( async ( ctx ) => {

  if ( ctx.url === '/' && ctx.method === 'GET' ) {
    // 当GET请求时候返回表单页面
    let html = `
      <h1>koa2 request post demo</h1>
      <form method="POST" action="/">
        <p>userName</p>
        <input name="userName" /><br/>
        <p>nickName</p>
        <input name="nickName" /><br/>
        <p>email</p>
        <input name="email" /><br/>
        <button type="submit">submit</button>
      </form>
    `
    ctx.body = html
  } else if ( ctx.url === '/' && ctx.method === 'POST' ) {
    // 当POST请求的时候,中间件koa-bodyparser解析POST表单里的数据,并显示出来
    let postData = ctx.request.body
    ctx.body = postData
  } else {
    // 其他请求显示404
    ctx.body = '<h1>404!!! o(╯□╰)o</h1>'
  }
})

app.listen(3000, () => {
  console.log('[demo] request post is starting at port 3000')
})

启动例子

node post-middleware.js

访问页面

 

提交表单发起POST请求结果显示

 

原生koa2实现静态资源服务器

前言

一个http请求访问web服务静态资源,一般响应结果有三种情况

  • 访问文本,例如js,css,png,jpg,gif
  • 访问静态目录
  • 找不到资源,抛出404错误

 

原生koa2 静态资源服务器例子

https://github.com/ChenShenhai/koa2-note/blob/master/demo/static-server/

代码目录

├── static # 静态资源目录
│   ├── css/
│   ├── image/
│   ├── js/
│   └── index.html
├── util # 工具代码
│   ├── content.js # 读取请求内容
│   ├── dir.js # 读取目录内容
│   ├── file.js # 读取文件内容
│   ├── mimes.js # 文件类型列表
│   └── walk.js # 遍历目录内容
└── index.js # 启动入口文件

代码解析

index.js

const Koa = require('koa')
const path = require('path')
const content = require('./util/content')
const mimes = require('./util/mimes')

const app = new Koa()

// 静态资源目录对于相对入口文件index.js的路径
const staticPath = './static'

// 解析资源类型
function parseMime( url ) {
  let extName = path.extname( url )
  extName = extName ?  extName.slice(1) : 'unknown'
  return  mimes[ extName ]
}

app.use( async ( ctx ) => {
  // 静态资源目录在本地的绝对路径
  let fullStaticPath = path.join(__dirname, staticPath)

  // 获取静态资源内容,有可能是文件内容,目录,或404
  let _content = await content( ctx, fullStaticPath )

  // 解析请求内容的类型
  let _mime = parseMime( ctx.url )

  // 如果有对应的文件类型,就配置上下文的类型
  if ( _mime ) {
    ctx.type = _mime
  }

  // 输出静态资源内容
  if ( _mime && _mime.indexOf('image/') >= 0 ) {
    // 如果是图片,则用node原生res,输出二进制数据
    ctx.res.writeHead(200)
    ctx.res.write(_content, 'binary')
    ctx.res.end()
  } else {
    // 其他则输出文本
    ctx.body = _content
  }
})

app.listen(3000)
console.log('[demo] static-server is starting at port 3000')

util/content.js

const path = require('path')
const fs = require('fs')

// 封装读取目录内容方法
const dir = require('./dir')

// 封装读取文件内容方法
const file = require('./file')


/**
 * 获取静态资源内容
 * @param  {object} ctx koa上下文
 * @param  {string} 静态资源目录在本地的绝对路径
 * @return  {string} 请求获取到的本地内容
 */
async function content( ctx, fullStaticPath ) {
  
  // 封装请求资源的完绝对径
  let reqPath = path.join(fullStaticPath, ctx.url)

  // 判断请求路径是否为存在目录或者文件
  let exist = fs.existsSync( reqPath )
  
  // 返回请求内容, 默认为空
  let content = ''

  if( !exist ) {
    //如果请求路径不存在,返回404
    content = '404 Not Found! o(╯□╰)o!'
  } else {
    //判断访问地址是文件夹还是文件
    let stat = fs.statSync( reqPath )

    if( stat.isDirectory() ) {
      //如果为目录,则渲读取目录内容
      content = dir( ctx.url, reqPath )

    } else {
      // 如果请求为文件,则读取文件内容
      content = await file( reqPath )
    }
  }

  return content
}

module.exports = content

util/dir.js

const url = require('url')
const fs = require('fs')
const path = require('path')

// 遍历读取目录内容方法
const walk = require('./walk')

/**
 * 封装目录内容
 * @param  {string} url 当前请求的上下文中的url,即ctx.url
 * @param  {string} reqPath 请求静态资源的完整本地路径
 * @return {string} 返回目录内容,封装成HTML
 */
function dir ( url, reqPath ) {
  
  // 遍历读取当前目录下的文件、子目录
  let contentList = walk( reqPath )

  let html = `<ul>`
  for ( let [ index, item ] of contentList.entries() ) {
    html = `${html}<li><a href="${url === '/' ? '' : url}/${item}">${item}</a>` 
  }
  html = `${html}</ul>`
  
  return html
}

module.exports = dir

util/file.js

const fs = require('fs')

/**
 * 读取文件方法
 * @param  {string} 文件本地的绝对路径
 * @return {string|binary} 
 */
function file ( filePath ) {

 let content = fs.readFileSync(filePath, 'binary' )
 return content
}

module.exports = file

util/walk.js

const fs = require('fs')
const mimes = require('./mimes')

/**
 * 遍历读取目录内容(子目录,文件名)
 * @param  {string} reqPath 请求资源的绝对路径
 * @return {array} 目录内容列表
 */
function walk( reqPath ){

  let files = fs.readdirSync( reqPath );

  let dirList = [], fileList = [];
  for( let i=0, len=files.length; i<len; i++ ) {
    let item = files[i];
    let itemArr = item.split("\.");
    let itemMime = ( itemArr.length > 1 ) ? itemArr[ itemArr.length - 1 ] : "undefined";

    if( typeof mimes[ itemMime ] === "undefined" ) {
      dirList.push( files[i] );
    } else {
      fileList.push( files[i] );
    }
  }


  let result = dirList.concat( fileList );

  return result;
};

module.exports = walk;

util/mime.js

let mimes = {
  'css': 'text/css',
  'less': 'text/css',
  'gif': 'image/gif',
  'html': 'text/html',
  'ico': 'image/x-icon',
  'jpeg': 'image/jpeg',
  'jpg': 'image/jpeg',
  'js': 'text/javascript',
  'json': 'application/json',
  'pdf': 'application/pdf',
  'png': 'image/png',
  'svg': 'image/svg+xml',
  'swf': 'application/x-shockwave-flash',
  'tiff': 'image/tiff',
  'txt': 'text/plain',
  'wav': 'audio/x-wav',
  'wma': 'audio/x-ms-wma',
  'wmv': 'video/x-ms-wmv',
  'xml': 'text/xml'
}

module.exports = mimes

运行效果

启动服务

node index.js

效果

访问http://localhost:3000

 

访问http://localhost:3000/index.html

 

访问http://localhost:3000/js/index.js

 

 

koa2使用cookie

使用方法

koa提供了从上下文直接读取、写入cookie的方法

  • ctx.cookies.get(name, [options]) 读取上下文请求中的cookie
  • ctx.cookies.set(name, value, [options]) 在上下文中写入cookie

koa2 中操作的cookies是使用了npm的cookies模块,源码在https://github.com/pillarjs/cookies,所以在读写cookie的使用参数与该模块的使用一致。

例子代码

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

app.use( async ( ctx ) => {

  if ( ctx.url === '/index' ) {
    ctx.cookies.set(
      'cid', 
      'hello world',
      {
        domain: 'localhost',  // 写cookie所在的域名
        path: '/index',       // 写cookie所在的路径
        maxAge: 10 * 60 * 1000, // cookie有效时长
        expires: new Date('2017-02-15'),  // cookie失效时间
        httpOnly: false,  // 是否只用于http请求中获取
        overwrite: false  // 是否允许重写
      }
    )
    ctx.body = 'cookie is ok'
  } else {
    ctx.body = 'hello world' 
  }

})

app.listen(3000, () => {
  console.log('[demo] cookie is starting at port 3000')
})

运行例子

node index.js

运行结果

访问http://localhost:3000/index

  • 可以在控制台的cookie列表中中看到写在页面上的cookie
  • 在控制台的console中使用document.cookie可以打印出在页面的所有cookie(需要是httpOnly设置false才能显示)

 

cookie-result-01

 

koa-static中间件使用

使用例子

https://github.com/ChenShenhai/koa2-note/blob/master/demo/static-use-middleware/

const Koa = require('koa')
const path = require('path')
const static = require('koa-static')

const app = new Koa()

// 静态资源目录对于相对入口文件index.js的路径
const staticPath = './static'

app.use(static(
  path.join( __dirname,  staticPath)
))


app.use( async ( ctx ) => {
  ctx.body = 'hello world'
})

app.listen(3000, () => {
  console.log('[demo] static-use-middleware is starting at port 3000')
})

效果

访问http://localhost:3000

static-server-result

访问http://localhost:3000/index.html

static-server-result

访问http://localhost:3000/js/index.js

static-server-result

 

koa2实现session

前言

koa2原生功能只提供了cookie的操作,但是没有提供session操作。session就只用自己实现或者通过第三方中间件实现。在koa2中实现session的方案有一下几种

  • 如果session数据量很小,可以直接存在内存中
  • 如果session数据量很大,则需要存储介质存放session数据

数据库存储方案

  • 将session存放在MySQL数据库中
  • 需要用到中间件
    • koa-session-minimal 适用于koa2 的session中间件,提供存储介质的读写接口 。
    • koa-mysql-session 为koa-session-minimal中间件提供MySQL数据库的session数据读写操作。
    • 将sessionId和对应的数据存到数据库
  • 将数据库的存储的sessionId存到页面的cookie中
  • 根据cookie的sessionId去获取对于的session信息

快速使用

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/session/index.js

例子代码

const Koa = require('koa')
const session = require('koa-session-minimal')
const MysqlSession = require('koa-mysql-session')

const app = new Koa()

// 配置存储session信息的mysql
let store = new MysqlSession({
  user: 'root',
  password: 'abc123',
  database: 'koa_demo',
  host: '127.0.0.1',
})

// 存放sessionId的cookie配置
let cookie = {
  maxAge: '', // cookie有效时长
  expires: '',  // cookie失效时间
  path: '', // 写cookie所在的路径
  domain: '', // 写cookie所在的域名
  httpOnly: '', // 是否只用于http请求中获取
  overwrite: '',  // 是否允许重写
  secure: '',
  sameSite: '',
  signed: '',
  
}

// 使用session中间件
app.use(session({
  key: 'SESSION_ID',
  store: store,
  cookie: cookie
}))

app.use( async ( ctx ) => {

  // 设置session
  if ( ctx.url === '/set' ) {
    ctx.session = {
      user_id: Math.random().toString(36).substr(2),
      count: 0
    }
    ctx.body = ctx.session
  } else if ( ctx.url === '/' ) {

    // 读取session信息
    ctx.session.count = ctx.session.count + 1
    ctx.body = ctx.session
  } 
  
})

app.listen(3000)
console.log('[demo] session is starting at port 3000')

运行例子

node index.js

访问连接设置session

http://localhost:3000/set 

session-result-01

 

查看数据库session是否存储

session-result-01

查看cookie中是否种下了sessionId

http://localhost:3000 

session-result-01

 

koa2加载模板引擎

安装模块

# 安装koa模板使用中间件
npm install --save koa-views

# 安装ejs模板引擎
npm install --save ejs

 

使用模板引擎

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/ejs/

文件目录

├── package.json
├── index.js
└── view
    └── index.ejs

./index.js文件

const Koa = require('koa')
const views = require('koa-views')
const path = require('path')
const app = new Koa()

// 加载模板引擎
app.use(views(path.join(__dirname, './view'), {
  extension: 'ejs'
}))

app.use( async ( ctx ) => {
  let title = 'hello koa2'
  await ctx.render('index', {
    title,
  })
})

app.listen(3000)

./view/index.ejs 模板

<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p>EJS Welcome to <%= title %></p>
</body>
</html>

 

koa上传功能之busboy模块

安装

npm install --save busboy

模块简介

busboy 模块是用来解析POST请求,node原生req中的文件流。

开始使用

const inspect = require('util').inspect 
const path = require('path')
const fs = require('fs')
const Busboy = require('busboy')

// req 为node原生请求
const busboy = new Busboy({ headers: req.headers })

// ...

// 监听文件解析事件
busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
  console.log(`File [${fieldname}]: filename: ${filename}`)


  // 文件保存到特定路径
  file.pipe(fs.createWriteStream('./upload'))

  // 开始解析文件流
  file.on('data', function(data) {
    console.log(`File [${fieldname}] got ${data.length} bytes`)
  })

  // 解析文件结束
  file.on('end', function() {
    console.log(`File [${fieldname}] Finished`)
  })
})

// 监听请求中的字段
busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated) {
  console.log(`Field [${fieldname}]: value: ${inspect(val)}`)
})

// 监听结束事件
busboy.on('finish', function() {
  console.log('Done parsing form!')
  res.writeHead(303, { Connection: 'close', Location: '/' })
  res.end()
})
req.pipe(busboy)

更多模块信息

更多详细API可以访问npm官方文档 https://www.npmjs.com/package/busboy

 

上传文件简单实现

 

安装依赖

npm install --save busboy
  • busboy 是用来解析出请求中文件流

例子源码

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/upload/

封装上传文件到写入服务的方法

const inspect = require('util').inspect
const path = require('path')
const os = require('os')
const fs = require('fs')
const Busboy = require('busboy')

/**
 * 同步创建文件目录
 * @param  {string} dirname 目录绝对地址
 * @return {boolean}        创建目录结果
 */
function mkdirsSync( dirname ) {
  if (fs.existsSync( dirname )) {
    return true
  } else {
    if (mkdirsSync( path.dirname(dirname)) ) {
      fs.mkdirSync( dirname )
      return true
    }
  }
}

/**
 * 获取上传文件的后缀名
 * @param  {string} fileName 获取上传文件的后缀名
 * @return {string}          文件后缀名
 */
function getSuffixName( fileName ) {
  let nameList = fileName.split('.')
  return nameList[nameList.length - 1]
}

/**
 * 上传文件
 * @param  {object} ctx     koa上下文
 * @param  {object} options 文件上传参数 fileType文件类型, path文件存放路径
 * @return {promise}         
 */
function uploadFile( ctx, options) {
  let req = ctx.req
  let res = ctx.res
  let busboy = new Busboy({headers: req.headers})

  // 获取类型
  let fileType = options.fileType || 'common'
  let filePath = path.join( options.path,  fileType)
  let mkdirResult = mkdirsSync( filePath )
  
  return new Promise((resolve, reject) => {
    console.log('文件上传中...')
    let result = { 
      success: false,
      formData: {},
    }

    // 解析请求文件事件
    busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
      let fileName = Math.random().toString(16).substr(2) + '.' + getSuffixName(filename)
      let _uploadFilePath = path.join( filePath, fileName )
      let saveTo = path.join(_uploadFilePath)

      // 文件保存到制定路径
      file.pipe(fs.createWriteStream(saveTo))

      // 文件写入事件结束
      file.on('end', function() {
        result.success = true
        result.message = '文件上传成功'

        console.log('文件上传成功!')
        resolve(result)
      })
    })

    // 解析表单中其他字段信息
    busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
      console.log('表单字段数据 [' + fieldname + ']: value: ' + inspect(val));
      result.formData[fieldname] = inspect(val);
    });

    // 解析结束事件
    busboy.on('finish', function( ) {
      console.log('文件上结束')
      resolve(result)
    })

    // 解析错误事件
    busboy.on('error', function(err) {
      console.log('文件上出错')
      reject(result)
    })

    req.pipe(busboy)
  })
    
} 


module.exports =  {
  uploadFile
}

入口文件

const Koa = require('koa')
const path = require('path')
const app = new Koa()
// const bodyParser = require('koa-bodyparser')

const { uploadFile } = require('./util/upload')

// app.use(bodyParser())

app.use( async ( ctx ) => {

  if ( ctx.url === '/' && ctx.method === 'GET' ) {
    // 当GET请求时候返回表单页面
    let html = `
      <h1>koa2 upload demo</h1>
      <form method="POST" action="/upload.json" enctype="multipart/form-data">
        <p>file upload</p>
        <span>picName:</span><input name="picName" type="text" /><br/>
        <input name="file" type="file" /><br/><br/>
        <button type="submit">submit</button>
      </form>
    `
    ctx.body = html

  } else if ( ctx.url === '/upload.json' && ctx.method === 'POST' ) {
    // 上传文件请求处理
    let result = { success: false }
    let serverFilePath = path.join( __dirname, 'upload-files' )

    // 上传文件事件
    result = await uploadFile( ctx, {
      fileType: 'album', // common or album
      path: serverFilePath
    })

    ctx.body = result
  } else {
    // 其他请求显示404
    ctx.body = '<h1>404!!! o(╯□╰)o</h1>'
  }
})

app.listen(3000, () => {
  console.log('[demo] upload-simple is starting at port 3000')
})

运行结果

upload-simple-result

upload-simple-result

upload-simple-result

upload-simple-result

 

异步上传图片实现

快速上手

demo 地址

https://github.com/ChenShenhai/koa2-note/tree/master/demo/upload-async

源码理解

.
├── index.js # 后端启动文件
├── node_modules
├── package.json
├── static # 静态资源目录
│   ├── image # 异步上传图片存储目录
│   └── js
│       └── index.js # 上传图片前端js操作
├── util
│   └── upload.js # 后端处理图片流操作
└── view
    └── index.ejs # ejs后端渲染模板

后端代码

入口文件 demo/upload-async/index.js

const Koa = require('koa')
const views = require('koa-views')
const path = require('path')
const convert = require('koa-convert')
const static = require('koa-static')
const { uploadFile } = require('./util/upload')

const app = new Koa()

/**
 * 使用第三方中间件 start 
 */
app.use(views(path.join(__dirname, './view'), {
  extension: 'ejs'
}))

// 静态资源目录对于相对入口文件index.js的路径
const staticPath = './static'
// 由于koa-static目前不支持koa2
// 所以只能用koa-convert封装一下
app.use(convert(static(
  path.join( __dirname,  staticPath)
)))
/**
 * 使用第三方中间件 end 
 */

app.use( async ( ctx ) => {
  if ( ctx.method === 'GET' ) {
    let title = 'upload pic async'
    await ctx.render('index', {
      title,
    })
  } else if ( ctx.url === '/api/picture/upload.json' && ctx.method === 'POST' ) {
    // 上传文件请求处理
    let result = { success: false }
    let serverFilePath = path.join( __dirname, 'static/image' )

    // 上传文件事件
    result = await uploadFile( ctx, {
      fileType: 'album',
      path: serverFilePath
    })
    ctx.body = result
  } else {
    // 其他请求显示404
    ctx.body = '<h1>404!!! o(╯□╰)o</h1>'
  }
  
})

app.listen(3000, () => {
  console.log('[demo] upload-pic-async is starting at port 3000')
})

后端上传图片流写操作 入口文件 demo/upload-async/util/upload.js

const inspect = require('util').inspect
const path = require('path')
const os = require('os')
const fs = require('fs')
const Busboy = require('busboy')

/**
 * 同步创建文件目录
 * @param  {string} dirname 目录绝对地址
 * @return {boolean}        创建目录结果
 */
function mkdirsSync( dirname ) {
  if (fs.existsSync( dirname )) {
    return true
  } else {
    if (mkdirsSync( path.dirname(dirname)) ) {
      fs.mkdirSync( dirname )
      return true
    }
  }
}

/**
 * 获取上传文件的后缀名
 * @param  {string} fileName 获取上传文件的后缀名
 * @return {string}          文件后缀名
 */
function getSuffixName( fileName ) {
  let nameList = fileName.split('.')
  return nameList[nameList.length - 1]
}

/**
 * 上传文件
 * @param  {object} ctx     koa上下文
 * @param  {object} options 文件上传参数 fileType文件类型, path文件存放路径
 * @return {promise}         
 */
function uploadFile( ctx, options) {
  let req = ctx.req
  let res = ctx.res
  let busboy = new Busboy({headers: req.headers})

  // 获取类型
  let fileType = options.fileType || 'common'
  let filePath = path.join( options.path,  fileType)
  let mkdirResult = mkdirsSync( filePath )
  
  return new Promise((resolve, reject) => {
    console.log('文件上传中...')
    let result = { 
      success: false,
      message: '',
      data: null
    }

    // 解析请求文件事件
    busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
      let fileName = Math.random().toString(16).substr(2) + '.' + getSuffixName(filename)
      let _uploadFilePath = path.join( filePath, fileName )
      let saveTo = path.join(_uploadFilePath)

      // 文件保存到制定路径
      file.pipe(fs.createWriteStream(saveTo))

      // 文件写入事件结束
      file.on('end', function() {
        result.success = true
        result.message = '文件上传成功'
        result.data = {
          pictureUrl: `//${ctx.host}/image/${fileType}/${fileName}`
        }
        console.log('文件上传成功!')
        resolve(result)
      })
    })

    // 解析结束事件
    busboy.on('finish', function( ) {
      console.log('文件上结束')
      resolve(result)
    })

    // 解析错误事件
    busboy.on('error', function(err) {
      console.log('文件上出错')
      reject(result)
    })

    req.pipe(busboy)
  })
    
} 

module.exports =  {
  uploadFile
}

前端代码

<button class="btn" id="J_UploadPictureBtn">上传图片</button>
<hr/>
<p>上传进度<span id="J_UploadProgress">0</span>%</p>
<p>上传结果图片</p>
<div id="J_PicturePreview" class="preview-picture">
</div>
<script src="/js/index.js"></script>

上传操作代码

(function(){

let btn = document.getElementById('J_UploadPictureBtn')
let progressElem = document.getElementById('J_UploadProgress')
let previewElem = document.getElementById('J_PicturePreview')
btn.addEventListener('click', function(){
  uploadAction({
    success: function( result ) {
      console.log( result )
      if ( result && result.success && result.data && result.data.pictureUrl ) {
        previewElem.innerHTML = '<img src="'+ result.data.pictureUrl +'" style="max-width: 100%">'
      }
    },
    progress: function( data ) {
      if ( data && data * 1 > 0 ) {
        progressElem.innerText = data
      }
    }
  })
})


/**
 * 类型判断
 * @type {Object}
 */
let UtilType = {
  isPrototype: function( data ) {
    return Object.prototype.toString.call(data).toLowerCase();
  },

  isJSON: function( data ) {
    return this.isPrototype( data ) === '[object object]';
  },

  isFunction: function( data ) {
    return this.isPrototype( data ) === '[object function]';
  }
}

/**
 * form表单上传请求事件
 * @param  {object} options 请求参数
 */
function requestEvent( options ) {
  try {
    let formData = options.formData
    let xhr = new XMLHttpRequest()
    xhr.onreadystatechange = function() {

      if ( xhr.readyState === 4 && xhr.status === 200 ) {
        options.success(JSON.parse(xhr.responseText))
      } 
    }

    xhr.upload.onprogress = function(evt) {
      let loaded = evt.loaded
      let tot = evt.total
      let per = Math.floor(100 * loaded / tot) 
      options.progress(per)
    }
    xhr.open('post', '/api/picture/upload.json')
    xhr.send(formData)
  } catch ( err ) {
    options.fail(err)
  }
}

/**
 * 上传事件
 * @param  {object} options 上传参数      
 */
function uploadEvent ( options ){
  let file
  let formData = new FormData()
  let input = document.createElement('input')
  input.setAttribute('type', 'file')
  input.setAttribute('name', 'files')

  input.click()
  input.onchange = function () {
    file = input.files[0]
    formData.append('files', file)

    requestEvent({
      formData,
      success: options.success,
      fail: options.fail,
      progress: options.progress
    })  
  }

}

/**
 * 上传操作
 * @param  {object} options 上传参数     
 */
function uploadAction( options ) {
  if ( !UtilType.isJSON( options ) ) {
    console.log( 'upload options is null' )
    return
  }
  let _options = {}
  _options.success = UtilType.isFunction(options.success) ? options.success : function() {}
  _options.fail = UtilType.isFunction(options.fail) ? options.fail : function() {}
  _options.progress = UtilType.isFunction(options.progress) ? options.progress : function() {}
  
  uploadEvent(_options)
}


})()

运行效果

images/upload-async-result

 

Koa持久化框架之mysql模块

安装MySQL数据库

https://www.mysql.com/downloads/

安装 node.js的mysql模块

npm install --save mysql

模块介绍

mysql模块是node操作MySQL的引擎,可以在node.js环境下对MySQL数据库进行建表,增、删、改、查等操作。

开始使用

创建数据库会话

const mysql      = require('mysql')
const connection = mysql.createConnection({
  host     : '127.0.0.1',   // 数据库地址
  user     : 'root',    // 数据库用户
  password : '123456'   // 数据库密码
  database : 'my_database'  // 选中数据库
})
 
// 执行sql脚本对数据库进行读写 
connection.query('SELECT * FROM my_table',  (error, results, fields) => {
  if (error) throw error
  // connected! 
  
  // 结束会话
  connection.release() 
});

注意:一个事件就有一个从开始到结束的过程,数据库会话操作执行完后,就需要关闭掉,以免占用连接资源。

创建数据连接池

一般情况下操作数据库是很复杂的读写过程,不只是一个会话,如果直接用会话操作,就需要每次会话都要配置连接参数。所以这时候就需要连接池管理会话。

const mysql = require('mysql')

// 创建数据池
const pool  = mysql.createPool({
  host     : '127.0.0.1',   // 数据库地址
  user     : 'root',    // 数据库用户
  password : '123456'   // 数据库密码
  database : 'my_database'  // 选中数据库
})
 
// 在数据池中进行会话操作
pool.getConnection(function(err, connection) {
   
  connection.query('SELECT * FROM my_table',  (error, results, fields) => {
    
    // 结束会话
    connection.release();
 
    // 如果有错误就抛出
    if (error) throw error;
  })
})

更多模块信息

更多详细API可以访问npm官方文档 https://www.npmjs.com/package/mysql

 

async/await封装使用mysql

前言

由于mysql模块的操作都是异步操作,每次操作的结果都是在回调函数中执行,现在有了async/await,就可以用同步的写法去操作数据库

Promise封装mysql模块

Promise封装 ./async-db

const mysql = require('mysql')
const pool = mysql.createPool({
  host     :  '127.0.0.1',
  user     :  'root',
  password :  '123456',
  database :  'my_database'
})

let query = function( sql, values ) {
  return new Promise(( resolve, reject ) => {
    pool.getConnection(function(err, connection) {
      if (err) {
        reject( err )
      } else {
        connection.query(sql, values, ( err, rows) => {

          if ( err ) {
            reject( err )
          } else {
            resolve( rows )
          }
          connection.release()
        })
      }
    })
  })
}

module.exports = { query }

async/await使用

const { query } = require('./async-db')
async function selectAllData( ) {
  let sql = 'SELECT * FROM my_table'
  let dataList = await query( sql )
  return dataList
}

async function getData() {
  let dataList = await selectAllData()
  console.log( dataList )
}

getData()

 

建表初始化

前言

通常初始化数据库要建立很多表,特别在项目开发的时候表的格式可能会有些变动,这时候就需要封装对数据库建表初始化的方法,保留项目的sql脚本文件,然后每次需要重新建表,则执行建表初始化程序就行

快速开始

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/mysql/

源码目录

├── index.js # 程序入口文件
├── node_modules/
├── package.json
├── sql   # sql脚本文件目录
│   ├── data.sql
│   └── user.sql
└── util    # 工具代码
    ├── db.js # 封装的mysql模块方法
    ├── get-sql-content-map.js # 获取sql脚本文件内容
    ├── get-sql-map.js # 获取所有sql脚本文件
    └── walk-file.js # 遍历sql脚本文件

具体流程

       +---------------------------------------------------+
       |                                                   |
       |   +-----------+   +-----------+   +-----------+   |
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
+----------+  遍历sql  +---+ 解析所有sql +---+  执行sql  +------------>
       |   |  目录下的  |   |  文件脚本  |   |   脚本     |   |
+----------+  sql文件   +---+   内容    +---+           +------------>
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
       |   |           |   |           |   |           |   |
       |   +-----------+   +-----------+   +-----------+   |
       |                                                   |
       +---------------------------------------------------+

源码详解

数据库操作文件 ./util/db.js

const mysql = require('mysql')

const pool = mysql.createPool({
  host     :  '127.0.0.1',
  user     :  'root',
  password :  'abc123',
  database :  'koa_demo'
})

let query = function( sql, values ) {

  return new Promise(( resolve, reject ) => {
    pool.getConnection(function(err, connection) {
      if (err) {
        reject( err )
      } else {
        connection.query(sql, values, ( err, rows) => {

          if ( err ) {
            reject( err )
          } else {
            resolve( rows )
          }
          connection.release()
        })
      }
    })
  })

}

module.exports = {
  query
}

获取所有sql脚本内容 ./util/get-sql-content-map.js

const fs = require('fs')
const getSqlMap = require('./get-sql-map')

let sqlContentMap = {}

/**
 * 读取sql文件内容
 * @param  {string} fileName 文件名称
 * @param  {string} path     文件所在的路径
 * @return {string}          脚本文件内容
 */
function getSqlContent( fileName,  path ) {
  let content = fs.readFileSync( path, 'binary' )
  sqlContentMap[ fileName ] = content
}

/**
 * 封装所有sql文件脚本内容
 * @return {object} 
 */
function getSqlContentMap () {
  let sqlMap = getSqlMap()
  for( let key in sqlMap ) {
    getSqlContent( key, sqlMap[key] )
  }

  return sqlContentMap
}

module.exports = getSqlContentMap

获取sql目录详情 ./util/get-sql-map.js

const fs = require('fs')
const walkFile = require('./walk-file')

/**
 * 获取sql目录下的文件目录数据
 * @return {object} 
 */
function getSqlMap () {
  let basePath = __dirname
  basePath = basePath.replace(/\\/g, '\/')

  let pathArr = basePath.split('\/')
  pathArr = pathArr.splice( 0, pathArr.length - 1 )
  basePath = pathArr.join('/') + '/sql/'

  let fileList = walkFile( basePath, 'sql' )
  return fileList
}

module.exports = getSqlMap

遍历目录操作 ./util/walk-file.js

const fs = require('fs')

/**
 * 遍历目录下的文件目录
 * @param  {string} pathResolve  需进行遍历的目录路径
 * @param  {string} mime         遍历文件的后缀名
 * @return {object}              返回遍历后的目录结果
 */
const walkFile = function(  pathResolve , mime ){

  let files = fs.readdirSync( pathResolve )

  let fileList = {}

   for( let [ i, item] of files.entries() ) {
    let itemArr = item.split('\.')

    let itemMime = ( itemArr.length > 1 ) ? itemArr[ itemArr.length - 1 ] : 'undefined'
    let keyName = item + ''
    if( mime === itemMime ) {
      fileList[ item ] =  pathResolve + item
    }
  }

  return fileList
}

module.exports = walkFile

入口文件 ./index.js

const fs = require('fs');
const getSqlContentMap = require('./util/get-sql-content-map');
const { query } = require('./util/db');


// 打印脚本执行日志
const eventLog = function( err , sqlFile, index ) {
  if( err ) {
    console.log(`[ERROR] sql脚本文件: ${sqlFile} 第${index + 1}条脚本 执行失败 o(╯□╰)o !`)
  } else {
    console.log(`[SUCCESS] sql脚本文件: ${sqlFile} 第${index + 1}条脚本 执行成功 O(∩_∩)O !`)
  }
}

// 获取所有sql脚本内容
let sqlContentMap = getSqlContentMap()

// 执行建表sql脚本
const createAllTables = async () => {
  for( let key in sqlContentMap ) {
    let sqlShell = sqlContentMap[key]
    let sqlShellList = sqlShell.split(';')

    for ( let [ i, shell ] of sqlShellList.entries() ) {
      if ( shell.trim() ) {
        let result = await query( shell )
        if ( result.serverStatus * 1 === 2 ) {
          eventLog( null,  key, i)
        } else {
          eventLog( true,  key, i) 
        }
      }
    }
  }
  console.log('sql脚本执行结束!')
  console.log('请按 ctrl + c 键退出!')

}

createAllTables()

sql脚本文件 ./sql/data.sql

CREATE TABLE   IF NOT EXISTS  `data` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `data_info` json DEFAULT NULL,
  `create_time` varchar(20) DEFAULT NULL,
  `modified_time` varchar(20) DEFAULT NULL,
  `level` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

sql脚本文件 ./sql/user.sql

CREATE TABLE   IF NOT EXISTS  `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `email` varchar(255) DEFAULT NULL,
  `password` varchar(255) DEFAULT NULL,
  `name` varchar(255) DEFAULT NULL,
  `nick` varchar(255) DEFAULT NULL,
  `detail_info` json DEFAULT NULL,
  `create_time` varchar(20) DEFAULT NULL,
  `modified_time` varchar(20) DEFAULT NULL,
  `level` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `user` set email='1@example.com', password='123456';
INSERT INTO `user` set email='2@example.com', password='123456';
INSERT INTO `user` set email='3@example.com', password='123456';

执行脚本

node index.js

执行结果

mysql-init-result-01

查看数据库写入数据

mysql-init-result-01

 

原生koa2实现jsonp

前言

在项目复杂的业务场景,有时候需要在前端跨域获取数据,这时候提供数据的服务就需要提供跨域请求的接口,通常是使用JSONP的方式提供跨域接口。

实现JSONP

demo地址

https://github.com/ChenShenhai/koa2-note/blob/master/demo/jsonp/

具体原理

  // 判断是否为JSONP的请求 
  if ( ctx.method === 'GET' && ctx.url.split('?')[0] === '/getData.jsonp') {
    // 获取jsonp的callback
    let callbackName = ctx.query.callback || 'callback'
    let returnData = {
      success: true,
      data: {
        text: 'this is a jsonp api',
        time: new Date().getTime(),
      }
    } 

    // jsonp的script字符串
    let jsonpStr = `;${callbackName}(${JSON.stringify(returnData)})`

    // 用text/javascript,让请求支持跨域获取
    ctx.type = 'text/javascript'

    // 输出jsonp字符串
    ctx.body = jsonpStr
  }  

解析原理

  • JSONP跨域输出的数据是可执行的JavaScript代码
    • ctx输出的类型应该是'text/javascript'
    • ctx输出的内容为可执行的返回数据JavaScript代码字符串
  • 需要有回调函数名callbackName,前端获取后会通过动态执行JavaScript代码字符,获取里面的数据

效果截图

同域访问JSON请求

jsonp-result-01

跨域访问JSON请求

jsonp-result-02

完整demo代码

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

app.use( async ( ctx ) => {


  // 如果jsonp 的请求为GET
  if ( ctx.method === 'GET' && ctx.url.split('?')[0] === '/getData.jsonp') {

    // 获取jsonp的callback
    let callbackName = ctx.query.callback || 'callback'
    let returnData = {
      success: true,
      data: {
        text: 'this is a jsonp api',
        time: new Date().getTime(),
      }
    }

    // jsonp的script字符串
    let jsonpStr = `;${callbackName}(${JSON.stringify(returnData)})`

    // 用text/javascript,让请求支持跨域获取
    ctx.type = 'text/javascript'

    // 输出jsonp字符串
    ctx.body = jsonpStr

  } else {

    ctx.body = 'hello jsonp'

  }
})

app.listen(3000, () => {
  console.log('[demo] jsonp is starting at port 3000')
})

 

koa-jsonp中间件

koa.js 官方wiki中也介绍了不少jsonp的中间件 

jsonp-wiki

其中koa-jsonp是支持koa2的,使用方式也非常简单,koa-jsonp的官方demo也很容易理解

快速使用

demo地址

https://github.com/ChenShenhai/koa2-note/blob/master/demo/jsonp-use-middleware/

安装

npm install --save koa-jsonp

简单例子

const Koa = require('koa')
const jsonp = require('koa-jsonp')
const app = new Koa()

// 使用中间件
app.use(jsonp())

app.use( async ( ctx ) => {
  
  let returnData = {
    success: true,
    data: {
      text: 'this is a jsonp api',
      time: new Date().getTime(),
    }
  }

  // 直接输出JSON
  ctx.body = returnData
})

app.listen(3000, () => {
  console.log('[demo] jsonp is starting at port 3000')
})

 

单元测试

前言

测试是一个项目周期里必不可少的环节,开发者在开发过程中也是无时无刻进行“人工测试”,如果每次修改一点代码,都要牵一发动全身都要手动测试关联接口,这样子是禁锢了生产力。为了解放大部分测试生产力,相关的测试框架应运而生,比较出名的有mocha,karma,jasmine等。虽然框架繁多,但是使用起来都是大同小异。

准备工作

安装测试相关框架

npm install --save-dev mocha chai supertest
  • mocha 模块是测试框架
  • chai 模块是用来进行测试结果断言库,比如一个判断 1 + 1 是否等于 2
  • supertest 模块是http请求测试库,用来请求API接口

测试例子

demo地址

https://github.com/ChenShenhai/koa2-note/blob/master/demo/test-unit/

例子目录

.
├── index.js # api文件
├── package.json
└── test # 测试目录
    └── index.test.js # 测试用例

所需测试demo

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

const server = async ( ctx, next ) => {
  let result = {
    success: true,
    data: null
  }

  if ( ctx.method === 'GET' ) { 
    if ( ctx.url === '/getString.json' ) {
      result.data = 'this is string data'
    } else if ( ctx.url === '/getNumber.json' ) {
      result.data = 123456
    } else {
      result.success = false
    }
    ctx.body = result
    next && next()
  } else if ( ctx.method === 'POST' ) {
    if ( ctx.url === '/postData.json' ) {
      result.data = 'ok'
    } else {
      result.success = false
    }
    ctx.body = result
    next && next()
  } else {
    ctx.body = 'hello world'
    next && next()
  }
}

app.use(server)

module.exports = app

app.listen(3000, () => {
  console.log('[demo] test-unit is starting at port 3000')
})

启动服务后访问接口会看到以下数据

http://localhost:3000/getString.json

test-unit-result-01

开始写测试用例

demo/test-unit/test/index.test.js

const supertest = require('supertest')
const chai = require('chai')
const app = require('./../index')

const expect = chai.expect
const request = supertest( app.listen() )

// 测试套件/组
describe( '开始测试demo的GET请求', ( ) => {
  
  // 测试用例
  it('测试/getString.json请求', ( done ) => {
      request
        .get('/getString.json')
        .expect(200)
        .end(( err, res ) => {
            // 断言判断结果是否为object类型
            expect(res.body).to.be.an('object')
            expect(res.body.success).to.be.an('boolean')
            expect(res.body.data).to.be.an('string')
            done()
        })
  })
})

执行测试用例

# node.js <= 7.5.x
./node_modules/.bin/mocha  --harmony

# node.js = 7.6.0
./node_modules/.bin/mocha

注意:

  1. 如果是全局安装了mocha,可以直接在当前项目目录下执行 mocha --harmony 命令
  2. 如果当前node.js版本低于7.6,由于7.5.x以下还直接不支持async/awiar就需要加上--harmony

会自动读取执行命令 ./test 目录下的测用例文件 inde.test.js,并执行。测试结果如下 test-unit-result-03

用例详解

服务入口加载

如果要对一个服务的API接口,进行单元测试,要用supertest加载服务的入口文件

const supertest = require('supertest')
const request = supertest( app.listen() )

测试套件、用例

  • describe()描述的是一个测试套件
  • 嵌套在describe()的it()是对接口进行自动化测试的测试用例
  • 一个describe()可以包含多个it()
describe( '开始测试demo的GET请求', ( ) => {
    it('测试/getString.json请求', () => {
        // TODO ...
    })
})
  • supertest封装服务request,是用来请求接口
  • chai.expect使用来判断测试结果是否与预期一样
  • chai 断言有很多种方法,这里只是用了数据类型断言

 

开发调试debug

环境

  • node环境 8.x +
  • chrome 60+

启动脚本

调试demo

https://github.com/ChenShenhai/koa2-note/blob/master/demo/start-quick/

node --inspect index.js

指令框显示

指令框就会出现以下字样

Debugger listening on ws://127.0.0.1:9229/4c23c723-5197-4d23-9b90-d473f1164abe
For help see https://nodejs.org/en/docs/inspector

debug-result

访问chrome浏览器调试server

debug-result

打开浏览器调试窗口会看到一个node.js 的小logo

debug-result

打开chrome浏览器的node调试窗口

debug-result

debug-result

 注意打开了node的调试窗口后,原来绿色的node按钮会变灰色,同时调试框会显示debug状态

debug-result

debug-result

可以自定义打断点调试了

debug-result

 

项目demo

快速启动

https://github.com/ChenShenhai/koa2-note/blob/master/demo/project/

初始化数据库

  • 安装MySQL5.6以上版本
  • 创建数据库koa_demo
create database koa_demo;
  • 配置项目config.js

https://github.com/ChenShenhai/koa2-note/blob/master/demo/project/

const config = {
  // 启动端口
  port: 3001,

  // 数据库配置
  database: {
    DATABASE: 'koa_demo',
    USERNAME: 'root',
    PASSWORD: 'abc123',
    PORT: '3306',
    HOST: 'localhost'
  }
}

module.exports = config

启动脚本

# 安装淘宝镜像cnpm
npm install -g cnpm --registry=https://registry.npm.taobao.org

# 安装依赖
cnpm install

# 数据建库初始化
npm run init_sql

# 编译react.js源码
npm run start_static

# 启动服务
npm run start_server 

访问项目demo

http://localhost:3001/admin

project-result

 

框架设计

实现概要

  • koa2 搭建服务
  • MySQL作为数据库
    • mysql 5.7 版本
    • 储存普通数据
    • 存储session登录态数据
  • 渲染
    • 服务端渲染:ejs作为服务端渲染的模板引擎
    • 前端渲染:用webpack4环境编译react.js动态渲染页面,使用ant-design框架

文件目录设计

demo源码

https://github.com/ChenShenhai/koa2-note/blob/master/demo/project/

├── init # 数据库初始化目录
│   ├── index.js # 初始化入口文件
│   ├── sql/    # sql脚本文件目录
│   └── util/   # 工具操作目录
├── package.json 
├── config.js # 配置文件
├── server  # 后端代码目录
│   ├── app.js # 后端服务入口文件
│   ├── codes/ # 提示语代码目录
│   ├── controllers/    # 操作层目录
│   ├── models/ # 数据模型model层目录
│   ├── routers/ # 路由目录
│   ├── services/   # 业务层目录
│   ├── utils/  # 工具类目录
│   └── views/  # 模板目录
└── static # 前端静态代码目录
    ├── build/   # webpack编译配置目录
    ├── output/  # 编译后前端代码目录&静态资源前端访问目录
    └── src/ # 前端源代码目录

入口文件预览

const path = require('path')
const Koa = require('koa')
const convert = require('koa-convert')
const views = require('koa-views')
const koaStatic = require('koa-static')
const bodyParser = require('koa-bodyparser')
const koaLogger = require('koa-logger')
const session = require('koa-session-minimal')
const MysqlStore = require('koa-mysql-session')

const config = require('./../config')
const routers = require('./routers/index')

const app = new Koa()

// session存储配置
const sessionMysqlConfig= {
  user: config.database.USERNAME,
  password: config.database.PASSWORD,
  database: config.database.DATABASE,
  host: config.database.HOST,
}

// 配置session中间件
app.use(session({
  key: 'USER_SID',
  store: new MysqlStore(sessionMysqlConfig)
}))

// 配置控制台日志中间件
app.use(convert(koaLogger()))

// 配置ctx.body解析中间件
app.use(bodyParser())

// 配置静态资源加载中间件
app.use(convert(koaStatic(
  path.join(__dirname , './../static')
)))

// 配置服务端模板渲染引擎中间件
app.use(views(path.join(__dirname, './views'), {
  extension: 'ejs'
}))

// 初始化路由中间件
app.use(routers.routes()).use(routers.allowedMethods())

// 监听启动端口
app.listen( config.port )
console.log(`the server is start at port ${config.port}`)

 

分层设计

后端代码目录

└── server
    ├── controllers # 操作层 执行服务端模板渲染,json接口返回数据,页面跳转
    │   ├── admin.js
    │   ├── index.js
    │   ├── user-info.js
    │   └── work.js
    ├── models # 数据模型层 执行数据操作
    │   └── user-Info.js
    ├── routers # 路由层 控制路由
    │   ├── admin.js
    │   ├── api.js
    │   ├── error.js
    │   ├── home.js
    │   ├── index.js
    │   └── work.js
    ├── services # 业务层 实现数据层model到操作层controller的耦合封装
    │   └── user-info.js
    └── views # 服务端模板代码
        ├── admin.ejs
        ├── error.ejs
        ├── index.ejs
        └── work.ejs

 

数据库设计

初始化数据库脚本

脚本目录

./demos/project/init/sql/

CREATE TABLE   IF NOT EXISTS  `user_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT, # 用户ID
  `email` varchar(255) DEFAULT NULL,    # 邮箱地址
  `password` varchar(255) DEFAULT NULL, # 密码
  `name` varchar(255) DEFAULT NULL,     # 用户名
  `nick` varchar(255) DEFAULT NULL,     # 用户昵称
  `detail_info` longtext DEFAULT NULL,  # 详细信息
  `create_time` varchar(20) DEFAULT NULL,   # 创建时间
  `modified_time` varchar(20) DEFAULT NULL, # 修改时间
  `level` int(11) DEFAULT NULL, # 权限级别
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

# 插入默认信息
INSERT INTO `user_info` set name='admin001', email='admin001@example.com', password='123456';

 

 

路由设计

使用koa-router中间件

路由目录

# ...
└── server # 后端代码目录
    └── routers
        ├── admin.js # /admin/* 子路由
        ├── api.js #  resetful /api/* 子路由
        ├── error.js #   /error/* 子路由
        ├── home.js # 主页子路由
        ├── index.js # 子路由汇总文件
        └── work.js # /work/* 子路由
 # ...

子路由配置

resetful API 子路由

例如api子路由/user/getUserInfo.json,整合到主路由,加载到中间件后,请求的路径会是 http://www.example.com/api/user/getUserInfo.json

./demos/project/server/routers/api.js

/**
 * restful api 子路由
 */

const router = require('koa-router')()
const userInfoController = require('./../controllers/user-info')

const routers = router
  .get('/user/getUserInfo.json', userInfoController.getLoginUserInfo)
  .post('/user/signIn.json', userInfoController.signIn)
  .post('/user/signUp.json', userInfoController.signUp)

module.exports = routers

子路由汇总

./demos/project/server/routers/index.js

/**
 * 整合所有子路由
 */

const router = require('koa-router')()

const home = require('./home')
const api = require('./api')
const admin = require('./admin')
const work = require('./work')
const error = require('./error')

router.use('/', home.routes(), home.allowedMethods())
router.use('/api', api.routes(), api.allowedMethods())
router.use('/admin', admin.routes(), admin.allowedMethods())
router.use('/work', work.routes(), work.allowedMethods())
router.use('/error', error.routes(), error.allowedMethods())
module.exports = router

app.js加载路由中间件

./demos/project/server/app.js

const routers = require('./routers/index')

// 初始化路由中间件
app.use(routers.routes()).use(routers.allowedMethods())

 

webpack4 环境搭建

前言

由于demos/project 前端渲染是通过react.js渲染的,这就需要webpack4 对react.js及其相关JSX,ES6/7代码进行编译和混淆压缩。

安装和文档

可访问网https://webpack.js.org/

配置webpack4编译react.js + less + sass + antd 环境

文件目录

└── static # 项目静态文件目录
    ├── build
    │   ├── webpack.base.config.js # 基础编译脚本
    │   ├── webpack.dev.config.js # 开发环境编译脚本
    │   └── webpack.prod.config.js # 生产环境编译脚本
    ├── output # 编译后输出目录
    │   ├── asset
    │   ├── dist
    │   └── upload
    └── src # 待编译的ES6/7、JSX源代码
        ├── api
        ├── apps
        ├── components
        ├── pages
        ├── texts
        └── utils

webpack4 编译基础配置

babel@7 配置

const babelConfig = {
  presets: [
    '@babel/env',
    // [
    //   '@babel/env',
    //   {
    //     targets: {
    //       edge: '17',
    //       firefox: '60',
    //       chrome: '67',
    //       safari: '11.1'
    //     },
    //     useBuiltIns: 'usage'
    //   }
    // ],
    '@babel/preset-react'
  ],
  'plugins': [
    [
      'import',
      { 'libraryName': 'antd', 'libraryDirectory': 'lib' },
      'ant'
    ],
    [
      'import',
      { 'libraryName': 'antd-mobile', 'libraryDirectory': 'lib' },
      'antd-mobile'
    ],
    '@babel/plugin-proposal-class-properties'
  ]
};

module.exports = babelConfig;

webpack.base.config.js

const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const babelConfig = require('./babel.config');

// const prodMode = process.env.NODE_ENV === 'production';

const srcResolve = function (file) {
  return path.join(__dirname, '..', 'src', file);
};

const distResolve = function (file) {
  return path.join(__dirname, '..', 'output', 'dist', file);
};

module.exports = {
  entry: {
    'index': srcResolve('js/index'),
    'admin' : srcResolve('pages/admin.js'),
    'work' : srcResolve('pages/work.js'),
    'index' : srcResolve('pages/index.js'),
    'error' : srcResolve('pages/error.js'),
  },
  output: {
    path: distResolve(''),
    filename: 'vendorjs/[name].js'
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: {
          loader: 'babel-loader',
          options: babelConfig
        }
      },
      {
        test: /\.(css|less)$/,
        use: [
          // devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
          // 'style-loader',
          MiniCssExtractPlugin.loader,
          'css-loader',
          // 'postcss-loader',
          {
            loader: 'postcss-loader',
            options: {
              plugins: () => {
                return [];
              }
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].css'
    })
  ],
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  }
};

配置开发&生产环境webpack4 编译设置

为了方便编译基本配置代码统一管理,开发环境(wepack.dev.config.js)和生产环境(webpack.prod.config.js)的编译配置都是继承了基本配置(wepack.base.config.js)的代码

开发环境配置 wepack.dev.config.js

var merge = require('webpack-merge')
var webpack = require('webpack')
var baseWebpackConfig = require('./webpack.base.config');

module.exports = merge(baseWebpackConfig, {

  devtool: 'source-map',
  plugins: [
    
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: JSON.stringify('development')
      }
    }),
  ]
})

编译环境配置 wepack.prod.config.js

process.env.NODE_ENV = 'production';

const merge = require('webpack-merge');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const config = require('./webpack.base.config');

module.exports = merge(config, {
  mode: 'production',
  // plugins: [
  //   new UglifyJsPlugin()
  // ]
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true
      }),
      new OptimizeCSSAssetsPlugin({})
    ]
  }
});

 

使用react.js

react.js简介

react.js 是作为前端渲染的js库(注意:不是框架)。react.js用JSX开发来描述DOM结构,通过编译成virtual dom的在浏览器中进行view渲染和动态交互处理。更多了解可查阅GitHubhttps://facebook.github.io/react/

编译使用

由于react.js开发过程用JSX编程,无法直接在浏览器中运行,需要编译成浏览器可识别运行的virtual dom。从JSX开发到运行,需要有一个编译的过程。目前最常用的方案是用webpack + babel进行编译打包。

前端待编译源文件目录

demos/project/static/

.
├── build # 编译的webpack脚本
│   ├── webpack.base.config.js
│   ├── webpack.dev.config.js
│   └── webpack.prod.config.js
├── output # 输出文件
│   ├── asset
│   ├── dist #  react.js编译后的文件目录
│   └── ...
└── src
   ├── apps # 页面react.js应用
   │   ├── admin.jsx
   │   ├── error.jsx
   │   ├── index.jsx
   │   └── work.jsx
   ├── components # jsx 模块、组件
   │   ├── footer-common.jsx
   │   ├── form-group.jsx
   │   ├── header-nav.jsx
   │   ├── sign-in-form.jsx
   │   └── sign-up-form.jsx
   └── pages # react.js 执行render文件目录
       ├── admin.js
       ├── error.js
       ├── index.js
       └── work.js
        ...

react.js页面应用文件

static/src/apps/index.jsx 文件

import React from 'react'
import ReactDOM from 'react-dom'
import { Layout, Menu, Breadcrumb } from 'antd'
import HeadeNav from './../components/header-nav.jsx'
import FooterCommon from './../components/footer-common.jsx'
import 'antd/lib/layout/style/css'

const { Header, Content, Footer } = Layout

class App extends React.Component {
  render() {
    return (
      <Layout className="layout">
        <HeadeNav/>
        <Content style={{ padding: '0 50px' }}>
          <Breadcrumb style={{ margin: '12px 0' }}>
            <Breadcrumb.Item>Home</Breadcrumb.Item>
          </Breadcrumb>
          <div style={{ background: '#fff', padding: 24, minHeight: 280 }}>
            <p>index</p>
          </div>
        </Content>
        <FooterCommon />
      </Layout>
    )
  }
}
export default App

react.js执行render渲染

static/src/pages/index.js 文件

import React from 'react'
import ReactDOM from 'react-dom'
import App from './../apps/index.jsx'

ReactDOM.render( <App />,
  document.getElementById("app"))

静态页面引用react.js编译后文件

<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
    <link rel="stylesheet" href="/output/dist/css/index.css">
</head>
<body>
    <div id="app"></div>
    <script src="/output/dist/js/vendor.js"></script>
    <script src="/output/dist/js/index.js"></script>
</body>
</html>

页面渲染效果

project-result-01.png

 

登录注册功能实现

用户模型dao操作

/**
   * 数据库创建用户
   * @param  {object} model 用户数据模型
   * @return {object}       mysql执行结果
   */
  async create ( model ) {
    let result = await dbUtils.insertData( 'user_info', model )
    return result
  },

  /**
   * 查找一个存在用户的数据
   * @param  {obejct} options 查找条件参数
   * @return {object|null}        查找结果
   */
  async getExistOne(options ) {
    let _sql = `
    SELECT * from user_info
      where email="${options.email}" or name="${options.name}"
      limit 1`
    let result = await dbUtils.query( _sql )
    if ( Array.isArray(result) && result.length > 0 ) {
      result = result[0]
    } else {
      result = null
    }
    return result
  },

  /**
   * 根据用户名和密码查找用户
   * @param  {object} options 用户名密码对象
   * @return {object|null}         查找结果
   */
  async getOneByUserNameAndPassword( options ) {
    let _sql = `
    SELECT * from user_info
      where password="${options.password}" and name="${options.name}"
      limit 1`
    let result = await dbUtils.query( _sql )
    if ( Array.isArray(result) && result.length > 0 ) {
      result = result[0]
    } else {
      result = null
    }
    return result
  },

  /**
   * 根据用户名查找用户信息
   * @param  {string} userName 用户账号名称
   * @return {object|null}     查找结果
   */
  async getUserInfoByUserName( userName ) {

    let result = await dbUtils.select(
      'user_info',
      ['id', 'email', 'name', 'detail_info', 'create_time', 'modified_time', 'modified_time' ])
    if ( Array.isArray(result) && result.length > 0 ) {
      result = result[0]
    } else {
      result = null
    }
    return result
  },

业务层操作

/**
   * 创建用户
   * @param  {object} user 用户信息
   * @return {object}      创建结果
   */
  async create( user ) {
    let result = await userModel.create(user)
    return result
  },

  /**
   * 查找存在用户信息
   * @param  {object} formData 查找的表单数据
   * @return {object|null}      查找结果
   */
  async getExistOne( formData ) {
    let resultData = await userModel.getExistOne({
      'email': formData.email,
      'name': formData.userName
    })
    return resultData
  },

  /**
   * 登录业务操作
   * @param  {object} formData 登录表单信息
   * @return {object}          登录业务操作结果
   */
  async signIn( formData ) {
    let resultData = await userModel.getOneByUserNameAndPassword({
      'password': formData.password,
      'name': formData.userName})
    return resultData
  },


  /**
   * 根据用户名查找用户业务操作
   * @param  {string} userName 用户名
   * @return {object|null}     查找结果
   */
  async getUserInfoByUserName( userName ) {
    
    let resultData = await userModel.getUserInfoByUserName( userName ) || {}
    let userInfo = {
      // id: resultData.id,
      email: resultData.email,
      userName: resultData.name,
      detailInfo: resultData.detail_info,
      createTime: resultData.create_time
    }
    return userInfo
  },


  /**
   * 检验用户注册数据
   * @param  {object} userInfo 用户注册数据
   * @return {object}          校验结果
   */
  validatorSignUp( userInfo ) {
    let result = {
      success: false,
      message: '',
    }

    if ( /[a-z0-9\_\-]{6,16}/.test(userInfo.userName) === false ) {
      result.message = userCode.ERROR_USER_NAME
      return result
    }
    if ( !validator.isEmail( userInfo.email ) ) {
      result.message = userCode.ERROR_EMAIL
      return result
    }
    if ( !/[\w+]{6,16}/.test( userInfo.password )  ) {
      result.message = userCode.ERROR_PASSWORD
      return result
    }
    if ( userInfo.password !== userInfo.confirmPassword ) {
      result.message = userCode.ERROR_PASSWORD_CONFORM
      return result
    }

    result.success = true

    return result
  }

controller 操作

 /**
   * 登录操作
   * @param  {obejct} ctx 上下文对象
   */
  async signIn( ctx ) {
    let formData = ctx.request.body
    let result = {
      success: false,
      message: '',
      data: null,
      code: ''
    }

    let userResult = await userInfoService.signIn( formData )

    if ( userResult ) {
      if ( formData.userName === userResult.name ) {
        result.success = true
      } else {
        result.message = userCode.FAIL_USER_NAME_OR_PASSWORD_ERROR
        result.code = 'FAIL_USER_NAME_OR_PASSWORD_ERROR'
      }
    } else {
      result.code = 'FAIL_USER_NO_EXIST',
      result.message = userCode.FAIL_USER_NO_EXIST
    }

    if ( formData.source === 'form' && result.success === true ) {
      let session = ctx.session
      session.isLogin = true
      session.userName = userResult.name
      session.userId = userResult.id

      ctx.redirect('/work')
    } else {
      ctx.body = result
    }
  },

  /**
   * 注册操作
   * @param   {obejct} ctx 上下文对象
   */
  async signUp( ctx ) {
    let formData = ctx.request.body
    let result = {
      success: false,
      message: '',
      data: null
    }

    let validateResult = userInfoService.validatorSignUp( formData )

    if ( validateResult.success === false ) {
      result = validateResult
      ctx.body = result
      return
    }

    let existOne  = await userInfoService.getExistOne(formData)
    console.log( existOne )

    if ( existOne  ) {
      if ( existOne .name === formData.userName ) {
        result.message = userCode.FAIL_USER_NAME_IS_EXIST
        ctx.body = result
        return
      }
      if ( existOne .email === formData.email ) {
        result.message = userCode.FAIL_EMAIL_IS_EXIST
        ctx.body = result
        return
      }
    }


    let userResult = await userInfoService.create({
      email: formData.email,
      password: formData.password,
      name: formData.userName,
      create_time: new Date().getTime(),
      level: 1,
    })

    console.log( userResult )

    if ( userResult && userResult.insertId * 1 > 0) {
      result.success = true
    } else {
      result.message = userCode.ERROR_SYS
    }

    ctx.body = result
  },

api路由操作

const router = require('koa-router')()
const userInfoController = require('./../controllers/user-info')

const routers = router
  .get('/user/getUserInfo.json', userInfoController.getLoginUserInfo)
  .post('/user/signIn.json', userInfoController.signIn)
  .post('/user/signUp.json', userInfoController.signUp)

前端用react.js实现效果

登录模式 

project-result-01

注册模式

 project-result-01

 

session登录态判断处理

使用session中间件

// code ...
const session = require('koa-session-minimal')
const MysqlStore = require('koa-mysql-session')

const config = require('./../config')

// code ...

const app = new Koa()

// session存储配置
const sessionMysqlConfig= {
  user: config.database.USERNAME,
  password: config.database.PASSWORD,
  database: config.database.DATABASE,
  host: config.database.HOST,
}

// 配置session中间件
app.use(session({
  key: 'USER_SID',
  store: new MysqlStore(sessionMysqlConfig)
}))
// code ...

登录成功后设置session到MySQL和设置sessionId到cookie

let session = ctx.session
session.isLogin = true
session.userName = userResult.name
session.userId = userResult.id

需要判断登录态页面进行session判断

async indexPage ( ctx ) {
    // 判断是否有session
    if ( ctx.session && ctx.session.isLogin && ctx.session.userName ) {
      const title = 'work页面'
      await ctx.render('work', {
        title,
      })
    } else {
      // 没有登录态则跳转到错误页面
      ctx.redirect('/error')
    }
  },

 

使用import/export特性

前言

Node 9最激动人心的是提供了在flag模式下使用ECMAScript Modules,虽然现在还是Stability: 1 - Experimental阶段,但是可以让Noder抛掉babel等工具的束缚,直接在Node环境下愉快地去玩耍import/export

如果觉得文字太多,看不下去,可以直接去玩玩demo,地址是https://github.com/chenshenhai/node-modules-demo

Node 9下import/export使用简单须知

  • Node 环境必须在 9.0以上
  • 不加loader时候,使用import/export的文件后缀名必须为*.mjs(下面会讲利用Loader Hooks兼容*.js后缀文件)
  • 启动必须加上flag --experimental-modules
  • 文件的importexport必须严格按照ECMAScript Modules语法
  • ECMAScript Modulesrequire()的cache机制不一样

使用简述

Node 9.x官方文档 https://nodejs.org/dist/latest-v9.x/docs/api/esm.html

与require()区别

能力描述require()import
NODE_PATH从NODE_PATH加载依赖模块YN
cache缓存机制可以通过require的API操作缓存自己独立的缓存机制,目前不可访问
path引用路径文件路径URL格式文件路径,例如import A from './a?v=2017'
extensions扩展名机制require.extensionsLoader Hooks
natives原生模块引用直接支持直接支持
npmnpm模块引用直接支持需要Loader Hooks
file文件(引用)*.js,*.json等直接支持默认只能是*.mjs,通过Loader Hooks可以自定义配置规则支持*.js,*.json等Node原有支持文件

Loader Hooks模式使用

由于历史原因,在ES6的Modules还没确定之前,JavaScript的模块化处理方案都是八仙过海,各显神通,例如前端的AMD、CMD模块方案,Node的CommonJS方案也在这个“乱世”诞生。 当到了ES6规范确定后,Node的CommonJS方案已经是JavaScript中比较成熟的模块化方案,但ES6怎么说都是正统的规范,“法理”上是需要兼容的,所以*.mjs这个针对ECMAScript Modules规范的Node文件方案在一片讨论声中应运而生。

当然如果import/export只能对*.mjs文件起作用,意味着Node原生模块和npm所有第三方模块都不能。所以这时候Node 9就提供了 Loader Hooks,开发者可自定义配置Resolve Hook规则去利用import/export加载使用Node原生模块,*.js文件,npm模块,C/C++的Node编译模块等Node生态圈的模块。

Loader Hooks 使用步骤

  • 自定义loader规则
  • 启动的flag要加载loader规则文件
    • 例如:node --experimental-modules --loader ./custom-loader.mjs ./index.js

Koa2 直接使用import/export

看看demo4,https://github.com/chenshenhai/node-modules-demo/tree/master/demo4

  • 文件目录
├── esm
│   ├── README.md
│   ├── custom-loader.mjs
│   ├── index.js
│   ├── lib
│   │   ├── data.json
│   │   ├── path.js
│   │   └── render.js
│   ├── package.json
│   └── view
│       ├── index.html
│       ├── index.html
│       └── todo.html

代码片段太多,不一一贴出来,只显示主文件

import Koa from 'koa';
import { render } from './lib/render.js';
import data from './lib/data.json';

let app = new Koa();
app.use((ctx, next) => {
    let view = ctx.url.substr(1);
    let content;
    if ( view === '' ) {
        content = render('index');
    } else if ( view === 'data' ) {
        content = data;
    } else {
        content = render(view);
    }
    ctx.body = content;
})
app.listen(3000, ()=>{
    console.log('the modules test server is starting');
})
  • 执行代码
node --experimental-modules  --loader ./custom-loader.mjs ./index.js

自定义loader规则优化

从上面官方提供的自定义loader例子看出,只是对*.js文件做import/export做loader兼容,然而我们在实际开发中需要对npm模块,*.json文件也使用import/export

loader规则优化解析

import url from 'url';
import path from 'path';
import process from 'process';
import fs from 'fs';

// 从package.json中
// 的dependencies、devDependencies获取项目所需npm模块信息
const ROOT_PATH = process.cwd();
const PKG_JSON_PATH = path.join( ROOT_PATH, 'package.json' );
const PKG_JSON_STR = fs.readFileSync(PKG_JSON_PATH, 'binary');
const PKG_JSON = JSON.parse(PKG_JSON_STR);
// 项目所需npm模块信息
const allDependencies = {
  ...PKG_JSON.dependencies || {},
  ...PKG_JSON.devDependencies || {}
}

//Node原生模信息
const builtins = new Set(
  Object.keys(process.binding('natives')).filter((str) =>
    /^(?!(?:internal|node|v8)\/)/.test(str))
);

// 文件引用兼容后缀名
const JS_EXTENSIONS = new Set(['.js', '.mjs']);
const JSON_EXTENSIONS = new Set(['.json']);

export function resolve(specifier, parentModuleURL, defaultResolve) {
  // 判断是否为Node原生模块
  if (builtins.has(specifier)) {
    return {
      url: specifier,
      format: 'builtin'
    };
  }

  // 判断是否为npm模块
  if ( allDependencies && typeof allDependencies[specifier] === 'string' ) {
    return defaultResolve(specifier, parentModuleURL);
  }

  // 如果是文件引用,判断是否路径格式正确
  if (/^\.{0,2}[/]/.test(specifier) !== true && !specifier.startsWith('file:')) { 
    throw new Error(
      `imports must begin with '/', './', or '../'; '${specifier}' does not`);
  }

  // 判断是否为*.js、*.mjs、*.json文件
  const resolved = new url.URL(specifier, parentModuleURL);
  const ext = path.extname(resolved.pathname);
  if (!JS_EXTENSIONS.has(ext) && !JSON_EXTENSIONS.has(ext)) {
    throw new Error(
      `Cannot load file with non-JavaScript file extension ${ext}.`);
  }

  // 如果是*.js、*.mjs文件
  if (JS_EXTENSIONS.has(ext)) {
    return {
      url: resolved.href,
      format: 'esm'
    };
  }
  
  // 如果是*.json文件
  if (JSON_EXTENSIONS.has(ext)) {
    return {
      url: resolved.href,
      format: 'json'
    };
  }

}

规则总结

在自定义loader中,export的resolve规则最核心的代码是

return {
  url: '',
  format: ''
}
  • url 是模块名称或者文件URL格式路径
  • format 是模块格式有esmcjsjsonbuiltinaddon这四种模块/文件格式.

注意: 目前Node对import/export的支持现在还是Stability: 1 - Experimental阶段,后续的发展还有很多不确定因素,自己练手玩玩还可以,但是在还没去flag使用之前,尽量不要在生产环境中使用。Node 9.x 更详细import/export的使用,可参考 https://github.com/ChenShenhai/blog/issues/24

 

 

使用TypeScript开发

🌈 Tkoa是使用 typescript 编写的 koa 框架! 

尽管它是基于 typescript 编写,但是你依然还是可以使用一些 node.js 框架和基于 koa 的中间件。

不仅如此,你还可以享受 typescript 的类型检查系统和方便地使用 typescript 进行测试!

安装

TKoa 需要 >= typescript v3.1.0 和 node v7.6.0 版本。

$ npm install tkoa

Hello T-koa

import tKoa = require('tkoa');

interface ctx {
    res: {
        end: Function
    }
}

const app = new tKoa();

// response
app.use((ctx: ctx) => {
    ctx.res.end('Hello T-koa!');
});

app.listen(3000);

Middleware

Tkoa 是一个中间件框架,拥有两种中间件:

  • 异步中间件
  • 普通中间件

下面是一个日志记录中间件示例,其中使用了不同的中间件类型:

async functions (node v7.6+):

interface ctx {
  method: string,
  url: string
}

app.use(async (ctx: ctx, next: Function) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

Common function

// Middleware normally takes two parameters (ctx, next), ctx is the context for one request,
// next is a function that is invoked to execute the downstream middleware. It returns a Promise with a then function for running code after completion.

interface ctx {
  method: string,
  url: string
}

app.use((ctx: ctx, next: Function) => {
  const start = Date.now();
  return next().then(() => {
    const ms = Date.now() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
  });
});

Getting started

TypeScript

  • 大于等于 v3.1 版本

Node.js

  • 大于等于 v7.6.0 版本

License

MIT

 

 

Egg.js 与 Koa

异步编程模型

Node.js 是一个异步的世界,官方 API 支持的都是 callback 形式的异步编程模型,这会带来许多问题,例如

  • callback hell: 最臭名昭著的 callback 嵌套问题。
  • release zalgo: 异步函数中可能同步调用 callback 返回数据,带来不一致性。

因此社区提供了各种异步的解决方案,最终胜出的是 Promise,它也内置到了 ECMAScript 2015 中。而在 Promise 的基础上,结合 Generator 提供的切换上下文能力,出现了 co 等第三方类库来让我们用同步写法编写异步代码。同时,async function 这个官方解决方案也于 ECMAScript 2017 中发布,并在 Node.js 8 中实现。

async function

async function 是语言层面提供的语法糖,在 async function 中,我们可以通过 await 关键字来等待一个 Promise 被 resolve(或者 reject,此时会抛出异常), Node.js 现在的 LTS 版本(8.x)已原生支持。

const fn = async function() {
  const user = await getUser();
  const posts = await fetchPosts(user.id);
  return { user, posts };
};
fn().then(res => console.log(res)).catch(err => console.error(err.stack));

Koa

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

Koa 和 Express 的设计风格非常类似,底层也都是共用的同一套 HTTP 基础库,但是有几个显著的区别,除了上面提到的默认异步解决方案之外,主要的特点还有下面几个。

Middleware

Koa 的中间件和 Express 不同,Koa 选择了洋葱圈模型。

  • 中间件洋葱图:

  • 中间件执行顺序图:

所有的请求经过一个中间件的时候都会执行两次,对比 Express 形式的中间件,Koa 的模型可以非常方便的实现后置处理逻辑,对比 Koa 和 Express 的 Compress 中间件就可以明显的感受到 Koa 中间件模型的优势。

Context

和 Express 只有 Request 和 Response 两个对象不同,Koa 增加了一个 Context 的对象,作为这次请求的上下文对象(在 Koa 1 中为中间件的 this,在 Koa 2 中作为中间件的第一个参数传入)。我们可以将一次请求相关的上下文都挂载到这个对象上。类似 traceId 这种需要贯穿整个请求(在后续任何一个地方进行其他调用都需要用到)的属性就可以挂载上去。相较于 request 和 response 而言更加符合语义。

同时 Context 上也挂载了 Request 和 Response 两个对象。和 Express 类似,这两个对象都提供了大量的便捷方法辅助开发,例如

  • get request.query
  • get request.hostname
  • set response.body
  • set response.status

异常处理

通过同步方式编写异步代码带来的另外一个非常大的好处就是异常处理非常自然,使用 try catch 就可以将按照规范编写的代码中的所有错误都捕获到。这样我们可以很便捷的编写一个自定义的错误处理中间件。

async function onerror(ctx, next) {
  try {
    await next();
  } catch (err) {
    ctx.app.emit('error', err);
    ctx.body = 'server error';
    ctx.status = err.status || 500;
  }
}

只需要将这个中间件放在其他中间件之前,就可以捕获它们所有的同步或者异步代码中抛出的异常了。

Egg 继承于 Koa

如上述,Koa 是一个非常优秀的框架,然而对于企业级应用来说,它还比较基础。

而 Egg 选择了 Koa 作为其基础框架,在它的模型基础上,进一步对它进行了一些增强。

扩展

在基于 Egg 的框架或者应用中,我们可以通过定义 app/extend/{application,context,request,response}.js 来扩展 Koa 中对应的四个对象的原型,通过这个功能,我们可以快速的增加更多的辅助方法,例如我们在 app/extend/context.js 中写入下列代码:

// 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.';
};

更多关于扩展的内容,请查看扩展章节。

插件

众所周知,在 Express 和 Koa 中,经常会引入许许多多的中间件来提供各种各样的功能,例如引入 koa-session 提供 Session 的支持,引入 koa-bodyparser 来解析请求 body。而 Egg 提供了一个更加强大的插件机制,让这些独立领域的功能模块可以更加容易编写。

一个插件可以包含

  • extend:扩展基础对象的上下文,提供各种工具类、属性。
  • middleware:增加一个或多个中间件,提供请求的前置、后置处理逻辑。
  • config:配置各个环境下插件自身的默认配置项。

一个独立领域下的插件实现,可以在代码维护性非常高的情况下实现非常完善的功能,而插件也支持配置各个环境下的默认(最佳)配置,让我们使用插件的时候几乎可以不需要修改配置项。

egg-security 插件就是一个典型的例子。

更多关于插件的内容,请查看插件章节。

Egg 与 Koa 的版本关系

Egg 1.x

Egg 1.x 发布时,Node.js 的 LTS 版本尚不支持 async function,所以 Egg 1.x 仍然基于 Koa 1.x 开发,但是在此基础上,Egg 全面增加了 async function 的支持,再加上 Egg 对 Koa 2.x 的中间件也完全兼容,应用层代码可以完全基于 async function 来开发。

  • 底层基于 Koa 1.x,异步解决方案基于 co 封装的 generator function。
  • 官方插件以及 Egg 核心使用 generator function 编写,保持对 Node.js LTS 版本的支持,在必要处通过 co 包装以兼容在 async function 中的使用。
  • 应用开发者可以选择 async function(Node.js 8.x+) 或者 generator function(Node.js 6.x+)进行编写。

Egg 2.x

Node.js 8 正式进入 LTS 后,async function 可以在 Node.js 中使用并且没有任何性能问题了,Egg 2.x 基于 Koa 2.x,框架底层以及所有内置插件都使用 async function 编写,并保持了对 Egg 1.x 以及 generator function 的完全兼容,应用层只需要升级到 Node.js 8 即可从 Egg 1.x 迁移到 Egg 2.x。

  • 底层基于 Koa 2.x,异步解决方案基于 async function。
  • 官方插件以及 Egg 核心使用 async function 编写。
  • 建议业务层迁移到 async function 方案。
  • 只支持 Node.js 8 及以上的版本。

 

Egg.js 是什么?

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

注:Egg.js 缩写为 Egg

设计原则

我们深知企业级应用在追求规范和共建的同时,还需要考虑如何平衡不同团队之间的差异,求同存异。所以我们没有选择社区常见框架的大集市模式(集成如数据库、模板引擎、前端框架等功能),而是专注于提供 Web 开发的核心功能和一套灵活可扩展的插件机制。我们不会做出技术选型,因为固定的技术选型会使框架的扩展性变差,无法满足各种定制需求。通过 Egg,团队的架构师和技术负责人可以非常容易地基于自身的技术架构在 Egg 基础上扩展出适合自身业务场景的框架。

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

Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,团队内部采用这种方式可以减少开发人员的学习成本,开发人员不再是『钉子』,可以流动起来。没有约定的团队,沟通成本是非常高的,比如有人会按目录分栈而其他人按目录分功能,开发者认知不一致很容易犯错。但约定不等于扩展性差,相反 Egg 有很高的扩展性,可以按照团队的约定定制框架。使用 Loader 可以让框架根据不同环境定义默认配置,还可以覆盖 Egg 的默认约定。

与社区框架的差异

Express 是 Node.js 社区广泛使用的框架,简单且扩展性强,非常适合做个人项目。但框架本身缺少约定,标准的 MVC 模型会有各种千奇百怪的写法。Egg 按照约定进行开发,奉行『约定优于配置』,团队协作成本低。

Sails 是和 Egg 一样奉行『约定优于配置』的框架,扩展性也非常好。但是相比 Egg,Sails 支持 Blueprint REST API、WaterLine 这样可扩展的 ORM、前端集成、WebSocket 等,但这些功能都是由 Sails 提供的。而 Egg 不直接提供功能,只是集成各种功能插件,比如实现 egg-blueprint,egg-waterline 等这样的插件,再使用 sails-egg 框架整合这些插件就可以替代 Sails 了。

特性

 

Egg快速入门

本文将从实例的角度,一步步地搭建出一个 Egg.js 应用,让你能快速的入门 Egg.js。

环境准备

  • 操作系统:支持 macOS,Linux,Windows
  • 运行环境:建议选择 LTS 版本,最低要求 8.x。

快速初始化

我们推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(npm >=6.1.0):

$ mkdir egg-example && cd egg-example
$ npm init egg --type=simple
$ npm i

启动项目:

$ npm run dev
$ open http://localhost:7001

逐步搭建

通常你可以通过上一节的方式,使用 npm init egg 快速选择适合对应业务模型的脚手架,快速启动 Egg.js 项目的开发。

但为了让大家更好的了解 Egg.js,接下来,我们将跳过脚手架,手动一步步的搭建出一个 Hacker News

注意:实际项目中,我们推荐使用上一节的脚手架直接初始化。

Egg HackerNews Snapshoot

初始化项目

先来初始化下目录结构:

$ mkdir egg-example
$ cd egg-example
$ npm init
$ npm i egg --save
$ npm i egg-bin --save-dev

添加 npm scripts 到 package.json

{
  "name": "egg-example",
  "scripts": {
    "dev": "egg-bin dev"
  }
}

编写 Controller

如果你熟悉 Web 开发或 MVC,肯定猜到我们第一步需要编写的是 Controller 和 Router

// app/controller/home.js
const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    this.ctx.body = 'Hello world';
  }
}

module.exports = HomeController;

配置路由映射:

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

加一个配置文件

// config/config.default.js
exports.keys = <此处改为你自己的 Cookie 安全字符串>;

此时目录结构如下:

egg-example
├── app
│   ├── controller
│   │   └── home.js
│   └── router.js
├── config
│   └── config.default.js
└── package.json

完整的目录结构规范参见目录结构

好,现在可以启动应用来体验下

$ npm run dev
$ open http://localhost:7001

注意:

  • Controller 有 class 和 exports 两种编写方式,本文示范的是前者,你可能需要参考 Controller 文档。
  • Config 也有 module.exports 和 exports 的写法,具体参考 Node.js modules 文档

静态资源

Egg 内置了 static 插件,线上环境建议部署到 CDN,无需该插件。

static 插件默认映射 /public/* -> app/public/* 目录

此处,我们把静态资源都放到 app/public 目录即可:

app/public
├── css
│   └── news.css
└── js
    ├── lib.js
    └── news.js

模板渲染

绝大多数情况,我们都需要读取数据后渲染模板,然后呈现给用户。故我们需要引入对应的模板引擎。

框架并不强制你使用某种模板引擎,只是约定了 View 插件开发规范,开发者可以引入不同的插件来实现差异化定制。

更多用法参见 View

在本例中,我们使用 Nunjucks 来渲染,先安装对应的插件 egg-view-nunjucks :

$ npm i egg-view-nunjucks --save

开启插件:

// config/plugin.js
exports.nunjucks = {
  enable: true,
  package: 'egg-view-nunjucks'
};
// config/config.default.js
exports.keys = <此处改为你自己的 Cookie 安全字符串>;
// 添加 view 配置
exports.view = {
  defaultViewEngine: 'nunjucks',
  mapping: {
    '.tpl': 'nunjucks',
  },
};

注意:是 config 目录,不是 app/config!

为列表页编写模板文件,一般放置在 app/view 目录下

<!-- app/view/news/list.tpl -->
<html>
  <head>
    <title>Hacker News</title>
    <link rel="stylesheet" href="/public/css/news.css" />
  </head>
  <body>
    <ul class="news-view view">
      {% for item in list %}
        <li class="item">
          <a href="{{ item.url }}">{{ item.title }}</a>
        </li>
      {% endfor %}
    </ul>
  </body>
</html>

添加 Controller 和 Router

// app/controller/news.js
const Controller = require('egg').Controller;

class NewsController extends Controller {
  async list() {
    const dataList = {
      list: [
        { id: 1, title: 'this is news 1', url: '/news/1' },
        { id: 2, title: 'this is news 2', url: '/news/2' }
      ]
    };
    await this.ctx.render('news/list.tpl', dataList);
  }
}

module.exports = NewsController;

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/news', controller.news.list);
};

启动浏览器,访问 http://localhost:7001/news 即可看到渲染后的页面。

提示:开发期默认开启了 development 插件,修改后端代码后,会自动重启 Worker 进程。

编写 service

在实际应用中,Controller 一般不会自己产出数据,也不会包含复杂的逻辑,复杂的过程应抽象为业务逻辑层 Service

我们来添加一个 Service 抓取 Hacker News 的数据 ,如下:

// app/service/news.js
const Service = require('egg').Service;

class NewsService extends Service {
  async list(page = 1) {
    // read config
    const { serverUrl, pageSize } = this.config.news;

    // use build-in http client to GET hacker-news api
    const { data: idList } = await this.ctx.curl(`${serverUrl}/topstories.json`, {
      data: {
        orderBy: '"$key"',
        startAt: `"${pageSize * (page - 1)}"`,
        endAt: `"${pageSize * page - 1}"`,
      },
      dataType: 'json',
    });

    // parallel GET detail
    const newsList = await Promise.all(
      Object.keys(idList).map(key => {
        const url = `${serverUrl}/item/${idList[key]}.json`;
        return this.ctx.curl(url, { dataType: 'json' });
      })
    );
    return newsList.map(res => res.data);
  }
}

module.exports = NewsService;

框架提供了内置的 HttpClient 来方便开发者使用 HTTP 请求。

然后稍微修改下之前的 Controller:

// app/controller/news.js
const Controller = require('egg').Controller;

class NewsController extends Controller {
  async list() {
    const ctx = this.ctx;
    const page = ctx.query.page || 1;
    const newsList = await ctx.service.news.list(page);
    await ctx.render('news/list.tpl', { list: newsList });
  }
}

module.exports = NewsController;

还需增加 app/service/news.js 中读取到的配置:

// config/config.default.js
// 添加 news 的配置项
exports.news = {
  pageSize: 5,
  serverUrl: 'https://hacker-news.firebaseio.com/v0',
};

编写扩展

遇到一个小问题,我们的资讯时间的数据是 UnixTime 格式的,我们希望显示为便于阅读的格式。

框架提供了一种快速扩展的方式,只需在 app/extend 目录下提供扩展脚本即可,具体参见扩展

在这里,我们可以使用 View 插件支持的 Helper 来实现:

$ npm i moment --save
// app/extend/helper.js
const moment = require('moment');
exports.relativeTime = time => moment(new Date(time * 1000)).fromNow();

在模板里面使用:

<!-- app/view/news/list.tpl -->
{{ helper.relativeTime(item.time) }}

编写 Middleware

假设有个需求:我们的新闻站点,禁止百度爬虫访问。

聪明的同学们一定很快能想到可以通过 Middleware 判断 User-Agent,如下:

// app/middleware/robot.js
// options === app.config.robot
module.exports = (options, app) => {
  return async function robotMiddleware(ctx, next) {
    const source = ctx.get('user-agent') || '';
    const match = options.ua.some(ua => ua.test(source));
    if (match) {
      ctx.status = 403;
      ctx.message = 'Go away, robot.';
    } else {
      await next();
    }
  }
};

// config/config.default.js
// add middleware robot
exports.middleware = [
  'robot'
];
// robot's configurations
exports.robot = {
  ua: [
    /Baiduspider/i,
  ]
};

现在可以使用 curl http://localhost:7001/news -A "Baiduspider" 看看效果。

更多参见中间件文档。

配置文件

写业务的时候,不可避免的需要有配置文件,框架提供了强大的配置合并管理功能:

  • 支持按环境变量加载不同的配置文件,如 config.local.js, config.prod.js 等等。
  • 应用/插件/框架都可以配置自己的配置文件,框架将按顺序合并加载。
  • 具体合并逻辑可参见配置文件
// config/config.default.js
exports.robot = {
  ua: [
    /curl/i,
    /Baiduspider/i,
  ],
};

// config/config.local.js
// only read at development mode, will override default
exports.robot = {
  ua: [
    /Baiduspider/i,
  ],
};

// app/service/some.js
const Service = require('egg').Service;

class SomeService extends Service {
  async list() {
    const rule = this.config.robot.ua;
  }
}

module.exports = SomeService;

单元测试

单元测试非常重要,框架也提供了 egg-bin 来帮开发者无痛的编写测试。

测试文件应该放在项目根目录下的 test 目录下,并以 test.js 为后缀名,即 {app_root}/test/**/*.test.js

// test/app/middleware/robot.test.js
const { app, mock, assert } = require('egg-mock/bootstrap');

describe('test/app/middleware/robot.test.js', () => {
  it('should block robot', () => {
    return app.httpRequest()
      .get('/')
      .set('User-Agent', "Baiduspider")
      .expect(403);
  });
});

然后配置依赖和 npm scripts

{
  "scripts": {
    "test": "egg-bin test",
    "cov": "egg-bin cov"
  }
}
$ npm i egg-mock --save-dev

执行测试:

$ npm test

就这么简单,更多请参见 单元测试

后记

短短几章内容,只能讲 Egg 的冰山一角,我们建议开发者继续阅读其他章节:

  • 关于骨架类型,参见骨架说明
  • 提供了强大的扩展机制,参见插件
  • 一个大规模的团队需要遵循一定的约束和约定,在 Egg 里我们建议封装适合自己团队的上层框架,参见 框架开发
  • 这是一个渐进式的框架,代码的共建,复用和下沉,竟然可以这么的无痛,建议阅读 渐进式开发
  • 写单元测试其实很简单的事,Egg 也提供了非常多的配套辅助,我们强烈建议大家测试驱动开发,具体参见 单元测试

 

渐进式开发

在 Egg 里面,有插件,也有框架,前者还包括了 path 和 package 两种加载模式,那我们应该如何选择呢?

本文将以实例的方式,一步步给大家演示下,如何渐进式地进行代码演进。

全部的示例代码可以参见 eggjs/examples/progressive

最初始的状态

假设我们有一段分析 UA 的代码,实现以下功能:

  • ctx.isAndroid
  • ctx.isIOS

通过之前的教程,大家一定可以很快地写出来,我们快速回顾下:

对应的代码参见 step1

目录结构:

example-app
├── app
│   ├── extend
│   │   └── context.js
│   └── router.js
├── test
│   └── index.test.js
└── package.json

核心代码:

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

插件的雏形

我们很明显能感知到,这段逻辑是具备通用性的,可以写成插件。

但一开始的时候,功能还没完善,直接独立插件,维护起来比较麻烦。

此时,我们可以把代码写成插件的形式,但并不独立出去。

对应的代码参见 step2

新的目录结构:

example-app
├── app
│   └── router.js
├── config
│   └── plugin.js
├── lib
│   └── plugin
│       └── egg-ua
│           ├── app
│           │   └── extend
│           │       └── context.js
│           └── package.json
├── test
│   └── index.test.js
└── package.json

核心代码:

  • app/extend/context.js 移动到 lib/plugin/egg-ua/app/extend/context.js

  • lib/plugin/egg-ua/package.json 声明插件。

{
  "eggPlugin": {
    "name": "ua"
  }
}
  • config/plugin.js 中通过 path 来挂载插件。
// config/plugin.js
const path = require('path');
exports.ua = {
  enable: true,
  path: path.join(__dirname, '../lib/plugin/egg-ua'),
};

抽成独立插件

经过一段时间开发后,该模块的功能成熟,此时可以考虑抽出来成为独立的插件。

首先,我们抽出一个 egg-ua 插件,看过插件文档的同学应该都比较熟悉,我们这里只简单过一下:

目录结构:

egg-ua
├── app
│   └── extend
│       └── context.js
├── test
│   ├── fixtures
│   │   └── test-app
│   │       ├── app
│   │       │   └── router.js
│   │       └── package.json
│   └── ua.test.js
└── package.json

对应的代码参见 step3/egg-ua

然后改造原有的应用,对应的代码参见 step3/example-app

  • 移除 lib/plugin/egg-ua 目录。
  • package.json 中声明对 egg-ua 的依赖。
  • config/plugin.js 中修改依赖声明为 package 方式。
// config/plugin.js
exports.ua = {
  enable: true,
  package: 'egg-ua',
};

注意:在插件还没发布前,可以通过 npm link 的方式进行本地测试,具体参见 npm-link

$ cd example-app
$ npm link ../egg-ua
$ npm i
$ npm test

沉淀到框架

重复上述的过程,很快我们会积累了好几个插件和配置,并且我们会发现,在团队的大部分项目中,都会用到这些插件。

此时,就可以考虑抽象出一个适合团队业务场景的框架。

首先,抽象出 example-framework 框架,如上看过框架文档的同学应该都比较熟悉,我们这里只简单过一下:

目录结构:

example-framework
├── config
│   ├── config.default.js
│   └── plugin.js
├── lib
│   ├── agent.js
│   └── application.js
├── test
│   ├── fixtures
│   │   └── test-app
│   └── framework.test.js
├── README.md
├── index.js
└── package.json
  • 对应的代码参见 example-framework
  • 把原来的 egg-ua 等插件的依赖,从 example-app 中移除,配置到该框架的 package.json 和 config/plugin.js 中。

然后改造原有的应用,对应的代码参见 step4/example-app

  • 移除 config/plugin.js 中对 egg-ua 的依赖。
  • package.json 中移除对 egg-ua 的依赖。
  • package.json 中声明对 example-framework 的依赖,并配置 egg.framework
{
  "name": "progressive",
  "version": "1.0.0",
  "private": true,
  "egg": {
    "framework": "example-framework"
  },
  "dependencies": {
    "example-framework": "*"
  }
}

注意:在框架还没发布前,可以通过 npm link 的方式进行本地测试,具体参见 npm-link

$ cd example-app
$ npm link ../egg-framework
$ npm i
$ npm test

写在最后

综上所述,大家可以看到我们是如何一步步渐进地去进行框架演进,这得益于 Egg 强大的插件机制、代码的共建,以及复用和下沉,这些步骤竟然可以这么地无痛来得以完成!

  • 一般来说,当应用中有可能会复用到的代码时,直接放到 lib/plugin 目录去,如例子中的 egg-ua
  • 当该插件功能稳定后,即可独立出来作为一个 node module 。
  • 如此以往,应用中相对复用性较强的代码都会逐渐独立为单独的插件。
  • 当你的应用逐渐进化到针对某类业务场景的解决方案时,将其抽象为独立的 framework 进行发布。
  • 当在新项目中抽象出的插件,下沉集成到框架后,其他项目只需要简单的重新 npm install 下就可以使用上,对整个团队的效率有极大的提升。
  • 注意:不管是应用/插件/框架,都必须编写单元测试,并尽量实现 100% 覆盖率。

 

 

Egg@2 升级指南

背景

随着 Node.js 8 LTS 的发布, 内建了对 ES2017 Async Function 的支持。

在这之前,TJ 的 co 使我们可以提前享受到 async/await 的编程体验,但同时它不可避免的也带来一些问题:

现在 Egg 正式发布了 2.x 版本:

  • 保持了对 Egg 1.x 以及 generator function 的完全兼容
  • 基于 Koa 2.x,异步解决方案基于 async function
  • 只支持 Node.js 8 及以上版本。
  • 去除 co 后堆栈信息更清晰,带来 30% 左右的性能提升(不含 Node 带来的性能提升),详细参见:benchmark

Egg 的理念之一是渐进式增强,故我们为开发者提供渐进升级的体验。

快速升级

  • Node.js 使用最新的 LTS 版本(>=8.9.0)。
  • 修改 package.json 中 egg 的依赖为 ^2.0.0
  • 检查相关插件是否发布新版本(可选)。
  • 重新安装依赖,跑单元测试。

搞定!几乎不需要修改任何一行代码,就已经完成了升级。

插件变更说明

egg-multipart

yield parts 需修改为 await parts() 或 yield parts()

// old
const parts = ctx.multipart();
while ((part = yield parts) != null) {
  // do something
}

// yield parts() also work
while ((part = yield parts()) != null) {
  // do something
}

// new
const parts = ctx.multipart();
while ((part = await parts()) != null) {
  // do something
}

egg-userrole

不再兼容 1.x 形式的 role 定义,因为 koa-roles 已经无法兼容了。 请求上下文 Context 从 this 传入改成了第一个参数 ctx 传入,原有的 scope 变成了第二个参数。

// old
app.role.use('user', function() {
  return !!this.user;
});

// new
app.role.use((ctx, scope) => {
  return !!ctx.user
});

app.role.use('user', ctx => {
  return !!ctx.user;
});

进一步升级

得益于 Egg 对 1.x 的完全兼容,我们可以如何非常快速的完成升级。

不过,为了更好的统一代码风格,以及更佳的性能和错误堆栈,我们建议开发者进一步升级:

中间件使用 Koa2 风格

2.x 仍然保持对 1.x 风格的中间件的兼容,故不修改也能继续使用。

  • 返回的函数入参改为 Koa 2 的 (ctx, next) 风格。
    • 第一个参数为 ctx,代表当前请求的上下文,是 Context 的实例。
    • 第二个参数为 next,用 await 执行它来执行后续中间件的逻辑。
  • 不建议使用 async (ctx, next) => {} 格式,避免错误堆栈丢失函数名。
  • yield next 改为函数调用 await next() 的方式。
// 1.x
module.exports = () => {
  return function* responseTime(next) {
    const start = Date.now();
    yield next;
    const delta = Math.ceil(Date.now() - start);
    this.set('X-Response-Time', delta + 'ms');
  };
};

// 2.x
module.exports = () => {
  return async function responseTime(ctx, next) {
    const start = Date.now();
    // 注意,和 generator function 格式的中间件不同,此时 next 是一个方法,必须要调用它
    await next();
    const delta = Math.ceil(Date.now() - start);
    ctx.set('X-Response-Time', delta + 'ms');
  };
};

yieldable to awaitable

我们早在 Egg 1.x 时就已经支持 async,故若应用层已经是 async-base 的,就可以跳过本小节内容了。

co 支持了 yieldable 兼容类型:

  • promises
  • array (parallel execution)
  • objects (parallel execution)
  • thunks (functions)
  • generators (delegation)
  • generator functions (delegation)

尽管 generator 和 async 两者的编程模型基本一模一样,但由于上述的 co 的一些特殊处理,导致在移除 co 后,我们需要根据不同场景自行处理:

promise

直接替换即可:

function echo(msg) {
  return Promise.resolve(msg);
}

yield echo('hi egg');
// change to
await echo('hi egg');

array - yield []

yield [] 常用于并发请求,如:

const [ news, user ] = yield [
  ctx.service.news.list(topic),
  ctx.service.user.get(uid),
];

这种修改起来比较简单,用 Promise.all() 包装下即可:

const [ news, user ] = await Promise.all([
  ctx.service.news.list(topic),
  ctx.service.user.get(uid),
]);

object - yield {}

yield {} 和 yield map 的方式也常用于并发请求,但由于 Promise.all 不支持 Object,会稍微有点复杂。

// app/service/biz.js
class BizService extends Service {
  * list(topic, uid) {
    return {
      news: ctx.service.news.list(topic),
      user: ctx.service.user.get(uid),
    };
  }
}

// app/controller/home.js
const { news, user } = yield ctx.service.biz.list(topic, uid);

建议修改为 await Promise.all([]) 的方式:

// app/service/biz.js
class BizService extends Service {
  list(topic, uid) {
    return Promise.all([
      ctx.service.news.list(topic),
      ctx.service.user.get(uid),
    ]);
  }
}

// app/controller/home.js
const [ news, user ] = await ctx.service.biz.list(topic, uid);

如果无法修改对应的接口,可以临时兼容下:

  • 使用我们提供的 Utils 方法 app.toPromise
  • 建议尽量改掉,因为实际上就是丢给 co,会带回对应的性能损失和堆栈问题。
const { news, user } = await app.toPromise(ctx.service.biz.list(topic, uid));

其他

  • thunks (functions)
  • generators (delegation)
  • generator functions (delegation)

修改为对应的 async function 即可,如果不能修改,则可以用 app.toAsyncFunction 简单包装下。

注意

  • toAsyncFunction 和 toPromise 实际使用的是 co 包装,因此会带回对应的性能损失和堆栈问题,建议开发者还是尽量全链路升级。
  • toAsyncFunction 在调用 async function 时不会有损失。

@sindresorhus 编写了许多基于 promise 的 helper 方法,灵活的运用它们配合 async function 能让代码更加具有可读性。

插件升级

应用开发者只需升级插件开发者修改后的依赖版本即可,也可以用我们提供的命令 egg-bin autod 快速更新。

以下内容针对插件开发者,指导如何升级插件:

升级事项

  • 完成上面章节提到的升级项。
    • 所有的 generator function 改为 async function 格式。
    • 升级中间件风格。
  • 接口兼容(可选),如下。
  • 发布大版本。

接口兼容

某些场景下,插件开发者提供给应用开发者的接口是同时支持 generator 和 async 的,一般是会用 co 包装一层。

  • 在 2.x 里为了更好的性能和错误堆栈,我们建议修改为 async-first
  • 如有需要,使用 toAsyncFunction 和 toPromise 来兼容。

譬如 egg-schedule 插件,支持应用层使用 generator 或 async 定义 task。

// {app_root}/app/schedule/cleandb.js
exports.task = function* (ctx) {
  yield ctx.service.db.clean();
};

// {app_root}/app/schedule/log.js
exports.task = async function splitLog(ctx) {
  await ctx.service.log.split();
};

插件开发者可以简单包装下原始函数:

// https://github.com/eggjs/egg-schedule/blob/80252ef/lib/load_schedule.js#L38
task = app.toAsyncFunction(schedule.task);

插件发布规则

  • 需要发布大版本
    • 除非插件提供的接口都是 promise 的,且代码里面不存在 async,如 egg-view-nunjucks
  • 修改 package.json
    • 修改 devDependencies 依赖的 egg 为 ^2.0.0
    • 修改 engines.node 为 >=8.0.0
    • 修改 ci.version 为 8, 9, 并重新安装依赖以便生成新的 travis 配置文件。
  • 修改 README.md 的示例为 async function。
  • 编写升级指引。
  • 修改 test/fixtures 为 async function,可选,建议分开另一个 PR 方便 Review。

一般还会需要继续维护上一个版本,故需要:

  • 对上一个版本建立一个 1.x 这类的 branch 分支
  • 修改上一个版本的 package.json 的 publishConfig.tag 为 release-1.x
  • 这样如果上一个版本有 BugFix 时,npm 版本时就会发布为 release-1.x 这个 tag,用户通过 npm i egg-xx@release-1.x 来引入旧版本。
  • 参见 npm 文档

 

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 (可选)
│       ├── request.js (可选)
│       ├── response.js (可选)
│       ├── context.js (可选)
│       ├── 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 路由规则,具体参见 Router
  • app/controller/** 用于解析用户的输入,处理后返回相应的结果,具体参见 Controller
  • app/service/** 用于编写业务逻辑层,可选,建议使用,具体参见 Service
  • app/middleware/** 用于编写中间件,可选,具体参见 Middleware
  • app/public/** 用于放置静态资源,可选,具体参见内置插件 egg-static
  • app/extend/** 用于框架的扩展,可选,具体参见框架扩展
  • config/config.{env}.js 用于编写配置文件,具体参见配置
  • config/plugin.js 用于配置需要加载的插件,具体参见插件
  • test/** 用于单元测试,具体参见单元测试
  • app.js 和 agent.js 用于自定义启动时的初始化工作,可选,具体参见启动自定义。关于agent.js的作用参见Agent机制

由内置插件约定的目录:

  • app/public/** 用于放置静态资源,可选,具体参见内置插件 egg-static
  • app/schedule/** 用于定时任务,可选,具体参见定时任务

若需自定义自己的目录规范,参见 Loader API

  • app/view/** 用于放置模板文件,可选,由模板插件约定,具体参见模板渲染
  • app/model/** 用于放置领域模型,可选,由领域类相关插件约定,如 egg-sequelize

 

Egg框架内置基础对象

在本章,我们会初步介绍一下框架中内置的一些基础对象,包括从 Koa 继承而来的 4 个对象(Application, Context, Request, Response) 以及框架扩展的一些对象(Controller, Service, Helper, Config, Logger),在后续的文档阅读中我们会经常遇到它们。

Application

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

事件

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

  • server: 该事件一个 worker 进程只会触发一次,在 HTTP 服务完成启动后,会将 HTTP server 通过这个事件暴露出来给开发者。
  • error: 运行时有任何的异常被 onerror 插件捕获后,都会触发 error 事件,将错误对象和关联的上下文(如果有)暴露给开发者,可以进行自定义的日志记录上报等处理。
  • request 和 response: 应用收到请求和响应请求时,分别会触发 request 和 response 事件,并将当前请求上下文暴露出来,开发者可以监听这两个事件来进行日志记录。
// 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
  });
};

获取方式

Application 对象几乎可以在编写应用时的任何一个地方获取到,下面介绍几个经常用到的获取方式:

几乎所有被框架 Loader 加载的文件(Controller,Service,Schedule 等),都可以 export 一个函数,这个函数会被 Loader 调用,并使用 app 作为参数:

  • 启动自定义脚本

    // app.js
    module.exports = app => {
      app.cache = new Cache();
    };
    
  • Controller 文件

    // app/controller/user.js
    class UserController extends Controller {
      async fetch() {
        this.ctx.body = this.app.cache.get(this.ctx.query.id);
      }
    }
    

和 Koa 一样,在 Context 对象上,可以通过 ctx.app 访问到 Application 对象。以上面的 Controller 文件举例:

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    this.ctx.body = this.ctx.app.cache.get(this.ctx.query.id);
  }
}

在继承于 Controller, Service 基类的实例中,可以通过 this.app 访问到 Application 对象。

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    this.ctx.body = this.app.cache.get(this.ctx.query.id);
  }
};

Context

Context 是一个请求级别的对象,继承自 Koa.Context。在每一次收到用户请求时,框架会实例化一个 Context 对象,这个对象封装了这次用户请求的信息,并提供了许多便捷的方法来获取请求参数或者设置响应信息。框架会将所有的 Service 挂载到 Context 实例上,一些插件也会将一些其他的方法和对象挂载到它上面(egg-sequelize 会将所有的 model 挂载在 Context 上)。

获取方式

最常见的 Context 实例获取方式是在 MiddlewareController 以及 Service 中。Controller 中的获取方式在上面的例子中已经展示过了,在 Service 中获取和 Controller 中获取的方式一样,在 Middleware 中获取 Context 实例则和 Koa 框架在中间件中获取 Context 对象的方式一致。

框架的 Middleware 同时支持 Koa v1 和 Koa v2 两种不同的中间件写法,根据不同的写法,获取 Context 实例的方式也稍有不同:

// Koa v1
function* middleware(next) {
  // this is instance of Context
  console.log(this.query);
  yield next;
}

// Koa v2
async function middleware(ctx, next) {
  // ctx is instance of Context
  console.log(ctx.query);
}

除了在请求时可以获取 Context 实例之外, 在有些非用户请求的场景下我们需要访问 service / model 等 Context 实例上的对象,我们可以通过 Application.createAnonymousContext() 方法创建一个匿名 Context 实例:

// app.js
module.exports = app => {
  app.beforeStart(async () => {
    const ctx = app.createAnonymousContext();
    // preload before app start
    await ctx.service.posts.load();
  });
}

定时任务中的每一个 task 都接受一个 Context 实例作为参数,以便我们更方便的执行一些定时的业务逻辑:

// app/schedule/refresh.js
exports.task = async ctx => {
  await ctx.service.posts.refresh();
};

Request & Response

Request 是一个请求级别的对象,继承自 Koa.Request。封装了 Node.js 原生的 HTTP Request 对象,提供了一系列辅助方法获取 HTTP 请求常用参数。

Response 是一个请求级别的对象,继承自 Koa.Response。封装了 Node.js 原生的 HTTP Response 对象,提供了一系列辅助方法设置 HTTP 响应。

获取方式

可以在 Context 的实例上获取到当前请求的 Request(ctx.request) 和 Response(ctx.response) 实例。

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    const { app, ctx } = this;
    const id = ctx.request.query.id;
    ctx.response.body = app.cache.get(id);
  }
}
  • Koa 会在 Context 上代理一部分 Request 和 Response 上的方法和属性,参见 Koa.Context
  • 如上面例子中的 ctx.request.query.id 和 ctx.query.id 是等价的,ctx.response.body= 和 ctx.body= 是等价的。
  • 需要注意的是,获取 POST 的 body 应该使用 ctx.request.body,而不是 ctx.body

Controller

框架提供了一个 Controller 基类,并推荐所有的 Controller 都继承于该基类实现。这个 Controller 基类有下列属性:

  • ctx - 当前请求的 Context 实例。
  • app - 应用的 Application 实例。
  • config - 应用的配置
  • service - 应用所有的 service
  • logger - 为当前 controller 封装的 logger 对象。

在 Controller 文件中,可以通过两种方式来引用 Controller 基类:

// app/controller/user.js

// 从 egg 上获取(推荐)
const Controller = require('egg').Controller;
class UserController extends Controller {
  // implement
}
module.exports = UserController;

// 从 app 实例上获取
module.exports = app => {
  return class UserController extends app.Controller {
    // implement
  };
};

Service

框架提供了一个 Service 基类,并推荐所有的 Service 都继承于该基类实现。

Service 基类的属性和 Controller 基类属性一致,访问方式也类似:

// app/service/user.js

// 从 egg 上获取(推荐)
const Service = require('egg').Service;
class UserService extends Service {
  // implement
}
module.exports = UserService;

// 从 app 实例上获取
module.exports = app => {
  return class UserService extends app.Service {
    // implement
  };
};

Helper

Helper 用来提供一些实用的 utility 函数。它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处,同时可以更好的编写测试用例。

Helper 自身是一个类,有和 Controller 基类一样的属性,它也会在每次请求时进行实例化,因此 Helper 上的所有函数也能获取到当前请求相关的上下文信息。

获取方式

可以在 Context 的实例上获取到当前请求的 Helper(ctx.helper) 实例。

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    const { app, ctx } = this;
    const id = ctx.query.id;
    const user = app.cache.get(id);
    ctx.body = ctx.helper.formatUser(user);
  }
}

除此之外,Helper 的实例还可以在模板中获取到,例如可以在模板中获取到 security 插件提供的 shtml 方法。

// app/view/home.nj
{{ helper.shtml(value) }}

自定义 helper 方法

应用开发中,我们可能经常要自定义一些 helper 方法,例如上面例子中的 formatUser,我们可以通过框架扩展的形式来自定义 helper 方法。

// app/extend/helper.js
module.exports = {
  formatUser(user) {
    return only(user, [ 'name', 'phone' ]);
  }
};

Config

我们推荐应用开发遵循配置和代码分离的原则,将一些需要硬编码的业务配置都放到配置文件中,同时配置文件支持各个不同的运行环境使用不同的配置,使用起来也非常方便,所有框架、插件和应用级别的配置都可以通过 Config 对象获取到,关于框架的配置,可以详细阅读 Config 配置章节。

获取方式

我们可以通过 app.config 从 Application 实例上获取到 config 对象,也可以在 Controller, Service, Helper 的实例上通过 this.config 获取到 config 对象。

Logger

框架内置了功能强大的日志功能,可以非常方便的打印各种级别的日志到对应的日志文件中,每一个 logger 对象都提供了 4 个级别的方法:

  • logger.debug()
  • logger.info()
  • logger.warn()
  • logger.error()

在框架中提供了多个 Logger 对象,下面我们简单的介绍一下各个 Logger 对象的获取方式和使用场景。

App Logger

我们可以通过 app.logger 来获取到它,如果我们想做一些应用级别的日志记录,如记录启动阶段的一些数据信息,记录一些业务上与请求无关的信息,都可以通过 App Logger 来完成。

App CoreLogger

我们可以通过 app.coreLogger 来获取到它,一般我们在开发应用时都不应该通过 CoreLogger 打印日志,而框架和插件则需要通过它来打印应用级别的日志,这样可以更清晰的区分应用和框架打印的日志,通过 CoreLogger 打印的日志会放到和 Logger 不同的文件中。

Context Logger

我们可以通过 ctx.logger 从 Context 实例上获取到它,从访问方式上我们可以看出来,Context Logger 一定是与请求相关的,它打印的日志都会在前面带上一些当前请求相关的信息(如 [$userId/$ip/$traceId/${cost}ms $method $url]),通过这些信息,我们可以从日志快速定位请求,并串联一次请求中的所有的日志。

Context CoreLogger

我们可以通过 ctx.coreLogger 获取到它,和 Context Logger 的区别是一般只有插件和框架会通过它来记录日志。

Controller Logger & Service Logger

我们可以在 Controller 和 Service 实例上通过 this.logger 获取到它们,它们本质上就是一个 Context Logger,不过在打印日志的时候还会额外的加上文件路径,方便定位日志的打印位置。

Subscription

订阅模型是一种比较常见的开发模式,譬如消息中间件的消费者或调度任务。因此我们提供了 Subscription 基类来规范化这个模式。

可以通过以下方式来引用 Subscription 基类:

const Subscription = require('egg').Subscription;

class Schedule extends Subscription {
  // 需要实现此方法
  // subscribe 可以为 async function 或 generator function
  async subscribe() {}
}

插件开发者可以根据自己的需求基于它定制订阅规范,如定时任务就是使用这种规范实现的。

 

Egg运行环境

一个 Web 应用本身应该是无状态的,并拥有根据运行环境设置自身的能力。

指定运行环境

框架有两种方式指定运行环境:

  1. 通过 config/env 文件指定,该文件的内容就是运行环境,如 prod。一般通过构建工具来生成这个文件。
// config/env
prod
  1. 通过 EGG_SERVER_ENV 环境变量指定运行环境更加方便,比如在生产环境启动应用:
EGG_SERVER_ENV=prod npm start

应用内获取运行环境

框架提供了变量 app.config.env 来表示应用当前的运行环境。

运行环境相关配置

不同的运行环境会对应不同的配置,具体请阅读 Config 配置

与 NODE_ENV 的区别

很多 Node.js 应用会使用 NODE_ENV 来区分运行环境,但 EGG_SERVER_ENV 区分得更加精细。一般的项目开发流程包括本地开发环境、测试环境、生产环境等,除了本地开发环境和测试环境外,其他环境可统称为服务器环境,服务器环境的 NODE_ENV 应该为 production。而且 npm 也会使用这个变量,在应用部署的时候一般不会安装 devDependencies,所以这个值也应该为 production

框架默认支持的运行环境及映射关系(如果未指定 EGG_SERVER_ENV 会根据 NODE_ENV 来匹配)

NODE_ENVEGG_SERVER_ENV说明
 local本地开发环境
testunittest单元测试
productionprod生产环境

例如,当 NODE_ENV 为 production 而 EGG_SERVER_ENV 未指定时,框架会将 EGG_SERVER_ENV 设置成 prod

自定义环境

常规开发流程可能不仅仅只有以上几种环境,Egg 支持自定义环境来适应自己的开发流程。

比如,要为开发流程增加集成测试环境 SIT。将 EGG_SERVER_ENV 设置成 sit(并建议设置 NODE_ENV = production),启动时会加载 config/config.sit.js,运行环境变量 app.config.env 会被设置成 sit

与 Koa 的区别

在 Koa 中我们通过 app.env 来进行环境判断,app.env 默认的值是 process.env.NODE_ENV。但是在 Egg(和基于 Egg 的框架)中,配置统一都放置在 app.config 上,所以我们需要通过 app.config.env 来区分环境,app.env 不再使用。

 

Egg之Config 配置

框架提供了强大且可扩展的配置功能,可以自动合并应用、插件、框架的配置,按顺序覆盖,且可以根据环境维护不同的配置。合并后的配置可直接从 app.config 获取。

配置的管理有多种方案,以下列一些常见的方案

  1. 使用平台管理配置,应用构建时将当前环境的配置放入包内,启动时指定该配置。但应用就无法一次构建多次部署,而且本地开发环境想使用配置会变的很麻烦。
  2. 使用平台管理配置,在启动时将当前环境的配置通过环境变量传入,这是比较优雅的方式,但框架对运维的要求会比较高,需要部署平台支持,同时开发环境也有相同痛点。
  3. 使用代码管理配置,在代码中添加多个环境的配置,在启动时传入当前环境的参数即可。但无法全局配置,必须修改代码。

我们选择了最后一种配置方案,配置即代码,配置的变更也应该经过 review 后才能发布。应用包本身是可以部署在多个环境的,只需要指定运行环境即可。

多环境配置

框架支持根据环境来加载配置,定义多个环境的配置文件,具体环境请查看运行环境配置

config
|- config.default.js
|- config.prod.js
|- config.unittest.js
`- config.local.js

config.default.js 为默认的配置文件,所有环境都会加载这个配置文件,一般也会作为开发环境的默认配置文件。

当指定 env 时会同时加载对应的配置文件,并覆盖默认配置文件的同名配置。如 prod 环境会加载 config.prod.js 和 config.default.js 文件,config.prod.js 会覆盖 config.default.js 的同名配置。

配置写法

配置文件返回的是一个 object 对象,可以覆盖框架的一些配置,应用也可以将自己业务的配置放到这里方便管理。

// 配置 logger 文件的目录,logger 默认配置由框架提供
module.exports = {
  logger: {
    dir: '/home/admin/logs/demoapp',
  },
};

配置文件也可以简化的写成 exports.key = value 形式

exports.keys = 'my-cookie-secret-key';
exports.logger = {
  level: 'DEBUG',
};

配置文件也可以返回一个 function,可以接受 appInfo 参数

// 将 logger 目录放到代码目录下
const path = require('path');
module.exports = appInfo => {
  return {
    logger: {
      dir: path.join(appInfo.baseDir, 'logs'),
    },
  };
};

内置的 appInfo 有

appInfo说明
pkgpackage.json
name应用名,同 pkg.name
baseDir应用代码的目录
HOME用户目录,如 admin 账户为 /home/admin
root应用根目录,只有在 local 和 unittest 环境下为 baseDir,其他都为 HOME。

appInfo.root 是一个优雅的适配,比如在服务器环境我们会使用 /home/admin/logs 作为日志目录,而本地开发时又不想污染用户目录,这样的适配就很好解决这个问题。

请根据具体场合选择合适的写法,但请确保没有写出以下代码:

// config/config.default.js
exports.someKeys = 'abc';
module.exports = appInfo => {
  const config = {};
  config.keys = '123456';
  return config;
};

配置加载顺序

应用、插件、框架都可以定义这些配置,而且目录结构都是一致的,但存在优先级(应用 > 框架 > 插件),相对于此运行环境的优先级会更高。

比如在 prod 环境加载一个配置的加载顺序如下,后加载的会覆盖前面的同名配置。

-> 插件 config.default.js
-> 框架 config.default.js
-> 应用 config.default.js
-> 插件 config.prod.js
-> 框架 config.prod.js
-> 应用 config.prod.js

注意:插件之间也会有加载顺序,但大致顺序类似,具体逻辑可查看加载器

合并规则

配置的合并使用 extend2 模块进行深度拷贝,extend2 fork 自 extend,处理数组时会存在差异。

const a = {
  arr: [ 1, 2 ],
};
const b = {
  arr: [ 3 ],
};
extend(true, a, b);
// => { arr: [ 3 ] }

根据上面的例子,框架直接覆盖数组而不是进行合并。

配置结果

框架在启动时会把合并后的最终配置 dump 到 run/application_config.json(worker 进程)和 run/agent_config.json(agent 进程)中,可以用来分析问题。

配置文件中会隐藏一些字段,主要包括两类:

  • 如密码、密钥等安全字段,这里可以通过 config.dump.ignore 配置,必须是 Set 类型,查看默认配置
  • 如函数、Buffer 等类型,JSON.stringify 后的内容特别大

还会生成 run/application_config_meta.json(worker 进程)和 run/agent_config_meta.json(agent 进程)文件,用来排查属性的来源,如

{
  "logger": {
    "dir": "/path/to/config/config.default.js"
  }
}

 

 

Egg中间件(Middleware)

前面的章节中,我们介绍了 Egg 是基于 Koa 实现的,所以 Egg 的中间件形式和 Koa 的中间件形式是一样的,都是基于洋葱圈模型。每次我们编写一个中间件,就相当于在洋葱外面包了一层。

编写中间件

写法

我们先来通过编写一个简单的 gzip 中间件,来看看中间件的写法。

// app/middleware/gzip.js
const isJSON = require('koa-is-json');
const zlib = require('zlib');

async function gzip(ctx, next) {
  await next();

  // 后续中间件执行完成后将响应体转换成 gzip
  let body = ctx.body;
  if (!body) return;
  if (isJSON(body)) body = JSON.stringify(body);

  // 设置 gzip body,修正响应头
  const stream = zlib.createGzip();
  stream.end(body);
  ctx.body = stream;
  ctx.set('Content-Encoding', 'gzip');
}

可以看到,框架的中间件和 Koa 的中间件写法是一模一样的,所以任何 Koa 的中间件都可以直接被框架使用。

配置

一般来说中间件也会有自己的配置。在框架中,一个完整的中间件是包含了配置处理的。我们约定一个中间件是一个放置在 app/middleware 目录下的单独文件,它需要 exports 一个普通的 function,接受两个参数:

  • options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来。
  • app: 当前应用 Application 的实例。

我们将上面的 gzip 中间件做一个简单的优化,让它支持指定只有当 body 大于配置的 threshold 时才进行 gzip 压缩,我们要在 app/middleware 目录下新建一个文件 gzip.js

// app/middleware/gzip.js
const isJSON = require('koa-is-json');
const zlib = require('zlib');

module.exports = options => {
  return async function gzip(ctx, next) {
    await next();

    // 后续中间件执行完成后将响应体转换成 gzip
    let body = ctx.body;
    if (!body) return;

    // 支持 options.threshold
    if (options.threshold && ctx.length < options.threshold) return;

    if (isJSON(body)) body = JSON.stringify(body);

    // 设置 gzip body,修正响应头
    const stream = zlib.createGzip();
    stream.end(body);
    ctx.body = stream;
    ctx.set('Content-Encoding', 'gzip');
  };
};

使用中间件

中间件编写完成后,我们还需要手动挂载,支持以下方式:

在应用中使用中间件

在应用中,我们可以完全通过配置来加载自定义的中间件,并决定它们的顺序。

如果我们需要加载上面的 gzip 中间件,在 config.default.js 中加入下面的配置就完成了中间件的开启和配置:

module.exports = {
  // 配置需要的中间件,数组顺序即为中间件的加载顺序
  middleware: [ 'gzip' ],

  // 配置 gzip 中间件的配置
  gzip: {
    threshold: 1024, // 小于 1k 的响应体不压缩
  },
};

该配置最终将在启动时合并到 app.config.appMiddleware

在框架和插件中使用中间件

框架和插件不支持在 config.default.js 中匹配 middleware,需要通过以下方式:

// app.js
module.exports = app => {
  // 在中间件最前面统计请求时间
  app.config.coreMiddleware.unshift('report');
};

// app/middleware/report.js
module.exports = () => {
  return async function (ctx, next) {
    const startTime = Date.now();
    await next();
    // 上报请求时间
    reportTime(Date.now() - startTime);
  }
};

应用层定义的中间件(app.config.appMiddleware)和框架默认中间件(app.config.coreMiddleware)都会被加载器加载,并挂载到 app.middleware 上。

router 中使用中间件

以上两种方式配置的中间件是全局的,会处理每一次请求。 如果你只想针对单个路由生效,可以直接在 app/router.js 中实例化和挂载,如下:

module.exports = app => {
  const gzip = app.middleware.gzip({ threshold: 1024 });
  app.router.get('/needgzip', gzip, app.controller.handler);
};

框架默认中间件

除了应用层加载中间件之外,框架自身和其他的插件也会加载许多中间件。所有的这些自带中间件的配置项都通过在配置中修改中间件同名配置项进行修改,例如框架自带的中间件中有一个 bodyParser 中间件(框架的加载器会将文件名中的各种分隔符都修改成驼峰形式的变量名),我们想要修改 bodyParser 的配置,只需要在 config/config.default.js 中编写

module.exports = {
  bodyParser: {
    jsonLimit: '10mb',
  },
};

 

 

注意:框架和插件加载的中间件会在应用层配置的中间件之前,框架默认中间件不能被应用层中间件覆盖,如果应用层有自定义同名中间件,在启动时会报错。

 

使用 Koa 的中间件

在框架里面可以非常容易的引入 Koa 中间件生态。

以 koa-compress 为例,在 Koa 中使用时:

const koa = require('koa');
const compress = require('koa-compress');

const app = koa();

const options = { threshold: 2048 };
app.use(compress(options));

我们按照框架的规范来在应用中加载这个 Koa 的中间件:

// app/middleware/compress.js
// koa-compress 暴露的接口(`(options) => middleware`)和框架对中间件要求一致
module.exports = require('koa-compress');
// config/config.default.js
module.exports = {
  middleware: [ 'compress' ],
  compress: {
    threshold: 2048,
  },
};

如果使用到的 Koa 中间件不符合入参规范,则可以自行处理下:

// config/config.default.js
module.exports = {
  webpack: {
    compiler: {},
    others: {},
  },
};

// app/middleware/webpack.js
const webpackMiddleware = require('some-koa-middleware');

module.exports = (options, app) => {
  return webpackMiddleware(options.compiler, options.others);
}

通用配置

无论是应用层加载的中间件还是框架自带中间件,都支持几个通用的配置项:

  • enable:控制中间件是否开启。
  • match:设置只有符合某些规则的请求才会经过这个中间件。
  • ignore:设置符合某些规则的请求不经过这个中间件。

enable

如果我们的应用并不需要默认的 bodyParser 中间件来进行请求体的解析,此时我们可以通过配置 enable 为 false 来关闭它

module.exports = {
  bodyParser: {
    enable: false,
  },
};

match 和 ignore

match 和 ignore 支持的参数都一样,只是作用完全相反,match 和 ignore 不允许同时配置。

如果我们想让 gzip 只针对 /static 前缀开头的 url 请求开启,我们可以配置 match 选项

module.exports = {
  gzip: {
    match: '/static',
  },
};

match 和 ignore 支持多种类型的配置方式

  1. 字符串:当参数为字符串类型时,配置的是一个 url 的路径前缀,所有以配置的字符串作为前缀的 url 都会匹配上。 当然,你也可以直接使用字符串数组。
  2. 正则:当参数为正则时,直接匹配满足正则验证的 url 的路径。
  3. 函数:当参数为一个函数时,会将请求上下文传递给这个函数,最终取函数返回的结果(true/false)来判断是否匹配。
module.exports = {
  gzip: {
    match(ctx) {
      // 只有 ios 设备才开启
      const reg = /iphone|ipad|ipod/i;
      return reg.test(ctx.get('user-agent'));
    },
  },
};

有关更多的 match 和 ignore 配置情况,详见 egg-path-matching.

 

Egg之路由(Router)

Router 主要用来描述请求 URL 和具体承担执行动作的 Controller 的对应关系, 框架约定了 app/router.js 文件用于统一所有路由规则。

通过统一的配置,我们可以避免路由规则逻辑散落在多个地方,从而出现未知的冲突,集中在一起我们可以更方便的来查看全局的路由规则。

如何定义 Router

  • app/router.js 里面定义 URL 路由规则
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/user/:id', controller.user.info);
};
  • app/controller 目录下面实现 Controller
// app/controller/user.js
class UserController extends Controller {
  async info() {
    const { ctx } = this;
    ctx.body = {
      name: `hello ${ctx.params.id}`,
    };
  }
}

这样就完成了一个最简单的 Router 定义,当用户执行 GET /user/123user.js 这个里面的 info 方法就会执行。

Router 详细定义说明

下面是路由的完整定义,参数可以根据场景的不同,自由选择:

router.verb('path-match', app.controller.action);
router.verb('router-name', 'path-match', app.controller.action);
router.verb('path-match', middleware1, ..., middlewareN, app.controller.action);
router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);

路由完整定义主要包括5个主要部分:

  • verb - 用户触发动作,支持 get,post 等所有 HTTP 方法,后面会通过示例详细说明。
    • router.head - HEAD
    • router.options - OPTIONS
    • router.get - GET
    • router.put - PUT
    • router.post - POST
    • router.patch - PATCH
    • router.delete - DELETE
    • router.del - 由于 delete 是一个保留字,所以提供了一个 delete 方法的别名。
    • router.redirect - 可以对 URL 进行重定向处理,比如我们最经常使用的可以把用户访问的根目录路由到某个主页。
  • router-name 给路由设定一个别名,可以通过 Helper 提供的辅助函数 pathFor 和 urlFor 来生成 URL。(可选)
  • path-match - 路由 URL 路径。
  • middleware1 - 在 Router 里面可以配置多个 Middleware。(可选)
  • controller - 指定路由映射到的具体的 controller 上,controller 可以有两种写法:
    • app.controller.user.fetch - 直接指定一个具体的 controller
    • 'user.fetch' - 可以简写为字符串形式

注意事项

  • 在 Router 定义中, 可以支持多个 Middleware 串联执行
  • Controller 必须定义在 app/controller 目录中。
  • 一个文件里面也可以包含多个 Controller 定义,在定义路由的时候,可以通过 ${fileName}.${functionName} 的方式指定对应的 Controller。
  • Controller 支持子目录,在定义路由的时候,可以通过 ${directoryName}.${fileName}.${functionName} 的方式制定对应的 Controller。

下面是一些路由定义的方式:

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/home', controller.home);
  router.get('/user/:id', controller.user.page);
  router.post('/admin', isAdmin, controller.admin);
  router.post('/user', isLoginUser, hasAdminPermission, controller.user.create);
  router.post('/api/v1/comments', controller.v1.comments.create); // app/controller/v1/comments.js
};

RESTful 风格的 URL 定义

如果想通过 RESTful 的方式来定义路由, 我们提供了 app.router.resources('routerName', 'pathMatch', controller) 快速在一个路径上生成 CRUD 路由结构。

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.resources('posts', '/api/posts', controller.posts);
  router.resources('users', '/api/v1/users', controller.v1.users); // app/controller/v1/users.js
};

上面代码就在 /posts 路径上部署了一组 CRUD 路径结构,对应的 Controller 为 app/controller/posts.js 接下来, 你只需要在 posts.js 里面实现对应的函数就可以了。

MethodPathRoute NameController.Action
GET/postspostsapp.controllers.posts.index
GET/posts/newnew_postapp.controllers.posts.new
GET/posts/:idpostapp.controllers.posts.show
GET/posts/:id/editedit_postapp.controllers.posts.edit
POST/postspostsapp.controllers.posts.create
PUT/posts/:idpostapp.controllers.posts.update
DELETE/posts/:idpostapp.controllers.posts.destroy
// app/controller/posts.js
exports.index = async () => {};

exports.new = async () => {};

exports.create = async () => {};

exports.show = async () => {};

exports.edit = async () => {};

exports.update = async () => {};

exports.destroy = async () => {};

如果我们不需要其中的某几个方法,可以不用在 posts.js 里面实现,这样对应 URL 路径也不会注册到 Router。

router 实战

下面通过更多实际的例子,来说明 router 的用法。

参数获取

Query String 方式

// app/router.js
module.exports = app => {
  app.router.get('/search', app.controller.search.index);
};

// app/controller/search.js
exports.index = async ctx => {
  ctx.body = `search: ${ctx.query.name}`;
};

// curl http://127.0.0.1:7001/search?name=egg

参数命名方式

// app/router.js
module.exports = app => {
  app.router.get('/user/:id/:name', app.controller.user.info);
};

// app/controller/user.js
exports.info = async ctx => {
  ctx.body = `user: ${ctx.params.id}, ${ctx.params.name}`;
};

// curl http://127.0.0.1:7001/user/123/xiaoming

复杂参数的获取

路由里面也支持定义正则,可以更加灵活的获取参数:

// app/router.js
module.exports = app => {
  app.router.get(/^\/package\/([\w-.]+\/[\w-.]+)$/, app.controller.package.detail);
};

// app/controller/package.js
exports.detail = async ctx => {
  // 如果请求 URL 被正则匹配, 可以按照捕获分组的顺序,从 ctx.params 中获取。
  // 按照下面的用户请求,`ctx.params[0]` 的 内容就是 `egg/1.0.0`
  ctx.body = `package:${ctx.params[0]}`;
};

// curl http://127.0.0.1:7001/package/egg/1.0.0

表单内容的获取

// app/router.js
module.exports = app => {
  app.router.post('/form', app.controller.form.post);
};

// app/controller/form.js
exports.post = async ctx => {
  ctx.body = `body: ${JSON.stringify(ctx.request.body)}`;
};

// 模拟发起 post 请求。
// curl -X POST http://127.0.0.1:7001/form --data '{"name":"controller"}' --header 'Content-Type:application/json'

附:

这里直接发起 POST 请求会报错:'secret is missing'。错误信息来自 koa-csrf/index.js#L69 。

原因:框架内部针对表单 POST 请求均会验证 CSRF 的值,因此我们在表单提交时,请带上 CSRF key 进行提交,可参考安全威胁csrf的防范

注意:上面的校验是因为框架中内置了安全插件 egg-security,提供了一些默认的安全实践,并且框架的安全插件是默认开启的,如果需要关闭其中一些安全防范,直接设置该项的 enable 属性为 false 即可。

「除非清楚的确认后果,否则不建议擅自关闭安全插件提供的功能。」

这里在写例子的话可临时在 config/config.default.js 中设置

exports.security = {
  csrf: false
};

表单校验

// app/router.js
module.exports = app => {
  app.router.post('/user', app.controller.user);
};

// app/controller/user.js
const createRule = {
  username: {
    type: 'email',
  },
  password: {
    type: 'password',
    compare: 're-password',
  },
};

exports.create = async ctx => {
  // 如果校验报错,会抛出异常
  ctx.validate(createRule);
  ctx.body = ctx.request.body;
};

// curl -X POST http://127.0.0.1:7001/user --data 'username=abc@abc.com&password=111111&re-password=111111'

重定向

内部重定向

// app/router.js
module.exports = app => {
  app.router.get('index', '/home/index', app.controller.home.index);
  app.router.redirect('/', '/home/index', 302);
};

// app/controller/home.js
exports.index = async ctx => {
  ctx.body = 'hello controller';
};

// curl -L http://localhost:7001

外部重定向

// app/router.js
module.exports = app => {
  app.router.get('/search', app.controller.search.index);
};

// app/controller/search.js
exports.index = async ctx => {
  const type = ctx.query.type;
  const q = ctx.query.q || 'nodejs';

  if (type === 'bing') {
    ctx.redirect(`http://cn.bing.com/search?q=${q}`);
  } else {
    ctx.redirect(`https://www.google.co.kr/search?q=${q}`);
  }
};

// curl http://localhost:7001/search?type=bing&q=node.js
// curl http://localhost:7001/search?q=node.js

中间件的使用

如果我们想把用户某一类请求的参数都大写,可以通过中间件来实现。 这里我们只是简单说明下如何使用中间件,更多请查看 中间件

// app/controller/search.js
exports.index = async ctx => {
  ctx.body = `search: ${ctx.query.name}`;
};

// app/middleware/uppercase.js
module.exports = () => {
  return async function uppercase(ctx, next) {
    ctx.query.name = ctx.query.name && ctx.query.name.toUpperCase();
    await next();
  };
};

// app/router.js
module.exports = app => {
  app.router.get('s', '/search', app.middleware.uppercase(), app.controller.search)
};

// curl http://localhost:7001/search?name=egg

太多路由映射?

如上所述,我们并不建议把路由规则逻辑散落在多个地方,会给排查问题带来困扰。

若确实有需求,可以如下拆分:

// app/router.js
module.exports = app => {
  require('./router/news')(app);
  require('./router/admin')(app);
};

// app/router/news.js
module.exports = app => {
  app.router.get('/news/list', app.controller.news.list);
  app.router.get('/news/detail', app.controller.news.detail);
};

// app/router/admin.js
module.exports = app => {
  app.router.get('/admin/user', app.controller.admin.user);
  app.router.get('/admin/log', app.controller.admin.log);
};

也可直接使用 egg-router-plus

 

Egg之控制器(Controller)

什么是 Controller

前面章节写到,我们通过 Router 将用户的请求基于 method 和 URL 分发到了对应的 Controller 上,那 Controller 负责做什么?

简单的说 Controller 负责解析用户的输入,处理后返回相应的结果,例如

  • 在 RESTful 接口中,Controller 接受用户的参数,从数据库中查找内容返回给用户或者将用户的请求更新到数据库中。
  • 在 HTML 页面请求中,Controller 根据用户访问不同的 URL,渲染不同的模板得到 HTML 返回给用户。
  • 在代理服务器中,Controller 将用户的请求转发到其他服务器上,并将其他服务器的处理结果返回给用户。

框架推荐 Controller 层主要对用户的请求参数进行处理(校验、转换),然后调用对应的 service 方法处理业务,得到业务结果后封装并返回:

  1. 获取用户通过 HTTP 传递过来的请求参数。
  2. 校验、组装参数。
  3. 调用 Service 进行业务处理,必要时处理转换 Service 的返回结果,让它适应用户的需求。
  4. 通过 HTTP 将结果响应给用户。

如何编写 Controller

所有的 Controller 文件都必须放在 app/controller 目录下,可以支持多级目录,访问的时候可以通过目录名级联访问。Controller 支持多种形式进行编写,可以根据不同的项目场景和开发习惯来选择。

Controller 类(推荐)

我们可以通过定义 Controller 类的方式来编写代码:

// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
  async create() {
    const { ctx, service } = this;
    const createRule = {
      title: { type: 'string' },
      content: { type: 'string' },
    };
    // 校验参数
    ctx.validate(createRule);
    // 组装参数
    const author = ctx.session.userId;
    const req = Object.assign(ctx.request.body, { author });
    // 调用 Service 进行业务处理
    const res = await service.post.create(req);
    // 设置响应内容和响应状态码
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}
module.exports = PostController;

我们通过上面的代码定义了一个 PostController 的类,类里面的每一个方法都可以作为一个 Controller 在 Router 中引用到,我们可以从 app.controller 根据文件名和方法名定位到它。

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.post('createPost', '/api/posts', controller.post.create);
}

Controller 支持多级目录,例如如果我们将上面的 Controller 代码放到 app/controller/sub/post.js 中,则可以在 router 中这样使用:

// app/router.js
module.exports = app => {
  app.router.post('createPost', '/api/posts', app.controller.sub.post.create);
}

定义的 Controller 类,会在每一个请求访问到 server 时实例化一个全新的对象,而项目中的 Controller 类继承于 egg.Controller,会有下面几个属性挂在 this 上。

  • this.ctx: 当前请求的上下文 Context 对象的实例,通过它我们可以拿到框架封装好的处理当前请求的各种便捷属性和方法。
  • this.app: 当前应用 Application 对象的实例,通过它我们可以拿到框架提供的全局对象和方法。
  • this.service:应用定义的 Service,通过它我们可以访问到抽象出的业务层,等价于 this.ctx.service 。
  • this.config:应用运行时的配置项
  • this.logger:logger 对象,上面有四个方法(debuginfowarnerror),分别代表打印四个不同级别的日志,使用方法和效果与 context logger 中介绍的一样,但是通过这个 logger 对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。

自定义 Controller 基类

按照类的方式编写 Controller,不仅可以让我们更好的对 Controller 层代码进行抽象(例如将一些统一的处理抽象成一些私有方法),还可以通过自定义 Controller 基类的方式封装应用中常用的方法。

// app/core/base_controller.js
const { Controller } = require('egg');
class BaseController extends Controller {
  get user() {
    return this.ctx.session.user;
  }

  success(data) {
    this.ctx.body = {
      success: true,
      data,
    };
  }

  notFound(msg) {
    msg = msg || 'not found';
    this.ctx.throw(404, msg);
  }
}
module.exports = BaseController;

此时在编写应用的 Controller 时,可以继承 BaseController,直接使用基类上的方法:

//app/controller/post.js
const Controller = require('../core/base_controller');
class PostController extends Controller {
  async list() {
    const posts = await this.service.listByUser(this.user);
    this.success(posts);
  }
}

Controller 方法(不推荐使用,只是为了兼容)

每一个 Controller 都是一个 async function,它的入参为请求的上下文 Context 对象的实例,通过它我们可以拿到框架封装好的各种便捷属性和方法。

例如我们写一个对应到 POST /api/posts 接口的 Controller,我们会在 app/controller 目录下创建一个 post.js 文件

// app/controller/post.js
exports.create = async ctx => {
  const createRule = {
    title: { type: 'string' },
    content: { type: 'string' },
  };
  // 校验参数
  ctx.validate(createRule);
  // 组装参数
  const author = ctx.session.userId;
  const req = Object.assign(ctx.request.body, { author });
  // 调用 service 进行业务处理
  const res = await ctx.service.post.create(req);
  // 设置响应内容和响应状态码
  ctx.body = { id: res.id };
  ctx.status = 201;
};

在上面的例子中我们引入了许多新的概念,但还是比较直观,容易理解的,我们会在下面对它们进行更详细的介绍。

HTTP 基础

由于 Controller 基本上是业务开发中唯一和 HTTP 协议打交道的地方,在继续往下了解之前,我们首先简单的看一下 HTTP 协议是怎样的。

如果我们发起一个 HTTP 请求来访问前面例子中提到的 Controller:

curl -X POST http://localhost:3000/api/posts --data '{"title":"controller", "content": "what is controller"}' --header 'Content-Type:application/json; charset=UTF-8'

通过 curl 发出的 HTTP 请求的内容就会是下面这样的:

POST /api/posts HTTP/1.1
Host: localhost:3000
Content-Type: application/json; charset=UTF-8

{"title": "controller", "content": "what is controller"}

请求的第一行包含了三个信息,我们比较常用的是前面两个:

  • method:这个请求中 method 的值是 POST
  • path:值为 /api/posts,如果用户的请求中包含 query,也会在这里出现

从第二行开始直到遇到的第一个空行位置,都是请求的 Headers 部分,这一部分中有许多常用的属性,包括这里看到的 Host,Content-Type,还有 CookieUser-Agent 等等。在这个请求中有两个头:

  • Host:我们在浏览器发起请求的时候,域名会用来通过 DNS 解析找到服务的 IP 地址,但是浏览器也会将域名和端口号放在 Host 头中一并发送给服务端。
  • Content-Type:当我们的请求有 body 的时候,都会有 Content-Type 来标明我们的请求体是什么格式的。

之后的内容全部都是请求的 body,当请求是 POST, PUT, DELETE 等方法的时候,可以带上请求体,服务端会根据 Content-Type 来解析请求体。

在服务端处理完这个请求后,会发送一个 HTTP 响应给客户端

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 8
Date: Mon, 09 Jan 2017 08:40:28 GMT
Connection: keep-alive

{"id": 1}

第一行中也包含了三段,其中我们常用的主要是响应状态码,这个例子中它的值是 201,它的含义是在服务端成功创建了一条资源。

和请求一样,从第二行开始到下一个空行之间都是响应头,这里的 Content-Type, Content-Length 表示这个响应的格式是 JSON,长度为 8 个字节。

最后剩下的部分就是这次响应真正的内容。

获取 HTTP 请求参数

从上面的 HTTP 请求示例中可以看到,有好多地方可以放用户的请求数据,框架通过在 Controller 上绑定的 Context 实例,提供了许多便捷方法和属性获取用户通过 HTTP 请求发送过来的参数。

query

在 URL 中 ? 后面的部分是一个 Query String,这一部分经常用于 GET 类型的请求中传递参数。例如 GET /posts?category=egg&language=node 中 category=egg&language=node 就是用户传递过来的参数。我们可以通过 ctx.query 拿到解析过后的这个参数体

class PostController extends Controller {
  async listPosts() {
    const query = this.ctx.query;
    // {
    //   category: 'egg',
    //   language: 'node',
    // }
  }
}

当 Query String 中的 key 重复时,ctx.query 只取 key 第一次出现时的值,后面再出现的都会被忽略。GET /posts?category=egg&category=koa 通过 ctx.query 拿到的值是 { category: 'egg' }

这样处理的原因是为了保持统一性,由于通常情况下我们都不会设计让用户传递 key 相同的 Query String,所以我们经常会写类似下面的代码:

const key = ctx.query.key || '';
if (key.startsWith('egg')) {
  // do something
}

而如果有人故意发起请求在 Query String 中带上重复的 key 来请求时就会引发系统异常。因此框架保证了从 ctx.query 上获取的参数一旦存在,一定是字符串类型。

queries

有时候我们的系统会设计成让用户传递相同的 key,例如 GET /posts?category=egg&id=1&id=2&id=3。针对此类情况,框架提供了 ctx.queries 对象,这个对象也解析了 Query String,但是它不会丢弃任何一个重复的数据,而是将他们都放到一个数组中:

// GET /posts?category=egg&id=1&id=2&id=3
class PostController extends Controller {
  async listPosts() {
    console.log(this.ctx.queries);
    // {
    //   category: [ 'egg' ],
    //   id: [ '1', '2', '3' ],
    // }
  }
}

ctx.queries 上所有的 key 如果有值,也一定会是数组类型。

Router params

在 Router 中,我们介绍了 Router 上也可以申明参数,这些参数都可以通过 ctx.params 获取到。

// app.get('/projects/:projectId/app/:appId', 'app.listApp');
// GET /projects/1/app/2
class AppController extends Controller {
  async listApp() {
    assert.equal(this.ctx.params.projectId, '1');
    assert.equal(this.ctx.params.appId, '2');
  }
}

body

虽然我们可以通过 URL 传递参数,但是还是有诸多限制:

  • 浏览器中会对 URL 的长度有所限制,如果需要传递的参数过多就会无法传递。
  • 服务端经常会将访问的完整 URL 记录到日志文件中,有一些敏感数据通过 URL 传递会不安全。

在前面的 HTTP 请求报文示例中,我们看到在 header 之后还有一个 body 部分,我们通常会在这个部分传递 POST、PUT 和 DELETE 等方法的参数。一般请求中有 body 的时候,客户端(浏览器)会同时发送 Content-Type 告诉服务端这次请求的 body 是什么格式的。Web 开发中数据传递最常用的两类格式分别是 JSON 和 Form。

框架内置了 bodyParser 中间件来对这两类格式的请求 body 解析成 object 挂载到 ctx.request.body 上。HTTP 协议中并不建议在通过 GET、HEAD 方法访问时传递 body,所以我们无法在 GET、HEAD 方法中按照此方法获取到内容。

// POST /api/posts HTTP/1.1
// Host: localhost:3000
// Content-Type: application/json; charset=UTF-8
//
// {"title": "controller", "content": "what is controller"}
class PostController extends Controller {
  async listPosts() {
    assert.equal(this.ctx.request.body.title, 'controller');
    assert.equal(this.ctx.request.body.content, 'what is controller');
  }
}

框架对 bodyParser 设置了一些默认参数,配置好之后拥有以下特性:

  • 当请求的 Content-Type 为 application/jsonapplication/json-patch+jsonapplication/vnd.api+json 和 application/csp-report 时,会按照 json 格式对请求 body 进行解析,并限制 body 最大长度为 100kb
  • 当请求的 Content-Type 为 application/x-www-form-urlencoded 时,会按照 form 格式对请求 body 进行解析,并限制 body 最大长度为 100kb
  • 如果解析成功,body 一定会是一个 Object(可能是一个数组)。

一般来说我们最经常调整的配置项就是变更解析时允许的最大长度,可以在 config/config.default.js 中覆盖框架的默认值。

module.exports = {
  bodyParser: {
    jsonLimit: '1mb',
    formLimit: '1mb',
  },
};

如果用户的请求 body 超过了我们配置的解析最大长度,会抛出一个状态码为 413 的异常,如果用户请求的 body 解析失败(错误的 JSON),会抛出一个状态码为 400 的异常。

注意:在调整 bodyParser 支持的 body 长度时,如果我们应用前面还有一层反向代理(Nginx),可能也需要调整它的配置,确保反向代理也支持同样长度的请求 body。

一个常见的错误是把 ctx.request.body 和 ctx.body 混淆,后者其实是 ctx.response.body 的简写。

获取上传的文件

请求 body 除了可以带参数之外,还可以发送文件,一般来说,浏览器上都是通过 Multipart/form-data 格式发送文件的,框架通过内置 Multipart 插件来支持获取用户上传的文件,我们为你提供了两种方式:

  • File 模式:

如果你完全不知道 Nodejs 中的 Stream 用法,那么 File 模式非常合适你:

1)在 config 文件中启用 file 模式:

// config/config.default.js
exports.multipart = {
  mode: 'file',
};

2)上传 / 接收文件:

  1. 上传 / 接收单个文件:

你的前端静态页面代码应该看上去如下样子:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
  title: <input name="title" />
  file: <input name="file" type="file" />
  <button type="submit">Upload</button>
</form>

对应的后端代码如下:

// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('mz/fs');

module.exports = class extends Controller {
  async upload() {
    const { ctx } = this;
    const file = ctx.request.files[0];
    const name = 'egg-multipart-test/' + path.basename(file.filename);
    let result;
    try {
      // 处理文件,比如上传到云端
      result = await ctx.oss.put(name, file.filepath);
    } finally {
      // 需要删除临时文件
      await fs.unlink(file.filepath);
    }

    ctx.body = {
      url: result.url,
      // 获取所有的字段值
      requestBody: ctx.request.body,
    };
  }
};
  1. 上传 / 接收多个文件:

对于多个文件,我们借助 ctx.request.files 属性进行遍历,然后分别进行处理:

你的前端静态页面代码应该看上去如下样子:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
  title: <input name="title" />
  file1: <input name="file1" type="file" />
  file2: <input name="file2" type="file" />
  <button type="submit">Upload</button>
</form>

对应的后端代码:

// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('mz/fs');

module.exports = class extends Controller {
  async upload() {
    const { ctx } = this;
    console.log(ctx.request.body);
    console.log('got %d files', ctx.request.files.length);
    for (const file of ctx.request.files) {
      console.log('field: ' + file.fieldname);
      console.log('filename: ' + file.filename);
      console.log('encoding: ' + file.encoding);
      console.log('mime: ' + file.mime);
      console.log('tmp filepath: ' + file.filepath);
      let result;
      try {
        // 处理文件,比如上传到云端
        result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath);
      } finally {
        // 需要删除临时文件
        await fs.unlink(file.filepath);
      }
      console.log(result);
    }
  }
};
  • Stream 模式:

如果你对于 Node 中的 Stream 模式非常熟悉,那么你可以选择此模式。在 Controller 中,我们可以通过 ctx.getFileStream() 接口能获取到上传的文件流。

  1. 上传 / 接受单个文件:
<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
  title: <input name="title" />
  file: <input name="file" type="file" />
  <button type="submit">Upload</button>
</form>
const path = require('path');
const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploaderController extends Controller {
  async upload() {
    const ctx = this.ctx;
    const stream = await ctx.getFileStream();
    const name = 'egg-multipart-test/' + path.basename(stream.filename);
    // 文件处理,上传到云存储等等
    let result;
    try {
      result = await ctx.oss.put(name, stream);
    } catch (err) {
      // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
      await sendToWormhole(stream);
      throw err;
    }

    ctx.body = {
      url: result.url,
      // 所有表单字段都能通过 `stream.fields` 获取到
      fields: stream.fields,
    };
  }
}

module.exports = UploaderController;

要通过 ctx.getFileStream 便捷的获取到用户上传的文件,需要满足两个条件:

  • 只支持上传一个文件。
  • 上传文件必须在所有其他的 fields 后面,否则在拿到文件流时可能还获取不到 fields。
  1. 上传 / 接受多个文件:

如果要获取同时上传的多个文件,不能通过 ctx.getFileStream() 来获取,只能通过下面这种方式:

const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploaderController extends Controller {
  async upload() {
    const ctx = this.ctx;
    const parts = ctx.multipart();
    let part;
    // parts() 返回 promise 对象
    while ((part = await parts()) != null) {
      if (part.length) {
        // 这是 busboy 的字段
        console.log('field: ' + part[0]);
        console.log('value: ' + part[1]);
        console.log('valueTruncated: ' + part[2]);
        console.log('fieldnameTruncated: ' + part[3]);
      } else {
        if (!part.filename) {
          // 这时是用户没有选择文件就点击了上传(part 是 file stream,但是 part.filename 为空)
          // 需要做出处理,例如给出错误提示消息
          return;
        }
        // part 是上传的文件流
        console.log('field: ' + part.fieldname);
        console.log('filename: ' + part.filename);
        console.log('encoding: ' + part.encoding);
        console.log('mime: ' + part.mime);
        // 文件处理,上传到云存储等等
        let result;
        try {
          result = await ctx.oss.put('egg-multipart-test/' + part.filename, part);
        } catch (err) {
          // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
          await sendToWormhole(part);
          throw err;
        }
        console.log(result);
      }
    }
    console.log('and we are done parsing the form!');
  }
}

module.exports = UploaderController;

为了保证文件上传的安全,框架限制了支持的的文件格式,框架默认支持白名单如下:

// images
'.jpg', '.jpeg', // image/jpeg
'.png', // image/png, image/x-png
'.gif', // image/gif
'.bmp', // image/bmp
'.wbmp', // image/vnd.wap.wbmp
'.webp',
'.tif',
'.psd',
// text
'.svg',
'.js', '.jsx',
'.json',
'.css', '.less',
'.html', '.htm',
'.xml',
// tar
'.zip',
'.gz', '.tgz', '.gzip',
// video
'.mp3',
'.mp4',
'.avi',

用户可以通过在 config/config.default.js 中配置来新增支持的文件扩展名,或者重写整个白名单

  • 新增支持的文件扩展名
module.exports = {
  multipart: {
    fileExtensions: [ '.apk' ] // 增加对 apk 扩展名的文件支持
  },
};
  • 覆盖整个白名单
module.exports = {
  multipart: {
    whitelist: [ '.png' ], // 覆盖整个白名单,只允许上传 '.png' 格式
  },
};

注意:当重写了 whitelist 时,fileExtensions 不生效。

欲了解更多相关此技术细节和详情,请参阅 Egg-Multipart

除了从 URL 和请求 body 上获取参数之外,还有许多参数是通过请求 header 传递的。框架提供了一些辅助属性和方法来获取。

  • ctx.headersctx.headerctx.request.headersctx.request.header:这几个方法是等价的,都是获取整个 header 对象。
  • ctx.get(name)ctx.request.get(name):获取请求 header 中的一个字段的值,如果这个字段不存在,会返回空字符串。
  • 我们建议用 ctx.get(name) 而不是 ctx.headers['name'],因为前者会自动处理大小写。

由于 header 比较特殊,有一些是 HTTP 协议规定了具体含义的(例如 Content-TypeAccept),有些是反向代理设置的,已经约定俗成(X-Forwarded-For),框架也会对他们增加一些便捷的 getter,详细的 getter 可以查看 API 文档。

特别是如果我们通过 config.proxy = true 设置了应用部署在反向代理(Nginx)之后,有一些 Getter 的内部处理会发生改变。

ctx.host

优先读通过 config.hostHeaders 中配置的 header 的值,读不到时再尝试获取 host 这个 header 的值,如果都获取不到,返回空字符串。

config.hostHeaders 默认配置为 x-forwarded-host

ctx.protocol

通过这个 Getter 获取 protocol 时,首先会判断当前连接是否是加密连接,如果是加密连接,返回 https。

如果处于非加密连接时,优先读通过 config.protocolHeaders 中配置的 header 的值来判断是 HTTP 还是 https,如果读取不到,我们可以在配置中通过 config.protocol 来设置兜底值,默认为 HTTP。

config.protocolHeaders 默认配置为 x-forwarded-proto

ctx.ips

通过 ctx.ips 获取请求经过所有的中间设备 IP 地址列表,只有在 config.proxy = true 时,才会通过读取 config.ipHeaders 中配置的 header 的值来获取,获取不到时为空数组。

config.ipHeaders 默认配置为 x-forwarded-for

ctx.ip

通过 ctx.ip 获取请求发起方的 IP 地址,优先从 ctx.ips 中获取,ctx.ips 为空时使用连接上发起方的 IP 地址。

注意:ip 和 ips 不同,ip 当 config.proxy = false 时会返回当前连接发起者的 ip 地址,ips 此时会为空数组。

HTTP 请求都是无状态的,但是我们的 Web 应用通常都需要知道发起请求的人是谁。为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie。服务端可以通过响应头(set-cookie)将少量数据响应给客户端,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上(浏览器也会遵循协议,只在访问符合 Cookie 指定规则的网站时带上对应的 Cookie 来保证安全性)。

通过 ctx.cookies,我们可以在 Controller 中便捷、安全的设置和读取 Cookie。

class CookieController extends Controller {
  async add() {
    const ctx = this.ctx;
    let count = ctx.cookies.get('count');
    count = count ? Number(count) : 0;
    ctx.cookies.set('count', ++count);
    ctx.body = count;
  }

  async remove() {
    const ctx = this.ctx;
    const count = ctx.cookies.set('count', null);
    ctx.status = 204;
  }
}

Cookie 虽然在 HTTP 中只是一个头,但是通过 foo=bar;foo1=bar1; 的格式可以设置多个键值对。

Cookie 在 Web 应用中经常承担了传递客户端身份信息的作用,因此有许多安全相关的配置,不可忽视,Cookie 文档中详细介绍了 Cookie 的用法和安全相关的配置项,可以深入阅读了解。

配置

对于 Cookie 来说,主要有下面几个属性可以在 config.default.js 中进行配置:

module.exports = {
  cookies: {
    // httpOnly: true | false,
    // sameSite: 'none|lax|strict',
  },
};

举例: 配置应用级别的 Cookie SameSite 属性等于 Lax

module.exports = {
  cookies: {
    sameSite: 'lax',
  },
};

Session

通过 Cookie,我们可以给每一个用户设置一个 Session,用来存储用户身份相关的信息,这份信息会加密后存储在 Cookie 中,实现跨请求的用户身份保持。

框架内置了 Session 插件,给我们提供了 ctx.session 来访问或者修改当前用户 Session 。

class PostController extends Controller {
  async fetchPosts() {
    const ctx = this.ctx;
    // 获取 Session 上的内容
    const userId = ctx.session.userId;
    const posts = await ctx.service.post.fetch(userId);
    // 修改 Session 的值
    ctx.session.visited = ctx.session.visited ? ++ctx.session.visited : 1;
    ctx.body = {
      success: true,
      posts,
    };
  }
}

Session 的使用方法非常直观,直接读取它或者修改它就可以了,如果要删除它,直接将它赋值为 null

class SessionController extends Controller {
  async deleteSession() {
    this.ctx.session = null;
  }
};

和 Cookie 一样,Session 也有许多安全等选项和功能,在使用之前也最好阅读 Session 文档深入了解。

配置

对于 Session 来说,主要有下面几个属性可以在 config.default.js 中进行配置:

module.exports = {
  key: 'EGG_SESS', // 承载 Session 的 Cookie 键值对名字
  maxAge: 86400000, // Session 的最大有效时间
};

参数校验

在获取到用户请求的参数后,不可避免的要对参数进行一些校验。

借助 Validate 插件提供便捷的参数校验机制,帮助我们完成各种复杂的参数校验。

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

通过 ctx.validate(rule, [body]) 直接对参数进行校验:

class PostController extends Controller {
  async create() {
    // 校验参数
    // 如果不传第二个参数会自动校验 `ctx.request.body`
    this.ctx.validate({
      title: { type: 'string' },
      content: { type: 'string' },
    });
  }
}

当校验异常时,会直接抛出一个异常,异常的状态码为 422,errors 字段包含了详细的验证不通过信息。如果想要自己处理检查的异常,可以通过 try catch 来自行捕获。

class PostController extends Controller {
  async create() {
    const ctx = this.ctx;
    try {
      ctx.validate(createRule);
    } catch (err) {
      ctx.logger.warn(err.errors);
      ctx.body = { success: false };
      return;
    }
  }
};

校验规则

参数校验通过 Parameter 完成,支持的校验规则可以在该模块的文档中查阅到。

自定义校验规则

除了上一节介绍的内置检验类型外,有时候我们希望自定义一些校验规则,让开发时更便捷,此时可以通过 app.validator.addRule(type, check) 的方式新增自定义规则。

// app.js
app.validator.addRule('json', (rule, value) => {
  try {
    JSON.parse(value);
  } catch (err) {
    return 'must be json string';
  }
});

添加完自定义规则之后,就可以在 Controller 中直接使用这条规则来进行参数校验了

class PostController extends Controller {
  async handler() {
    const ctx = this.ctx;
    // query.test 字段必须是 json 字符串
    const rule = { test: 'json' };
    ctx.validate(rule, ctx.query);
  }
};

调用 Service

我们并不想在 Controller 中实现太多业务逻辑,所以提供了一个 Service 层进行业务逻辑的封装,这不仅能提高代码的复用性,同时可以让我们的业务逻辑更好测试。

在 Controller 中可以调用任何一个 Service 上的任何方法,同时 Service 是懒加载的,只有当访问到它的时候框架才会去实例化它。

class PostController extends Controller {
  async create() {
    const ctx = this.ctx;
    const author = ctx.session.userId;
    const req = Object.assign(ctx.request.body, { author });
    // 调用 service 进行业务处理
    const res = await ctx.service.post.create(req);
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}

Service 的具体写法,请查看 Service 章节。

发送 HTTP 响应

当业务逻辑完成之后,Controller 的最后一个职责就是将业务逻辑的处理结果通过 HTTP 响应发送给用户。

设置 status

HTTP 设计了非常多的状态码,每一个状态码都代表了一个特定的含义,通过设置正确的状态码,可以让响应更符合语义。

框架提供了一个便捷的 Setter 来进行状态码的设置

class PostController extends Controller {
  async create() {
    // 设置状态码为 201
    this.ctx.status = 201;
  }
};

具体什么场景设置什么样的状态码,可以参考 List of HTTP status codes 中各个状态码的含义。

设置 body

绝大多数的数据都是通过 body 发送给请求方的,和请求中的 body 一样,在响应中发送的 body,也需要有配套的 Content-Type 告知客户端如何对数据进行解析。

  • 作为一个 RESTful 的 API 接口 controller,我们通常会返回 Content-Type 为 application/json 格式的 body,内容是一个 JSON 字符串。
  • 作为一个 html 页面的 controller,我们通常会返回 Content-Type 为 text/html 格式的 body,内容是 html 代码段。

注意:ctx.body 是 ctx.response.body 的简写,不要和 ctx.request.body 混淆了。

class ViewController extends Controller {
  async show() {
    this.ctx.body = {
      name: 'egg',
      category: 'framework',
      language: 'Node.js',
    };
  }

  async page() {
    this.ctx.body = '<html><h1>Hello</h1></html>';
  }
}

由于 Node.js 的流式特性,我们还有很多场景需要通过 Stream 返回响应,例如返回一个大文件,代理服务器直接返回上游的内容,框架也支持直接将 body 设置成一个 Stream,并会同时处理好这个 Stream 上的错误事件。

class ProxyController extends Controller {
  async proxy() {
    const ctx = this.ctx;
    const result = await ctx.curl(url, {
      streaming: true,
    });
    ctx.set(result.header);
    // result.res 是一个 stream
    ctx.body = result.res;
  }
};

渲染模板

通常来说,我们不会手写 HTML 页面,而是会通过模板引擎进行生成。 框架自身没有集成任何一个模板引擎,但是约定了 View 插件的规范,通过接入的模板引擎,可以直接使用 ctx.render(template) 来渲染模板生成 html。

class HomeController extends Controller {
  async index() {
    const ctx = this.ctx;
    await ctx.render('home.tpl', { name: 'egg' });
    // ctx.body = await ctx.renderString('hi, {{ name }}', { name: 'egg' });
  }
};

具体示例可以查看模板渲染

JSONP

有时我们需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 CORS 实现,可以通过 JSONP 来进行响应。

由于 JSONP 如果使用不当会导致非常多的安全问题,所以框架中提供了便捷的响应 JSONP 格式数据的方法,封装了 JSONP XSS 相关的安全防范,并支持进行 CSRF 校验和 referrer 校验。

  • 通过 app.jsonp() 提供的中间件来让一个 controller 支持响应 JSONP 格式的数据。在路由中,我们给需要支持 jsonp 的路由加上这个中间件:
// app/router.js
module.exports = app => {
  const jsonp = app.jsonp();
  app.router.get('/api/posts/:id', jsonp, app.controller.posts.show);
  app.router.get('/api/posts', jsonp, app.controller.posts.list);
};
  • 在 Controller 中,只需要正常编写即可:
// app/controller/posts.js
class PostController extends Controller {
  async show() {
    this.ctx.body = {
      name: 'egg',
      category: 'framework',
      language: 'Node.js',
    };
  }
}

用户请求对应的 URL 访问到这个 controller 的时候,如果 query 中有 _callback=fn 参数,将会返回 JSONP 格式的数据,否则返回 JSON 格式的数据。

JSONP 配置

框架默认通过 query 中的 _callback 参数作为识别是否返回 JSONP 格式数据的依据,并且 _callback 中设置的方法名长度最多只允许 50 个字符。应用可以在 config/config.default.js 全局覆盖默认的配置:

// config/config.default.js
exports.jsonp = {
  callback: 'callback', // 识别 query 中的 `callback` 参数
  limit: 100, // 函数名最长为 100 个字符
};

通过上面的方式配置之后,如果用户请求 /api/posts/1?callback=fn,响应为 JSONP 格式,如果用户请求 /api/posts/1,响应格式为 JSON。

我们同样可以在 app.jsonp() 创建中间件时覆盖默认的配置,以达到不同路由使用不同配置的目的:

// app/router.js
module.exports = app => {
  const { router, controller, jsonp } = app;
  router.get('/api/posts/:id', jsonp({ callback: 'callback' }), controller.posts.show);
  router.get('/api/posts', jsonp({ callback: 'cb' }), controller.posts.list);
};

跨站防御配置

默认配置下,响应 JSONP 时不会进行任何跨站攻击的防范,在某些情况下,这是很危险的。我们初略将 JSONP 接口分为三种类型:

  1. 查询非敏感数据,例如获取一个论坛的公开文章列表。
  2. 查询敏感数据,例如获取一个用户的交易记录。
  3. 提交数据并修改数据库,例如给某一个用户创建一笔订单。

如果我们的 JSONP 接口提供下面两类服务,在不做任何跨站防御的情况下,可能泄露用户敏感数据甚至导致用户被钓鱼。因此框架给 JSONP 默认提供了 CSRF 校验支持和 referrer 校验支持。

CSRF

在 JSONP 配置中,我们只需要打开 csrf: true,即可对 JSONP 接口开启 CSRF 校验。

// config/config.default.js
module.exports = {
  jsonp: {
    csrf: true,
  },
};

注意,CSRF 校验依赖于 security 插件提供的基于 Cookie 的 CSRF 校验。

在开启 CSRF 校验时,客户端在发起 JSONP 请求时,也要带上 CSRF token,如果发起 JSONP 的请求方所在的页面和我们的服务在同一个主域名之下的话,可以读取到 Cookie 中的 CSRF token(在 CSRF token 缺失时也可以自行设置 CSRF token 到 Cookie 中),并在请求时带上该 token。

referrer 校验

如果在同一个主域之下,可以通过开启 CSRF 的方式来校验 JSONP 请求的来源,而如果想对其他域名的网页提供 JSONP 服务,我们可以通过配置 referrer 白名单的方式来限制 JSONP 的请求方在可控范围之内。

//config/config.default.js
exports.jsonp = {
  whiteList: /^https?:\/\/test.com\//,
  // whiteList: '.test.com',
  // whiteList: 'sub.test.com',
  // whiteList: [ 'sub.test.com', 'sub2.test.com' ],
};

whiteList 可以配置为正则表达式、字符串或者数组:

  • 正则表达式:此时只有请求的 Referrer 匹配该正则时才允许访问 JSONP 接口。在设置正则表达式的时候,注意开头的 ^ 以及结尾的 \/,保证匹配到完整的域名。
exports.jsonp = {
  whiteList: /^https?:\/\/test.com\//,
};
// matches referrer:
// https://test.com/hello
// http://test.com/
  • 字符串:设置字符串形式的白名单时分为两种,当字符串以 . 开头,例如 .test.com 时,代表 referrer 白名单为 test.com 的所有子域名,包括 test.com 自身。当字符串不以 . 开头,例如 sub.test.com,代表 referrer 白名单为 sub.test.com 这一个域名。(同时支持 HTTP 和 HTTPS)。
exports.jsonp = {
  whiteList: '.test.com',
};
// matches domain test.com:
// https://test.com/hello
// http://test.com/

// matches subdomain
// https://sub.test.com/hello
// http://sub.sub.test.com/

exports.jsonp = {
  whiteList: 'sub.test.com',
};
// only matches domain sub.test.com:
// https://sub.test.com/hello
// http://sub.test.com/
  • 数组:当设置的白名单为数组时,代表只要满足数组中任意一个元素的条件即可通过 referrer 校验。
exports.jsonp = {
  whiteList: [ 'sub.test.com', 'sub2.test.com' ],
};
// matches domain sub.test.com and sub2.test.com:
// https://sub.test.com/hello
// http://sub2.test.com/

当 CSRF 和 referrer 校验同时开启时,请求发起方只需要满足任意一个条件即可通过 JSONP 的安全校验。

设置 Header

我们通过状态码标识请求成功与否、状态如何,在 body 中设置响应的内容。而通过响应的 Header,还可以设置一些扩展信息。

通过 ctx.set(key, value) 方法可以设置一个响应头,ctx.set(headers) 设置多个 Header。

// app/controller/api.js
class ProxyController extends Controller {
  async show() {
    const ctx = this.ctx;
    const start = Date.now();
    ctx.body = await ctx.service.post.get();
    const used = Date.now() - start;
    // 设置一个响应头
    ctx.set('show-response-time', used.toString());
  }
};

重定向

框架通过 security 插件覆盖了 koa 原生的 ctx.redirect 实现,以提供更加安全的重定向。

  • ctx.redirect(url) 如果不在配置的白名单域名内,则禁止跳转。
  • ctx.unsafeRedirect(url) 不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。

用户如果使用ctx.redirect方法,需要在应用的配置文件中做如下配置:

// config/config.default.js
exports.security = {
  domainWhiteList:['.domain.com'],  // 安全白名单,以 . 开头
};

若用户没有配置 domainWhiteList 或者 domainWhiteList数组内为空,则默认会对所有跳转请求放行,即等同于ctx.unsafeRedirect(url)

 

Egg之服务(Service)

简单来说,Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,提供这个抽象有以下几个好处:

  • 保持 Controller 中的逻辑更加简洁。
  • 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
  • 将逻辑和展现分离,更容易编写测试用例,测试用例的编写具体可以查看这里

使用场景

  • 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回用户显示。或者计算完成后,更新到数据库。
  • 第三方服务的调用,比如 GitHub 信息获取等。

定义 Service

// app/service/user.js
const Service = require('egg').Service;

class UserService extends Service {
  async find(uid) {
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}

module.exports = UserService;

属性

每一次用户请求,框架都会实例化对应的 Service 实例,由于它继承于 egg.Service,故拥有下列属性方便我们进行开发:

  • this.ctx: 当前请求的上下文 Context 对象的实例,通过它我们可以拿到框架封装好的处理当前请求的各种便捷属性和方法。
  • this.app: 当前应用 Application 对象的实例,通过它我们可以拿到框架提供的全局对象和方法。
  • this.service:应用定义的 Service,通过它我们可以访问到其他业务层,等价于 this.ctx.service 。
  • this.config:应用运行时的配置项
  • this.logger:logger 对象,上面有四个方法(debuginfowarnerror),分别代表打印四个不同级别的日志,使用方法和效果与 context logger 中介绍的一样,但是通过这个 logger 对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。

Service ctx 详解

为了可以获取用户请求的链路,我们在 Service 初始化中,注入了请求上下文, 用户在方法中可以直接通过 this.ctx 来获取上下文相关信息。关于上下文的具体详解可以参看 Context, 有了 ctx 我们可以拿到框架给我们封装的各种便捷属性和方法。比如我们可以用:

  • this.ctx.curl 发起网络调用。
  • this.ctx.service.otherService 调用其他 Service。
  • this.ctx.db 发起数据库调用等, db 可能是其他插件提前挂载到 app 上的模块。

注意事项

  • Service 文件必须放在 app/service 目录,可以支持多级目录,访问的时候可以通过目录名级联访问。

    app/service/biz/user.js => ctx.service.biz.user
    app/service/sync_user.js => ctx.service.syncUser
    app/service/HackerNews.js => ctx.service.hackerNews
    
  • 一个 Service 文件只能包含一个类, 这个类需要通过 module.exports 的方式返回。

  • Service 需要通过 Class 的方式定义,父类必须是 egg.Service

  • Service 不是单例,是 请求级别 的对象,框架在每次请求中首次访问 ctx.service.xx 时延迟实例化,所以 Service 中可以通过 this.ctx 获取到当前请求的上下文。

使用 Service

下面就通过一个完整的例子,看看怎么使用 Service。

// app/router.js
module.exports = app => {
  app.router.get('/user/:id', app.controller.user.info);
};

// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
  async info() {
    const { ctx } = this;
    const userId = ctx.params.id;
    const userInfo = await ctx.service.user.find(userId);
    ctx.body = userInfo;
  }
}
module.exports = UserController;

// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {
  // 默认不需要提供构造函数。
  // constructor(ctx) {
  //   super(ctx); 如果需要在构造函数做一些处理,一定要有这句话,才能保证后面 `this.ctx`的使用。
  //   // 就可以直接通过 this.ctx 获取 ctx 了
  //   // 还可以直接通过 this.app 获取 app 了
  // }
  async find(uid) {
    // 假如 我们拿到用户 id 从数据库获取用户详细信息
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);

    // 假定这里还有一些复杂的计算,然后返回需要的信息。
    const picture = await this.getPicture(uid);

    return {
      name: user.user_name,
      age: user.age,
      picture,
    };
  }

  async getPicture(uid) {
    const result = await this.ctx.curl(`http://photoserver/uid=${uid}`, { dataType: 'json' });
    return result.data;
  }
}
module.exports = UserService;

// curl http://127.0.0.1:7001/user/1234

 

 

Egg之插件模块

插件机制是我们框架的一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。有人可能会问了

  • Koa 已经有了中间件的机制,为啥还要插件呢?
  • 中间件、插件、应用它们之间是什么关系,有什么区别?
  • 我该怎么使用一个插件?
  • 如何编写一个插件?
  • ...

接下来我们就来逐一讨论

为什么要插件

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

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

综上所述,我们需要一套更加强大的机制,来管理、编排那些相对独立的业务逻辑。

中间件、插件、应用的关系

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

他们的关系是:

  • 应用可以直接引入 Koa 的中间件。
  • 当遇到上一节提到的场景时,则应用需引入插件。
  • 插件本身可以包含中间件。
  • 多个插件可以包装为一个上层框架

使用插件

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

$ npm i egg-mysql --save

注意:我们建议通过 ^ 的方式引入依赖,并且强烈不建议锁定版本。

{
  "dependencies": {
    "egg-mysql": "^3.0.0"
  }
}

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

// config/plugin.js
// 使用 mysql 插件
exports.mysql = {
  enable: true,
  package: 'egg-mysql',
};

就可以直接使用插件提供的功能:

app.mysql.query(sql, values);

参数介绍

plugin.js 中的每个配置项支持:

  • {Boolean} enable - 是否开启此插件,默认为 true
  • {String} package - npm 模块名称,通过 npm 模块形式引入插件
  • {String} path - 插件绝对路径,跟 package 配置互斥
  • {Array} env - 只有在指定运行环境才能开启,会覆盖插件自身 package.json 中的配置

开启和关闭

在上层框架内部内置的插件,应用在使用时就不用配置 package 或者 path,只需要指定 enable 与否:

// 对于内置插件,可以用下面的简洁方式开启或关闭
exports.onerror = false;

根据环境配置

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

比如定义了一个开发环境使用的插件 egg-dev,只希望在本地环境加载,可以安装到 devDependencies

// npm i egg-dev --save-dev
// package.json
{
  "devDependencies": {
    "egg-dev": "*"
  }
}

然后在 plugin.local.js 中声明:

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

这样在生产环境可以 npm i --production 不需要下载 egg-dev 的包了。

注意:

  • 不存在 plugin.default.js
  • 只能在应用层使用,在框架层请勿使用。

package 和 path

  • package 是 npm 方式引入,也是最常见的引入方式
  • path 是绝对路径引入,如应用内部抽了一个插件,但还没达到开源发布独立 npm 的阶段,或者是应用自己覆盖了框架的一些插件
  • 关于这两种方式的使用场景,可以参见渐进式开发
// config/plugin.js
const path = require('path');
exports.mysql = {
  enable: true,
  path: path.join(__dirname, '../lib/plugin/egg-mysql'),
};

插件配置

插件一般会包含自己的默认配置,应用开发者可以在 config.default.js 覆盖对应的配置:

// config/config.default.js
exports.mysql = {
  client: {
    host: 'mysql.com',
    port: '3306',
    user: 'test_user',
    password: 'test_password',
    database: 'test',
  },
};

具体合并规则可以参见配置

插件列表

如何开发一个插件

参见文档:插件开发

 

Egg之定时任务

虽然我们通过框架开发的 HTTP Server 是请求响应模型的,但是仍然还会有许多场景需要执行一些定时任务,例如:

  1. 定时上报应用状态。
  2. 定时从远程接口更新本地缓存。
  3. 定时进行文件切割、临时文件删除。

框架提供了一套机制来让定时任务的编写和维护更加优雅。

编写定时任务

所有的定时任务都统一存放在 app/schedule 目录下,每一个文件都是一个独立的定时任务,可以配置定时任务的属性和要执行的方法。

一个简单的例子,我们定义一个更新远程数据到内存缓存的定时任务,就可以在 app/schedule 目录下创建一个 update_cache.js 文件

const Subscription = require('egg').Subscription;

class UpdateCache extends Subscription {
  // 通过 schedule 属性来设置定时任务的执行间隔等配置
  static get schedule() {
    return {
      interval: '1m', // 1 分钟间隔
      type: 'all', // 指定所有的 worker 都需要执行
    };
  }

  // subscribe 是真正定时任务执行时被运行的函数
  async subscribe() {
    const res = await this.ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    this.ctx.app.cache = res.data;
  }
}

module.exports = UpdateCache;

还可以简写为

module.exports = {
  schedule: {
    interval: '1m', // 1 分钟间隔
    type: 'all', // 指定所有的 worker 都需要执行
  },
  async task(ctx) {
    const res = await ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    ctx.app.cache = res.data;
  },
};

这个定时任务会在每一个 Worker 进程上每 1 分钟执行一次,将远程数据请求回来挂载到 app.cache 上。

任务

  • task 或 subscribe 同时支持 generator function 和 async function
  • task 的入参为 ctx,匿名的 Context 实例,可以通过它调用 service 等。

定时方式

定时任务可以指定 interval 或者 cron 两种不同的定时方式。

interval

通过 schedule.interval 参数来配置定时任务的执行时机,定时任务将会每间隔指定的时间执行一次。interval 可以配置成

  • 数字类型,单位为毫秒数,例如 5000
  • 字符类型,会通过 ms 转换成毫秒数,例如 5s
module.exports = {
  schedule: {
    // 每 10 秒执行一次
    interval: '10s',
  },
};

cron

通过 schedule.cron 参数来配置定时任务的执行时机,定时任务将会按照 cron 表达式在特定的时间点执行。cron 表达式通过 cron-parser 进行解析。

注意:cron-parser 支持可选的秒(linux crontab 不支持)。

*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    |
│    │    │    │    │    └ day of week (0 - 7) (0 or 7 is Sun)
│    │    │    │    └───── month (1 - 12)
│    │    │    └────────── day of month (1 - 31)
│    │    └─────────────── hour (0 - 23)
│    └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, optional)
module.exports = {
  schedule: {
    // 每三小时准点执行一次
    cron: '0 0 */3 * * *',
  },
};

类型

框架提供的定时任务默认支持两种类型,worker 和 all。worker 和 all 都支持上面的两种定时方式,只是当到执行时机时,会执行定时任务的 worker 不同:

  • worker 类型:每台机器上只有一个 worker 会执行这个定时任务,每次执行定时任务的 worker 的选择是随机的。
  • all 类型:每台机器上的每个 worker 都会执行这个定时任务。

其他参数

除了刚才介绍到的几个参数之外,定时任务还支持这些参数:

  • cronOptions: 配置 cron 的时区等,参见 cron-parser 文档
  • immediate:配置了该参数为 true 时,这个定时任务会在应用启动并 ready 后立刻执行一次这个定时任务。
  • disable:配置该参数为 true 时,这个定时任务不会被启动。
  • env:数组,仅在指定的环境下才启动该定时任务。

执行日志

执行日志会输出到 ${appInfo.root}/logs/{app_name}/egg-schedule.log,默认不会输出到控制台,可以通过 config.customLogger.scheduleLogger 来自定义。

// config/config.default.js
config.customLogger = {
  scheduleLogger: {
    // consoleLevel: 'NONE',
    // file: path.join(appInfo.root, 'logs', appInfo.name, 'egg-schedule.log'),
  },
};

动态配置定时任务

有时候我们需要配置定时任务的参数。定时任务还有支持另一种写法:

module.exports = app => {
  return {
    schedule: {
      interval: app.config.cacheTick,
      type: 'all',
    },
    async task(ctx) {
      const res = await ctx.curl('http://www.api.com/cache', {
        contentType: 'json',
      });
      ctx.app.cache = res.data;
    },
  };
};

手动执行定时任务

我们可以通过 app.runSchedule(schedulePath) 来运行一个定时任务。app.runSchedule 接受一个定时任务文件路径(app/schedule 目录下的相对路径或者完整的绝对路径),执行对应的定时任务,返回一个 Promise。

有一些场景我们可能需要手动的执行定时任务,例如

  • 通过手动执行定时任务可以更优雅的编写对定时任务的单元测试。
const mm = require('egg-mock');
const assert = require('assert');

it('should schedule work fine', async () => {
  const app = mm.app();
  await app.ready();
  await app.runSchedule('update_cache');
  assert(app.cache);
});
  • 应用启动时,手动执行定时任务进行系统初始化,等初始化完毕后再启动应用。参见应用启动自定义章节,我们可以在 app.js 中编写初始化逻辑。
module.exports = app => {
  app.beforeStart(async () => {
    // 保证应用启动监听端口前数据已经准备好了
    // 后续数据的更新由定时任务自动触发
    await app.runSchedule('update_cache');
  });
};

扩展定时任务类型

默认框架提供的定时任务只支持每台机器的单个进程执行和全部进程执行,有些情况下,我们的服务并不是单机部署的,这时候可能有一个集群的某一个进程执行一个定时任务的需求。

框架并没有直接提供此功能,但开发者可以在上层框架自行扩展新的定时任务类型。

在 agent.js 中继承 agent.ScheduleStrategy,然后通过 agent.schedule.use() 注册即可:

module.exports = agent => {
  class ClusterStrategy extends agent.ScheduleStrategy {
    start() {
      // 订阅其他的分布式调度服务发送的消息,收到消息后让一个进程执行定时任务
      // 用户在定时任务的 schedule 配置中来配置分布式调度的场景(scene)
      agent.mq.subscribe(schedule.scene, () => this.sendOne());
    }
  }
  agent.schedule.use('cluster', ClusterStrategy);
};

ScheduleStrategy 基类提供了:

  • schedule - 定时任务的属性,disable 是默认统一支持的,其他配置可以自行解析。
  • this.sendOne(...args) - 随机通知一个 worker 执行 task,args 会传递给 subscribe(...args) 或 task(ctx, ...args)
  • this.sendAll(...args) - 通知所有的 worker 执行 task。

 

Egg之框架扩展

框架提供了多种扩展点扩展自身的功能:

  • Application
  • Context
  • Request
  • Response
  • Helper

在开发中,我们既可以使用已有的扩展 API 来方便开发,也可以对以上对象进行自定义扩展,进一步加强框架的功能。

Application

app 对象指的是 Koa 的全局应用对象,全局只有一个,在应用启动时被创建。

访问方式

  • ctx.app

  • Controller,Middleware,Helper,Service 中都可以通过 this.app 访问到 Application 对象,例如 this.app.config 访问配置对象。

  • 在 app.js 中 app 对象会作为第一个参数注入到入口函数中

    // app.js
    module.exports = app => {
      // 使用 app 对象
    };
    

扩展方式

框架会把 app/extend/application.js 中定义的对象与 Koa Application 的 prototype 对象进行合并,在应用启动时会基于扩展后的 prototype 生成 app 对象。

方法扩展

例如,我们要增加一个 app.foo() 方法:

// app/extend/application.js
module.exports = {
  foo(param) {
    // this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
  },
};

属性扩展

一般来说属性的计算只需要进行一次,那么一定要实现缓存,否则在多次访问属性时会计算多次,这样会降低应用性能。

推荐的方式是使用 Symbol + Getter 的模式。

例如,增加一个 app.bar 属性 Getter:

// app/extend/application.js
const BAR = Symbol('Application#bar');

module.exports = {
  get bar() {
    // this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
    if (!this[BAR]) {
      // 实际情况肯定更复杂
      this[BAR] = this.config.xx + this.config.yy;
    }
    return this[BAR];
  },
};

Context

Context 指的是 Koa 的请求上下文,这是 请求级别 的对象,每次请求生成一个 Context 实例,通常我们也简写成 ctx。在所有的文档中,Context 和 ctx 都是指 Koa 的上下文对象。

访问方式

  • middleware 中 this 就是 ctx,例如 this.cookies.get('foo')
  • controller 有两种写法,类的写法通过 this.ctx,方法的写法直接通过 ctx 入参。
  • helper,service 中的 this 指向 helper,service 对象本身,使用 this.ctx 访问 context 对象,例如 this.ctx.cookies.get('foo')

扩展方式

框架会把 app/extend/context.js 中定义的对象与 Koa Context 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 ctx 对象。

方法扩展

例如,我们要增加一个 ctx.foo() 方法:

// app/extend/context.js
module.exports = {
  foo(param) {
    // this 就是 ctx 对象,在其中可以调用 ctx 上的其他方法,或访问属性
  },
};

属性扩展

一般来说属性的计算在同一次请求中只需要进行一次,那么一定要实现缓存,否则在同一次请求中多次访问属性时会计算多次,这样会降低应用性能。

推荐的方式是使用 Symbol + Getter 的模式。

例如,增加一个 ctx.bar 属性 Getter:

// app/extend/context.js
const BAR = Symbol('Context#bar');

module.exports = {
  get bar() {
    // this 就是 ctx 对象,在其中可以调用 ctx 上的其他方法,或访问属性
    if (!this[BAR]) {
      // 例如,从 header 中获取,实际情况肯定更复杂
      this[BAR] = this.get('x-bar');
    }
    return this[BAR];
  },
};

Request

Request 对象和 Koa 的 Request 对象相同,是 请求级别 的对象,它提供了大量请求相关的属性和方法供使用。

访问方式

ctx.request

ctx 上的很多属性和方法都被代理到 request 对象上,对于这些属性和方法使用 ctx 和使用 request 去访问它们是等价的,例如 ctx.url === ctx.request.url

Koa 内置的代理 request 的属性和方法列表:Koa - Request aliases

扩展方式

框架会把 app/extend/request.js 中定义的对象与内置 request 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 request 对象。

例如,增加一个 request.foo 属性 Getter:

// app/extend/request.js
module.exports = {
  get foo() {
    return this.get('x-request-foo');
  },
};

Response

Response 对象和 Koa 的 Response 对象相同,是 请求级别 的对象,它提供了大量响应相关的属性和方法供使用。

访问方式

ctx.response

ctx 上的很多属性和方法都被代理到 response 对象上,对于这些属性和方法使用 ctx 和使用 response 去访问它们是等价的,例如 ctx.status = 404 和 ctx.response.status = 404 是等价的。

Koa 内置的代理 response 的属性和方法列表:Koa Response aliases

扩展方式

框架会把 app/extend/response.js 中定义的对象与内置 response 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 response 对象。

例如,增加一个 response.foo 属性 setter:

// app/extend/response.js
module.exports = {
  set foo(value) {
    this.set('x-response-foo', value);
  },
};

就可以这样使用啦:this.response.foo = 'bar';

Helper

Helper 函数用来提供一些实用的 utility 函数。

它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处。另外还有一个好处是 Helper 这样一个简单的函数,可以让我们更容易编写测试用例。

框架内置了一些常用的 Helper 函数。我们也可以编写自定义的 Helper 函数。

访问方式

通过 ctx.helper 访问到 helper 对象,例如:

// 假设在 app/router.js 中定义了 home router
app.get('home', '/', 'home.index');

// 使用 helper 计算指定 url path
ctx.helper.pathFor('home', { by: 'recent', limit: 20 })
// => /?by=recent&limit=20

扩展方式

框架会把 app/extend/helper.js 中定义的对象与内置 helper 的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 helper 对象。

例如,增加一个 helper.foo() 方法:

// app/extend/helper.js
module.exports = {
  foo(param) {
    // this 是 helper 对象,在其中可以调用其他 helper 方法
    // this.ctx => context 对象
    // this.app => application 对象
  },
};

按照环境进行扩展

另外,还可以根据环境进行有选择的扩展,例如,只在 unittest 环境中提供 mockXX() 方法以便进行 mock 方便测试。

// app/extend/application.unittest.js
module.exports = {
  mockXX(k, v) {
  }
};

这个文件只会在 unittest 环境加载。

同理,对于 Application,Context,Request,Response,Helper 都可以使用这种方式针对某个环境进行扩展,更多参见运行环境

 

Egg之启动自定义

我们常常需要在应用启动期间进行一些初始化工作,等初始化完成后应用才可以启动成功,并开始对外提供服务。

框架提供了统一的入口文件(app.js)进行启动过程自定义,这个文件返回一个 Boot 类,我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。

框架提供了这些 生命周期函数供开发人员处理:

  • 配置文件即将加载,这是最后动态修改配置的时机(configWillLoad
  • 配置文件加载完成(configDidLoad
  • 文件加载完成(didLoad
  • 插件启动完毕(willReady
  • worker 准备就绪(didReady
  • 应用启动完成(serverDidReady
  • 应用即将关闭(beforeClose

我们可以在 app.js 中定义这个 Boot 类,下面我们抽取几个在应用开发中常用的生命周期函数来举例:

// app.js
class AppBootHook {
  constructor(app) {
    this.app = app;
  }

  configWillLoad() {
    // 此时 config 文件已经被读取并合并,但是还并未生效
    // 这是应用层修改配置的最后时机
    // 注意:此函数只支持同步调用

    // 例如:参数中的密码是加密的,在此处进行解密
    this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
    // 例如:插入一个中间件到框架的 coreMiddleware 之间
    const statusIdx = this.app.config.coreMiddleware.indexOf('status');
    this.app.config.coreMiddleware.splice(statusIdx + 1, 0, 'limit');
  }

  async didLoad() {
    // 所有的配置已经加载完毕
    // 可以用来加载应用自定义的文件,启动自定义的服务

    // 例如:创建自定义应用的示例
    this.app.queue = new Queue(this.app.config.queue);
    await this.app.queue.init();

    // 例如:加载自定义的目录
    this.app.loader.loadToContext(path.join(__dirname, 'app/tasks'), 'tasks', {
      fieldClass: 'tasksClasses',
    });
  }

  async willReady() {
    // 所有的插件都已启动完毕,但是应用整体还未 ready
    // 可以做一些数据初始化等操作,这些操作成功才会启动应用

    // 例如:从数据库加载数据到内存缓存
    this.app.cacheData = await this.app.model.query(QUERY_CACHE_SQL);
  }

  async didReady() {
    // 应用已经启动完毕

    const ctx = await this.app.createAnonymousContext();
    await ctx.service.Biz.request();
  }

  async serverDidReady() {
    // http / https server 已启动,开始接受外部请求
    // 此时可以从 app.server 拿到 server 的实例

    this.app.server.on('timeout', socket => {
      // handle socket timeout
    });
  }
}

module.exports = AppBootHook;

注意:在自定义生命周期函数中不建议做太耗时的操作,框架会有启动的超时检测。

如果你的 Egg 框架的生命周期函数是旧版本的,建议你升级到类方法模式;详情请查看升级你的生命周期事件函数

 

参考地址:https://eggjs.org/zh-cn/intro/egg-and-koa.html

参考地址:https://gitee.com/tkoajs/tkoa?_from=gitee_search

 

 

  • 26
    点赞
  • 117
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值