Koa基于NodeJS的WEB框架

2021-04-13 16:05:45 浏览数 (1)

前言

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指定返回类型。

代码语言:javascript复制
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()方法可以发出一个跳转,将用户导向另一个路由。

代码语言:javascript复制
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中编写代码如下:

代码语言:javascript复制
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目录下,内容如下:

代码语言:javascript复制
<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 &lt;script&gt;alert("小明")&lt;/script&gt;</h1>

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

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

代码语言:javascript复制
<!-- 循环输出名字 -->
<body>
    <h3>Fruits List</h3>
    {% for f in fruits %}
    <p>{{ f }}</p>
    {% endfor %}
</body>

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

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

代码语言:javascript复制
<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。子模板可以有选择地对块进行重新定义:

代码语言:javascript复制
{% 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函数,就可以把执行权转交给下一个中间件。

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

  1. 最外层的中间件首先执行。
  2. 调用next函数,把执行权交给下一个中间件。
  3. 最内层的中间件最后执行。
  4. 执行结束后,把执行权交回上一层的中间件。
  5. 最外层的中间件收回执行权之后,执行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 函数。

代码语言:javascript复制
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错误。

代码语言:javascript复制
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错误。

代码语言:javascript复制
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太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。

代码语言:javascript复制
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事件。监听这个事件,也可以处理错误。

代码语言:javascript复制
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事件,才能让监听函数生效。

代码语言:javascript复制
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。

代码语言:javascript复制
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

还可以使用更安全的sha256sha512

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有很多不同的算法,如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算法如下:

代码语言: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加密文件,我们可以导出原始的私钥,命令如下:

代码语言:javascript复制
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服务器去处理证书。

6.2 Sqlite

工具类

代码语言:javascript复制
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   " ErrorNumber:"   errno);
};

DB.SqliteDB.prototype.createTable = function (sql) {
  DB.db.serialize(function () {
    DB.db.run(sql, function (err) {
      if (null != err) {
        DB.printErrorInfo(err);
        return;
      }
    });
  });
};

/// tilesData format; [[level, column, row, content], [level, column, row, content]]
DB.SqliteDB.prototype.insertData = function (sql, objects) {
  DB.db.serialize(function () {
    var stmt = DB.db.prepare(sql);
    for (var i = 0; i < objects.length;   i) {
      stmt.run(objects[i]);
    }

    stmt.finalize();
  });
};

DB.SqliteDB.prototype.queryData = function (sql, callback) {
  DB.db.all(sql, function (err, rows) {
    if (null != err) {
      DB.printErrorInfo(err);
      return;
    }

    /// deal query data.
    if (callback) {
      callback(rows);
    }
  });
};

DB.SqliteDB.prototype.executeSql = function (sql) {
  DB.db.run(sql, function (err) {
    if (null != err) {
      DB.printErrorInfo(err);
    }
  });
};

DB.SqliteDB.prototype.close = function () {
  DB.db.close();
};

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

调用代码

代码语言:javascript复制
// Import SqliteDB.
var SqliteDB = require('./utils/sqlite').SqliteDB;
var file = "./db/mydb.db";
var sqliteDB = new SqliteDB(file);

// create table.
var createUserTableSql = "CREATE TABLE IF NOT EXISTS t_user(id integer NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,name TEXT,age INTERGER);";

sqliteDB.createTable(createUserTableSql);

// insert data.
var userData = [
  ["张三", "8"],
  ["李四", "18"],
  ["王五", "28"],
  ["赵六", "38"],
];

var insertUserSql = "insert into t_user(name, age) values(?, ?)";
sqliteDB.insertData(insertUserSql, userData);

// update data.
var updateSql = 'update t_user set age = 36 where age = 38';
sqliteDB.executeSql(updateSql);

// query data.
var querySql = 'select * from t_user where age > 10 and age < 30';
sqliteDB.queryData(querySql, dataDeal);

sqliteDB.close();

function dataDeal(objects) {
  for (var i = 0; i < objects.length;   i) {
    console.log(objects[i]);
  }
  console.log("------------------------------------");
}

结果

{ id: 2, name: ‘李四’, age: 18 } { id: 3, name: ‘王五’, age: 28 }

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创建工程mykoa,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)的方式。

工程mykoa结构如下:

代码语言:javascript复制
mykoa/
|
 - controllers/ <-- Controller
|
 - views/ <-- html模板文件
|
 - static/ <-- 静态资源文件
|
 - controller.js <-- 扫描注册Controller
|
 - app.js <-- 使用koa的js
|
 - package.json <-- 项目描述文件
|
 - node_modules/ <-- npm安装的所有依赖包

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

代码语言:javascript复制
"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

代码语言:javascript复制
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();
};

处理首页 GET /

我们定义一个async函数处理首页URL/

代码语言:javascript复制
var fn_index = async (ctx, next) => {
    ctx.render('index.html', {
        title: 'Welcome'
    });
}

注意到koa并没有在ctx对象上提供render方法,这里我们假设应该这么使用,这样,我们在编写Controller的时候,最后一步调用ctx.render(view, model)就完成了页面输出。

处理登录请求 POST /signin

我们再定义一个async函数处理登录请求/signin

代码语言:javascript复制
let fn_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'
        });
    }
}

module.exports = {
    'GET /': fn_index,
    'POST /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目录下:

代码语言:javascript复制
mykoa/
|
 - static/
   |
    - css/
   |
    - fonts/
   |
    - 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:

代码语言:javascript复制
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里加一行代码:

代码语言:javascript复制
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:

代码语言:javascript复制
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添加如下代码:

代码语言:javascript复制
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

运行

一切顺利的话,这个mykoa工程应该可以顺利运行。运行前,我们再检查一下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:

代码语言:javascript复制
app.use(templating('view', {
    noCache: !isProduction,
    watch: !isProduction
}));

最后一个middleware处理URL路由:

代码语言:javascript复制
const controller = require('./controller');
app.use(controller());

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

0 人点赞