基于NodeJS的WEB框架Koa

前言

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。

1. 基本用法

1.1 Hello World

koa_demo 下创建 koa01.js

1
2
3
4
5
6
7
8
9
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);

安装

1
npm i koa --save

运行这个脚本。

1
node koa01.js

这样我们就可以通过以下地址访问

http://127.0.0.1:3000/

1.2 Response 的类型

Koa 默认的返回类型是text/plain (纯文本的形式),如果想返回其他类型的内容,可以先用ctx.request.accepts判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept字段),然后使用ctx.response.type指定返回类型。

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 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 先读取模板文件,然后将这个模板返回给用户。

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
5
6
7
8
9
10
11
12
<!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 原生路由

1
2
3
4
5
6
7
8
9
10
11
12
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

POST请求获取参数需要用到Koa 中koa-bodyparser中间件

1
2
npm install koa-router --save
npm install --save koa-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
const Koa = require('koa')
const Router = require('koa-router')
var bodyParser = require('koa-bodyparser');

const app = new Koa()
const router = new Router()

//routes()返回路由器中间件,它调度与请求匹配的路由。
//allowedMethods()处理的业务是当所有路由中间件执行完成之后,若ctx.status为空或者404的时候,丰富response对象的header头.
app.use(router.routes()).use(router.allowedMethods());
app.use(bodyParser());

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)

注意

路由的方法中如果有异步操作,路由的方法一定要添加async关键字,并且方法中的所有异步操作一定要await,否则页面会返回404。

2.3 重定向

有些场合,服务器需要重定向访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()方法可以发出一个跳转,将用户导向另一个路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

安装依赖

1
npm install koa-static --save

代码

1
2
3
4
5
6
7
8
9
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框架。

安装

1
npm i nunjucks

紧接着,我们要编写使用Nunjucks的函数render。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.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
26
27
28
29
30
31
32
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)方法,正好传入viewmodel两个参数,并返回字符串。

创建env需要的参数可以查看文档获知。我们用autoescape = opts.autoescape && true这样的代码给每个参数加上默认值,最后使用new nunjucks.FileSystemLoader('views')创建一个文件系统加载器,从views目录读取模板。

我们编写一个hello.html模板文件,放到views目录下,内容如下:

1
<h1>Hello {{ name }}</h1>

然后,我们就可以用下面的代码来渲染这个模板:

1
2
var s = env.render('hello.html', { name: '小明' });
console.log(s);

获得输出如下:

1
<h1>Hello 小明</h1>

咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:

1
2
var s = env.render('hello.html', { name: '<script>alert("小明")</script>' });
console.log(s);

获得输出如下:

1
<h1>Hello &lt;script&gt;alert("小明")&lt;/script&gt;</h1>

这样就避免了输出恶意脚本。

此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:

1
2
3
4
5
6
7
<!-- 循环输出名字 -->
<body>
<h3>Fruits List</h3>
{% for f in fruits %}
<p>{{ f }}</p>
{% endfor %}
</body>

Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。

更好的方式是使用继承。先定义一个基本的网页框架base.html

1
2
3
4
5
6
7
<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定义了三个可编辑的块,分别命名为headerbodyfooter。子模板可以有选择地对块进行重新定义:

1
2
3
4
5
{% extends 'base.html' %}

{% block header %}<h1>{{ header }}</h1>{% endblock %}

{% block body %}<p>{{ body }}</p>{% endblock %}

然后,我们对子模板进行渲染:

1
2
3
4
console.log(env.render('extend.html', {
header: 'Hello',
body: 'bla bla bla...'
}));

输出HTML如下:

1
2
3
4
5
6
7
8
<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

1
2
3
4
module.exports = (ctx, next) => {
console.log(`${new Date().toLocaleString()} ${ctx.request.method} ${ctx.request.url}`);
next();
}

./logger.js

1
2
3
4
5
6
7
8
9
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函数,就可以把执行权转交给下一个中间件。

多个中间件会形成一个栈结构,以”先进后出”的顺序执行。

  1. 最外层的中间件首先执行。
  2. 调用next函数,把执行权交给下一个中间件。
  3. 最内层的中间件最后执行。
  4. 执行结束后,把执行权交回上一层的中间件。
  5. 最外层的中间件收回执行权之后,执行next函数后面的代码。

例子:

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 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函数,那么执行权就不会传递下去。

结果

image-20210409154529301

3.3 异步中间件

迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成async 函数。

1
npm install fs.promised

npm中的fs.promised

1
2
3
4
5
6
7
8
9
10
11
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 函数。

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
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

3.5 POST请求获取请求参数

1
npm install koa-bodyparser --save

使用

1
2
3
4
5
6
7
8
9
const Koa = require('koa')
const Router = require('koa-router')
var bodyParser = require('koa-bodyparser');

const app = new Koa()
const router = new Router()

app.use(bodyParser());
app.use(router.routes()).use(router.allowedMethods());

3.6 接口允许跨域

安装koa2-cors

1
npm install --save koa2-cors

引入 koa2-cors 并且配置中间件

1
2
3
4
5
var Koa = require('koa');
var cors = require('koa2-cors');

var app = new Koa();
app.use(cors());

经历这几步以后,koa2后台就设置好跨域了,我们现在可以放心的用get post 获取提交数据了

请求发送两次

第一条的请求方式为OPTIONS,第二条请求,才是我们预想中的请求。所以为什么发生两条请求的原因就变成了为什么发生OPTIONS请求。

options请求: 在发生正式的请求之前,先进行一次预检请求。看服务端返回一些信息,浏览器拿到之后,看后台是否允许进行访问。

CORS是比较好的解决跨域方案,这个模式会有”预检”的请求,也就是正常请求之前的options请求

如何产生OPTIONS请求:

跨域并产生了复杂请求。

复杂请求对应的就是简单请求。

简单请求的定义是:

  1. 请求方法是GET、HEAD或者POST,并且当请求方法是POST时,Content-Type必须是application/x-www-form-urlencoded, multipart/form-data或着text/plain中的一个值。
  2. 请求中没有自定义HTTP头部。所谓的自定义头部,在实际的项目里,我们经常会遇到需要在header头部加上一些token或者其他的用户信息,用来做用户信息的校验。

其它的都是复杂请求。例如:

  • 通过设置Content-Typeappliaction/json使其成为非简单请求
  • 添加自定义Header

OPTIONS请求如何避免

其实通过以上的分析,我们能得出以下解决方案:

  1. 使用代理,避开跨域。
  2. 将复杂跨域请求更改为简单跨域请求。
  3. 不使用带自定义配置的header头部。

所以

我们这里就是因为跨域导致的,所以不用在意,正式部署后就不会有跨域问题了。

4. 处理错误

4.1 500/404错误

如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()方法,用来抛出错误,ctx.throw(500)就是抛出500错误。

1
2
3
4
5
6
7
8
9
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错误。

1
2
3
4
5
6
7
8
9
10
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太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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事件。监听这个事件,也可以处理错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
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事件,才能让监听函数生效。

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
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的功能

ctx.cookies用来读写 Cookie。

1
2
3
4
5
6
7
8
9
10
11
12
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

1
npm install koa-body
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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模块还可以用来处理文件上传。

1
2
3
npm i koa
npm i koa-body
npm i koa-router

koa04.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!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是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:

1
2
3
4
5
6
7
8
9
10
11
const crypto = require("crypto");

function md5(str) {
const hash = crypto.createHash("md5");
hash.update(str);
return hash.digest("hex").toUpperCase();
}

exports.md5 = function (str) {
return md5(md5(md5(str)));
};

update()方法默认字符串编码为UTF-8,也可以传入Buffer。

如果要计算SHA1,只需要把'md5'改成'sha1',就可以得到SHA1的结果1f32b9c9932c02227819a4151feed43e131aca40

还可以使用更安全的sha256sha512

Hmac

Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:

1
2
3
4
5
6
7
8
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支持,但是需要自己封装好函数,便于使用:

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 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有很多不同的算法,如aes192aes-128-ecbaes-256-cbc等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Nodejs全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,字符串密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。

Diffie-Hellman

DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:

  1. 小明先选一个素数和一个底数,例如,素数p=23,底数g=5(底数可以任选),再选择一个秘密整数a=6,计算A=g^a mod p=8,然后大声告诉小红:p=23,g=5,A=8
  2. 小红收到小明发来的pgA后,也选一个秘密整数b=15,然后计算B=g^b mod p=19,并大声告诉小明:B=19
  3. 小明自己计算出s=B^a mod p=2,小红也自己计算出s=A^b mod p=2,因此,最终协商的密钥s2

在这个过程中,密钥2并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=23g=5A=8B=19,由于不知道双方选的秘密整数a=6b=15,因此无法计算出密钥2

用crypto模块实现DH算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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密钥对:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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'));

执行后,可以得到解密后的消息,与原始消息相同。

接下来我们使用公钥加密,私钥解密:

1
2
3
4
5
6
7
// 使用公钥加密:
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服务器去处理证书。

6.2 Sqlite

添加依赖

1
npm install sqlite3 --save

工具类

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
var fs = require("fs");
var path = require("path");
var sqlite3 = require("sqlite3").verbose();

var DB = DB || {};

// 递归创建目录 同步方法
function mkdirsSync(dirname) {
if (fs.existsSync(dirname)) {
return true;
} else {
if (mkdirsSync(path.dirname(dirname))) {
fs.mkdirSync(dirname);
return true;
}
}
}

DB.SqliteDB = function (file) {
let parentpath = path.resolve(file, "..");
if (!fs.existsSync(parentpath)) {
mkdirsSync(parentpath);
}
DB.db = new sqlite3.Database(file);

DB.exist = fs.existsSync(file);
if (!DB.exist) {
console.log("Creating db file!");
fs.openSync(file, "w");
}
};

DB.printErrorInfo = function (err) {
console.log("Error Message:" + err.message);
};

/// 创建表
DB.SqliteDB.prototype.createTable = function (sql) {
new Promise((resolve, reject) => {
DB.db.serialize(function () {
DB.db.run(sql, function (err) {
if (null != err) {
reject(err);
DB.printErrorInfo(err);
} else {
resolve("success");
}
});
});
});
};

/// 批量执行增删改
/// tilesData format; [[level, column, row, content], [level, column, row, content]]
DB.SqliteDB.prototype.executeSqlBatch = function (sql, objects) {
let lastIDs = [];
let changes = 0;
return new Promise((resolve, reject) => {
DB.db.serialize(function () {
const stmt = DB.db.prepare(sql);
for (let i = 0; i < objects.length; ++i) {
stmt.run(objects[i], function (err) {
lastIDs.push(this.lastID);
changes += this.changes;
if (err) {
reject(err);
} else if (lastIDs.length === objects.length) {
resolve({
err,
changes,
lastIDs,
});
}
});
}
stmt.finalize();
});
});
};

/// 增删改
DB.SqliteDB.prototype.executeSql = function (sql, paras) {
return new Promise((resolve, reject) => {
DB.db.run(sql, paras, function (err) {
if (null != err) {
reject(err);
DB.printErrorInfo(err);
}
resolve({
err: err,
changes: this.changes,
lastID: this.lastID,
});
});
});
};

/// 查询单条数据
DB.SqliteDB.prototype.queryDataObj = function (sql, paras) {
return new Promise((resolve, reject) => {
DB.db.get(sql, paras, function (err, row) {
if (null != err) {
DB.printErrorInfo(err);
reject(err);
} else {
resolve(row);
}
});
});
};

/// 查询列表数据
DB.SqliteDB.prototype.queryDataList = function (sql, paras) {
return new Promise((resolve, reject) => {
DB.db.all(sql, paras, function (err, rows) {
if (null != err) {
reject(err);
DB.printErrorInfo(err);
} else {
resolve(rows);
}
});
});
};

/// 关闭连接
DB.SqliteDB.prototype.close = function () {
DB.db.close();
};

/// export SqliteDB.
exports.SqliteDB = DB.SqliteDB;

调用代码

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// Import SqliteDB.
var SqliteDB = require("./sqlite").SqliteDB;
var file = "./db/mydb.db";
var sqliteDB = new SqliteDB(file);

var mydb = mydb || {};

mydb.createDb = function () {
// 用户表
let createUserTableSql = `CREATE TABLE IF NOT EXISTS t_zuser
(
userid integer NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
username text,
userpwd text,
nickname text
);`;

sqliteDB.createTable(createUserTableSql);

// 命令
let createCMDTableSql = `CREATE TABLE IF NOT EXISTS t_zcmd
(
cmdid integer NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
cmdname text,
cmd text ,
content text
);`;

sqliteDB.createTable(createCMDTableSql);
};

mydb.login = function (name, pwd) {
var querySql = "select * from t_zuser where username=? and userpwd=?";
return sqliteDB.queryDataObj(querySql, [name, pwd]);
};

mydb.changeNickname = function (nickname, username) {
var querySql = "update t_zuser set nickname=? where username=?";
return sqliteDB.executeSql(querySql, [nickname, username]);
};

mydb.insertCmd = function (cmdname, cmd, content) {
var querySql = "insert into t_zcmd(cmdname, cmd,content) values(?, ?,?)";
return sqliteDB.executeSql(querySql, [cmdname, cmd, content]);
};

mydb.insertCmdBatch = function (datas) {
var querySql = "insert into t_zcmd(cmdname, cmd,content) values(?, ?,?)";
return sqliteDB.executeSqlBatch(querySql, datas);
};

mydb.deleteCmd = function (cmd) {
var querySql = "delete from t_zcmd where cmd=?";
return sqliteDB.executeSql(querySql, [cmd]);
};

mydb.queryCmdList = function () {
var querySql = "select * from t_zcmd";
return sqliteDB.queryDataList(querySql, []);
};

mydb.queryCmdObj = function () {
var querySql = "select * from t_zcmd";
return sqliteDB.queryDataObj(querySql, []);
};

mydb.createDb();
mydb
.login("admin", "CF814721358D09942B255746542AD2A4")
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.changeNickname("超级管理员", "admin")
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.insertCmd("查询当前文件夹", "pwd", "可以查看当前所在文件夹")
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.insertCmdBatch([
["查看当前目录", "ls", "查看当前目录"],
["进入项目目录", "cd /data", "进入项目目录"],
])
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.queryCmdList()
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.deleteCmd("pwd")
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});
mydb
.queryCmdObj()
.then((data) => {
console.info("data:", data);
})
.catch((err) => {
console.info("err:", err);
});

exports.mydb = mydb;

方法介绍

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,反之为一个错误的对象。

7. MVC

我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!

当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:

1
ctx.render('home.html', { name: 'Michael' });

通过Nunjucks把数据用指定的模板渲染成HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:

mvc

这就是传说中的MVC:Model-View-Controller,中文名“模型-视图-控制器”。

异步函数是C:Controller,Controller负责业务逻辑,比如检查用户名是否存在,取出用户信息等等;

包含变量的模板就是V:View,View负责显示逻辑,通过简单地替换一些变量,View最终输出的就是用户看到的HTML。

MVC中的Model在哪?Model是用来传给View的,这样View在替换变量的时候,就可以从Model中取出相应的数据。

上面的例子中,Model就是一个JavaScript对象:

1
{ name: 'Michael' }

下面,我们根据原来的url2-koa创建工程mykoa,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)的方式。

工程mykoa结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mykoa/
|
+- controllers/ <-- Controller
|
+- views/ <-- html模板文件
|
+- static/ <-- 静态资源文件
|
+- controller.js <-- 扫描注册Controller
|
+- app.js <-- 使用koa的js
|
+- package.json <-- 项目描述文件
|
+- node_modules/ <-- npm安装的所有依赖包

package.json中,我们将要用到的依赖包有:

1
2
3
4
5
6
"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

根目录添加controller.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
26
27
28
29
30
31
32
33
34
35
36
37
38
const fs = require('fs');

function addMapping(router, mapping) {
for (var url in mapping) {
if (url.startsWith('GET ')) {
var path = url.substring(4);
router.get(path, mapping[url]);
console.log(`register URL mapping: GET ${path}`);
} else if (url.startsWith('POST ')) {
var path = url.substring(5);
router.post(path, mapping[url]);
console.log(`register URL mapping: POST ${path}`);
} else {
console.log(`invalid URL: ${url}`);
}
}
}

function addControllers(router) {
var files = fs.readdirSync(__dirname + '/controllers');
var js_files = files.filter((f) => {
return f.endsWith('.js');
});

for (var f of js_files) {
console.log(`process controller: ${f}...`);
let mapping = require(__dirname + '/controllers/' + f);
addMapping(router, mapping);
}
}

module.exports = function (dir) {
let
controllers_dir = dir || 'controllers', // 如果不传参数,扫描目录默认为'controllers'
router = require('koa-router')();
addControllers(router, controllers_dir);
return router.routes();
};

根目录下添加controllers添加user_controller.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
26
27
28
29
30
31
var fn_index = async (ctx, next) => {
ctx.render("index.html", {
title: "Welcome",
});
};

let fn_signin = async (ctx, next) => {
console.info("ctx", ctx);
var username = ctx.request.body.username || "",
password = ctx.request.body.password || "";
if (username === "admin" && password === "123456") {
// 登录成功:
ctx.response.type = "json";
ctx.response.body = {
code: 0,
msg: "登录成功",
};
} else {
// 登录失败:
ctx.response.type = "json";
ctx.response.body = {
code: 0,
msg: "登录失败",
};
}
};

module.exports = {
"GET /": fn_index,
"POST /api/signin": fn_signin,
};

由于登录请求是一个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
2
3
4
5
6
7
8
9
mykoa/
|
+- static/
|
+- css/
|
+- fonts/
|
+- js/

这样我们在编写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
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
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里加一行代码:

1
2
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:

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
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添加如下代码:

1
2
3
4
5
6
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时,也可以根据环境变量判断:

1
2
3
4
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

运行

一切顺利的话,这个mykoa工程应该可以顺利运行。运行前,我们再检查一下app.js里的middleware的顺序:

第一个middleware是记录URL以及页面执行时间:

1
2
3
4
5
6
7
8
9
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处理静态文件:

1
2
3
4
if (!isProduction) {
let staticFiles = require('./static-files');
app.use(staticFiles('/static/', __dirname + '/static'));
}

第三个middleware解析POST请求:

1
app.use(bodyParser());

第四个middleware负责给ctx加上render()来使用Nunjucks:

1
2
3
4
app.use(templating('view', {
noCache: !isProduction,
watch: !isProduction
}));

最后一个middleware处理URL路由:

1
2
const controller = require("./controller");
app.use(controller());

现在,在VS Code中运行代码,不出意外的话,在浏览器输入localhost:3000/,可以看到首页内容

守护模式运行

1
npm install -g pm2

常用命令

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
# 启动进程/应用           
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