常用中间件的实现

常用中间件的实现

上一章 当中,我们简单的介绍了中间件的基本概念,以及洋葱模型,在最后我们也手动实现了一个简单的 compose() 方法,所以本章当中我们就主要手动的来实现一个最基本的 Koa.js 框架以及 Koa.js 当中一些比较常用的中间件的简单实现,比如 koa-loggerkoa-static

文中所有的示例源码均可见 koa2-example

ctx

在实现基本的框架之前,我们先来看看 Koa.js 当中的 ctx 这个对象,一般我们使用的话是这么用的

1
2
3
app.use(async (ctx, next) => {
ctx.body = 'hello world'
})

上面示例当中的 ctx,其实就是 Context,大多数人称之为上下文对象,这个对象下有四个主要的属性,它们分别是

  • ctx.req,原生的 req 对象
  • ctx.res,原生的 res 对象
  • ctx.requestKoa.js 自己封装的 request 对象
  • ctx.responseKoa.js 自己封装的 response 对象

其中 Koa.js 自己封装的和原生的最大的区别在于 Koa.js 自己封装的请求和响应对象的内容不仅囊括原生的还添加了一些额外的东西,除此之外,ctx 本身还代理了 ctx.requestctx.response 身上的属性,比如下面的示例

1
2
3
4
5
// 如果是在原生当中,则是需要经过 url.parse(p, true).query 才能得到的 query 对象
console.log(ctx.query)

// 如果是在原生当中,则是需要经过 url.parse(p).pathname 才能得到的路径(url 去除 query 部分)
console.log(ctx.path)

框架的实现

我们先来简单的总结一下 Koa.js 的一些基本特点

  • 有一个可以注册使用中间件的 use() 方法
  • 还有一个服务事件监听事件 listen() 方法,并且可以接收回调函数

我们先来使用最简单的回调方法来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const http = require('http')
const Emitter = require('events')

class WebServer extends Emitter {

constructor() {
super()
this.middleware = []
this.context = Object.create(null)
}

// 服务事件监听
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}

// 注册使用中间件
use(fn) {
if (typeof fn === 'function') {
this.middleware.push(fn)
}
}

// 回调方法
callback() {
let that = this

if (this.listeners('error').length === 0) {
this.on('error', this.onerror)
}

const henadleRequest = (req, res) => {
let context = that.createContext(req, res)
this.middleware.forEach((cb, idx) => {
try {
cb(context)
} catch (err) {
that.onerror(err)
}

if (idx + 1 >= this.middleware.length) {
if (res && typeof res.end === 'function') {
res.end()
}
}
})
}
return henadleRequest
}

// 异常监听处理
onerror(err) {
console.log(err)
}

// 创建通用上下文
createContext(req, res) {
let content = Object.create(this.context)
content.req = req
content.res = res
return content
}
}

module.exports = WebServer

然后来稍微的测试一下我们上面定义的服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const WebServer = require('./index')
const app = new WebServer()

app.use(ctx => {
ctx.res.write('hello world 1 \n')
})

app.use(ctx => {
ctx.res.write('hello world 2 \n')
})

app.use(ctx => {
ctx.res.write('hello world 3 \n')
})

app.listen(3000, _ => {
console.log(`app is running at port 3000`)
})

发现是可以正常使用的,但是这里面有一个问题,就是我们在处理中间件队列的时候,底层使用的是回调嵌套去处理的,但是中间件越多,回调嵌套越深,代码的可读性和可扩展性就很差,所以我们就可以考虑将我们的 handleRequest 方法调整为 async/await 方式,所以在这种情况下,我们就可以使用我们之前已经定义过的 compose() 方法,如下

1
2
3
4
5
6
7
8
const handleRequest = (req, res) => {
let context = this.createContext(req, res)
let middleware = this.middleware
// 执行中间件
compose(middleware)(context).catch(err => this.onerror(err))
}

return handleRequest

下面是整合后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const http = require('http')
const Emitter = require('events')
const compose = require('./compose')

// 通用上下文
const context = {
_body: null,

get body() {
return this._body
},

set body(val) {
this._body = val
this.res.end(this._body)
}
}

class SimpleKoa extends Emitter {
constructor() {
super()
this.middleware = []
this.context = Object.create(context)
}

// 服务事件监听
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}

// 注册使用中间件
use(fn) {
if (typeof fn === 'function') {
this.middleware.push(fn)
}
}

// 中间件总回调方法
callback() {

if (this.listeners('error').length === 0) {
this.on('error', this.onerror)
}

const handleRequest = (req, res) => {
let context = this.createContext(req, res)
let middleware = this.middleware
// 执行中间件
compose(middleware)(context).catch(err => this.onerror(err))
}

return handleRequest
}

// 异常处理监听
onerror(err) {
console.log(err)
}

// 创建通用上下文
createContext(req, res) {
let context = Object.create(this.context)
context.req = req
context.res = res
return context
}
}

module.exports = SimpleKoa

测试一下

1
2
3
4
5
6
7
8
9
10
11
const SimpleKoa = require('./index')

const app = new SimpleKoa()

app.use(async ctx => {
ctx.body = '<p>SimpleKoa</p>'
})

app.listen(3000, () => {
console.log(`app is running at port 3000`)
})

发现是可以正常使用的,下面我们再来看看 Koa.js 当中的一些比较常用的中间件的实现

koa-logger

我们先来看一个比较简单的 koa-logger 的实现,我们这里只实现简单的拦截请求,打印请求的 url,以及操作响应并且打印出响应的 url,通过实现可以发现,这里就用到了我们之前提到过的洋葱模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const logger = async (ctx, next) => {
let res = ctx.res

// 拦截操作请求 request
console.log(`<== 请求的方式和地址为 ${ctx.method} ${ctx.url}`)

await next()

// 拦截操作响应 request
res.on('finish', _ => {
console.log(`==> 响应的方式和地址为 ${ctx.method} ${ctx.url}`)
})
}

module.exports = logger

直接引入使用即可

1
2
3
4
5
6
7
8
9
10
11
const Koa = require('koa')
const logger = require('./log')
const app = new Koa()

app.use(logger)

app.use(async (ctx, next) => {
ctx.body = `hello world`
})

app.listen(3000)

koa-send

主要参考的是官方的 koajs/send,主要流程如下

  • 拦截请求,判断该请求是否请求本地静态资源文件
  • 操作响应,返回对应的静态文件文本内容或出错提示

简单的梳理一下,可以分为以下几个步骤

  • 配置静态资源绝对目录地址
  • 判断是否支持隐藏文件
  • 获取文件或者目录信息
  • 判断是否需要压缩
  • 设置 HTTP 头信息
  • 静态文件读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
const fs = require('fs')
const path = require('path')
const {
basename,
extname
} = path

const defaultOpts = {
root: '',
maxage: 0,
immutable: false,
extensions: false,
hidden: false,
brotli: false,
gzip: false,
setHeaders: () => { }
}

async function send(ctx, urlPath, opts = defaultOpts) {
const { root, hidden, immutable, maxage, brotli, gzip, setHeaders } = opts
let filePath = urlPath

// 配置静态资源绝对目录地址
try {
filePath = decodeURIComponent(filePath)
if (/[\.]{2,}/ig.test(filePath)) {
ctx.throw(403, 'Forbidden')
}
} catch (err) {
ctx.throw(400, 'failed to decode')
}

filePath = path.join(root, urlPath)
const fileBasename = basename(filePath)

// 判断是否支持隐藏文件
if (hidden !== true && fileBasename.startsWith('.')) {
ctx.throw(404, '404 Not Found')
return
}

// 获取文件或者目录信息
let stats
try {
stats = fs.statSync(filePath)
if (stats.isDirectory()) {
ctx.throw(404, '404 Not Found')
}
} catch (err) {
const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
if (notfound.includes(err.code)) {
ctx.throw(404, '404 Not Found')
return
}
err.status = 500
throw err
}

let encodingExt = ''
// 判断是否需要压缩
if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (fs.existsSync(filePath + '.br'))) {
filePath = filePath + '.br'
ctx.set('Content-Encoding', 'br')
ctx.res.removeHeader('Content-Length')
encodingExt = '.br'
} else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (fs.existsSync(filePath + '.gz'))) {
filePath = filePath + '.gz'
ctx.set('Content-Encoding', 'gzip')
ctx.res.removeHeader('Content-Length')
encodingExt = '.gz'
}

// 设置 http 头信息
if (typeof setHeaders === 'function') {
setHeaders(ctx.res, filePath, stats)
}

ctx.set('Content-Length', stats.size)
if (!ctx.response.get('Last-Modified')) {
ctx.set('Last-Modified', stats.mtime.toUTCString())
}
if (!ctx.response.get('Cache-Control')) {
const directives = ['max-age=' + (maxage / 1000 | 0)]
if (immutable) {
directives.push('immutable')
}
ctx.set('Cache-Control', directives.join(','))
}

const ctxType = encodingExt !== '' ? extname(basename(filePath, encodingExt)) : extname(filePath)
ctx.type = ctxType

// 静态文件读取
ctx.body = fs.createReadStream(filePath)
}

module.exports = send

使用如下

1
2
3
4
5
6
7
8
9
const send = require('./send')
const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
await send(ctx, ctx.path, { root: `${__dirname}/public` })
})

app.listen(3000)

koa-static

之前我们简单的介绍了 koa-send 这个中间件,但是这个中间件平常使用的较少,因为 Koa.js 官方对 koa-send 进行了二次封装,推出了我们所熟知的 koa-static 中间件,目标是用于做静态服务器或者项目静态资源管理,当然,还是主要依赖我们之前已经实现的 koa-send 这个中间件,因为需要它的静态文件读取过程,我们先来简单的梳理一下实现流程

  • 配置静态资源绝对目录地址
  • 判断是否支持等待其他请求
  • 判断是否为 GETHEAD 类型的请求
  • 通过 koa-send 中间件读取和返回静态文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// static.js
const { resolve } = require('path')
const send = require('./send')

function statics(opts = { root: '' }) {
opts.root = resolve(opts.root)

// 是否需要等待其他请求
if (opts.defer !== true) {
// 如果需要等待其他请求
return async function statics(ctx, next) {
let done = false

if (ctx.method === 'HEAD' || ctx.method === 'GET') {
try {
await send(ctx, ctx.path, opts)
done = true
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}

if (!done)
await next()
}
} else {
// 如果不需要等待其他请求
return async function statics(ctx, next) {
await next()

if (ctx.method !== 'HEAD' && ctx.method !== 'GET') {
return
}

if (ctx.body != null || ctx.status !== 404) {
return
}

try {
await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
}
}

module.exports = statics

使用如下

1
2
3
4
5
6
7
8
9
10
const path = require('path')
const Koa = require('koa')
const statics = require('./static')

const app = new Koa()

const root = path.join(__dirname, './public')
app.use(statics({ root }))

app.listen(3000)

koa-view

这一个中间件,在官方当中比较有代表性的是 koa-ejs 中间件,它实现了代理上下文(Context),即把渲染的方法挂载在 Koa 实例 Appapp.context 属性中,所以这里我们就简单的实现一个模版渲染中间件来模仿 koa-ejs 的基本能力,老规矩,简单的梳理一下实现流程

  • 初始化一个 Koa 实例(let app = new Koa()
  • 将需要的属性或者方法 view 挂载在 app.context 上(app.context.view
  • app.use() 中间件直接使用 ctx.view 方法或属性渲染模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// view.js
const path = require('path')
const fs = require('fs')

function view(app, opts = {}) {
const { baseDir = '' } = opts

// 将需要的属性或者方法挂载在 app.context 上
app.context.view = function (page = '', obj = {}) {
let ctx = this
let filePath = path.join(baseDir, page)
if (fs.existsSync(filePath)) {
let tpl = fs.readFileSync(filePath, 'binary')
ctx.body = tpl
} else {
ctx.throw(404)
}
}
}

module.exports = view

然后我们来使用一下,目录结构如下

1
2
3
4
5
6
.
├── view.js
├── index.js
└── views
├── hello.html
└── index.html

index.js 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Koa = require('koa')
const path = require('path')
const view = require('./view')

// 初始化一个 Koa 实例
const app = new Koa()

// 将需要的属性或者方法挂载在 app.context 上
view(app, {
baseDir: path.join(__dirname, 'views')
})

app.use(async ctx => {
await ctx.view(`${ctx.path}.html`, {
title: 'index page'
})
})

app.use(async ctx => {
await ctx.view(`${ctx.path}.html`, {
title: 'index page'
})
})

app.listen(3000)

直接运行,然后在浏览器当中访问对应的路由即可(/hello/index

koa-jsonp

下面来看一个跟我们之前实现的 koa-view 非常类似的一个示例,本质上原理是一致的,就是首先初始化一个 Koa 实例,将需要的属性或者方法 jsonp 挂载在 app.context 上,当前请求响应要返回 jsonp 数据时候设置 ctx.body = ctx.jsonp(result)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// jsonp.js
function jsonp(app, opts = {}) {
let callback = opts.callback || 'callback'

app.context.jsonp = function (obj = {}) {
let ctx = this
if (Object.prototype.toString.call(obj).toLowerCase() === '[object object]') {
let jsonpStr = `;${callback}(${JSON.stringify(obj)})`

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

// 输出 jsonp 字符串
ctx.body = jsonpStr
} else {
ctx.throw(500, 'result most be a json')
}
}
}

module.exports = jsonp

然后我们来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Koa = require('koa')
const jsonp = require('./jsonp')
const app = new Koa()

jsonp(app, {})

app.use(async ctx => {
await ctx.jsonp({
data: 'this is jsonp test',
success: true
})
})

app.listen(3000)

可以发现,访问 3000 端口的时候可以看到我们返回的 callback

koa-bodyparser

本节主要参考的是官方 koajs/bodyparser,如果有使用过 bodyparser 这个中间件,就会了解到 bodyparser 中间件的主要作用就是

拦截 POST 请求,然后等待解析表单信息,最后把表单信息代理到 ctx.request.body

这样一来,在后面的中间件当中都可以使用 ctx.request.body 来获取表单当中的数据,下面我们就来看看是如何实现的,我们先来看看如何来解析请求体当中的数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
function readStream(req) {
return new Promise((resolve, reject) => {
try {
streamEventListen(req, (data, err) => {
if (data && !isError(err)) {
resolve(data)
} else {
reject(err)
}
})
} catch (err) {
reject(err)
}
})
}

function isError(err) {
return Object.prototype.toString.call(err).toLowerCase() === '[object error]'
}

function streamEventListen(req, callback) {
let stream = req.req || req
let chunk = []
let complete = false

// attach listeners
stream.on('aborted', onAborted)
stream.on('close', cleanup)
stream.on('data', onData)
stream.on('end', onEnd)
stream.on('error', onEnd)

function onAborted() {
if (complete) {
return
}
callback(null, new Error('request body parse aborted'))
}

function cleanup() {
stream.removeListener('aborted', onAborted)
stream.removeListener('data', onData)
stream.removeListener('end', onEnd)
stream.removeListener('error', onEnd)
stream.removeListener('close', cleanup)
}

function onData(data) {
if (complete) {
return
}
if (data) {
chunk.push(data.toString())
}
}

function onEnd(err) {
if (complete) {
return
}

if (isError(err)) {
callback(null, err)
return
}

complete = true
let result = chunk.join('')
chunk = []
callback(result, null)
}
}


module.exports = readStream

然后再来实现我们的 bodyparser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const readStream = require('./readStream')
let strictJSONReg = /^[\x20\x09\x0a\x0d]*(\[|\{)/

let jsonTypes = [
'application/json'
]

let formTypes = [
'application/x-www-form-urlencoded'
]

let textTypes = [
'text/plain'
]

function parseQueryStr(queryStr) {
let queryData = {}
let queryStrList = queryStr.split('&')
for (let [index, queryStr] of queryStrList.entries()) {
let itemList = queryStr.split('=')
queryData[itemList[0]] = decodeURIComponent(itemList[1])
}
return queryData
}

function bodyParser(opts = {}) {
return async function (ctx, next) {

// 拦截 POST 请求
if (!ctx.request.body && ctx.method === 'POST') {

// 解析请求体中的表单信息
let body = await readStream(ctx.request.req)
let result = body
if (ctx.request.is(formTypes)) {
result = parseQueryStr(body)
} else if (ctx.request.is(jsonTypes)) {
if (strictJSONReg.test(body)) {
try {
result = JSON.parse(body)
} catch (err) {
ctx.throw(500, err)
}
}
} else if (ctx.request.is(textTypes)) {
result = body
}

// 将请求体中的信息挂载到上下文的 request 属性中
ctx.request.body = result
}
await next()
}
}

module.exports = bodyParser

下面我们建立个表单来测试一下,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>index</title>
</head>
<body>
<p>form post demo</p>
<form method="POST" action="/post">
<span>data</span>
<input name="userName" type="text">
<button type="submit">submit</button>
</form>
<script src="./index.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Koa = require('koa')
const fs = require('fs')
const path = require('path')
const body = require('./bodyparser')
const app = new Koa()

app.use(body())

app.use(async (ctx, next) => {
if (ctx.url === '/') {
// 当 GET 请求时候返回表单页面
let html = fs.readFileSync(path.join(__dirname, './index.html'), 'binary')
ctx.body = html
} else if (ctx.url === '/post' && ctx.method === 'POST') {
// 当 POST 请求的时候,解析 POST 表单里的数据,并显示出来
ctx.body = ctx.request.body
} else {
ctx.body = '404'
}

await next()
})

app.listen(3000)

可以发现,页面可以正常输出

koa-router

最后我们来看一个不直接提供中间件,而是通过间接方式提供了中间件,最具代表性的莫过于 koa-router 了,我们先来看下实现步骤

  • 初始化路由实例
  • 注册路由请求信息缓存到实例中
    • 请求类型
    • 请求 path
    • 对应的请求后操作
  • 注册的路由操作就是子中间件
  • 路由实例输出父中间件
    • 返回一个父中间件
    • 中间件里对每次请求进行遍历匹配缓存中注册的路由操作
    • 匹配上请求类型,路径就执行对应路由子中间件
  • app.use() 路由实例返回的父中间件

实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const methods = [
'GET',
'PUT',
'PATCH',
'POST',
'DELETE'
]

class Layer {
constructor(path, methods, middleware, opts) {
this.path = path
this.methods = methods
this.middleware = middleware
this.opts = opts
}
}

class Router {
constructor(opts = {}) {
this.stack = []
}

register(path, methods, middleware, opts) {
let route = new Layer(path, methods, middleware, opts)
this.stack.push(route)
return this
}

routes() {
let stock = this.stack
return async function (ctx, next) {
let currentPath = ctx.path
let route
for (let i = 0; i < stock.length; i++) {
let item = stock[i]
if (currentPath === item.path && item.methods.indexOf(ctx.method) >= 0) {
route = item.middleware
break
}
}
if (typeof route === 'function') {
route(ctx, next)
return
}
await next()
}
}
}

methods.forEach(method => {
Router.prototype[method.toLowerCase()] = Router.prototype[method] = function (path, middleware) {
this.register(path, [method], middleware)
}
})

module.exports = Router

测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Koa = require('koa')
const Router = require('./router')
const app = new Koa()

// 初始化路由实例
const router = new Router()

// 注册路由请求信息缓存到实例中
router.get('/index', async ctx => { ctx.body = 'index page' })
router.get('/post', async ctx => { ctx.body = 'post page' })
router.get('/list', async ctx => { ctx.body = 'list page' })
router.get('/item', async ctx => { ctx.body = 'item page' })

app.use(router.routes())

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

app.listen(3000)

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×