一个包含一些小示例的存储库,这些示例说明了如何使用Koa创建Web应用程序和其他HTTP服务器。
https://github.com/koajs/examples[源码包含以下示例]
component.json
s 搜寻用户和组织的存储库
翻译来源:https://github.com/koajs/examples
注:下文是其他参考资料,非常详细
# 初始化package.json
npm init
# 安装koa2
npm install koa
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')
由于koa2是基于async/await操作中间件,目前node.js 7.x的harmony模式下才能使用,所以启动的时的脚本如下:
node index.js
访问http:localhost:3000,效果如下
先复制以下这段代码,在粘贴在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中执行结果如下
├── lib
│ ├── application.js
│ ├── context.js
│ ├── request.js
│ └── response.js
└── package.json
这个就是 GitHub
https://github.com/koajs/koa上开源的koa2源码的源文件结构,核心代码就是lib目录下的四个文件
async/await
的中间件容器。async/await
的来处理传统回调嵌套问题和代替koa@1的generator,但是需要在node.js 7.x的harmony模式下才能支持async/await
。async/await
封装的,如果要使用koa@1基于generator中间件,需要通过中间件koa-convert封装一下才能使用。// 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
// 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')
注:原文地址在我的博客issue里https://github.com/ChenShenhai/blog/issues/15
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 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 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')
/* ./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 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')
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
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')
执行运行脚本
node -harmony index.js
运行效果如下
访问http://localhost:3000/index
如果依靠ctx.request.url去手动处理路由,将会写很多处理代码,这时候就需要对应的路由的中间件对路由进行控制,这里介绍一个比较好用的路由中间件koa-router
# koa2 对应的版本是 7.x
npm install --save koa-router@7
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 = `
`
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')
})
在koa中,获取GET请求数据源头是koa中request对象中的query方法或querystring方法,query返回是格式化好的参数对象,querystring返回的是请求字符串,由于ctx对request的API有直接引用的方式,所以获取GET请求数据有两个途径。
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请求的处理,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
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 = `
koa2 request post demo
`
ctx.body = html
} else if ( ctx.url === '/' && ctx.method === 'POST' ) {
// 当POST请求的时候,解析POST表单里的数据,并显示出来
let postData = await parsePostData( ctx )
ctx.body = postData
} else {
// 其他请求显示404
ctx.body = '404!!! o(╯□╰)o
'
}
})
// 解析上下文里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中间件可以把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 = `
koa2 request post demo
`
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 = '404!!! o(╯□╰)o
'
}
})
app.listen(3000, () => {
console.log('[demo] request post is starting at port 3000')
})
node post-middleware.js
一个http请求访问web服务静态资源,一般响应结果有三种情况
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 = ``
for ( let [ index, item ] of contentList.entries() ) {
html = `${html}- ${item}`
}
html = `${html}
`
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 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
koa提供了从上下文直接读取、写入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
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
访问http://localhost:3000/index.html
访问http://localhost:3000/js/index.js
koa2原生功能只提供了cookie的操作,但是没有提供session操作。session就只用自己实现或者通过第三方中间件实现。在koa2中实现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是否存储
查看cookie中是否种下了sessionId
http://localhost:3000
# 安装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 模板
<%= title %>
<%= title %>
EJS Welcome to <%= title %>
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
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 = `
koa2 upload demo
`
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 = '404!!! o(╯□╰)o
'
}
})
app.listen(3000, () => {
console.log('[demo] upload-simple is starting at port 3000')
})
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 = '404!!! o(╯□╰)o
'
}
})
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
}
上传进度0%
上传结果图片
上传操作代码
(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 = '
'
}
},
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)
}
})()
https://www.mysql.com/downloads/
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
由于mysql模块的操作都是异步操作,每次操作的结果都是在回调函数中执行,现在有了async/await,就可以用同步的写法去操作数据库
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 }
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文件 +---+ 内容 +---+ +------------>
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| +-----------+ +-----------+ +-----------+ |
| |
+---------------------------------------------------+
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
}
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
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
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
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()
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
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='[email protected]', password='123456';
INSERT INTO `user` set email='[email protected]', password='123456';
INSERT INTO `user` set email='[email protected]', password='123456';
node index.js
在项目复杂的业务场景,有时候需要在前端跨域获取数据,这时候提供数据的服务就需要提供跨域请求的接口,通常是使用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
}
同域访问JSON请求
跨域访问JSON请求
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.js 官方wiki中也介绍了不少jsonp的中间件
其中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
demo地址
https://github.com/ChenShenhai/koa2-note/blob/master/demo/test-unit/
.
├── index.js # api文件
├── package.json
└── test # 测试目录
└── index.test.js # 测试用例
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
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
注意:
- 如果是全局安装了mocha,可以直接在当前项目目录下执行 mocha --harmony 命令
- 如果当前node.js版本低于7.6,由于7.5.x以下还直接不支持async/awiar就需要加上--harmony
会自动读取执行命令 ./test 目录下的测用例文件 inde.test.js,并执行。测试结果如下
服务入口加载
如果要对一个服务的API接口,进行单元测试,要用supertest加载服务的入口文件
const supertest = require('supertest')
const request = supertest( app.listen() )
测试套件、用例
describe( '开始测试demo的GET请求', ( ) => {
it('测试/getString.json请求', () => {
// TODO ...
})
})
调试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
访问chrome浏览器调试server
打开浏览器调试窗口会看到一个node.js 的小logo
打开chrome浏览器的node调试窗口
注意打开了node的调试窗口后,原来绿色的node按钮会变灰色,同时调试框会显示debug状态
可以自定义打断点调试了
https://github.com/ChenShenhai/koa2-note/blob/master/demo/project/
create database koa_demo;
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
http://localhost:3001/admin
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='[email protected]', password='123456';
# ...
└── server # 后端代码目录
└── routers
├── admin.js # /admin/* 子路由
├── api.js # resetful /api/* 子路由
├── error.js # /error/* 子路由
├── home.js # 主页子路由
├── index.js # 子路由汇总文件
└── work.js # /work/* 子路由
# ...
例如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())
由于demos/project 前端渲染是通过react.js渲染的,这就需要webpack4 对react.js及其相关JSX,ES6/7代码进行编译和混淆压缩。
可访问网https://webpack.js.org/
└── 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
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'
}
}
}
}
};
为了方便编译基本配置代码统一管理,开发环境(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 是作为前端渲染的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
...
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 (
Home
index
)
}
}
export default App
static/src/pages/index.js 文件
import React from 'react'
import ReactDOM from 'react-dom'
import App from './../apps/index.jsx'
ReactDOM.render( ,
document.getElementById("app"))
<%= title %>
/**
* 数据库创建用户
* @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
}
/**
* 登录操作
* @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
},
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)
登录模式
注册模式
// 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 ...
let session = ctx.session
session.isLogin = true
session.userName = userResult.name
session.userId = userResult.id
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')
}
},
Node 9最激动人心的是提供了在flag模式下使用ECMAScript Modules
,虽然现在还是Stability: 1 - Experimental
阶段,但是可以让Noder抛掉babel等工具的束缚,直接在Node环境下愉快地去玩耍import/export
如果觉得文字太多,看不下去,可以直接去玩玩demo,地址是https://github.com/chenshenhai/node-modules-demo
import/export
的文件后缀名必须为*.mjs
(下面会讲利用Loader Hooks兼容*.js
后缀文件)--experimental-modules
import
和export
必须严格按照ECMAScript Modules
语法ECMAScript Modules
和require()
的cache机制不一样Node 9.x官方文档 https://nodejs.org/dist/latest-v9.x/docs/api/esm.html
与require()区别
能力 | 描述 | require() | import |
---|---|---|---|
NODE_PATH | 从NODE_PATH加载依赖模块 | Y | N |
cache | 缓存机制 | 可以通过require的API操作缓存 | 自己独立的缓存机制,目前不可访问 |
path | 引用路径 | 文件路径 | URL格式文件路径,例如import A from './a?v=2017' |
extensions | 扩展名机制 | require.extensions | Loader Hooks |
natives | 原生模块引用 | 直接支持 | 直接支持 |
npm | npm模块引用 | 直接支持 | 需要Loader Hooks |
file | 文件(引用) | *.js ,*.json 等直接支持 |
默认只能是*.mjs ,通过Loader Hooks 可以自定义配置规则支持*.js ,*.json 等Node原有支持文件 |
由于历史原因,在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生态圈的模块。
node --experimental-modules --loader ./custom-loader.mjs ./index.js
看看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例子看出,只是对*.js
文件做import/export
做loader兼容,然而我们在实际开发中需要对npm模块,*.json
文件也使用import/export
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: ''
}
esm
, cjs
, json
, builtin
, addon
这四种模块/文件格式.注意: 目前Node对import/export
的支持现在还是Stability: 1 - Experimental
阶段,后续的发展还有很多不确定因素,自己练手玩玩还可以,但是在还没去flag使用之前,尽量不要在生产环境中使用。Node 9.x 更详细import/export
的使用,可参考 https://github.com/ChenShenhai/blog/issues/24
Tkoa是使用 typescript 编写的 koa 框架!
尽管它是基于 typescript 编写,但是你依然还是可以使用一些 node.js 框架和基于 koa 的中间件。
不仅如此,你还可以享受 typescript 的类型检查系统和方便地使用 typescript 进行测试!
TKoa 需要 >= typescript v3.1.0 和 node v7.6.0 版本。
$ npm install tkoa
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);
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`);
});
});
MIT
Node.js 是一个异步的世界,官方 API 支持的都是 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)已原生支持。
|
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。
Koa 和 Express 的设计风格非常类似,底层也都是共用的同一套 HTTP 基础库,但是有几个显著的区别,除了上面提到的默认异步解决方案之外,主要的特点还有下面几个。
Middleware
Koa 的中间件和 Express 不同,Koa 选择了洋葱圈模型。
所有的请求经过一个中间件的时候都会执行两次,对比 Express 形式的中间件,Koa 的模型可以非常方便的实现后置处理逻辑,对比 Koa 和 Express 的 Compress 中间件就可以明显的感受到 Koa 中间件模型的优势。
和 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
就可以将按照规范编写的代码中的所有错误都捕获到。这样我们可以很便捷的编写一个自定义的错误处理中间件。
|
只需要将这个中间件放在其他中间件之前,就可以捕获它们所有的同步或者异步代码中抛出的异常了。
如上述,Koa 是一个非常优秀的框架,然而对于企业级应用来说,它还比较基础。
而 Egg 选择了 Koa 作为其基础框架,在它的模型基础上,进一步对它进行了一些增强。
在基于 Egg 的框架或者应用中,我们可以通过定义 app/extend/{application,context,request,response}.js
来扩展 Koa 中对应的四个对象的原型,通过这个功能,我们可以快速的增加更多的辅助方法,例如我们在 app/extend/context.js
中写入下列代码:
|
在 Controller 中,我们就可以使用到刚才定义的这个便捷属性了:
|
更多关于扩展的内容,请查看扩展章节。
众所周知,在 Express 和 Koa 中,经常会引入许许多多的中间件来提供各种各样的功能,例如引入 koa-session 提供 Session 的支持,引入 koa-bodyparser 来解析请求 body。而 Egg 提供了一个更加强大的插件机制,让这些独立领域的功能模块可以更加容易编写。
一个插件可以包含
一个独立领域下的插件实现,可以在代码维护性非常高的情况下实现非常完善的功能,而插件也支持配置各个环境下的默认(最佳)配置,让我们使用插件的时候几乎可以不需要修改配置项。
egg-security 插件就是一个典型的例子。
更多关于插件的内容,请查看插件章节。
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
来开发。
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。
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.js 应用,让你能快速的入门 Egg.js。
我们推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(npm >=6.1.0
):
|
启动项目:
|
通常你可以通过上一节的方式,使用 npm init egg
快速选择适合对应业务模型的脚手架,快速启动 Egg.js 项目的开发。
但为了让大家更好的了解 Egg.js,接下来,我们将跳过脚手架,手动一步步的搭建出一个 Hacker News。
注意:实际项目中,我们推荐使用上一节的脚手架直接初始化。
先来初始化下目录结构:
|
添加 npm scripts
到 package.json
:
|
如果你熟悉 Web 开发或 MVC,肯定猜到我们第一步需要编写的是 Controller 和 Router。
|
配置路由映射:
|
加一个配置文件:
|
此时目录结构如下:
|
完整的目录结构规范参见目录结构。
好,现在可以启动应用来体验下
|
注意:
- Controller 有
class
和exports
两种编写方式,本文示范的是前者,你可能需要参考 Controller 文档。- Config 也有
module.exports
和exports
的写法,具体参考 Node.js modules 文档。
Egg 内置了 static 插件,线上环境建议部署到 CDN,无需该插件。
static 插件默认映射 /public/* -> app/public/*
目录
此处,我们把静态资源都放到 app/public
目录即可:
|
绝大多数情况,我们都需要读取数据后渲染模板,然后呈现给用户。故我们需要引入对应的模板引擎。
框架并不强制你使用某种模板引擎,只是约定了 View 插件开发规范,开发者可以引入不同的插件来实现差异化定制。
更多用法参见 View。
在本例中,我们使用 Nunjucks 来渲染,先安装对应的插件 egg-view-nunjucks :
|
开启插件:
|
|
注意:是 config
目录,不是 app/config
!
为列表页编写模板文件,一般放置在 app/view
目录下
|
添加 Controller 和 Router
|
启动浏览器,访问 http://localhost:7001/news 即可看到渲染后的页面。
提示:开发期默认开启了 development 插件,修改后端代码后,会自动重启 Worker 进程。
在实际应用中,Controller 一般不会自己产出数据,也不会包含复杂的逻辑,复杂的过程应抽象为业务逻辑层 Service。
我们来添加一个 Service 抓取 Hacker News 的数据 ,如下:
|
框架提供了内置的 HttpClient 来方便开发者使用 HTTP 请求。
然后稍微修改下之前的 Controller:
|
还需增加 app/service/news.js
中读取到的配置:
|
遇到一个小问题,我们的资讯时间的数据是 UnixTime 格式的,我们希望显示为便于阅读的格式。
框架提供了一种快速扩展的方式,只需在 app/extend
目录下提供扩展脚本即可,具体参见扩展。
在这里,我们可以使用 View 插件支持的 Helper 来实现:
|
|
在模板里面使用:
|
假设有个需求:我们的新闻站点,禁止百度爬虫访问。
聪明的同学们一定很快能想到可以通过 Middleware 判断 User-Agent,如下:
|
现在可以使用 curl http://localhost:7001/news -A "Baiduspider"
看看效果。
更多参见中间件文档。
写业务的时候,不可避免的需要有配置文件,框架提供了强大的配置合并管理功能:
config.local.js
, config.prod.js
等等。 |
单元测试非常重要,框架也提供了 egg-bin 来帮开发者无痛的编写测试。
测试文件应该放在项目根目录下的 test 目录下,并以 test.js
为后缀名,即 {app_root}/test/**/*.test.js
。
|
然后配置依赖和 npm scripts
:
|
|
执行测试:
|
就这么简单,更多请参见 单元测试。
短短几章内容,只能讲 Egg 的冰山一角,我们建议开发者继续阅读其他章节:
在 Egg 里面,有插件,也有框架,前者还包括了 path
和 package
两种加载模式,那我们应该如何选择呢?
本文将以实例的方式,一步步给大家演示下,如何渐进式地进行代码演进。
全部的示例代码可以参见 eggjs/examples/progressive。
假设我们有一段分析 UA 的代码,实现以下功能:
ctx.isAndroid
ctx.isIOS
通过之前的教程,大家一定可以很快地写出来,我们快速回顾下:
对应的代码参见 step1。
目录结构:
|
核心代码:
|
我们很明显能感知到,这段逻辑是具备通用性的,可以写成插件。
但一开始的时候,功能还没完善,直接独立插件,维护起来比较麻烦。
此时,我们可以把代码写成插件的形式,但并不独立出去。
对应的代码参见 step2。
新的目录结构:
|
核心代码:
app/extend/context.js
移动到 lib/plugin/egg-ua/app/extend/context.js
。
lib/plugin/egg-ua/package.json
声明插件。
|
config/plugin.js
中通过 path
来挂载插件。 |
经过一段时间开发后,该模块的功能成熟,此时可以考虑抽出来成为独立的插件。
首先,我们抽出一个 egg-ua 插件,看过插件文档的同学应该都比较熟悉,我们这里只简单过一下:
目录结构:
|
对应的代码参见 step3/egg-ua。
然后改造原有的应用,对应的代码参见 step3/example-app。
lib/plugin/egg-ua
目录。package.json
中声明对 egg-ua
的依赖。config/plugin.js
中修改依赖声明为 package
方式。 |
注意:在插件还没发布前,可以通过 npm link
的方式进行本地测试,具体参见 npm-link。
|
重复上述的过程,很快我们会积累了好几个插件和配置,并且我们会发现,在团队的大部分项目中,都会用到这些插件。
此时,就可以考虑抽象出一个适合团队业务场景的框架。
首先,抽象出 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
。 |
注意:在框架还没发布前,可以通过 npm link
的方式进行本地测试,具体参见 npm-link。
|
综上所述,大家可以看到我们是如何一步步渐进地去进行框架演进,这得益于 Egg 强大的插件机制、代码的共建,以及复用和下沉,这些步骤竟然可以这么地无痛来得以完成!
lib/plugin
目录去,如例子中的 egg-ua
。node module
。npm install
下就可以使用上,对整个团队的效率有极大的提升。
随着 Node.js 8 LTS 的发布, 内建了对 ES2017 Async Function 的支持。
在这之前,TJ 的 co 使我们可以提前享受到 async/await
的编程体验,但同时它不可避免的也带来一些问题:
现在 Egg 正式发布了 2.x 版本:
generator function
的完全兼容。async function
。Egg 的理念之一是渐进式增强
,故我们为开发者提供渐进升级
的体验。
插件开发者
的升级指南>=8.9.0
)。package.json
中 egg
的依赖为 ^2.0.0
。搞定!几乎不需要修改任何一行代码,就已经完成了升级。
yield parts
需修改为 await parts()
或 yield parts()
|
不再兼容 1.x 形式的 role 定义,因为 koa-roles 已经无法兼容了。 请求上下文 Context
从 this 传入改成了第一个参数 ctx
传入,原有的 scope
变成了第二个参数。
|
得益于 Egg 对 1.x 的完全兼容,我们可以如何非常快速的完成升级。
不过,为了更好的统一代码风格,以及更佳的性能和错误堆栈,我们建议开发者进一步升级:
yieldable
转为 awaitable
2.x 仍然保持对 1.x 风格的中间件的兼容,故不修改也能继续使用。
(ctx, next)
风格。
ctx
,代表当前请求的上下文,是 Context 的实例。next
,用 await 执行它来执行后续中间件的逻辑。async (ctx, next) => {}
格式,避免错误堆栈丢失函数名。yield next
改为函数调用 await next()
的方式。 |
我们早在 Egg 1.x 时就已经支持 async,故若应用层已经是 async-base 的,就可以跳过本小节内容了。
co 支持了 yieldable
兼容类型:
尽管 generator
和 async
两者的编程模型基本一模一样,但由于上述的 co
的一些特殊处理,导致在移除 co
后,我们需要根据不同场景自行处理:
promise
直接替换即可:
|
array - yield []
yield []
常用于并发请求,如:
|
这种修改起来比较简单,用 Promise.all()
包装下即可:
|
object - yield {}
yield {}
和 yield map
的方式也常用于并发请求,但由于 Promise.all
不支持 Object,会稍微有点复杂。
|
建议修改为 await Promise.all([])
的方式:
|
如果无法修改对应的接口,可以临时兼容下:
|
其他
修改为对应的 async function 即可,如果不能修改,则可以用 app.toAsyncFunction 简单包装下。
注意
@sindresorhus 编写了许多基于 promise 的 helper 方法,灵活的运用它们配合 async function 能让代码更加具有可读性。
应用开发者
只需升级插件开发者
修改后的依赖版本即可,也可以用我们提供的命令 egg-bin autod
快速更新。
以下内容针对插件开发者
,指导如何升级插件:
generator function
改为 async function
格式。某些场景下,插件开发者
提供给应用开发者
的接口是同时支持 generator 和 async 的,一般是会用 co 包装一层。
async-first
。譬如 egg-schedule 插件,支持应用层使用 generator 或 async 定义 task。
|
插件开发者
可以简单包装下原始函数:
|
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
release-1.x
这个 tag,用户通过 npm i [email protected]
来引入旧版本。
在快速入门中,大家对框架应该有了初步的印象,接下来我们简单了解下目录约定规范。
|
如上,由框架约定的目录:
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。
在本章,我们会初步介绍一下框架中内置的一些基础对象,包括从 Koa 继承而来的 4 个对象(Application, Context, Request, Response) 以及框架扩展的一些对象(Controller, Service, Helper, Config, Logger),在后续的文档阅读中我们会经常遇到它们。
Application 是全局应用对象,在一个应用中,只会实例化一个,它继承自 Koa.Application,在它上面我们可以挂载一些全局的方法和对象。我们可以轻松的在插件或者应用中扩展 Application 对象。
在框架运行时,会在 Application 实例上触发一些事件,应用开发者或者插件开发者可以监听这些事件做一些操作。作为应用开发者,我们一般会在启动自定义脚本中进行监听。
server
: 该事件一个 worker 进程只会触发一次,在 HTTP 服务完成启动后,会将 HTTP server 通过这个事件暴露出来给开发者。error
: 运行时有任何的异常被 onerror 插件捕获后,都会触发 error
事件,将错误对象和关联的上下文(如果有)暴露给开发者,可以进行自定义的日志记录上报等处理。request
和 response
: 应用收到请求和响应请求时,分别会触发 request
和 response
事件,并将当前请求上下文暴露出来,开发者可以监听这两个事件来进行日志记录。 |
Application 对象几乎可以在编写应用时的任何一个地方获取到,下面介绍几个经常用到的获取方式:
几乎所有被框架 Loader 加载的文件(Controller,Service,Schedule 等),都可以 export 一个函数,这个函数会被 Loader 调用,并使用 app 作为参数:
启动自定义脚本
|
Controller 文件
|
和 Koa 一样,在 Context 对象上,可以通过 ctx.app
访问到 Application 对象。以上面的 Controller 文件举例:
|
在继承于 Controller, Service 基类的实例中,可以通过 this.app
访问到 Application 对象。
|
Context 是一个请求级别的对象,继承自 Koa.Context。在每一次收到用户请求时,框架会实例化一个 Context 对象,这个对象封装了这次用户请求的信息,并提供了许多便捷的方法来获取请求参数或者设置响应信息。框架会将所有的 Service 挂载到 Context 实例上,一些插件也会将一些其他的方法和对象挂载到它上面(egg-sequelize 会将所有的 model 挂载在 Context 上)。
最常见的 Context 实例获取方式是在 Middleware, Controller 以及 Service 中。Controller 中的获取方式在上面的例子中已经展示过了,在 Service 中获取和 Controller 中获取的方式一样,在 Middleware 中获取 Context 实例则和 Koa 框架在中间件中获取 Context 对象的方式一致。
框架的 Middleware 同时支持 Koa v1 和 Koa v2 两种不同的中间件写法,根据不同的写法,获取 Context 实例的方式也稍有不同:
|
除了在请求时可以获取 Context 实例之外, 在有些非用户请求的场景下我们需要访问 service / model 等 Context 实例上的对象,我们可以通过 Application.createAnonymousContext()
方法创建一个匿名 Context 实例:
|
在定时任务中的每一个 task 都接受一个 Context 实例作为参数,以便我们更方便的执行一些定时的业务逻辑:
|
Request 是一个请求级别的对象,继承自 Koa.Request。封装了 Node.js 原生的 HTTP Request 对象,提供了一系列辅助方法获取 HTTP 请求常用参数。
Response 是一个请求级别的对象,继承自 Koa.Response。封装了 Node.js 原生的 HTTP Response 对象,提供了一系列辅助方法设置 HTTP 响应。
可以在 Context 的实例上获取到当前请求的 Request(ctx.request
) 和 Response(ctx.response
) 实例。
|
ctx.request.query.id
和 ctx.query.id
是等价的,ctx.response.body=
和 ctx.body=
是等价的。ctx.request.body
,而不是 ctx.body
。框架提供了一个 Controller 基类,并推荐所有的 Controller 都继承于该基类实现。这个 Controller 基类有下列属性:
ctx
- 当前请求的 Context 实例。app
- 应用的 Application 实例。config
- 应用的配置。service
- 应用所有的 service。logger
- 为当前 controller 封装的 logger 对象。在 Controller 文件中,可以通过两种方式来引用 Controller 基类:
|
框架提供了一个 Service 基类,并推荐所有的 Service 都继承于该基类实现。
Service 基类的属性和 Controller 基类属性一致,访问方式也类似:
|
Helper 用来提供一些实用的 utility 函数。它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处,同时可以更好的编写测试用例。
Helper 自身是一个类,有和 Controller 基类一样的属性,它也会在每次请求时进行实例化,因此 Helper 上的所有函数也能获取到当前请求相关的上下文信息。
可以在 Context 的实例上获取到当前请求的 Helper(ctx.helper
) 实例。
|
除此之外,Helper 的实例还可以在模板中获取到,例如可以在模板中获取到 security 插件提供的 shtml
方法。
|
应用开发中,我们可能经常要自定义一些 helper 方法,例如上面例子中的 formatUser
,我们可以通过框架扩展的形式来自定义 helper 方法。
|
我们推荐应用开发遵循配置和代码分离的原则,将一些需要硬编码的业务配置都放到配置文件中,同时配置文件支持各个不同的运行环境使用不同的配置,使用起来也非常方便,所有框架、插件和应用级别的配置都可以通过 Config 对象获取到,关于框架的配置,可以详细阅读 Config 配置章节。
我们可以通过 app.config
从 Application 实例上获取到 config 对象,也可以在 Controller, Service, Helper 的实例上通过 this.config
获取到 config 对象。
框架内置了功能强大的日志功能,可以非常方便的打印各种级别的日志到对应的日志文件中,每一个 logger 对象都提供了 4 个级别的方法:
logger.debug()
logger.info()
logger.warn()
logger.error()
在框架中提供了多个 Logger 对象,下面我们简单的介绍一下各个 Logger 对象的获取方式和使用场景。
我们可以通过 app.logger
来获取到它,如果我们想做一些应用级别的日志记录,如记录启动阶段的一些数据信息,记录一些业务上与请求无关的信息,都可以通过 App Logger 来完成。
我们可以通过 app.coreLogger
来获取到它,一般我们在开发应用时都不应该通过 CoreLogger 打印日志,而框架和插件则需要通过它来打印应用级别的日志,这样可以更清晰的区分应用和框架打印的日志,通过 CoreLogger 打印的日志会放到和 Logger 不同的文件中。
我们可以通过 ctx.logger
从 Context 实例上获取到它,从访问方式上我们可以看出来,Context Logger 一定是与请求相关的,它打印的日志都会在前面带上一些当前请求相关的信息(如 [$userId/$ip/$traceId/${cost}ms $method $url]
),通过这些信息,我们可以从日志快速定位请求,并串联一次请求中的所有的日志。
我们可以通过 ctx.coreLogger
获取到它,和 Context Logger 的区别是一般只有插件和框架会通过它来记录日志。
我们可以在 Controller 和 Service 实例上通过 this.logger
获取到它们,它们本质上就是一个 Context Logger,不过在打印日志的时候还会额外的加上文件路径,方便定位日志的打印位置。
订阅模型是一种比较常见的开发模式,譬如消息中间件的消费者或调度任务。因此我们提供了 Subscription 基类来规范化这个模式。
可以通过以下方式来引用 Subscription 基类:
|
插件开发者可以根据自己的需求基于它定制订阅规范,如定时任务就是使用这种规范实现的。
一个 Web 应用本身应该是无状态的,并拥有根据运行环境设置自身的能力。
框架有两种方式指定运行环境:
config/env
文件指定,该文件的内容就是运行环境,如 prod
。一般通过构建工具来生成这个文件。 |
EGG_SERVER_ENV
环境变量指定运行环境更加方便,比如在生产环境启动应用: |
框架提供了变量 app.config.env
来表示应用当前的运行环境。
不同的运行环境会对应不同的配置,具体请阅读 Config 配置。
很多 Node.js 应用会使用 NODE_ENV
来区分运行环境,但 EGG_SERVER_ENV
区分得更加精细。一般的项目开发流程包括本地开发环境、测试环境、生产环境等,除了本地开发环境和测试环境外,其他环境可统称为服务器环境,服务器环境的 NODE_ENV
应该为 production
。而且 npm 也会使用这个变量,在应用部署的时候一般不会安装 devDependencies,所以这个值也应该为 production
。
框架默认支持的运行环境及映射关系(如果未指定 EGG_SERVER_ENV
会根据 NODE_ENV
来匹配)
NODE_ENV | EGG_SERVER_ENV | 说明 |
---|---|---|
local | 本地开发环境 | |
test | unittest | 单元测试 |
production | prod | 生产环境 |
例如,当 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 中我们通过 app.env
来进行环境判断,app.env
默认的值是 process.env.NODE_ENV
。但是在 Egg(和基于 Egg 的框架)中,配置统一都放置在 app.config
上,所以我们需要通过 app.config.env
来区分环境,app.env
不再使用。
框架提供了强大且可扩展的配置功能,可以自动合并应用、插件、框架的配置,按顺序覆盖,且可以根据环境维护不同的配置。合并后的配置可直接从 app.config
获取。
配置的管理有多种方案,以下列一些常见的方案
我们选择了最后一种配置方案,配置即代码,配置的变更也应该经过 review 后才能发布。应用包本身是可以部署在多个环境的,只需要指定运行环境即可。
框架支持根据环境来加载配置,定义多个环境的配置文件,具体环境请查看运行环境配置
|
config.default.js
为默认的配置文件,所有环境都会加载这个配置文件,一般也会作为开发环境的默认配置文件。
当指定 env 时会同时加载对应的配置文件,并覆盖默认配置文件的同名配置。如 prod
环境会加载 config.prod.js
和 config.default.js
文件,config.prod.js
会覆盖 config.default.js
的同名配置。
配置文件返回的是一个 object 对象,可以覆盖框架的一些配置,应用也可以将自己业务的配置放到这里方便管理。
|
配置文件也可以简化的写成 exports.key = value
形式
|
配置文件也可以返回一个 function,可以接受 appInfo 参数
|
内置的 appInfo 有
appInfo | 说明 |
---|---|
pkg | package.json |
name | 应用名,同 pkg.name |
baseDir | 应用代码的目录 |
HOME | 用户目录,如 admin 账户为 /home/admin |
root | 应用根目录,只有在 local 和 unittest 环境下为 baseDir,其他都为 HOME。 |
appInfo.root
是一个优雅的适配,比如在服务器环境我们会使用 /home/admin/logs
作为日志目录,而本地开发时又不想污染用户目录,这样的适配就很好解决这个问题。
请根据具体场合选择合适的写法,但请确保没有写出以下代码:
|
应用、插件、框架都可以定义这些配置,而且目录结构都是一致的,但存在优先级(应用 > 框架 > 插件),相对于此运行环境的优先级会更高。
比如在 prod 环境加载一个配置的加载顺序如下,后加载的会覆盖前面的同名配置。
|
注意:插件之间也会有加载顺序,但大致顺序类似,具体逻辑可查看加载器。
配置的合并使用 extend2 模块进行深度拷贝,extend2 fork 自 extend,处理数组时会存在差异。
|
根据上面的例子,框架直接覆盖数组而不是进行合并。
框架在启动时会把合并后的最终配置 dump 到 run/application_config.json
(worker 进程)和 run/agent_config.json
(agent 进程)中,可以用来分析问题。
配置文件中会隐藏一些字段,主要包括两类:
config.dump.ignore
配置,必须是 Set 类型,查看默认配置。JSON.stringify
后的内容特别大还会生成 run/application_config_meta.json
(worker 进程)和 run/agent_config_meta.json
(agent 进程)文件,用来排查属性的来源,如
|
在前面的章节中,我们介绍了 Egg 是基于 Koa 实现的,所以 Egg 的中间件形式和 Koa 的中间件形式是一样的,都是基于洋葱圈模型。每次我们编写一个中间件,就相当于在洋葱外面包了一层。
我们先来通过编写一个简单的 gzip 中间件,来看看中间件的写法。
|
可以看到,框架的中间件和 Koa 的中间件写法是一模一样的,所以任何 Koa 的中间件都可以直接被框架使用。
一般来说中间件也会有自己的配置。在框架中,一个完整的中间件是包含了配置处理的。我们约定一个中间件是一个放置在 app/middleware
目录下的单独文件,它需要 exports 一个普通的 function,接受两个参数:
app.config[${middlewareName}]
传递进来。我们将上面的 gzip 中间件做一个简单的优化,让它支持指定只有当 body 大于配置的 threshold 时才进行 gzip 压缩,我们要在 app/middleware
目录下新建一个文件 gzip.js
|
中间件编写完成后,我们还需要手动挂载,支持以下方式:
在应用中,我们可以完全通过配置来加载自定义的中间件,并决定它们的顺序。
如果我们需要加载上面的 gzip 中间件,在 config.default.js
中加入下面的配置就完成了中间件的开启和配置:
|
该配置最终将在启动时合并到 app.config.appMiddleware
。
框架和插件不支持在 config.default.js
中匹配 middleware
,需要通过以下方式:
|
应用层定义的中间件(app.config.appMiddleware
)和框架默认中间件(app.config.coreMiddleware
)都会被加载器加载,并挂载到 app.middleware
上。
以上两种方式配置的中间件是全局的,会处理每一次请求。 如果你只想针对单个路由生效,可以直接在 app/router.js
中实例化和挂载,如下:
|
除了应用层加载中间件之外,框架自身和其他的插件也会加载许多中间件。所有的这些自带中间件的配置项都通过在配置中修改中间件同名配置项进行修改,例如框架自带的中间件中有一个 bodyParser 中间件(框架的加载器会将文件名中的各种分隔符都修改成驼峰形式的变量名),我们想要修改 bodyParser 的配置,只需要在 config/config.default.js
中编写
|
注意:框架和插件加载的中间件会在应用层配置的中间件之前,框架默认中间件不能被应用层中间件覆盖,如果应用层有自定义同名中间件,在启动时会报错。
在框架里面可以非常容易的引入 Koa 中间件生态。
以 koa-compress 为例,在 Koa 中使用时:
|
我们按照框架的规范来在应用中加载这个 Koa 的中间件:
|
|
如果使用到的 Koa 中间件不符合入参规范,则可以自行处理下:
|
无论是应用层加载的中间件还是框架自带中间件,都支持几个通用的配置项:
如果我们的应用并不需要默认的 bodyParser 中间件来进行请求体的解析,此时我们可以通过配置 enable 为 false 来关闭它
|
match 和 ignore 支持的参数都一样,只是作用完全相反,match 和 ignore 不允许同时配置。
如果我们想让 gzip 只针对 /static
前缀开头的 url 请求开启,我们可以配置 match 选项
|
match 和 ignore 支持多种类型的配置方式
|
有关更多的 match 和 ignore 配置情况,详见 egg-path-matching.
Router 主要用来描述请求 URL 和具体承担执行动作的 Controller 的对应关系, 框架约定了 app/router.js
文件用于统一所有路由规则。
通过统一的配置,我们可以避免路由规则逻辑散落在多个地方,从而出现未知的冲突,集中在一起我们可以更方便的来查看全局的路由规则。
app/router.js
里面定义 URL 路由规则 |
app/controller
目录下面实现 Controller |
这样就完成了一个最简单的 Router 定义,当用户执行 GET /user/123
,user.js
这个里面的 info 方法就会执行。
下面是路由的完整定义,参数可以根据场景的不同,自由选择:
|
路由完整定义主要包括5个主要部分:
pathFor
和 urlFor
来生成 URL。(可选)app.controller.user.fetch
- 直接指定一个具体的 controller'user.fetch'
- 可以简写为字符串形式app/controller
目录中。${fileName}.${functionName}
的方式指定对应的 Controller。${directoryName}.${fileName}.${functionName}
的方式制定对应的 Controller。下面是一些路由定义的方式:
|
如果想通过 RESTful 的方式来定义路由, 我们提供了 app.router.resources('routerName', 'pathMatch', controller)
快速在一个路径上生成 CRUD 路由结构。
|
上面代码就在 /posts
路径上部署了一组 CRUD 路径结构,对应的 Controller 为 app/controller/posts.js
接下来, 你只需要在 posts.js
里面实现对应的函数就可以了。
Method | Path | Route Name | Controller.Action |
---|---|---|---|
GET | /posts | posts | app.controllers.posts.index |
GET | /posts/new | new_post | app.controllers.posts.new |
GET | /posts/:id | post | app.controllers.posts.show |
GET | /posts/:id/edit | edit_post | app.controllers.posts.edit |
POST | /posts | posts | app.controllers.posts.create |
PUT | /posts/:id | post | app.controllers.posts.update |
DELETE | /posts/:id | post | app.controllers.posts.destroy |
|
如果我们不需要其中的某几个方法,可以不用在 posts.js
里面实现,这样对应 URL 路径也不会注册到 Router。
下面通过更多实际的例子,来说明 router 的用法。
Query String 方式
|
参数命名方式
|
复杂参数的获取
路由里面也支持定义正则,可以更加灵活的获取参数:
|
|
附:
这里直接发起 POST 请求会报错:'secret is missing'。错误信息来自 koa-csrf/index.js#L69 。
原因:框架内部针对表单 POST 请求均会验证 CSRF 的值,因此我们在表单提交时,请带上 CSRF key 进行提交,可参考安全威胁csrf的防范
注意:上面的校验是因为框架中内置了安全插件 egg-security,提供了一些默认的安全实践,并且框架的安全插件是默认开启的,如果需要关闭其中一些安全防范,直接设置该项的 enable 属性为 false 即可。
「除非清楚的确认后果,否则不建议擅自关闭安全插件提供的功能。」
这里在写例子的话可临时在
config/config.default.js
中设置
|
|
内部重定向
|
外部重定向
|
如果我们想把用户某一类请求的参数都大写,可以通过中间件来实现。 这里我们只是简单说明下如何使用中间件,更多请查看 中间件。
|
如上所述,我们并不建议把路由规则逻辑散落在多个地方,会给排查问题带来困扰。
若确实有需求,可以如下拆分:
|
也可直接使用 egg-router-plus。
前面章节写到,我们通过 Router 将用户的请求基于 method 和 URL 分发到了对应的 Controller 上,那 Controller 负责做什么?
简单的说 Controller 负责解析用户的输入,处理后返回相应的结果,例如
框架推荐 Controller 层主要对用户的请求参数进行处理(校验、转换),然后调用对应的 service 方法处理业务,得到业务结果后封装并返回:
所有的 Controller 文件都必须放在 app/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 对象,上面有四个方法(debug
,info
,warn
,error
),分别代表打印四个不同级别的日志,使用方法和效果与 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 都是一个 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; }; |
在上面的例子中我们引入了许多新的概念,但还是比较直观,容易理解的,我们会在下面对它们进行更详细的介绍。
由于 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"} |
请求的第一行包含了三个信息,我们比较常用的是前面两个:
POST
。/api/posts
,如果用户的请求中包含 query,也会在这里出现从第二行开始直到遇到的第一个空行位置,都是请求的 Headers 部分,这一部分中有许多常用的属性,包括这里看到的 Host,Content-Type,还有 Cookie
,User-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 请求示例中可以看到,有好多地方可以放用户的请求数据,框架通过在 Controller 上绑定的 Context 实例,提供了许多便捷方法和属性获取用户通过 HTTP 请求发送过来的参数。
在 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 中,我们介绍了 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'); } } |
虽然我们可以通过 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 设置了一些默认参数,配置好之后拥有以下特性:
application/json
,application/json-patch+json
,application/vnd.api+json
和 application/csp-report
时,会按照 json 格式对请求 body 进行解析,并限制 body 最大长度为 100kb
。application/x-www-form-urlencoded
时,会按照 form 格式对请求 body 进行解析,并限制 body 最大长度为 100kb
。一般来说我们最经常调整的配置项就是变更解析时允许的最大长度,可以在 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)上传 / 接收文件:
你的前端静态页面代码应该看上去如下样子:
对应的后端代码如下:
// 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, }; } }; |
对于多个文件,我们借助 ctx.request.files
属性进行遍历,然后分别进行处理:
你的前端静态页面代码应该看上去如下样子:
对应的后端代码:
// 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()
接口能获取到上传的文件流。
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
便捷的获取到用户上传的文件,需要满足两个条件:
如果要获取同时上传的多个文件,不能通过 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.headers
,ctx.header
,ctx.request.headers
,ctx.request.header
:这几个方法是等价的,都是获取整个 header 对象。ctx.get(name)
,ctx.request.get(name)
:获取请求 header 中的一个字段的值,如果这个字段不存在,会返回空字符串。ctx.get(name)
而不是 ctx.headers['name']
,因为前者会自动处理大小写。由于 header 比较特殊,有一些是 HTTP
协议规定了具体含义的(例如 Content-Type
,Accept
),有些是反向代理设置的,已经约定俗成(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', }, }; |
通过 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); } }; |
我们并不想在 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 章节。
当业务逻辑完成之后,Controller 的最后一个职责就是将业务逻辑的处理结果通过 HTTP 响应发送给用户。
HTTP 设计了非常多的状态码,每一个状态码都代表了一个特定的含义,通过设置正确的状态码,可以让响应更符合语义。
框架提供了一个便捷的 Setter 来进行状态码的设置
class PostController extends Controller { async create() { // 设置状态码为 201 this.ctx.status = 201; } }; |
具体什么场景设置什么样的状态码,可以参考 List of HTTP status codes 中各个状态码的含义。
绝大多数的数据都是通过 body 发送给请求方的,和请求中的 body 一样,在响应中发送的 body,也需要有配套的 Content-Type 告知客户端如何对数据进行解析。
application/json
格式的 body,内容是一个 JSON 字符串。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 = ' |
由于 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' }); } }; |
具体示例可以查看模板渲染。
有时我们需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 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); }; |
// 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 接口分为三种类型:
如果我们的 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
可以配置为正则表达式、字符串或者数组:
^
以及结尾的 \/
,保证匹配到完整的域名。 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/ |
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 的安全校验。
我们通过状态码标识请求成功与否、状态如何,在 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)
简单来说,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 对象,上面有四个方法(debug
,info
,warn
,error
),分别代表打印四个不同级别的日志,使用方法和效果与 context logger 中介绍的一样,但是通过这个 logger 对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。为了可以获取用户请求的链路,我们在 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。
// 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 |
插件机制是我们框架的一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。有人可能会问了
接下来我们就来逐一讨论
我们在使用 Koa 中间件过程中发现了下面一些问题:
综上所述,我们需要一套更加强大的机制,来管理、编排那些相对独立的业务逻辑。
一个插件其实就是一个『迷你的应用』,和应用(app)几乎一样:
plugin.js
,只能声明跟其他插件的依赖,而不能决定其他插件的开启与否。他们的关系是:
插件一般通过 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
是 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', }, }; |
具体合并规则可以参见配置。
参见文档:插件开发。
虽然我们通过框架开发的 HTTP Server 是请求响应模型的,但是仍然还会有许多场景需要执行一些定时任务,例如:
框架提供了一套机制来让定时任务的编写和维护更加优雅。
所有的定时任务都统一存放在 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
。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。
框架提供了多种扩展点扩展自身的功能:
在开发中,我们既可以使用已有的扩展 API 来方便开发,也可以对以上对象进行自定义扩展,进一步加强框架的功能。
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 指的是 Koa 的请求上下文,这是 请求级别 的对象,每次请求生成一个 Context 实例,通常我们也简写成 ctx
。在所有的文档中,Context 和 ctx
都是指 Koa 的上下文对象。
this
就是 ctx,例如 this.cookies.get('foo')
。this.ctx
,方法的写法直接通过 ctx
入参。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 对象和 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 对象和 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 函数用来提供一些实用的 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 都可以使用这种方式针对某个环境进行扩展,更多参见运行环境。
我们常常需要在应用启动期间进行一些初始化工作,等初始化完成后应用才可以启动成功,并开始对外提供服务。
框架提供了统一的入口文件(app.js
)进行启动过程自定义,这个文件返回一个 Boot 类,我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。
框架提供了这些 生命周期函数供开发人员处理:
configWillLoad
)configDidLoad
)didLoad
)willReady
)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