前言
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。
准备
首先,检查 Node 版本
代码语言:javascript复制node -v
Koa 必须使用 7.6 以上的版本。如果你的版本低于这个要求,就要先升级 Node。
守护模式运行
代码语言:javascript复制npm install -g pm2
常用命令
代码语言:javascript复制# 启动进程/应用
pm2 start bin/www
# 或
pm2 start app.js
# 重命名进程/应用
pm2 start app.js --name wb123
# 添加进程/应用 watch
pm2 start bin/www --watch
# 结束进程/应用
pm2 stop www
# 结束所有进程/应用
pm2 stop all
# 删除进程/应用
pm2 delete www
# 删除所有进程/应用
pm2 delete all
# 列出所有进程/应用
pm2 list
# 查看某个进程/应用具体情况
pm2 describe www
# 查看进程/应用的资源消耗情况
pm2 monit
# 查看pm2的日志
pm2 logs
# 若要查看某个进程/应用的日志,使用
pm2 logs www
# 重新启动进程/应用
pm2 restart www
# 重新启动所有进程/应用
pm2 restart all
1. 基本用法
1.1 Hello World
koa_demo 下创建 koa01.js
代码语言:javascript复制const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {
//ctx是整个应用的上下文,包含常用的request、response
//ctx.response代表 HTTP Response。同样地,ctx.request代表 HTTP Request。
//ctx.response.body可以简写成ctx.body
ctx.response.body = 'hello world'
})
app.listen(3000);
安装
代码语言:javascript复制npm i koa
运行这个脚本。
代码语言:javascript复制node koa01.js
这样我们就可以通过以下地址访问
http://127.0.0.1:3000/
1.2 Response 的类型
Koa 默认的返回类型是text/plain
(纯文本的形式),如果想返回其他类型的内容,可以先用ctx.request.accepts
判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept
字段),然后使用ctx.response.type
指定返回类型。
const Koa = require('koa')
const app = new Koa()
//声明一个main中间件
const main = (ctx, next) => {
if (ctx.request.accepts('json')) {
ctx.response.type = 'json';
ctx.response.body = {
data: 'Hello World'
};
} else if (ctx.request.accepts('html')) {
ctx.response.type = 'html';
ctx.response.body = '<p>Hello World</p>';
} else if (ctx.request.accepts('xml')) {
ctx.response.type = 'xml';
ctx.response.body = '<data>Hello World</data>';
} else {
ctx.response.type = 'text';
ctx.response.body = 'Hello World';
};
};
//直接运行页面中会显示json格式,因为我们没有设置请求头,所以每一种格式都是ok的。
//app.use()用来加载中间件。
app.use(main)
app.listen(3000)
1.3 网页模板
实际开发中,返回给用户的网页往往都写成模板文件。我们可以让 Koa 先读取模板文件,然后将这个模板返回给用户。
代码语言:javascript复制const fs = require('fs');
const Koa = require('koa');
const app = new Koa();
const main = ctx => {
ctx.response.type = 'html';
ctx.response.body = fs.createReadStream('./html/index.html');
};
app.use(main);
app.listen(3000);
index.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>koa</title>
</head>
<body>
这是静态网页
</body>
</html>
2. 路由
2.1 原生路由
代码语言:javascript复制const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
if (ctx.request.url == '/') {//通过ctx.request.url获取用户请求路径
ctx.body = '<h1>首页</h1>'
} else if (ctx.request.url == '/my') {
ctx.body = '<h1>联系我们</h1>'
} else {
ctx.body = '<h1>404 not found</h1>'
}
})
app.listen(3000)
2.2 koa-router 模块路由
npm中的koa-router
代码语言:javascript复制npm install koa-router
代码
代码语言:javascript复制const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()
//routes()返回路由器中间件,它调度与请求匹配的路由。
//allowedMethods()处理的业务是当所有路由中间件执行完成之后,若ctx.status为空或者404的时候,丰富response对象的header头.
app.use(router.routes()).use(router.allowedMethods());
router.get('/', (ctx, next) => { //.get就是发送的get请求
ctx.response.body = '<h1>首页</h1>'
})
router.get('/my', (ctx, next) => {
ctx.response.body = '<h1>联系我们</h1>'
})
router.get('/user/:id', (ctx) => {
ctx.body = `<h1>这是用户 ${ctx.params.id}</h1>`
})
app.listen(3000)
2.3 重定向
有些场合,服务器需要重定向访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()
方法可以发出一个跳转,将用户导向另一个路由。
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router()
app.use(router.routes()).use(router.allowedMethods());
router.get('/cdx',(ctx,next)=>{
ctx.response.redirect('/');//发出一个跳转,将用户导向另一个路由。
})
router.get('/',(ctx,next)=>{
ctx.body = 'Hello World';
})
app.listen(3000);
访问 http://localhost:3000/cdx,浏览器会将用户导向根路由。
2.4 静态资源
如果网站提供静态资源(图片、字体、样式表、脚本……),为它们一个个写路由就很麻烦,也没必要koa-static模块封装了这部分的请求。请看下面的例子
npm中的koa-static
安装依赖
代码语言:javascript复制npm install koa-staic
代码
代码语言:javascript复制const Koa = require('koa');
const app = new Koa();
const path = require('path');
const serve = require('koa-static');
const main = serve(path.join(__dirname));
app.use(main);
app.listen(3000);
访问 http://localhost:3000/html/index.html,在浏览器里就可以看到这个文件的内容。
2.5 模板引擎Nunjucks
我们选择Nunjucks作为模板引擎。Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。
安装
代码语言:javascript复制npm i nunjucks
紧接着,我们要编写使用Nunjucks的函数render
。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.js
中编写代码如下:
const nunjucks = require('nunjucks');
function createEnv(path, opts) {
var
autoescape = opts.autoescape === undefined ? true : opts.autoescape,
noCache = opts.noCache || false,
watch = opts.watch || false,
throwOnUndefined = opts.throwOnUndefined || false,
env = new nunjucks.Environment(
new nunjucks.FileSystemLoader('views', {
noCache: noCache,
watch: watch,
}), {
autoescape: autoescape,
throwOnUndefined: throwOnUndefined
});
if (opts.filters) {
for (var f in opts.filters) {
env.addFilter(f, opts.filters[f]);
}
}
return env;
}
var env = createEnv('views', {
watch: true,
filters: {
hex: function (n) {
return '0x' n.toString(16);
}
}
});
变量env
就表示Nunjucks模板引擎对象,它有一个render(view, model)
方法,正好传入view
和model
两个参数,并返回字符串。
创建env
需要的参数可以查看文档获知。我们用autoescape = opts.autoescape && true
这样的代码给每个参数加上默认值,最后使用new nunjucks.FileSystemLoader('views')
创建一个文件系统加载器,从views
目录读取模板。
我们编写一个hello.html
模板文件,放到views
目录下,内容如下:
<h1>Hello {{ name }}</h1>
然后,我们就可以用下面的代码来渲染这个模板:
代码语言:javascript复制var s = env.render('hello.html', { name: '小明' });
console.log(s);
获得输出如下:
代码语言:javascript复制<h1>Hello 小明</h1>
咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:
代码语言:javascript复制var s = env.render('hello.html', { name: '<script>alert("小明")</script>' });
console.log(s);
获得输出如下:
代码语言:javascript复制<h1>Hello <script>alert("小明")</script></h1>
这样就避免了输出恶意脚本。
此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:
代码语言:javascript复制<!-- 循环输出名字 -->
<body>
<h3>Fruits List</h3>
{% for f in fruits %}
<p>{{ f }}</p>
{% endfor %}
</body>
Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。
更好的方式是使用继承。先定义一个基本的网页框架base.html
:
<html>
<body>
{% block header %} <h3>Unnamed</h3> {% endblock %}
{% block body %} <div>No body</div> {% endblock %}
{% block footer %} <div>copyright</div> {% endblock %}
</body>
</html>
base.html
定义了三个可编辑的块,分别命名为header
、body
和footer
。子模板可以有选择地对块进行重新定义:
{% extends 'base.html' %}
{% block header %}<h1>{{ header }}</h1>{% endblock %}
{% block body %}<p>{{ body }}</p>{% endblock %}
然后,我们对子模板进行渲染:
代码语言:javascript复制console.log(env.render('extend.html', {
header: 'Hello',
body: 'bla bla bla...'
}));
输出HTML如下:
代码语言:javascript复制<html>
<body>
<h1>Hello</h1>
<p>bla bla bla...</p>
<!-- footer没有重定义,所以仍使用父模板的内容 -->
<div>copyright</div>
</body>
</html>
性能
最后我们要考虑一下Nunjucks的性能。
对于模板渲染本身来说,速度是非常非常快的,因为就是拼字符串嘛,纯CPU操作。
性能问题主要出现在从文件读取模板内容这一步。这是一个IO操作,在Node.js环境中,我们知道,单线程的JavaScript最不能忍受的就是同步IO,但Nunjucks默认就使用同步IO读取模板文件。
好消息是Nunjucks会缓存已读取的文件内容,也就是说,模板文件最多读取一次,就会放在内存中,后面的请求是不会再次读取文件的,只要我们指定了noCache: false
这个参数。
在开发环境下,可以关闭cache,这样每次重新加载模板,便于实时修改模板。在生产环境下,一定要打开cache,这样就不会有性能问题。
Nunjucks也提供了异步读取的方式,但是这样写起来很麻烦,有简单的写法我们就不会考虑复杂的写法。保持代码简单是可维护性的关键。
3. 中间件
3.1 Logger功能
Koa 的最大特色,也是最重要的一个设计,就是中间件。为了理解中间件,我们先看一下 Logger (打印日志)功能的实现。
./logger/koa-logger.js
代码语言:javascript复制module.exports = (ctx, next) => {
console.log(`${new Date().toLocaleString()} ${ctx.request.method} ${ctx.request.url}`);
next();
}
./logger.js
代码语言:javascript复制const Koa = require('koa')
const koaLogger = require('./logger/koa-logger')
const app = new Koa();
app.use(koaLogger)
app.use((ctx, next) => {
ctx.response.body = 'hello world'
})
app.listen(3000)
3.2 中间件的概念
处在 HTTP Request 和 HTTP Response 中间,用来实现某种中间功能的函数,就叫做”中间件”。
基本上,Koa 所有的功能都是通过中间件实现的,前面例子里面的main
也是中间件。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next
函数。只要调用next
函数,就可以把执行权转交给下一个中间件。
多个中间件会形成一个栈结构,以”先进后出”的顺序执行。
- 最外层的中间件首先执行。
- 调用
next
函数,把执行权交给下一个中间件。 - …
- 最内层的中间件最后执行。
- 执行结束后,把执行权交回上一层的中间件。
- …
- 最外层的中间件收回执行权之后,执行
next
函数后面的代码。
例子:
代码语言:javascript复制const Koa = require('koa');
const app = new Koa();
app.use((ctx, next)=>{
console.log('>> one');
next();
console.log('<< one');
})
app.use((ctx, next)=>{
console.log('>> two');
next();
console.log('<< two');
})
app.use((ctx, next)=>{
console.log('>> three');
next();
console.log('<< three');
})
app.listen(3000);
如果中间件内部没有调用next
函数,那么执行权就不会传递下去。
结果
3.3 异步中间件
迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成async 函数。
代码语言:javascript复制npm install fs.promised
npm中的fs.promised
代码语言:javascript复制const fs = require('fs.promised');
const Koa = require('koa');
const app = new Koa();
const main = async function (ctx, next) {
ctx.response.type = 'html';
ctx.response.body = await fs.readFile('./data/index.html', 'utf8');
};
app.use(main);
app.listen(3000);
上面代码中,fs.readFile
是一个异步操作,必须写成await fs.readFile()
,然后中间件必须写成 async 函数。
const Koa = require('koa');
const app = new Koa();
app.use(async(ctx, next)=>{
ctx.body = '1'
//延时2秒执行下一个中间件,这样是没用的,因为是异步函数
setTimeout(()=>{
next()
},2000)
ctx.body = '2'
})
app.use(async(ctx, next)=>{
ctx.body = '3'
next()
ctx.body = '4'
})
// server.js正确做法
function delay(){
return new Promise((reslove,reject)=>{
setTimeout(()=>{
reslove()
},1000)
})
}
app.use(async(ctx, next)=>{
ctx.body = '1'
await next()
ctx.body = '2'
})
app.use(async(ctx, next)=>{
ctx.body = '3'
await delay()
await next()
ctx.body = '4'
})
app.listen(3000);
3.4 中间件的合成
koa-compose 模块可以将多个中间件合成一个。
npm中的koa-compose
代码语言:javascript复制npm install koa-compose
const Koa = require('koa');
const compose = require('koa-compose');
const app = new Koa();
const logger = (ctx, next) => {
console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
next();
}
const main = ctx => {
ctx.response.body = 'Hello World';
};
const middlewares = compose([logger, main]);//合成中间件
app.use(middlewares);//加载中间件
app.listen(3000);
输出结果:先打印日志,再在页面中显示Hello World
4. 处理错误
4.1 500/404错误
如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()
方法,用来抛出错误,ctx.throw(500)
就是抛出500错误。
const Koa = require('koa');
const app = new Koa();
const main = ctx => {
ctx.throw(500);//这个时候你访问首页会报一个500的错误(内部服务器错误)服务器会报错
};
app.use(main);
app.listen(3000);
404错误
如果将ctx.response.status
设置成404,就相当于ctx.throw(404)
,返回404错误。
const Koa = require('koa');
const app = new Koa();
const main = ctx => {
ctx.response.status = 404;//response返回的状态码就是404
ctx.response.body = 'Page Not Found';//让页面中显示该内容,服务器不不报错
};
app.use(main);
app.listen(3000);
4.2 Error处理
为了方便处理错误,最好使用try...catch
将其捕获。但是,为每个中间件都写try...catch
太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。
const Koa = require('koa');
const app = new Koa();
const handler = async (ctx, next) => {
try {
await next();//执行下个中间件
} catch (err) {
//如果main中间件是有问题的会走这里
ctx.response.status = err.statusCode || err.status || 500;
ctx.response.body = {
message: err.message//把错误信息返回到页面
};
}
};
const main = ctx => {
ctx.throw(500);
};
app.use(handler);
app.use(main);
app.listen(3000);
4.3 Error监听
运行过程中一旦出错,Koa 会触发一个error
事件。监听这个事件,也可以处理错误。
const Koa = require('koa');
const app = new Koa();
const main = ctx => {
ctx.throw(500);
};
app.on('error', (err, ctx) => {
//如果有报错的话会走这里
console.error('server error', err);//err是错误源头
});
app.use(main);
app.listen(3000);
4.5 Error转发
需要注意的是,如果错误被try...catch
捕获,就不会触发error
事件。这时,必须调用ctx.app.emit()
,手动释放error
事件,才能让监听函数生效。
const Koa = require('koa');
const app = new Koa();
const handler = async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.response.status = err.statusCode || err.status || 500;
ctx.response.type = 'html';
ctx.response.body = '<p>有问题,请与管理员联系</p>';
ctx.app.emit('error', err, ctx);//Error转发
}
};
const main = ctx => {
ctx.throw(500);
};
app.on('error', function (err) {
//释放error事件后这里的监听函数才可生效
console.log('错误', err.message);
console.log(err);
});
app.use(handler);
app.use(main);
app.listen(3000);
上面代码main
函数抛出错误,被handler
函数捕获。catch
代码块里面使用ctx.app.emit()
手动释放error
事件,才能让监听函数监听到。
5. Web App的功能
5.1 Cookie
ctx.cookies
用来读写 Cookie。
const Koa = require('koa');
const app = new Koa();
const main = function(ctx) {
//读取cookie//没有返回0
const n = Number(ctx.cookies.get('view') || 0) 1;
ctx.cookies.set('view', n);//设置cookie
ctx.response.body = n ' views';//显示cookie
}
app.use(main);
app.listen(3000);
5.2 表单
Web 应用离不开处理表单。本质上,表单就是 POST 方法发送到服务器的键值对。koa-body模块可以用来从 POST 请求的数据体里面提取键值对。
npm中的koa-body
代码语言:javascript复制npm install koa-body
代码语言:javascript复制const Koa = require('koa');
const koaBody = require('koa-body');
const app = new Koa();
const main = async function (ctx) {
const body = ctx.request.body;
if (!body.name){
ctx.throw(400, 'name required')
};
ctx.body = { name: body.name };
};
app.use(koaBody());
app.use(main);
app.listen(3000);
上面代码使用 POST 方法向服务器发送一个键值对,会被正确解析。如果发送的数据不正确,就会收到错误提示。
5.3 文件上传
koa-body模块还可以用来处理文件上传。
代码语言:javascript复制npm i koa
npm i koa-body
npm i koa-router
koa04.js
代码语言:javascript复制const Koa = require('koa');
const koaBody = require('koa-body');
const Router = require('koa-router');
const fs = require('fs');
const path = require('path');
const router = new Router()
const app = new Koa();
app.use(koaBody({
multipart: true, //解析多部分主体,默认false
formidable: {
maxFileSize: 200 * 1024 * 1024 // 设置上传文件大小最大限制,默认2M
}
}));
app.use(router.routes()).use(router.allowedMethods());
router.get('/', (ctx, next) => {
ctx.response.type = 'html';
ctx.response.body = fs.createReadStream('./html/fileupload.html');
});
router.post('/uploadfile', (ctx, next) => {
// 上传单个文件
const file = ctx.request.files.file; // 获取上传文件
let mypath = path.join(__dirname, 'static/');
if (!fs.existsSync(mypath)) {
fs.mkdirSync(mypath);
}
// 创建可读流
const reader = fs.createReadStream(file.path);
let filePath = mypath `/${file.name}`;
// 创建可写流
const upStream = fs.createWriteStream(filePath);
// 可读流通过管道写入可写流
reader.pipe(upStream);
return ctx.body = "上传成功!";
});
app.listen(3000)
./html/fileupload.html
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<form
action="http://127.0.0.1:3000/uploadfile"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="file" id="file" value="" multiple="multiple" />
<input type="submit" value="提交" />
</form>
</body>
</html>
6. 其他模块
6.1 crypto
crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C 实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。
MD5和SHA1
MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:
代码语言:javascript复制const crypto = require('crypto');
const hash = crypto.createHash('md5');
// 可任意多次调用update():
hash.update('Hello, world!');
hash.update('Hello, nodejs!');
console.log(hash.digest('hex')); // 7e1977739c748beac0c0fd14fd26a544
update()
方法默认字符串编码为UTF-8
,也可以传入Buffer。
如果要计算SHA1,只需要把'md5'
改成'sha1'
,就可以得到SHA1的结果1f32b9c9932c02227819a4151feed43e131aca40
。
还可以使用更安全的sha256
和sha512
。
Hmac
Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:
代码语言:javascript复制const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', 'secret-key');
hmac.update('Hello, world!');
hmac.update('Hello, nodejs!');
console.log(hmac.digest('hex')); // 80f7e22570...
只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。
AES
AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:
代码语言:javascript复制const crypto = require('crypto');
function aesEncrypt(data, key) {
const cipher = crypto.createCipher('aes192', key);
var crypted = cipher.update(data, 'utf8', 'hex');
crypted = cipher.final('hex');
return crypted;
}
function aesDecrypt(encrypted, key) {
const decipher = crypto.createDecipher('aes192', key);
var decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted = decipher.final('utf8');
return decrypted;
}
var data = 'Hello, this is a secret message!';
var key = 'Password!';
var encrypted = aesEncrypt(data, key);
var decrypted = aesDecrypt(encrypted, key);
console.log('Plain text: ' data);
console.log('Encrypted text: ' encrypted);
console.log('Decrypted text: ' decrypted);
运行结果如下:
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算法如下:
代码语言:javascript复制const crypto = require('crypto');
// xiaoming's keys:
var ming = crypto.createDiffieHellman(512);
var ming_keys = ming.generateKeys();
var prime = ming.getPrime();
var generator = ming.getGenerator();
console.log('Prime: ' prime.toString('hex'));
console.log('Generator: ' generator.toString('hex'));
// xiaohong's keys:
var hong = crypto.createDiffieHellman(prime, generator);
var hong_keys = hong.generateKeys();
// exchange and generate secret:
var ming_secret = ming.computeSecret(hong_keys);
var hong_secret = hong.computeSecret(ming_keys);
// print secret:
console.log('Secret of Xiao Ming: ' ming_secret.toString('hex'));
console.log('Secret of Xiao Hong: ' hong_secret.toString('hex'));
运行后,可以得到如下输出:
$ 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密钥对:
代码语言:javascript复制openssl genrsa -aes256 -out rsa-key.pem 2048
根据提示输入密码,这个密码是用来加密RSA密钥的,加密方式指定为AES256,生成的RSA的密钥长度是2048位。执行成功后,我们获得了加密的rsa-key.pem
文件。
第二步,通过上面的rsa-key.pem
加密文件,我们可以导出原始的私钥,命令如下:
openssl rsa -in rsa-key.pem -outform PEM -out rsa-prv.pem
输入第一步的密码,我们获得了解密后的私钥。
类似的,我们用下面的命令导出原始的公钥:
代码语言:javascript复制openssl rsa -in rsa-key.pem -outform PEM -pubout -out rsa-pub.pem
这样,我们就准备好了原始私钥文件rsa-prv.pem
和原始公钥文件rsa-pub.pem
,编码格式均为PEM。
下面,使用crypto
模块提供的方法,即可实现非对称加解密。
首先,我们用私钥加密,公钥解密:
代码语言:javascript复制const
fs = require('fs'),
crypto = require('crypto');
// 从文件加载key:
function loadKey(file) {
// key实际上就是PEM编码的字符串:
return fs.readFileSync(file, 'utf8');
}
let
prvKey = loadKey('./rsa-prv.pem'),
pubKey = loadKey('./rsa-pub.pem'),
message = 'Hello, world!';
// 使用私钥加密:
let enc_by_prv = crypto.privateEncrypt(prvKey, Buffer.from(message, 'utf8'));
console.log('encrypted by private key: ' enc_by_prv.toString('hex'));
let dec_by_pub = crypto.publicDecrypt(pubKey, enc_by_prv);
console.log('decrypted by public key: ' dec_by_pub.toString('utf8'));
执行后,可以得到解密后的消息,与原始消息相同。
接下来我们使用公钥加密,私钥解密:
代码语言:javascript复制// 使用公钥加密:
let enc_by_pub = crypto.publicEncrypt(pubKey, Buffer.from(message, 'utf8'));
console.log('encrypted by public key: ' enc_by_pub.toString('hex'));
// 使用私钥解密:
let dec_by_prv = crypto.privateDecrypt(prvKey, enc_by_pub);
console.log('decrypted by private key: ' dec_by_prv.toString('utf8'));
执行得到的解密后的消息仍与原始消息相同。
如果我们把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服务器去处理证书。
7. MVC
我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!
当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:
代码语言:javascript复制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对象:
代码语言:javascript复制{ name: 'Michael' }
下面,我们根据原来的url2-koa
创建工程view-koa
,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)
的方式。
工程view-koa
结构如下:
view-koa/
|
- .vscode/
| |
| - launch.json <-- VSCode 配置文件
|
- controllers/ <-- Controller
|
- views/ <-- html模板文件
|
- static/ <-- 静态资源文件
|
- controller.js <-- 扫描注册Controller
|
- app.js <-- 使用koa的js
|
- package.json <-- 项目描述文件
|
- node_modules/ <-- npm安装的所有依赖包
在package.json
中,我们将要用到的依赖包有:
"koa": "2.0.0",
"koa-bodyparser": "3.2.0",
"koa-router": "7.0.0",
"nunjucks": "2.4.2",
"mime": "1.3.4",
"mz": "2.4.0"
先用npm install
安装依赖包。
然后,我们准备编写以下两个Controller:
Controller
处理首页 GET /
我们定义一个async函数处理首页URL/
:
async (ctx, next) => {
ctx.render('index.html', {
title: 'Welcome'
});
}
注意到koa并没有在ctx
对象上提供render
方法,这里我们假设应该这么使用,这样,我们在编写Controller的时候,最后一步调用ctx.render(view, model)
就完成了页面输出。
处理登录请求 POST /signin
我们再定义一个async函数处理登录请求/signin
:
async (ctx, next) => {
var
email = ctx.request.body.email || '',
password = ctx.request.body.password || '';
if (email === 'admin@example.com' && password === '123456') {
// 登录成功:
ctx.render('signin-ok.html', {
title: 'Sign In OK',
name: 'Mr Node'
});
} else {
// 登录失败:
ctx.render('signin-failed.html', {
title: 'Sign In Failed'
});
}
}
由于登录请求是一个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
目录下:
view-koa/
|
- static/
|
- css/ <- 存放bootstrap.css等
|
- fonts/ <- 存放字体文件
|
- js/ <- 存放bootstrap.js等
这样我们在编写HTML的时候,可以直接用Bootstrap的CSS,像这样:
代码语言:javascript复制<link rel="stylesheet" href="/static/css/bootstrap.css">
现在,在使用MVC之前,第一个问题来了,如何处理静态文件?
我们把所有静态资源文件全部放入/static
目录,目的就是能统一处理静态文件。在koa中,我们需要编写一个middleware,处理以/static/
开头的URL。
编写middleware
我们来编写一个处理静态文件的middleware。编写middleware实际上一点也不复杂。我们先创建一个static-files.js
的文件,编写一个能处理静态文件的middleware:
const path = require('path');
const mime = require('mime');
const fs = require('mz/fs');
// url: 类似 '/static/'
// dir: 类似 __dirname '/static'
function staticFiles(url, dir) {
return async (ctx, next) => {
let rpath = ctx.request.path;
// 判断是否以指定的url开头:
if (rpath.startsWith(url)) {
// 获取文件完整路径:
let fp = path.join(dir, rpath.substring(url.length));
// 判断文件是否存在:
if (await fs.exists(fp)) {
// 查找文件的mime:
ctx.response.type = mime.lookup(rpath);
// 读取文件内容并赋值给response.body:
ctx.response.body = await fs.readFile(fp);
} else {
// 文件不存在:
ctx.response.status = 404;
}
} else {
// 不是指定前缀的URL,继续处理下一个middleware:
await next();
}
};
}
module.exports = staticFiles;
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官网搜索并查看其文档:
https://www.npmjs.com/
最后,这个middleware使用起来也很简单,在app.js
里加一行代码:
let staticFiles = require('./static-files');
app.use(staticFiles('/static/', __dirname '/static'));
注意:也可以去npm搜索能用于koa2的处理静态文件的包并直接使用。
集成Nunjucks
集成Nunjucks实际上也是编写一个middleware,这个middleware的作用是给ctx
对象绑定一个render(view, model)
的方法,这样,后面的Controller就可以调用这个方法来渲染模板了。
我们创建一个templating.js
来实现这个middleware:
const nunjucks = require('nunjucks');
function createEnv(path, opts) {
var
autoescape = opts.autoescape === undefined ? true : opts.autoescape,
noCache = opts.noCache || false,
watch = opts.watch || false,
throwOnUndefined = opts.throwOnUndefined || false,
env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(path || 'views', {
noCache: noCache,
watch: watch,
}), {
autoescape: autoescape,
throwOnUndefined: throwOnUndefined
});
if (opts.filters) {
for (var f in opts.filters) {
env.addFilter(f, opts.filters[f]);
}
}
return env;
}
function templating(path, opts) {
// 创建Nunjucks的env对象:
var env = createEnv(path, opts);
return async (ctx, next) => {
// 给ctx绑定render函数:
ctx.render = function (view, model) {
// 把render后的内容赋值给response.body:
ctx.response.body = env.render(view, Object.assign({}, ctx.state || {}, model || {}));
// 设置Content-Type:
ctx.response.type = 'text/html';
};
// 继续处理请求:
await next();
};
}
module.exports = templating;
注意到createEnv()
函数和前面使用Nunjucks时编写的函数是一模一样的。我们主要关心tempating()
函数,它会返回一个middleware,在这个middleware中,我们只给ctx
“安装”了一个render()
函数,其他什么事情也没干,就继续调用下一个middleware。
使用的时候,我们在app.js
添加如下代码:
const isProduction = process.env.NODE_ENV === 'production';
app.use(templating('views', {
noCache: !isProduction,
watch: !isProduction
}));
这里我们定义了一个常量isProduction
,它判断当前环境是否是production环境。如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启Node程序,会极大地降低开发效率。
Node.js在全局变量process
中定义了一个环境变量env.NODE_ENV
,为什么要使用该环境变量?因为我们在开发的时候,环境变量应该设置为'development'
,而部署到服务器时,环境变量应该设置为'production'
。在编写代码的时候,要根据当前环境作不同的判断。
注意:生产环境上必须配置环境变量NODE_ENV = 'production'
,而开发环境不需要配置,实际上NODE_ENV
可能是undefined
,所以判断的时候,不要用NODE_ENV === 'development'
。
类似的,我们在使用上面编写的处理静态文件的middleware时,也可以根据环境变量判断:
代码语言:javascript复制if (!isProduction) {
let staticFiles = require('./static-files');
app.use(staticFiles('/static/', __dirname '/static'));
}
这是因为在生产环境下,静态文件是由部署在最前面的反向代理服务器(如Nginx)处理的,Node程序不需要处理静态文件。而在开发环境下,我们希望koa能顺带处理静态文件,否则,就必须手动配置一个反向代理服务器,这样会导致开发环境非常复杂。
编写View
在编写View的时候,非常有必要先编写一个base.html
作为骨架,其他模板都继承自base.html
,这样,才能大大减少重复工作。
编写HTML不在本教程的讨论范围之内。这里我们参考Bootstrap的官网简单编写了base.html
。
运行
一切顺利的话,这个view-koa
工程应该可以顺利运行。运行前,我们再检查一下app.js
里的middleware的顺序:
第一个middleware是记录URL以及页面执行时间:
代码语言:javascript复制app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
var
start = new Date().getTime(),
execTime;
await next();
execTime = new Date().getTime() - start;
ctx.response.set('X-Response-Time', `${execTime}ms`);
});
第二个middleware处理静态文件:
代码语言:javascript复制if (!isProduction) {
let staticFiles = require('./static-files');
app.use(staticFiles('/static/', __dirname '/static'));
}
第三个middleware解析POST请求:
代码语言:javascript复制app.use(bodyParser());
第四个middleware负责给ctx
加上render()
来使用Nunjucks:
app.use(templating('view', {
noCache: !isProduction,
watch: !isProduction
}));
最后一个middleware处理URL路由:
代码语言:javascript复制app.use(controller());
现在,在VS Code中运行代码,不出意外的话,在浏览器输入localhost:3000/
,可以看到首页内容