koa 框架
Koa 是一个类似于 Express 的 Web 开发框架,创始人也是同一个人。它的主要特点是,使用了 ES6 的 Generator 函数,进行了架构的重新设计。也就是说,Koa 的原理和内部结构很像 Express,但是语法和内部结构进行了升级。
官方faq有这样一个问题:“为什么 koa 不是 Express 4.0?”,回答是这样的:“Koa 与 Express 有很大差异,整个设计都是不同的,所以如果将 Express 3.0 按照这种写法升级到 4.0,就意味着重写整个程序。所以,我们觉得创造一个新的库,是更合适的做法。”
Koa 应用
一个 Koa 应用就是一个对象,包含了一个 middleware 数组,这个数组由一组 Generator 函数组成。这些函数负责对 HTTP 请求进行各种加工,比如生成缓存、指定代理、请求重定向等等。
1 | var koa = require("koa"); |
上面代码中,变量 app 就是一个 Koa 应用。它监听 3000 端口,返回一个内容为 Hello World 的网页。
app.use 方法用于向 middleware 数组添加 Generator 函数。
listen 方法指定监听端口,并启动当前应用。它实际上等同于下面的代码。
1 | var http = require("http"); |
中间件
Koa 的中间件很像 Express 的中间件,也是对 HTTP 请求进行处理的函数,但是必须是一个 Generator 函数。而且,Koa 的中间件是一个级联式(Cascading)的结构,也就是说,属于是层层调用,第一个中间件调用第二个中间件,第二个调用第三个,以此类推。上游的中间件必须等到下游的中间件返回结果,才会继续执行,这点很像递归。
中间件通过当前应用的 use 方法注册。
1 | app.use(function* (next) { |
上面代码中,app.use
方法的参数就是中间件,它是一个 Generator 函数,最大的特征就是 function 命令与参数之间,必须有一个星号。Generator 函数的参数 next,表示下一个中间件。
Generator 函数内部使用 yield 命令,将程序的执行权转交给下一个中间件,即yield next
,要等到下一个中间件返回结果,才会继续往下执行。上面代码中,Generator 函数体内部,第一行赋值语句首先执行,开始计时,第二行 yield 语句将执行权交给下一个中间件,当前中间件就暂停执行。等到后面的中间件全部执行完成,执行权就回到原来暂停的地方,继续往下执行,这时才会执行第三行,计算这个过程一共花了多少时间,第四行将这个时间打印出来。
下面是一个两个中间件级联的例子。
1 | app.use(function* () { |
上面代码中,第一个中间件调用第二个中间件 saveResults,它们都向this.body
写入内容。最后,this.body
的输出如下。
1 | header |
只要有一个中间件缺少yield next
语句,后面的中间件都不会执行,这一点要引起注意。
1 | app.use(function* (next) { |
上面代码中,因为第二个中间件少了yield next
语句,第三个中间件并不会执行。
如果想跳过一个中间件,可以直接在该中间件的第一行语句写上return yield next
。
1 | app.use(function* (next) { |
由于 Koa 要求中间件唯一的参数就是 next,导致如果要传入其他参数,必须另外写一个返回 Generator 函数的函数。
1 | function logger(format) { |
上面代码中,真正的中间件是 logger 函数的返回值,而 logger 函数是可以接受参数的。
多个中间件的合并
由于中间件的参数统一为 next(意为下一个中间件),因此可以使用.call(this, next)
,将多个中间件进行合并。
1 | function* random(next) { |
上面代码中,中间件 all 内部,就是依次调用 random、backwards、pi,后一个中间件就是前一个中间件的参数。
Koa 内部使用 koa-compose 模块,进行同样的操作,下面是它的源码。
1 | function compose(middleware) { |
上面代码中,middleware 是中间件数组。前一个中间件的参数是后一个中间件,依次类推。如果最后一个中间件没有 next 参数,则传入一个空函数。
路由
可以通过this.path
属性,判断用户请求的路径,从而起到路由作用。
1 | app.use(function* (next) { |
下面是多路径的例子。
1 | let koa = require("koa"); |
上面代码中,每一个中间件负责一个路径,如果路径不符合,就传递给下一个中间件。
复杂的路由需要安装 koa-router 插件。
1 | var app = require("koa")(); |
上面代码对根路径设置路由。
Koa-router 实例提供一系列动词方法,即一种 HTTP 动词对应一种方法。典型的动词方法有以下五种。
- router.get()
- router.post()
- router.put()
- router.del()
- router.patch()
这些动词方法可以接受两个参数,第一个是路径模式,第二个是对应的控制器方法(中间件),定义用户请求该路径时服务器行为。
1 | router.get("/", function* (next) { |
上面代码中,router.get
方法的第一个参数是根路径,第二个参数是对应的函数方法。
注意,路径匹配的时候,不会把查询字符串考虑在内。比如,/index?param=xyz
匹配路径/index
。
有些路径模式比较复杂,Koa-router 允许为路径模式起别名。起名时,别名要添加为动词方法的第一个参数,这时动词方法变成接受三个参数。
1 | router.get("user", "/users/:id", function* (next) { |
上面代码中,路径模式\users\:id
的名字就是user
。路径的名称,可以用来引用对应的具体路径,比如 url 方法可以根据路径名称,结合给定的参数,生成具体的路径。
1 | router.url("user", 3); |
上面代码中,user 就是路径模式的名称,对应具体路径/users/:id
。url 方法的第二个参数 3,表示给定 id 的值是 3,因此最后生成的路径是/users/3
。
Koa-router 允许为路径统一添加前缀。
1 | var router = new Router({ |
路径的参数通过this.params
属性获取,该属性返回一个对象,所有路径参数都是该对象的成员。
1 | // 访问 /programming/how-to-node |
param 方法可以针对命名参数,设置验证条件。
1 | router |
上面代码中,如果/users/:user
的参数 user 对应的不是有效用户(比如访问/users/3
),param 方法注册的中间件会查到,就会返回 404 错误。
redirect 方法会将某个路径的请求,重定向到另一个路径,并返回 301 状态码。
1 | router.redirect("/login", "sign-in"); |
redirect 方法的第一个参数是请求来源,第二个参数是目的地,两者都可以用路径模式的别名代替。
context 对象
中间件当中的 this 表示上下文对象 context,代表一次 HTTP 请求和回应,即一次访问/回应的所有信息,都可以从上下文对象获得。context 对象封装了 request 和 response 对象,并且提供了一些辅助方法。每次 HTTP 请求,就会创建一个新的 context 对象。
1 | app.use(function* () { |
context 对象的很多方法,其实是定义在 ctx.request 对象或 ctx.response 对象上面,比如,ctx.type 和 ctx.length 对应于 ctx.response.type 和 ctx.response.length,ctx.path 和 ctx.method 对应于 ctx.request.path 和 ctx.request.method。
context 对象的全局属性。
- request:指向 Request 对象
- response:指向 Response 对象
- req:指向 Node 的 request 对象
- res:指向 Node 的 response 对象
- app:指向 App 对象
- state:用于在中间件传递信息。
1 | this.state.user = yield User.find(id); |
上面代码中,user 属性存放在this.state
对象上面,可以被另一个中间件读取。
context 对象的全局方法。
- throw():抛出错误,直接决定了 HTTP 回应的状态码。
- assert():如果一个表达式为 false,则抛出一个错误。
1 | this.throw(403); |
assert 方法的例子。
1 | // 格式 |
以下模块解析 POST 请求的数据。
1 | var parse = require('co-body'); |
错误处理机制
Koa 提供内置的错误处理机制,任何中间件抛出的错误都会被捕捉到,引发向客户端返回一个 500 错误,而不会导致进程停止,因此也就不需要 forever 这样的模块重启进程。
1 | app.use(function* () { |
上面代码中,中间件内部抛出一个错误,并不会导致 Koa 应用挂掉。Koa 内置的错误处理机制,会捕捉到这个错误。
当然,也可以额外部署自己的错误处理机制。
1 | app.use(function* () { |
上面代码自行部署了 try…catch 代码块,一旦产生错误,就用this.throw
方法抛出。该方法可以将指定的状态码和错误信息,返回给客户端。
对于未捕获错误,可以设置 error 事件的监听函数。
1 | app.on("error", function (err) { |
error 事件的监听函数还可以接受上下文对象,作为第二个参数。
1 | app.on("error", function (err, ctx) { |
如果一个错误没有被捕获,koa 会向客户端返回一个 500 错误“Internal Server Error”。
this.throw 方法用于向客户端抛出一个错误。
1 | this.throw(403); |
this.throw
方法的两个参数,一个是错误码,另一个是报错信息。如果省略状态码,默认是 500 错误。
this.assert
方法用于在中间件之中断言,用法类似于 Node 的 assert 模块。
1 | this.assert(this.user, 401, "User not found. Please login!"); |
上面代码中,如果 this.user 属性不存在,会抛出一个 401 错误。
由于中间件是层级式调用,所以可以把try { yield next }
当成第一个中间件。
1 | app.use(function* (next) { |
cookie
cookie 的读取和设置。
1 | this.cookies.get("view"); |
get 和 set 方法都可以接受第三个参数,表示配置参数。其中的 signed 参数,用于指定 cookie 是否加密。如果指定加密的话,必须用app.keys
指定加密短语。
1 | app.keys = ["secret1", "secret2"]; |
this.cookie 的配置对象的属性如下。
- signed:cookie 是否加密。
- expires:cookie 何时过期
- path:cookie 的路径,默认是“/”。
- domain:cookie 的域名。
- secure:cookie 是否只有 https 请求下才发送。
- httpOnly:是否只有服务器可以取到 cookie,默认为 true。
session
1 | var session = require("koa-session"); |
Request 对象
Request 对象表示 HTTP 请求。
(1)this.request.header
返回一个对象,包含所有 HTTP 请求的头信息。它也可以写成this.request.headers
。
(2)this.request.method
返回 HTTP 请求的方法,该属性可读写。
(3)this.request.length
返回 HTTP 请求的 Content-Length 属性,取不到值,则返回 undefined。
(4)this.request.path
返回 HTTP 请求的路径,该属性可读写。
(5)this.request.href
返回 HTTP 请求的完整路径,包括协议、端口和 url。
1 | this.request.href; |
(6)this.request.querystring
返回 HTTP 请求的查询字符串,不含问号。该属性可读写。
(7)this.request.search
返回 HTTP 请求的查询字符串,含问号。该属性可读写。
(8)this.request.host
返回 HTTP 请求的主机(含端口号)。
(9)this.request.hostname
返回 HTTP 的主机名(不含端口号)。
(10)this.request.type
返回 HTTP 请求的 Content-Type 属性。
1 | var ct = this.request.type; |
(11)this.request.charset
返回 HTTP 请求的字符集。
1 | this.request.charset; |
(12)this.request.query
返回一个对象,包含了 HTTP 请求的查询字符串。如果没有查询字符串,则返回一个空对象。该属性可读写。
比如,查询字符串color=blue&size=small
,会得到以下的对象。
1 | { |
(13)this.request.fresh
返回一个布尔值,表示缓存是否代表了最新内容。通常与 If-None-Match、ETag、If-Modified-Since、Last-Modified 等缓存头,配合使用。
1 | this.response.set('ETag', '123'); |
(14)this.request.stale
返回this.request.fresh
的相反值。
(15)this.request.protocol
返回 HTTP 请求的协议,https 或者 http。
(16)this.request.secure
返回一个布尔值,表示当前协议是否为 https。
(17)this.request.ip
返回发出 HTTP 请求的 IP 地址。
(18)this.request.subdomains
返回一个数组,表示 HTTP 请求的子域名。该属性必须与 app.subdomainOffset 属性搭配使用。app.subdomainOffset 属性默认为 2,则域名“tobi.ferrets.example.com”返回[“ferrets”, “tobi”],如果 app.subdomainOffset 设为 3,则返回[“tobi”]。
(19)this.request.is(types…)
返回指定的类型字符串,表示 HTTP 请求的 Content-Type 属性是否为指定类型。
1 | // Content-Type为 text/html; charset=utf-8 |
如果不满足条件,返回 false;如果 HTTP 请求不含数据,则返回 undefined。
1 | this.is("html"); // false |
它可以用于过滤 HTTP 请求,比如只允许请求下载图片。
1 | if (this.is("image/*")) { |
(20)this.request.accepts(types)
检查 HTTP 请求的 Accept 属性是否可接受,如果可接受,则返回指定的媒体类型,否则返回 false。
1 | // Accept: text/html |
如果 accepts 方法没有参数,则返回所有支持的类型(text/html,application/xhtml+xml,image/webp,application/xml,_/_)。
如果 accepts 方法的参数有多个参数,则返回最佳匹配。如果都不匹配则返回 false,并向客户端抛出一个 406”Not Acceptable“错误。
如果 HTTP 请求没有 Accept 字段,那么 accepts 方法返回它的第一个参数。
accepts 方法可以根据不同 Accept 字段,向客户端返回不同的字段。
1 | switch (this.request.accepts("json", "html", "text")) { |
(21)this.request.acceptsEncodings(encodings)
该方法根据 HTTP 请求的 Accept-Encoding 字段,返回最佳匹配,如果没有合适的匹配,则返回 false。
1 | // Accept-Encoding: gzip |
注意,acceptEncodings 方法的参数必须包括 identity(意为不编码)。
如果 HTTP 请求没有 Accept-Encoding 字段,acceptEncodings 方法返回所有可以提供的编码方法。
1 | // Accept-Encoding: gzip, deflate |
如果都不匹配,acceptsEncodings 方法返回 false,并向客户端抛出一个 406“Not Acceptable”错误。
(22)this.request.acceptsCharsets(charsets)
该方法根据 HTTP 请求的 Accept-Charset 字段,返回最佳匹配,如果没有合适的匹配,则返回 false。
1 | // Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 |
如果 acceptsCharsets 方法没有参数,则返回所有可接受的匹配。
1 | // Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 |
如果都不匹配,acceptsCharsets 方法返回 false,并向客户端抛出一个 406“Not Acceptable”错误。
(23)this.request.acceptsLanguages(langs)
该方法根据 HTTP 请求的 Accept-Language 字段,返回最佳匹配,如果没有合适的匹配,则返回 false。
1 | // Accept-Language: en;q=0.8, es, pt |
如果 acceptsCharsets 方法没有参数,则返回所有可接受的匹配。
1 | // Accept-Language: en;q=0.8, es, pt |
如果都不匹配,acceptsLanguages 方法返回 false,并向客户端抛出一个 406“Not Acceptable”错误。
(24)this.request.socket
返回 HTTP 请求的 socket。
(25)this.request.get(field)
返回 HTTP 请求指定的字段。
Response 对象
Response 对象表示 HTTP 回应。
(1)this.response.header
返回 HTTP 回应的头信息。
(2)this.response.socket
返回 HTTP 回应的 socket。
(3)this.response.status
返回 HTTP 回应的状态码。默认情况下,该属性没有值。该属性可读写,设置时等于一个整数。
(4)this.response.message
返回 HTTP 回应的状态信息。该属性与this.response.message
是配对的。该属性可读写。
(5)this.response.length
返回 HTTP 回应的 Content-Length 字段。该属性可读写,如果没有设置它的值,koa 会自动从 this.request.body 推断。
(6)this.response.body
返回 HTTP 回应的信息体。该属性可读写,它的值可能有以下几种类型。
- 字符串:Content-Type 字段默认为 text/html 或 text/plain,字符集默认为 utf-8,Content-Length 字段同时设定。
- 二进制 Buffer:Content-Type 字段默认为 application/octet-stream,Content-Length 字段同时设定。
- Stream:Content-Type 字段默认为 application/octet-stream。
- JSON 对象:Content-Type 字段默认为 application/json。
- null(表示没有信息体)
如果this.response.status
没设置,Koa 会自动将其设为 200 或 204。
(7)this.response.get(field)
返回 HTTP 回应的指定字段。
1 | var etag = this.get("ETag"); |
注意,get 方法的参数是区分大小写的。
(8)this.response.set()
设置 HTTP 回应的指定字段。
1 | this.set("Cache-Control", "no-cache"); |
set 方法也可以接受一个对象作为参数,同时为多个字段指定值。
1 | this.set({ |
(9)this.response.remove(field)
移除 HTTP 回应的指定字段。
(10)this.response.type
返回 HTTP 回应的 Content-Type 字段,不包括“charset”参数的部分。
1 | var ct = this.reponse.type; |
该属性是可写的。
1 | this.reponse.type = "text/plain; charset=utf-8"; |
设置 type 属性的时候,如果没有提供 charset 参数,Koa 会判断是否自动设置。如果this.response.type
设为 html,charset 默认设为 utf-8;但如果this.response.type
设为 text/html,就不会提供 charset 的默认值。
(10)this.response.is(types…)
该方法类似于this.request.is()
,用于检查 HTTP 回应的类型是否为支持的类型。
它可以在中间件中起到处理不同格式内容的作用。
1 | var minify = require("html-minifier"); |
上面代码是一个中间件,如果输出的内容类型为 HTML,就会进行最小化处理。
(11)this.response.redirect(url, [alt])
该方法执行 302 跳转到指定网址。
1 | this.redirect("back"); |
如果 redirect 方法的第一个参数是 back,将重定向到 HTTP 请求的 Referrer 字段指定的网址,如果没有该字段,则重定向到第二个参数或“/”网址。
如果想修改 302 状态码,或者修改 body 文字,可以采用下面的写法。
1 | this.status = 301; |
(12)this.response.attachment([filename])
该方法将 HTTP 回应的 Content-Disposition 字段,设为“attachment”,提示浏览器下载指定文件。
(13)this.response.headerSent
该方法返回一个布尔值,检查是否 HTTP 回应已经发出。
(14)this.response.lastModified
该属性以 Date 对象的形式,返回 HTTP 回应的 Last-Modified 字段(如果该字段存在)。该属性可写。
1 | this.response.lastModified = new Date(); |
(15)this.response.etag
该属性设置 HTTP 回应的 ETag 字段。
1 | this.response.etag = crypto.createHash("md5").update(this.body).digest("hex"); |
注意,不能用该属性读取 ETag 字段。
(16)this.response.vary(field)
该方法将参数添加到 HTTP 回应的 Vary 字段。
CSRF 攻击
CSRF 攻击是指用户的 session 被劫持,用来冒充用户的攻击。
koa-csrf 插件用来防止 CSRF 攻击。原理是在 session 之中写入一个秘密的 token,用户每次使用 POST 方法提交数据的时候,必须含有这个 token,否则就会抛出错误。
1 | var koa = require("koa"); |
POST 请求含有 token,可以是以下几种方式之一,koa-csrf 插件就能获得 token。
- 表单的_csrf 字段
- 查询字符串的_csrf 字段
- HTTP 请求头信息的 x-csrf-token 字段
- HTTP 请求头信息的 x-xsrf-token 字段
数据压缩
koa-compress 模块可以实现数据压缩。
1 | app.use(require("koa-compress")()); |
源码解读
每一个网站就是一个 app,它由lib/application
定义。
1 | function Application() { |
app.use()
用于注册中间件,即将 Generator 函数放入中间件数组。
1 | app.use = function (fn) { |
app.listen()
就是http.createServer(app.callback()).listen(...)
的缩写。
1 | app.listen = function () { |
上面代码中,app.callback()
会返回一个函数,用来处理 HTTP 请求。它的第一行mw = [respond].concat(this.middleware)
,表示将 respond 函数(这也是一个 Generator 函数)放入this.middleware
,现在 mw 就变成了[respond, S1, S2, S3]
。
compose(mw)
将中间件数组转为一个层层调用的 Generator 函数。
1 | function compose(middleware) { |
上面代码中,下一个 generator 函数总是上一个 Generator 函数的参数,从而保证了层层调用。
var fn = co.wrap(gen)
则是将 Generator 函数包装成一个自动执行的函数,并且返回一个 Promise。
1 | //co package |
由于co.wrap(compose(mw))
执行后,返回的是一个 Promise,所以可以对其使用 catch 方法指定捕捉错误的回调函数fn.call(ctx).catch(ctx.onerror)
。
将所有的上下文变量都放进 context 对象。
1 | app.createContext = function (req, res) { |
真正处理 HTTP 请求的是下面这个 Generator 函数。
1 | function* respond(next) { |
参考链接
- Koa Guide
- William XING, Is Koa.js right for me?