在 上一章 当中,我们简单的介绍了中间件的基本概念,以及洋葱模型,在最后我们也手动实现了一个简单的 compose()
方法,所以本章当中我们就主要手动的来实现一个最基本的 Koa.js
框架以及 Koa.js
当中一些比较常用的中间件的简单实现,比如 koa-logger
和 koa-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.request
,Koa.js
自己封装的 request
对象
ctx.response
,Koa.js
自己封装的 response
对象
其中 Koa.js
自己封装的和原生的最大的区别在于 Koa.js
自己封装的请求和响应对象的内容不仅囊括原生的还添加了一些额外的东西,除此之外,ctx
本身还代理了 ctx.request
和 ctx.response
身上的属性,比如下面的示例
1 2 3 4 5 console .log(ctx.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 console .log(`<== 请求的方式和地址为 ${ctx.method} ${ctx.url} ` ) await next() 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' } 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
这个中间件,因为需要它的静态文件读取过程,我们先来简单的梳理一下实现流程
配置静态资源绝对目录地址
判断是否支持等待其他请求
判断是否为 GET
和 HEAD
类型的请求
通过 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 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
实例 App
的 app.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 const path = require ('path' )const fs = require ('fs' )function view (app, opts = {} ) { const { baseDir = '' } = opts 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' )const app = new Koa()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 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)} )` ctx.type = 'text/javascript' 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 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 ) { 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 } 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 === '/' ) { let html = fs.readFileSync(path.join(__dirname, './index.html' ), 'binary' ) ctx.body = html } else if (ctx.url === '/post' && ctx.method === 'POST' ) { ctx.body = ctx.request.body } else { ctx.body = '404' } await next() }) app.listen(3000 )
可以发现,页面可以正常输出
koa-router 最后我们来看一个不直接提供中间件,而是通过间接方式提供了中间件,最具代表性的莫过于 koa-router
了,我们先来看下实现步骤
初始化路由实例
注册路由请求信息缓存到实例中
注册的路由操作就是子中间件
路由实例输出父中间件
返回一个父中间件
中间件里对每次请求进行遍历匹配缓存中注册的路由操作
匹配上请求类型,路径就执行对应路由子中间件
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 )
参考