愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

Koa学习笔记

一、起步

Koa2和Koa1的主要区别在于,Koa2全面采用async/await语法,所以Node.js应该支持async/await语法才能支持Koa2。故环境要求为Node.js版本>=v7.6.0

1、安装

$ npm install koa -S

2、示例

使用Koa2编写Hello,World的代码如下:

const Koa = require('koa')
const app = new Koa()
app.use(async (ctx) => {
    ctx.body = 'Hello, World'
})
app.listen(3000)

3、koa2结构

koa2的源码中,其结构如下:

+-- lib
|   |-- application.js
|   |-- context.js
|   |-- request.js
|   |-- response.js
+-- package.json

其中:

  • application.js 是Koa2的入口,封装了context、request、response以及中间件处理流程
  • context.js 处理应用的上下文,里面封装了部分request.js和response.js的方法
  • request.js 处理HTTP请求
  • response.js 处理HTTP响应

4、Koa2特性

  • 只提供封装好的HTTP上下文、请求、响应,以及基于async/await的中间件容器
  • 利用ES7的async/await代替koa1的generator/yield
  • 中间件只支持async/await封装的,若要使用koa1的generator中间件,那么应该先用koa-convert进行转化

5、如何开发和使用中间件

koa1的中间件,返回的应该是generator函数,编写方式如下:

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

module.exports = function() {
    return function* (next) {
        // 中间件处理流程开始
        log(this)
        // 中间件处理流程结束
        if (next) {
            yield next
        }
    }
}

在koa1中的使用方法:

const koa = require('koa')
const loggerGenerator = require('./logger-generator')
const app = koa()
app.use(loggerGenerator())
app.use(function* () {
    this.body = 'Hello, world'
})
app.listen(3000)

而在koa2中,需要使用的是async/await中间件,即中间件的返回值应该是个Promise,如果koa2要使用generator中间件,那么可以通过koa-convert模块先进行转化,如下:

const convert = require('koa-convert')
app.use(convert(loggerGenerator()))

async/await中间件的编写方式如下:

function log(ctx) {
    console.log(ctx.method, ctx.header.host + ctx.url)
}
module.exports = function() {
    return async function(ctx, next) {
        // 中间件处理流程开始
        log(ctx)
        // 中间件处理流程结束
        await next()
    }
}

而koa2终使用async/await中间件的方式如下:

const Koa = require('koa')
const loggerAsync = require('./loggerAsync')
const app = new Koa()
app.use(loggerAsync)
aoo.use(ctx => {
    ctx.body = 'Hello, world'
})
app.listen(3000)


二、路由

1、原生实现

路由的作用是为不同的请求信息,进行处理,然后响应相应的请求结果。路由的实现,主要是利用ctx.request.url这一信息,我们可以简单手动实现一个路由,如下:

const Koa = require('koa')
const fs = require('fs')
const app = new Koa()
/**
 *  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内容
 */
async function route(url) {
    let view = '404.html'
    switch(url) {
        case '/':
            view = 'index.html'
            break
        case '/todo':
            view = 'todo.html'
            break
        default:
            break
    }
    const html = await render(view)
    return html
}

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

2、koa-router中间件

手动处理路由总是比较麻烦的,所以为了简化这一处理流程,我们可以引入koa-router中间件模块,其用法示例如下(注意:koa2对应的koa-router版本为7.x):

const Koa = require('koa')
const Router = require('koa-router')
const fs = require('fs')
const app = new Koa()

const home = new Router()
home.get('/', async (ctx) => {
    ctx.body = 'Home'
})

const page = new Router()
page.get('/hello/:string', async (ctx) => {
    ctx.body = `Hello, ${ctx.params.string}`
})

const router = new Router()
router.use('/', home.routes(), home.allowedMethods())
router.use('/page', page.routes(), hone.allowedMethods())

app.use(router.routes()).use(router.allowedMethods())
app.listen(3000)

关于koa-router的详细文档,可参见https://www.npmjs.com/package/koa-router


三、请求数据获取

1、获取GET数据

GET请求的数据,可以有下列的获取方式:

  • 通过ctx上下文对象直接获取
    • 获取请求对象ctx.query,得到如:{ a: 1, b: 2 }
    • 获取请求字符串ctx.querystring,得到如:a=1&b=2
  • 通过ctx.request对象获取
    • 获取请求对象ctx.request.query
    • 获取请求字符串ctx.request.querystring

2、获取POST数据

POST的数据是放置于HTTP请求报文主体中的,Koa本身没有封装获得POST数据的方法,所以我们可以通过解析报文主体自行获取。需要注意的是:ctx.requestcontext经过封装的请求对象,而ctx.reqcontext提供的Node.js原生HTTP请求对象,response和res则同理。处理方式如下:

function parseQueryStr(queryStr) {
    let res = {}
    let list = queryStr.split('&')
    for (let part of list) {
        let [key, val] = part.split('=')
        res[key] = decodeURIComponent(val)
    }
    return res
}
function parsePostData(ctx) {
    return new Promise((resolve, reject) => {
        try {
            let postData = ''
            ctx.req.addListener('data', data => {
                postData += data
            })
            ctx.req.addListener('end', () => {
                let parsedData = parseQueryStr(postData)
                resolve(parseData)
            })
        } catch (err) {
            reject(err)
        }
    })
}

3、koa-bodyparser中间件

为了省去处理POST请求参数的过程,我们可以引入koa-bodyparser中间件,koa-bodyparser可以把koa2上下文中的formData数据解析到ctx.request.body中,使用例子:

const Koa = require('koa')
const app = new Koa()
const bodyParser = require('koa-bodyparser')
app.use(bodyParser())
app.use(async (ctx) => {
    if (ctx.url === '/' && ctx.method === 'POST') {
        let postData = ctx.request.body
        ctx.body = `Your name is : ${postData.name}`
    }
})


四、静态资源加载

1、原生实现

静态资源的处理,通常有以下的响应结果:

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

以下是原生实现一个简易静态资源服务器的例子,如:
mime.js

const 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

util.js

const fs = require('fs')
const path = require('path')
const mimes = require('./mimes')
/**
 *  文件、目录遍历
 */
function walk(reqPath) {
    let files = fs.readdirSync(reqPath)
    let dirList = []
    let fileList = []
    for (let i=0, len=files.length; i<len; ++i) {
        let file = files[i]
        let fileParts = file.split('.')
        let fileMime = (fileParts.length > 1)
            ? fileParts[fileParts.length - 1]
            : 'undefined'
        if (typeof mimes[fileMime] === 'undefined') {
            dirList.push(file)
        } else {
            fileList.push(file)
        }
    }
    let result = [...dirList, ...fileList]
    return result
}
/**
 *  展示目录内容
 */
function showDir(url, path) {
    const files = walk(path)
    let html = '<ul>'
    for (let file of files) {
        html += `<li><a href="${url === '/' ? '' : url}/${file}">${file}</a></li>`
    }
    html += '</ul>'
    return html
}
/**
 *  获取静态资源内容
 */
function getContent(ctx, fullStaticPath) {
    const reqPath = path.join(fullStaticPath, ctx.url)
    const exists = fs.existsSync(reqPath)
    let content
    if (!exists) {
        content = '404 Not Found'
    } else {
        const stat = fs.statSync(reqPath)
        if (stat.isDirectory()) {
            content = showDir(ctx.url, reqPath)
        } else {
            content = fs.readFileSync(reqPath, 'binary') 
        }
    }
    return content
}

module.exports = {
    walk,
    showDir,
    getContent
}

index.js

const Koa = require('koa')
const path = require('path')
const mimes = require('./mimes')
const { walk, showDir, getContent } = require('./util')

const app = new Koa()
const staticPath = './static'
function parseMime(url) {
    let extName = path.extname(url)
    extName = extName ? extName.slice(1) : 'unknown'
    return mimes[extName]
}
app.use(async (ctx) => {
    const fullStaticPath = path.join(__dirname, staticPath)
    const mime = parseMime(ctx.url)
    const content = getContent(ctx, fullStaticPath)
    if (mime) {
        ctx.type = mime
    }
    if (mime && mime.indexOf('image/') >= 0) {
        ctx.res.writeHead(200)
        ctx.res.write(content, 'binary')
        ctx.res.end()
    } else {
        ctx.body = content
    }
})
app.listen(3000)

2、koa-static中间件

我们可以使用koa-static中间件来处理静态资源的加载问题,例子如下:

const Koa = require('koa')
const path = require('path')
const staticMiddleware = require('koa-static')
const app = new Koa()
const staticPath = './static'
app.use(staticPath(
    path.join(__dirname, staticPath)
))
app.use(async (ctx) => {
    ctx.body = 'Hello world'
})
app.listen(3000, () => {
    console.log('[demo] static server is starting at port 3000')
})


五、cookie/session

1、使用cookie

koa本身提供了从上下文中直接读取和写入cookie的方法,如下:

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

koa对cookie的处理使用的是[cookies](https://github.com/pillarjs/cookies)模块,因此读写cookie的使用参数与该模块的使用一致。例子如下:

const Koa = require('koa')
const app = new Koa()
app.use(async (ctx) => {
    if (ctx.url === '/get') {
        const str = `读取cookie:${ctx.cookies.get('username')}`
        ctx.body = str
    } else {
        ctx.cookies.set('username', 'RuphiLau', {
            domain: '127.0.0.1',
            path: '/',
            maxAge: 3600,
            expires: new Date(new Date() + 3600),
            httpOnly: true
        })
        ctx.body = '设置COOKIE'
    }
})
app.listen(3000)

2、使用session

koa原生只提供了cookie的操作,但是没有提供session的操作。所以session需要自己实现,实现方案有:

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

其中,数据库存储方案如下:

  • 数据库可以选用Mysql
  • 使用中间件处理:
    • koa-session-minimal 提供存储介质的读写接口
    • koa-mysql-sessionkoa-session-minimal中间件提供Mysql数据库的session数据读写操作
    • 将sessionId与对应的数据存到数据库
  • sessionId保存在cookie中,通过cookie获得sessionId,从而获得session信息

编写后的例子如下:

const Koa = require('koa')
const session = require('koa-session-minimal')
const MysqlSession = require('koa-mysql-session')
const app = new Koa()
let store = new MysqlSession({
    user: 'user',
    password: 'pwd',
    database: 'dbname',
    host: '127.0.0.1'
})
let cookie = {
    maxAge,
    expires,
    path,
    domain,
    httpOnly,
    overwrite,
    secure,
    sameSite,
    signed
}
app.use(session({
    key: 'SESSION_ID',
    store,
    cookie
}))
app.use(async (ctx) => {
    // 设置session的方法
    ctx.session = {
        valA: 'a',
        valB: 'b'
    }
    // 读取session的方法
    ctx.session.valA
})


六、使用模板引擎

在Koa中使用模板引擎,可以引入koa-views和相应的模板引擎,如[ejs](https://github.com/mde/ejs),示例如下:
模板文件:

<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
</body>
</html>

代码:

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) => {
    const title = 'Hello, world'
    await ctx.render('index', {
        title
    })
})
app.listen(3000)


七、文件上传

busboy模块的作用是解析HTML中的formData,用法如下:

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

// ...
// req为Node原生请求对象
const busboy = new Busboy({ headers: req.headers })
// 处理文件上传
busboy.on('file', (fieldName, file, fileName, encoding, mimetype) => {
    // fieldName为表单中file控件的name
    // file为文件对象
    // fileName为文件名
    console.log(`File [${fieldName}]: fileName: ${fileName}`)
    // 保存文件到特定路径
    file.pipe(fs.createWriteStream('./upload'))
    // 解析文件流
    file.on('data', (data) => {
        console.log(`File [${fieldName}] got ${data.length} bytes`)
    })
    // 解析文件结束
    file.on('end', () => {
        console.log(`File [${fieldName}] finished`)
    })
})
// 处理表单中的非文件字段
busboy.on('field', (fieldName, value, fieldNameTruncated, valTruncated) => {
    console.log(`Filed [${fieldName}]: value: ${value}`)
})
// 监听结束处理
busboy.on('finish', () => {
    console.log('Form has been parsed over')
    res.writeHead(303, {
        Connection: 'close',
        Location: '/'
    })
    res.end()
})
req.pipe(busboy)
// ...