前言
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。
W3C school文档:https://www.w3cschool.cn/koajs/koajs-3mcb360j.html
准备
首先,检查 Node 版本
1 | node -v |
Koa 要求Node为7.6 以上的版本。如果你的版本低于这个要求,就要先升级 Node。
基本用法
Hello World
koa_demo 下创建 app.js
1 | const Koa = require('koa'); |
安装
1 | npm i koa --save |
运行这个脚本。
1 | node app.js |
这样我们就可以通过以下地址访问
Response 的类型
Koa 默认的返回类型是text/plain
(纯文本的形式),如果想返回其他类型的内容,可以先用ctx.request.accepts
判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept
字段),然后使用ctx.response.type
指定返回类型。
1 | const Koa = require('koa') |
网页模板
实际开发中,返回给用户的网页往往都写成模板文件。我们可以让 Koa 先读取模板文件,然后将这个模板返回给用户。
1 | const fs = require('fs'); |
index.html
1 |
|
路由
原生路由
1 | const Koa = require('koa') |
koa-router 模块路由
POST请求获取参数需要用到Koa 中koa-bodyparser
中间件
1 | npm install koa-router --save |
代码
1 | const Koa = require('koa') |
注意
路由的方法中如果有异步操作,路由的方法一定要添加
async
关键字,并且方法中的所有异步操作一定要await
,否则页面会返回404。
重定向
有些场合,服务器需要重定向访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()
方法可以发出一个跳转,将用户导向另一个路由。
1 | const Koa = require('koa'); |
静态资源
如果网站提供静态资源(图片、字体、样式表、脚本……),为它们一个个写路由就很麻烦,也没必要koa-static模块封装了这部分的请求。请看下面的例子
安装依赖
1 | npm install koa-static --save |
代码
1 | const Koa = require('koa'); |
访问 http://localhost:3000/html/index.html,在浏览器里就可以看到这个文件的内容。
模板引擎Nunjucks
我们选择Nunjucks作为模板引擎。Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。
安装
1 | npm i nunjucks |
紧接着,我们要编写使用Nunjucks的函数render
。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.js
中编写代码如下:
1 | const nunjucks = require('nunjucks'); |
变量env
就表示Nunjucks模板引擎对象,它有一个render(view, model)
方法,正好传入view
和model
两个参数,并返回字符串。
创建env
需要的参数可以查看文档获知。我们用autoescape = opts.autoescape && true
这样的代码给每个参数加上默认值,最后使用new nunjucks.FileSystemLoader('views')
创建一个文件系统加载器,从views
目录读取模板。
我们编写一个hello.html
模板文件,放到views
目录下,内容如下:
1 | <h1>Hello {{ name }}</h1> |
然后,我们就可以用下面的代码来渲染这个模板:
1 | var s = env.render('hello.html', { name: '小明' }); |
获得输出如下:
1 | <h1>Hello 小明</h1> |
咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:
1 | var s = env.render('hello.html', { name: '<script>alert("小明")</script>' }); |
获得输出如下:
1 | <h1>Hello <script>alert("小明")</script></h1> |
这样就避免了输出恶意脚本。
此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:
1 | <!-- 循环输出名字 --> |
Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。
更好的方式是使用继承。先定义一个基本的网页框架base.html
:
1 | <html> |
base.html
定义了三个可编辑的块,分别命名为header
、body
和footer
。子模板可以有选择地对块进行重新定义:
1 | {% extends 'base.html' %} |
然后,我们对子模板进行渲染:
1 | console.log(env.render('extend.html', { |
输出HTML如下:
1 | <html> |
性能
最后我们要考虑一下Nunjucks的性能。
对于模板渲染本身来说,速度是非常非常快的,因为就是拼字符串嘛,纯CPU操作。
性能问题主要出现在从文件读取模板内容这一步。这是一个IO操作,在Node.js环境中,我们知道,单线程的JavaScript最不能忍受的就是同步IO,但Nunjucks默认就使用同步IO读取模板文件。
好消息是Nunjucks会缓存已读取的文件内容,也就是说,模板文件最多读取一次,就会放在内存中,后面的请求是不会再次读取文件的,只要我们指定了noCache: false
这个参数。
在开发环境下,可以关闭cache,这样每次重新加载模板,便于实时修改模板。在生产环境下,一定要打开cache,这样就不会有性能问题。
Nunjucks也提供了异步读取的方式,但是这样写起来很麻烦,有简单的写法我们就不会考虑复杂的写法。保持代码简单是可维护性的关键。
中间件
Logger功能
Koa 的最大特色,也是最重要的一个设计,就是中间件。为了理解中间件,我们先看一下 Logger (打印日志)功能的实现。
./logger/koa-logger.js
1 | module.exports = (ctx, next) => { |
./logger.js
1 | const Koa = require('koa') |
中间件的概念
处在 HTTP Request 和 HTTP Response 中间,用来实现某种中间功能的函数,就叫做”中间件”。
基本上,Koa 所有的功能都是通过中间件实现的,前面例子里面的main
也是中间件。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next
函数。只要调用next
函数,就可以把执行权转交给下一个中间件。
多个中间件会形成一个栈结构,以”先进后出”的顺序执行。
- 最外层的中间件首先执行。
- 调用
next
函数,把执行权交给下一个中间件。 - …
- 最内层的中间件最后执行。
- 执行结束后,把执行权交回上一层的中间件。
- …
- 最外层的中间件收回执行权之后,执行
next
函数后面的代码。
例子:
1 | const Koa = require('koa'); |
如果中间件内部没有调用next
函数,那么执行权就不会传递下去。
结果
异步中间件
迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成async 函数。
1 | npm install fs.promised |
1 | const fs = require('fs.promised'); |
上面代码中,fs.readFile
是一个异步操作,必须写成await fs.readFile()
,然后中间件必须写成 async 函数。
1 | const Koa = require('koa'); |
中间件的合成
koa-compose 模块可以将多个中间件合成一个。
1 | npm install koa-compose |
输出结果:先打印日志,再在页面中显示Hello World
POST请求获取请求参数
1 | npm install koa-bodyparser --save |
使用
1 | const Koa = require('koa') |
接口允许跨域
安装koa2-cors
1 | npm install --save koa2-cors |
引入 koa2-cors 并且配置中间件
1 | var Koa = require('koa'); |
经历这几步以后,koa2后台就设置好跨域了,我们现在可以放心的用get post 获取提交数据了
MVC
我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!
当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:
1 | ctx.render('home.html', { name: 'Michael' }); |
通过Nunjucks把数据用指定的模板渲染成HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:
这就是传说中的MVC:Model-View-Controller,中文名“模型-视图-控制器”。
异步函数是C:Controller,Controller负责业务逻辑,比如检查用户名是否存在,取出用户信息等等;
包含变量的模板就是V:View,View负责显示逻辑,通过简单地替换一些变量,View最终输出的就是用户看到的HTML。
MVC中的Model在哪?Model是用来传给View的,这样View在替换变量的时候,就可以从Model中取出相应的数据。
上面的例子中,Model就是一个JavaScript对象:
1 | { name: 'Michael' } |
下面,创建工程server
,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)
的方式。
工程server
结构如下:
在package.json
中,我们将要用到的依赖包有:
1 | "devDependencies": { |
先用npm i
安装依赖包。
然后,我们准备编写以下两个Controller:
路由
根目录添加controller.js
1 | const fs = require('fs'); |
根目录下添加controllers
添加user_controller.js
1 | var fn_index = async (ctx, next) => { |
由于登录请求是一个POST,我们就用ctx.request.body.<name>
拿到POST请求的数据,并给一个默认值。
登录成功时我们用signin-ok.html
渲染,登录失败时我们用signin-failed.html
渲染,所以,我们一共需要以下3个View:
- index.html
- signin-ok.html
- signin-failed.html
静态资源
在编写View的时候,我们实际上是在编写HTML页。为了让页面看起来美观大方,使用一个现成的CSS框架是非常有必要的。我们用Bootstrap这个CSS框架。从首页下载zip包后解压,我们把所有静态资源文件放到/static
目录下:
1 | server/static/ |
这样我们在编写HTML的时候,可以直接用Bootstrap的CSS,像这样:
1 | <link rel="stylesheet" href="/static/css/bootstrap.css"> |
现在,在使用MVC之前,第一个问题来了,如何处理静态文件?
我们把所有静态资源文件全部放入/static
目录,目的就是能统一处理静态文件。在koa中,我们需要编写一个middleware,处理以/static/
开头的URL。
编写middleware
我们来编写一个处理静态文件的middleware。编写middleware实际上一点也不复杂。
我们先创建一个static-files.js
的文件,编写一个能处理静态文件的middleware:
1 | const path = require('path'); |
staticFiles
是一个普通函数,它接收两个参数:URL前缀和一个目录,然后返回一个async函数。这个async函数会判断当前的URL是否以指定前缀开头,如果是,就把URL的路径视为文件,并发送文件内容。如果不是,这个async函数就不做任何事情,而是简单地调用await next()
让下一个middleware去处理请求。
我们使用了一个mz
的包,并通过require('mz/fs');
导入。mz
提供的API和Node.js的fs
模块完全相同,但fs
模块使用回调,而mz
封装了fs
对应的函数,并改为Promise。这样,我们就可以非常简单的用await
调用mz
的函数,而不需要任何回调。
所有的第三方包都可以通过npm官网搜索并查看其文档:
最后,这个middleware使用起来也很简单,在app.js
里加一行代码:
1 | let staticFiles = require('./static-files'); |
注意:也可以去npm搜索能用于koa2的处理静态文件的包并直接使用。
服务端模板引擎
Nunjucks实际是一个服务端的模板引擎。前后端分离项目则不需要。
集成Nunjucks实际上也是编写一个middleware,这个middleware的作用是给ctx
对象绑定一个render(view, model)
的方法,这样,后面的Controller就可以调用这个方法来渲染模板了。
路由中的如下
1 | ctx.render("index.html", { |
我们创建一个templating.js
来实现这个middleware:
1 | const nunjucks = require('nunjucks'); |
注意到createEnv()
函数和前面使用Nunjucks时编写的函数是一模一样的。我们主要关心tempating()
函数,它会返回一个middleware,在这个middleware中,我们只给ctx
“安装”了一个render()
函数,其他什么事情也没干,就继续调用下一个middleware。
使用的时候,我们在app.js
添加如下代码:
1 | // 服务端模板引擎 |
这里我们定义了一个常量isProduction
,它判断当前环境是否是production环境。
如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启Node程序,会极大地降低开发效率。
注意:
生产环境上必须配置环境变量
NODE_ENV = 'production'
,而开发环境不需要配置,实际上NODE_ENV
可能是undefined
,所以判断的时候,不要用NODE_ENV === 'development'
。
编写View
1 |
|
运行
一切顺利的话,这个server
工程应该可以顺利运行。运行前,我们再检查一下app.js
里的middleware的顺序:
1 | const Koa = require('koa') |
现在,在VS Code中运行代码,不出意外的话,在浏览器输入localhost:3000/
,可以看到首页内容
守护模式运行
1 | npm install -g pm2 |
常用命令
1 | # 启动进程/应用 |
处理错误
500/404错误
如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()
方法,用来抛出错误,ctx.throw(500)
就是抛出500错误。
1 | const Koa = require('koa'); |
404错误
如果将ctx.response.status
设置成404,就相当于ctx.throw(404)
,返回404错误。
1 | const Koa = require('koa'); |
Error处理
为了方便处理错误,最好使用try...catch
将其捕获。但是,为每个中间件都写try...catch
太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。
1 | const Koa = require('koa'); |
Error监听
运行过程中一旦出错,Koa 会触发一个error
事件。监听这个事件,也可以处理错误。
1 | const Koa = require('koa'); |
Error转发
需要注意的是,如果错误被try...catch
捕获,就不会触发error
事件。这时,必须调用ctx.app.emit()
,手动释放error
事件,才能让监听函数生效。
1 | const Koa = require('koa'); |
上面代码main
函数抛出错误,被handler
函数捕获。catch
代码块里面使用ctx.app.emit()
手动释放error
事件,才能让监听函数监听到。
Web App的功能
Cookie
ctx.cookies
用来读写 Cookie。
1 | const Koa = require('koa'); |
表单
Web 应用离不开处理表单。本质上,表单就是 POST 方法发送到服务器的键值对。koa-body模块可以用来从 POST 请求的数据体里面提取键值对。
1 | npm install koa-body |
1 | const Koa = require('koa'); |
上面代码使用 POST 方法向服务器发送一个键值对,会被正确解析。如果发送的数据不正确,就会收到错误提示。
文件上传
koa-body模块还可以用来处理文件上传。
1 | npm i koa |
koa04.js
1 | const Koa = require('koa'); |
./html/fileupload.html
1 |
|
其他模块
crypto
crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C++实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。
MD5和SHA1
MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:
1 | const crypto = require("crypto"); |
update()
方法默认字符串编码为UTF-8
,也可以传入Buffer。
如果要计算SHA1,只需要把'md5'
改成'sha1'
,就可以得到SHA1的结果1f32b9c9932c02227819a4151feed43e131aca40
。
还可以使用更安全的sha256
和sha512
。
Hmac
Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:
1 | const crypto = require('crypto'); |
只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。
AES
AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:
1 | const crypto = require('crypto'); |
运行结果如下:
Plain text: Hello, this is a secret message!
Encrypted text: 8a944d97bdabc157a5b7a40cb180e7…
Decrypted text: Hello, this is a secret message!
可以看出,加密后的字符串通过解密又得到了原始内容。
注意到AES有很多不同的算法,如aes192
,aes-128-ecb
,aes-256-cbc
等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Nodejs全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,字符串密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。
Diffie-Hellman
DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:
- 小明先选一个素数和一个底数,例如,素数
p=23
,底数g=5
(底数可以任选),再选择一个秘密整数a=6
,计算A=g^a mod p=8
,然后大声告诉小红:p=23,g=5,A=8
; - 小红收到小明发来的
p
,g
,A
后,也选一个秘密整数b=15
,然后计算B=g^b mod p=19
,并大声告诉小明:B=19
; - 小明自己计算出
s=B^a mod p=2
,小红也自己计算出s=A^b mod p=2
,因此,最终协商的密钥s
为2
。
在这个过程中,密钥2
并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=23
,g=5
,A=8
,B=19
,由于不知道双方选的秘密整数a=6
和b=15
,因此无法计算出密钥2
。
用crypto模块实现DH算法如下:
1 | const crypto = require('crypto'); |
运行后,可以得到如下输出:
$ node dh.js
Prime: a8224c…deead3
Generator: 02
Secret of Xiao Ming: 695308…d519be
Secret of Xiao Hong: 695308…d519be
注意每次输出都不一样,因为素数的选择是随机的。
RSA
RSA算法是一种非对称加密算法,即由一个私钥和一个公钥构成的密钥对,通过私钥加密,公钥解密,或者通过公钥加密,私钥解密。其中,公钥可以公开,私钥必须保密。
RSA算法是1977年由Ron Rivest、Adi Shamir和Leonard Adleman共同提出的,所以以他们三人的姓氏的头字母命名。
当小明给小红发送信息时,可以用小明自己的私钥加密,小红用小明的公钥解密,也可以用小红的公钥加密,小红用她自己的私钥解密,这就是非对称加密。相比对称加密,非对称加密只需要每个人各自持有自己的私钥,同时公开自己的公钥,不需要像AES那样由两个人共享同一个密钥。
在使用Node进行RSA加密前,我们先要准备好私钥和公钥。
首先,在命令行执行以下命令以生成一个RSA密钥对:
1 | openssl genrsa -aes256 -out rsa-key.pem 2048 |
根据提示输入密码,这个密码是用来加密RSA密钥的,加密方式指定为AES256,生成的RSA的密钥长度是2048位。执行成功后,我们获得了加密的rsa-key.pem
文件。
第二步,通过上面的rsa-key.pem
加密文件,我们可以导出原始的私钥,命令如下:
1 | openssl rsa -in rsa-key.pem -outform PEM -out rsa-prv.pem |
输入第一步的密码,我们获得了解密后的私钥。
类似的,我们用下面的命令导出原始的公钥:
1 | openssl rsa -in rsa-key.pem -outform PEM -pubout -out rsa-pub.pem |
这样,我们就准备好了原始私钥文件rsa-prv.pem
和原始公钥文件rsa-pub.pem
,编码格式均为PEM。
下面,使用crypto
模块提供的方法,即可实现非对称加解密。
首先,我们用私钥加密,公钥解密:
1 | const |
执行后,可以得到解密后的消息,与原始消息相同。
接下来我们使用公钥加密,私钥解密:
1 | // 使用公钥加密: |
执行得到的解密后的消息仍与原始消息相同。
如果我们把message
字符串的长度增加到很长,例如1M,这时,执行RSA加密会得到一个类似这样的错误:data too large for key size
,这是因为RSA加密的原始信息必须小于Key的长度。那如何用RSA加密一个很长的消息呢?实际上,RSA并不适合加密大数据,而是先生成一个随机的AES密码,用AES加密原始信息,然后用RSA加密AES口令,这样,实际使用RSA时,给对方传的密文分两部分,一部分是AES加密的密文,另一部分是RSA加密的AES口令。对方用RSA先解密出AES口令,再用AES解密密文,即可获得明文。
证书
crypto模块也可以处理数字证书。数字证书通常用在SSL连接,也就是Web的https连接。一般情况下,https连接只需要处理服务器端的单向认证,如无特殊需求(例如自己作为Root给客户发认证证书),建议用反向代理服务器如Nginx等Web服务器去处理证书。
Sqlite
添加依赖
1 | npm install sqlite3 --save |
工具类
1 | var fs = require("fs"); |
调用代码
1 | // Import SqliteDB. |
方法介绍
close
- 用法:
close([callback])
。 - 功能:关闭和释放数据库对象。
run
用法:
run(sql,param,...],[callback])
。功能:运行指定参数的SQL语句,完成之后调用回调函数,它不返回任何数据。
在回调函数里面有一个参数,SQL语句执行成功,则参数的值为null,反之为一个错误的对象,它返回的是数据库的操作对象。在这个回调函数里面当中的this,里面包含有lastId(插入的ID)和change(操作影响的行数,如果执行SQL语句失败,则change的值永远为0)。
get
用法:
get(sql,[param,...],[callback])
。功能:主要用于查询返回单条数据。
运行指定参数的SQL语句,完成过后调用回调函数。
如果执行成功,则回调函数中的第一个参数为null,第二个参数为结果集中的第一行数据,反之则回调函数中只有一个参数,只参数为一个错误的对象。
all
用法:
all(sql,[param,...],[callback])
。功能:主要用于查询返回列表数据。
运行指定参数的SQL语句,完成过后调用回调函数。
如果执行成功,则回调函数中的第一个参数为null,第二个参数为查询的结果集,反之,则只有一个参数,且参数的值为一个错误的对象。
prepare
用法:
prepare(sql,[param,...],[callback])
。功能:预执行绑定指定参数的SQL语句,返回一个Statement对象。
如果执行成功,则回调函数的第一个参数为null,反之为一个错误的对象。