之前我们已经对 Express 进行了学习,今天要介绍的 Koa 同样出自于 Express 团队之手,但其定位,按照官方文档的说发,是下一代的 web 服务器框架 :
next generation web framework for node.js
相比于内置了许多功能的 express,koa 更加简洁,想实现某些功能,需要另外安装一些库。下面就开始介绍如何使用 koa 来快速搭建服务器。
快速搭建
首先是安装 koa:
npm i koa
复制代码
之后在文件中导入,并使用 new
的方式创建 app
,而不是像 express 那样直接调用 express()
:
// src\main.js 代码片段 1.1
const Koa = require('koa')
const app = new Koa()
复制代码
使用 app.listen()
启动服务器,传入要监听的端口号,这与 express 一样:
// src\main.js 代码片段 1.2
app.listen(4396, () => {
console.log('服务器启动成功')
})
复制代码
常量配置与 .env 文件的使用
为了方便修改代码片段 1.2 中的端口号,我们其实可以在项目根目录下新建个 .env 文件,然后在其中配置:
# .env
SERVER_PORT = 4396
复制代码
然后可以新建个 src\config\server.config.js 文件将常量统一管理:
const dotenv = require('dotenv')
dotenv.config()
const SERVER_PORT = process.env.SERVER_PORT
module.exports = {
SERVER_PORT
}
复制代码
dotenv 需要先进行安装 :
npm i dotenv
复制代码
然后调用 dotenv.config()
后,就可以通过 process.env
拿到项目根目录下 .env 文件中定义的 SERVER_PORT
了。最后,在 main.js 中就可以使用 server.config.js 导出的 SERVER_PORT
来替换原本代码片段 1.2 中的 4396
:
// src\main.js
const { SERVER_PORT } = require('./config/server.config')
app.listen(SERVER_PORT, () => {
console.log('服务器启动成功')
})
复制代码
部分源码探究
new Koa()
如果在去代码片段 1.1 的 const app = new Koa()
处打上断点,通过调试去查看 koa 的源码,可以看到实际上是执行了 new Application()
,也就是去执行了 Application
类的构造方法 :
// node_modules\koa\lib\application.js
module.exports = class Application extends Emitter {
constructor(options) {
// ...
this.middleware = [];
this.context = Object.create(context); // 上下文对象
this.request = Object.create(request); // 请求对象
this.response = Object.create(response); // 响应对象
}
}
复制代码
这里提前说明下,middleware
用于收集传入 app.use()
的中间件函数;context
为中间函数的第 1 个参数 ctx
,具体后文还会介绍。
app.listen()
查看 listen
方法,可以看到其本质上还是使用了 node 的 http 模块创建的服务:
// node_modules\koa\lib\application.js
const http = require('http');
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
复制代码
当请求发生时,就会调用传入 http.createServer()
的回调,也就是 this.callback()
的返回值,所以我们得去看看 callback
方法:
// node_modules\koa\lib\application.js
callback() {
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
复制代码
可以看到,当请求发生时,实际上执行的是 handleRequest
,在其内部会创建 ctx
,并且还会去调用 app 的 handleRequest
方法,我们会在后文探究其具体定义。
中间件
koa 中也有中间件,并且不像 express 那样可以使用什么 app.post()
等方法,而是只能使用 app.use()
的方式来传入中间件函数,中间件函数会被传入 2 个参数,ctx
和 next
,express 中的 req
和 res
在 koa 中都被上下文对象 ctx
替代。响应数据的方式则是通过给 ctx.body
赋值,如果想使用 end
方法,则需要通过 ctx.res.end('Hello Juejin')
:
app.use((ctx, next) => {
ctx.body = 'Hello Juejin'
})
复制代码
ctx
通过前面对源码的查看,我们知道每次请求时,都会通过 const ctx = this.createContext(req, res)
创建一个 ctx
对象,可以通过 ctx.request
和 ctx.response
来获取 koa 封装的请求对象和响应对象,比如打印一个 GET 请求的 ctx.request
结果如下:
{
method: 'GET',
url: '/search?name=Jay&age=20',
header: {
'user-agent': 'Apifox/1.0.0 (https://www.apifox.cn)',
accept: '* / *',
host: 'localhost:4396',
'accept-encoding': 'gzip, deflate, br',
connection: 'keep-alive',
'content-type': 'multipart/form-data; boundary=--------------------------092047209466522747437726',
'content-length': '904762'
}
}
复制代码
可以看到里面有请求的 method 、url 和 header 信息,但其实直接打印 ctx.request.path
,也能获取到值为 /search
,并且大部分在 ctx.request
或 ctx.response
属性,都能直接在 ctx
上获取到,比如可以获取 ctx.path
、ctx.method
。 而通过 ctx.req
和 ctx.res
则可以分别获取 node 的请求和响应对象,也就是使用 http.createServer((req, res) => {})
来创建服务时,传入的回调里的 req
和 res
, 其中包含的属性相对较多,这也是为何前面说想使用 end
方法返回数据需要通过 ctx.res
调用的原因。
next
同 express 一样,next
用于调用栈中的下一个中间件函数。比如有如下例子,我们将响应 ctx.body = 'hello'
写在于第 1 个中间件函数内,并放置于 next()
之后:
// 代码片段 2.1
app.use((ctx, next) => {
console.log(1)
next()
console.log(5)
ctx.body = 'hello'
})
app.use((ctx, next) => {
console.log(2)
next()
console.log(4)
})
app.use((ctx, next) => {
console.log(3)
})
复制代码
当接收到请求时,打印的顺序会是 1 -2 - 3 - 4 - 5,这种执行顺序就是所谓的“洋葱模型”:
异步代码执行顺序(洋葱模型)
如果其中有异步代码,比如最后一个中间件函数内有一个定时器:
// 代码片段 2.2
app.use((ctx, next) => {
console.log(1)
next()
console.log(5)
ctx.body = 'hello'
})
app.use((ctx, next) => {
console.log(2)
next()
console.log(4)
})
app.use((ctx, next) => {
setTimeout(() => {
console.log(3)
}, 1000)
})
复制代码
那么接收到请求后的打印顺序将是 1 - 2 - 4 - 5 - 3,4 和 5 的打印并不会等待异步代码执行完毕。如果仍旧想让打印顺序为 1 -2 - 3 - 4 - 5,也就是想符合洋葱模型,可以借助 async/await 改成下面这样:
// 代码片段 2.3
app.use(async (ctx, next) => {
console.log(1)
await next()
console.log(5)
ctx.body = 'hello'
})
app.use(async (ctx, next) => {
console.log(2)
await next()
console.log(4)
})
app.use(async (ctx, next) => {
await new Promise((resolve, reject) => {
setTimeout(() => {
console.log(3)
resolve()
}, 1000)
})
})
复制代码
app.use() 源码探究
其原因可通过直接在代码片段 2.3 的第 1 个 app.use
处打上断点,去查看 koa 的源码来一探究竟:
// node_modules\koa\lib\application.js
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
复制代码
可以看到在 use
方法中,主要是在第 11 行执行的 this.middleware.push(fn)
,将我们传入 app.use()
的回调函数 fn,也就是
async (ctx, next) => {
console.log(1)
await next()
console.log(5)
ctx.body = 'hello'
}
复制代码
加入到 middleware
数组中,之后会依次执行我们定义的下一个 app.use()
,最终将第 2、 3 个回调函数也加入到 middleware
中:
那么加入到 middleware
中的这些回调什么时候执行呢?前文在查看源码中的 callback
方法时提到,当每次请求发生时,会执行的其实是 handleRequest
,并且会返回 this.handleRequest(ctx, fn)
,也就是 app 的 handleRequest
:
// 代码片段 3.1
// node_modules\koa\lib\application.js
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
复制代码
fnMiddleware
就是前面 callback
里定义的 fn
,其值为 compose(this.middleware)
的返回值,也是一个函数,这个函数的返回值为 dispatch
的执行,执行的返回值为 Promise.resolve()
或 Promise.reject()
:
// node_modules\koa-compose\index.js
function compose (middleware) {
// ...
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
复制代码
可以看到,当请求发生时,传入第 1 个 app.use()
的回调是在执行 dispatch(0)
时,从 middleware
内获取赋值给 fn
,然后通过 Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
执行的,fn()
在执行时传入了 context
与 dispatch.bind(null, i + 1)
,所以这个 dispatch
其实就是传入 app.use()
的回调的第 2 个参数 next
。 当我们在第 1 个 app.use
的中间件函数中执行完 console.log(1)
,调用了 await next()
,即执行了 await dispatch(1)
, 就会从 middleware
中取出第 2 个回调执行,打印了 console.log(2)
,然后也调用了 await next()
,于是就会执行 await dispatch(2)
,取出我们定义的最后一个中间件函数执行:
async (ctx, next) => {
await new Promise(resolve => {
setTimeout(() => {
console.log(3)
resolve()
}, 1000)
})
}
复制代码
因为使用了 async/await 与 promise,所以会等待 console.log(3)
执行完,即dispatch(2)
的返回值Promise.resolve()
内的 fn()
执行完毕,第 2 个中间件回调的 await next()
有了结果,继续执行 console.log(4)
。然后 dispatch(1)
执行完毕,第 1 个中间件回调的 await next()
也有了结果,最后执行 console.log(5)
和 ctx.body = 'hello3'
。至此,赋值为第 1 个中间函数的 fn()
执行完毕,Promise.resolve(fn())
返回,作为 dispatch(0)
的执行结果返回,作为代码片段 3.1 的 fnMiddleware(ctx)
的执行结果,然后才会执行之后的 then()
方法里的回调 handleResponse
,最终通过 res.end(body)
发送响应数据:
// node_modules\koa\lib\application.js
function respond(ctx) {
// ...
if ('string' === typeof body) return res.end(body);
}
复制代码
通过对源码的探究,我们明白了如何让 koa 的中间件函数在执行异步操作时也能符合洋葱模型,而这点正是 koa 与 express 的区别之一,express 的中间件函数在执行同步代码时也符合洋葱模型,但在执行异步代码的时候则不符合。
路由的安装与使用
如果想让中间件针对某个特定的请求方法或路径,比如请求方法为 GET,路径为 '/article/list' 的请求,在 koa 中我们需要自己安装第三方的路由库,比如由官方团队提供的 @koa/router:
npm install @koa/router
复制代码
安装完成后我们可以在项目新建 src\router\article.router.js 文件,导入 @koa/router 并通过 new
得到路由对象 articleRouter
,下例中我们还传入了配置对象让路径的前缀 prefix
为 '/article'
:
// src\router\article.router.js
const KoaRouter = require('@koa/router')
const articleRouter = new KoaRouter({ prefix: '/article' })
复制代码
之后就能像在 express 那样定义匹配方法和路径的中间件了(定义接口):
// src\router\article.router.js
articleRouter.get('/list', (ctx, next) => {
ctx.body = 'Hello Juejin'
})
module.exports = articleRouter
复制代码
在 src\main.js 我们还需要使 KoaRouter
生效,其方法为向 app.use()
传入 articleRouter.routes()
:
// src\main.js
const Koa = require('koa')
const articleRouter = require('./router/articleRouter')
const { SERVER_PORT } = require('./config/server.config')
const app = new Koa()
app.use(articleRouter.routes())
app.use(articleRouter.allowedMethods())
app.listen(SERVER_PORT, () => {
console.log('服务器启动成功')
})
复制代码
我们还可以通过执行 app.use(articleRouter.allowedMethods())
,在请求方法不匹配我们的定义时,返回稍为具体的错误信息,比如当请求方法为 POST 时,就会返回 'Method Not Allowed' 而不是默认的 'Not Found':
请求参数的获取
接下来看看 koa 中如何获取请求携带的参数。
query 参数
假设现有 url 为 localhost:4396/article?name=Jay 的 GET 请求,我们可以直接从 ctx
或 ctx.request
中获取 query
对象:
// src\router\article.router.js
articleRouter.get('/', (ctx, next) => {
console.log(ctx.request.query) // [Object: null prototype] { name: 'Jay' }
console.log(ctx.query) // [Object: null prototype] { name: 'Jay' }
ctx.body = 'Hello Juejin'
})
复制代码
在实际项目中,如果在中间件函数中处理的逻辑比较复杂,可以把它抽离到 controller 文件中:
// src\controller\article.controller.js
class ArticleController {
create(ctx, next) {
console.log(ctx.request.query)
console.log(ctx.query)
ctx.body = 'Hello Juejin'
}
}
module.exports = new ArticleController()
复制代码
在 src\router\article.router.js 中仅需专注于处理路径和处理函数 controller 的映射关系:
// src\router\article.router.js
const { create } = require('../controller/article.controller')
articleRouter.get('/', create)
复制代码
如果还需要做一些额外的处理,比如用户的身份验证等,可以另外定义中间件,将它们定义在 middlewar 目录下。
params 参数
假设现有 url 为 localhost:4396/article/123 的 GET 请求,其中 123 是 id 值,为 params 参数,我们也可以直接从 ctx
或 ctx.request
中获取 params
对象:
articleRouter.get('/:id', (ctx, next) => {
console.log(ctx.request.params.id) // 123
console.log(ctx.params.id) // 123
ctx.body = 'Hello Juejin'
})
复制代码
body 内的参数
对于放在 body 里携带的 json 和 x-www-form-urlencoded 类型的参数,我们可以使用 koa 团队出品的 koa-bodyparser 来帮助解析。首先是安装:
npm i koa-bodyparser
复制代码
然后进行导入并使用:
const bodyParser = require('koa-bodyparser')
app.use(bodyParser())
复制代码
可以看到对 app 的处理越来越多,我们可以将 app 相关的代码抽离到一个单独的文件 src\app\index.js
// src\app\index.js
const koa = require('koa')
const bodyParser = require('koa-bodyparser')
const articleRouter = require('./router/articleRouter')
const app = new koa()
app.use(bodyParser())
app.use(articleRouter.routes())
app.use(articleRouter.allowedMethods())
module.exports = app
复制代码
然后在 src\main.js 中只是引入 app 然后启动服务器:
const app = require('./app')
const { SERVER_PORT } = require('./config/server.config')
app.listen(SERVER_PORT, () => console.log('服务器启动成功'))
复制代码
现在,body 里携带的参数就会被解析,然后存放到 ctx.request.body
内了:
json 类型
比如 body 内参数为 json 格式:
则直接通过 ctx.request.body
获取:
articleRouter.post('/', (ctx, next) => {
console.log(ctx.request.body) // { name: 'Jay', content: '寻找周杰伦' }
ctx.body = 'Hello Juejin'
})
复制代码
注意,这里就不能通过 ctx.body
获取了,因为 ctx.body
是用来返回数据的,默认值为 undefined
。
x-www-form-urlencoded 类型
同 json 类型的数据一样,也会被解析放到 ctx.request.body
中。
form-data 类型与文件上传
一般情况下只有涉及到文件上传我们才会使用 form-data 类型的参数,对其的解析需要使用另一个也是 koa 团队出品的 @koa/multer。使用方式与 express 解析 form-data 时用到的 multer 基本一样,另外这里也需要安装上 multer:
npm install --save @koa/multer multer
复制代码
当请求上传多文件时:
解析方式如下:
const multer = require('@koa/multer')
const upload = multer({ dest: 'uploads' })
articleRouter.post('/upload', upload.array('files'), (ctx, next) => {
console.log(ctx.request.body) // [Object: null prototype] { name: 'Jay' }
ctx.body = 'Hello Juejin'
})
复制代码
上传的多文件信息可以通过 ctx.request.files
获取。至于上传单文件或仅上传文本字段的处理,同 express 对 multer 使用一致,不再赘述。需要注意的是,即使是在 router\articleRouter.js 文件定义的 const upload = multer({ dest: 'uploads' })
, dest
的值也只需要直接写 'uploads'
,而不是 '../uploads'
,就可以将上传得到的文件存放在项目根目录下的 uploads 文件夹内。
部署静态资源
为了方便客户端查看上传的文件,我们可以将 uploads 目录设置为静态资源,不同于 express 可以直接使用内置的 express.static()
中间件函数,koa 需要安装 koa-static 来实现,它也是 koa 团队出品的:
npm install koa-static
复制代码
使用起来就和 express.static()
差不多了:
const serve = require('koa-static')
app.use(serve('./uploads'))
复制代码
将要设置为静态资源的目录传给 serve()
再传给 app.use()
即可。
响应数据的格式
koa 里使用给 ctx.body
赋值的形式向客户端返回数据,赋值的类型比较灵活,除了可以是字符串、对象、数组、null 外,还可以是 buffer 或 stream:
articleRouter.get('/', (ctx, next) => {
ctx.body = Buffer.from('Hello Juejin')
})
复制代码
返回流时,为了客户端正确地展示数据,最好通过 ctx.type
设置下数据的类型,比如返回的是 png 图片:
articleRouter.get('/', (ctx, next) => {
ctx.type = 'image/png'
ctx.body = fs.createReadStream('./uploads/5fe9f46825383d74531ffc6b45aee423')
})
复制代码
默认情况下,当返回 null
时,状态码为 204,返回其它格式的数据时状态码为 200。如果想指定状态码,可以通过 ctx.status
或 ctx.response.status
设置:
ctx.status = 201
ctx.response.status = 201
复制代码
错误处理
在 koa 中,无法像在 express 里那样将错误信息传给 next()
,然后统一在 app.use((err, req, res, next) => {})
处理。而是使用发射错误事件的方式 —— koa 会将 app
对象加到 ctx
对象上,而 app 对象前面通过查看源码也看到了,它是 Application
类的实例对象,并且继承自 Emitter:
// node_modules\koa\lib\application.js
const Emitter = require('events');
复制代码
所以当需要返回错误信息时,就可以通过 ctx.app
发射一个 err
事件,并携带上错误信息和 ctx
:
articleRouter.get('/:id', (ctx, next) => {
if (ctx.params.id !== '123') {
ctx.app.emit('err', 1000, ctx)
} else {
ctx.body = 'Hello Juejin'
}
})
复制代码
传入 ctx
是为了在统一处理错误,也就是监听 err
事件的地方(比如新建 src\utils\process-errors.js),最终能够通过 ctx.body
将信息响应给客户端:
// src\utils\process-errors.js
const app = require('../app')
app.on('err', (code, ctx) => {
let msg = ''
switch (code) {
case 1000:
msg = 'id 不存在'
break
// 其它情况
default:
break
}
ctx.body = {
code,
msg
}
})
复制代码
其中错误码 1000 也可以进一步优化,抽取为常量。
本文正在参加「金石计划」