koa2 处理请求

314 阅读12分钟

这里我们来学习下怎么使用 koa 处理请求,这里默认所有包自行安装(node 包大家都会安装吧..)

项目初始化

- KOA
	- form.html 
	- server.js  // 入口文件

处理请求

我想访问 /form,就给用户展示一个表单页面,用户填写后提交表单,我们需要去解析用户的参数。

form.html
<!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>Document</title>
</head>
<body>
    <form action="/login" method="POST">
        <input type="text" name="username">
        <input type="text" name="password">
        <button type="submit">提交</button>
    </form>
</body>
</html>

server.js
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const fs = require('fs');


app.use(async (ctx, next) => {
  if (ctx.path === '/form' && ctx.method == 'GET') {
    // 如果不设置 html 的请求头,就变成文件下载啦
    ctx.set('Content-type', 'text/html; utf-8'); 
    ctx.body = fs.createReadStream(path.resolve(__dirname, 'form.html'));
  } else {
    next();
  }
});

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    let arr = [];

    // 数据是分段传过来的,这里接收到的是一个个的 buffer
    ctx.req.on('data', function(chunk) {
      arr.push(chunk);
    });

    ctx.req.on('end', function() {
      // 表单默认格式 key=val&key=val
      console.log(Buffer.concat(arr).toString()); // username=111&password=222
      ctx.body = Buffer.concat(arr);
    });
  } else {
    next();
  }
});

app.listen(3000, function() {
  console.log(`server start 3000 `)
})


注意,此时我们访问 http://localhost:3000/form 页面能出来,提交表单也会发送请求给 /login,但是提交后页面却展示 Not Found(表单提交会导致页面跳转),我们 ctx.body 明明有值,为什么却返回 Not Found 呢。

记住 koa 会把我们的中间件组合成一个大 promise,第一个中间件代表的 promise 等待其他中间件执行完成时返回结果给客户端,当我们第二个请求来的时候,会依次执行中间件,第一个中间件直接进了 next(),而第二个匹配上的处理逻辑中,有 on('data', cb) 的存在,这是个异步操作,中间件的执行不会等待它,而直接完成,这时候取值 ctx.body = undefined(默认值),所以页面显示 Not Found。

为了等待效果,我们要把后面的整个处理逻辑保装成一个 promise,并在合适的时机完成它。

server.js
// 改写第二个中间件 包成一个 promsie
// 为了严谨,我们给所有的 next() 方法前加 await (当然这个栗子中不加 await 也没事)
app.use(async (ctx, next) => {
  if (ctx.path === '/form' && ctx.method == 'GET') {
    // 如果不设置 html 的请求头,就变成文件下载啦
    ctx.set('Content-type', 'text/html; utf-8'); 
    ctx.body = fs.createReadStream(path.resolve(__dirname, 'form.html'));
  } else {
    await next();
  }
});

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await new Promise((resolve, reject) => {
      let arr = [];

      // 数据是分段传过来的,这里接收到的是一个个的 buffer
      ctx.req.on('data', function(chunk) {
        arr.push(chunk);
      });
  
      ctx.req.on('end', function() {
        // 表单默认格式 key=val&key=val
        console.log(Buffer.concat(arr).toString()); // username=111&password=222
        resolve(Buffer.concat(arr));
      });
    });
  } else {
    await next();
  }
});

此时提交,页面就能展示响应的内容啦

全局中间件给 ctx 添加属性

这时候,如果我再写一个接口,比如注册,难道这中间件 2 这一大坨要再来一遍么,所以我们考虑把它封装一下。

server.js
// 拆分中间件 2,提取公共函数 bodyparser
function bodyparser (ctx){
  return new Promise((resolve, reject) => {
    let arr = [];

    // 数据是分段传过来的,这里接收到的是一个个的 buffer
    ctx.req.on('data', function(chunk) {
      arr.push(chunk);
    });

    ctx.req.on('end', function() {
      // 表单默认格式 key=val&key=val
      console.log(Buffer.concat(arr).toString()); // username=111&password=222
      resolve(Buffer.concat(arr).toString());
    });
  });
}

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await bodyparser(ctx);
  } else {
    await next();
  }
});

这样看起来好像简单了点,但是如果我把中间件按功能抽离呢,比如 login.js,home.js 等,那我岂不是每个文件都需要导入 bodyparser 方法啦,有没有更好的方法呢。

当然有了,我们知道中间件会依次执行,那么我们如果前面的中间件把函数挂载到 ctx 上,岂不是后续每个中间件都能直接使用?

server.js
// 中间件的作用,可以给 koa 中的属性扩展功能和方法,可以做一些鉴权相关
app.use(async (ctx, next) => {
  ctx.request.body = await new Promise((resolve, reject) => {
    let arr = [];

    // 数据是分段传过来的,这里接收到的是一个个的 buffer
    ctx.req.on('data', function(chunk) {
      arr.push(chunk);
    });

    ctx.req.on('end', function() {
      // 表单默认格式 key=val&key=val
      console.log(Buffer.concat(arr).toString()); // username=111&password=222
      resolve(Buffer.concat(arr).toString());
    });
  });

  await next();
});

// 中间件 1 略..

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await ctx.request.body;
  } else {
    await next();
  }
});

我们把公共中间件内的函数提出来

server.js
function bodyparser() {
  return async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
      let arr = [];
  
      // 数据是分段传过来的,这里接收到的是一个个的 buffer
      ctx.req.on('data', function(chunk) {
        arr.push(chunk);
      });
  
      ctx.req.on('end', function() {
        // 表单默认格式 key=val&key=val
        console.log(Buffer.concat(arr).toString()); // username=111&password=222
        resolve(Buffer.concat(arr).toString());
      });
    });
  
    await next();
  }
}

// 中间件的作用,可以给 koa 中的属性扩展功能和方法,可以做一些鉴权相关
app.use(bodyparser());

// 中间件 1 略..

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await ctx.request.body;
  } else {
    await next();
  }
});

新建 KOA/middlewares 中间件目录,添加 bodyParse.js

- KOA
	- middlewares
		- bodyParse.js 
	- form.html 
	- server.js  // 入口文件
bodyparser.js
function bodyparser() {
  return async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
      let arr = [];
  
      // 数据是分段传过来的,这里接收到的是一个个的 buffer
      ctx.req.on('data', function(chunk) {
        arr.push(chunk);
      });
  
      ctx.req.on('end', function() {
        resolve(Buffer.concat(arr).toString());
      });
    });
  
    await next();
  }
}

module.exports = bodyparser;

server.js 引用中间件

server.js
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const fs = require('fs');
const bodyparser = require('./middlewares/bodyParse');

// 中间件的作用,可以给 koa 中的属性扩展功能和方法,可以做一些鉴权相关
app.use(bodyparser());

app.use(async (ctx, next) => {
  if (ctx.path === '/form' && ctx.method == 'GET') {
    // 如果不设置 html 的请求头,就变成文件下载啦
    ctx.set('Content-type', 'text/html; utf-8'); 
    ctx.body = fs.createReadStream(path.resolve(__dirname, 'form.html'));
  } else {
    await next();
  }
});

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await ctx.request.body;
  } else {
    await next();
  }
});

app.listen(3000, function() {
  console.log(`server start 3000 `)
})

第三方包 koa-bodyparser

当然,如上的中间件 bodyparser 是我们自己写的,实际开发中这个中间件已经有人实现了。

// serve.js

// const bodyparser = require('./middlewares/bodyparser');
const bodyparser = require('koa-bodyparser');

app.use(bodyparser());

// 中间件 1 略...

app.use(async (ctx, next) => {
  if (ctx.path === '/login' && ctx.method == 'POST') {
    ctx.body = await ctx.request.body;
  } else {
    await next();
  }
});

这样,就能完成之前的需求,而且原先输出到页面的表单提交内容也被格式化。

`username=xxx&password=xxx`  ---->  {"username":"xxx","password":"xxx"}

我们看到,body-parser 把表单数据格式化为 json 对象,让我们来完善下我们的 bodyparser 吧。

完善我们的 bodyparser

querystring 用法

node 内置了 querystring 模块,它的用法很简单

const querystring = require("querystring");

querystring(`username=xxx&password=xxx`); 
// {"username":"xxx","password":"xxx"}

// 传入字段分隔符 和 key value分隔符
querystring(`username==111&&&password=222`, `&&&`, `==`); 
// { username: '111', password: '222' }

解析 json & 表单 & 文本的请求类型

json 类型对应的 Content-type 为 application/json 表单类型对应的 Content-type 为 application/x-www-form-urlencoded 文本类型对应的 Content-type 为 text/plain

bodyparser.js
const querystring = require("querystring");

function bodyparser() {
  return async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
      let arr = [];
  
      // 数据是分段传过来的,这里接收到的是一个个的 buffer
      ctx.req.on('data', function(chunk) {
        arr.push(chunk);
      });
  
      ctx.req.on('end', function() {
        // 用户提交一般有四种格式
        //    - json 格式
        //    - 表单格式
        //    - 文本格式
        //    - 文件格式
        let type = ctx.get('Content-Type'); // 请求格式
        let body = Buffer.concat(arr); // 响应内容

        if (type === 'application/x-www-form-urlencoded') { 
          // 表单格式转换为 json 返回
          resolve(querystring.parse(body.toString()))
        } else if (type.startsWith('text/plain')) { // 文本
          // 文本格式的请求,返回字符串即可
          resolve(body.toString());
        } else if (type.startsWith('application/json')) {
          // json 格式的请求 返回 json 对象
          resolve(JSON.parse(body.toString()));
        } else {
          // 没匹配到 不处理 返回空对象
          resolve({})
        }
      });
    });
  
    await next();
  }
}

module.exports = bodyparser;

form.html
<!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>Document</title>
</head>
<body>
    <form action="/login" method="POST">
        <input type="text" name="username">
        <input type="text" name="password">
        <button type="submit">提交</button>
    </form>
    <script>
      // 发送一个 json 类型的请求
      fetch('/login', {
        method: 'post',
        headers: {
          'Content-type': 'application/json' 
        },
        body: JSON.stringify({
          a: 1, 
          b: 2
        })
      }).then(res => res.json()).then(data => {
        console.log(data)
      })
    </script>
</body>
</html>

解析文件类型(formData)

- KOA
	- upload
		- index.html
		- xxxxxxx   // 用户上传的文件默认写到这里
	- middlewares
		- bodyParse.js 
	- form.html 
	- server.js  // 入口文件

文件类型对应的 content-type 为 multipart/form-data

我们在页面增加一个文件上传控件,并在服务端打印

form.html
<!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>Document</title>
</head>
<body>
    <form action="/login" method="POST" enctype="multipart/form-data">
        <input type="text" name="username">
        <input type="text" name="password">
        <input type="file" name="avatar">
        <button type="submit">提交</button>
    </form>
</body>
</html>

bodyparser 中我们打印下 body

bodyparser.js
function bodyparser() {
  return async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
      // ...
      ctx.req.on('end', function() {
        // 用户提交一般有四种格式
        //    - json 格式
        //    - 表单格式
        //    - 文本格式
        //    - 文件格式
        let type = ctx.get('Content-Type'); // 请求格式
        let body = Buffer.concat(arr); // 响应内容

        if (type === 'application/x-www-form-urlencoded') { 
          // 表单格式转换为 json 返回
          resolve(querystring.parse(body.toString()))
        } else if (type.startsWith('text/plain')) { // 文本
          // 文本格式的请求,返回字符串即可
          resolve(body.toString());
        } else if (type.startsWith('application/json')) {
          // json 格式的请求 返回 json 对象
          resolve(JSON.parse(body.toString()));
        } else {
          console.log(body.toString(), '文件上传 formData');
          // 没匹配到 不处理 返回空对象
          resolve({})
        }
      });
    });
  
    await next();
  }
}

此刻我们上传文件,提交表单(笔者上传的文件名为 aaa.txt,文件内容为 "加油"),输出如下。

所以我们要先拿到请求头中的分隔符,然后通过分割符拆分几个字段。

修改 bodyparser.js

  1. 匹配文件类型请求(multipart/form-data),根据分隔符拆分解析 formData 请求体
  2. 拿到文件内容,写入到文件夹下(我们这里默认 upload,用户可以定制),由于可能同名文件上传,需要给生成的文件增加加 uuid 标识。
  3. 返回文件路径给前端
bodyparser.js
const querystring = require("querystring");
const uuid = require('uuid')
const path = require('path')
const fs = require('fs')

// 给 Buffer 扩展 split 方法
// Buffer 有 indexOf 方法
Buffer.prototype.split = function(sep){
  let arr = [];
  // 分隔符的长度,每次截取后,偏移此长度 
  let len = Buffer.from(sep).length; 
  let offset = 0;
  while (-1 != (index = this.indexOf(sep,offset))) {
      arr.push(this.slice(offset,index));
      offset = index + len
  }
  // 最后一段也要 push 进去
  arr.push(this.slice(offset))

  return arr;
}

function bodyparser({dir} = {}) {
  return async (ctx, next) => {
    ctx.request.body = await new Promise((resolve, reject) => {
      let arr = [];

      // 数据是分段传过来的,这里接收到的是一个个的 buffer
      ctx.req.on('data', function (chunk) {
        arr.push(chunk);
      });

      ctx.req.on('end', function () {
        // 用户提交一般有四种格式
        //    - json 格式
        //    - 表单格式
        //    - 文本格式
        //    - 文件格式
        let type = ctx.get('Content-Type'); // 请求格式
        let body = Buffer.concat(arr); // 响应内容

        if (type === 'application/x-www-form-urlencoded') {
          // 表单格式转换为 json 返回
          resolve(querystring.parse(body.toString()))
        } else if (type.startsWith('text/plain')) { // 文本
          // 文本格式的请求,返回字符串即可
          resolve(body.toString());
        } else if (type.startsWith('application/json')) {
          // json 格式的请求 返回 json 对象
          resolve(JSON.parse(body.toString()));
        } else if (type.startsWith('multipart/form-data')) {
          let boundary = '--' + type.split('=')[1];
          // buffer 没有split,需要手动实现
          let lines = body.split(boundary).slice(1, -1); // 根据分隔符分割
          let formData = {};

          lines.forEach(line => {
            // 规范中定义的分割出来的 key 和 value之间有换行回车
            let [head, body] = line.split('\r\n\r\n'); 
            head = head.toString();
            let key = head.match(/name="(.+?)"/)[1] // 属性名
            if (head.includes('filename')) {
              // 文件需要放到服务器上
              // 文件内容 整体内容减去前面内容
              let content = line.slice(head.length + 4, -2);
              dir = dir || path.join(__dirname, 'upload'); // 文件上传目录,用户可以手动定制
              let filePath = uuid.v4(); // 产生一个唯一的文件名
              let uploadUrl = path.join(dir, filePath);
              console.log(uploadUrl, 'ssssssssss');
              // 图片的后缀可以自己识别
              formData[key] = {
                filename: uploadUrl,
                size: content.length,
              }
              fs.writeFileSync(uploadUrl, content)
            } else {
              let value = body.toString();
              // 值和下面内容还有个换行回车 也要干掉
              formData[key] = value.slice(0, -2); 
            }
          })
          resolve(formData);
        } else {
          // 没匹配到 不处理 返回空对象
          resolve({})
        }
      });
    });

    await next();
  }
}

module.exports = bodyparser;

用户手动定制内容:

server.js
// 用户可以手动定制内容
app.use(bodyparser({ dir: path.resolve(__dirname,'upload') }));

此刻我们上传文件,就会在 upload 目录中写入一个文件,比如我上传了 123.png,写入 upload 中一个 9f22b13f-c78e-4606-842d-5183b5cc03b0 文件。 创建 upload/index.html

<!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>Document</title>
</head>
<body>
  <img src="./9f22b13f-c78e-4606-842d-5183b5cc03b0">
</body>
</html>

就能展示图片了。

ajax 上传图片文件

上面的方式是通过 form 表单来做文件上传的,这不是我们常用的方式,我们可以通过 xhr 来做这件事。

form.html
<!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>Document</title>
</head>

<body>
  <form>
    <input type="file" name="avatar" onchange="selectChange(event)">
  </form>
  <script>
    function selectChange(e) {
      let fd = new FormData();
      fd.append('avatar', e.target.files[0]);

      let xhr = new XMLHttpRequest();
      xhr.open('POST', '/login', true);
      xhr.addEventListener('load', function () {

      });
      xhr.send(fd)
    }
  </script>
</body>
</html>

可以看到,浏览器会自动识别我们上传的文件,给我们添上请求头。

multipart/form-data; boundary=----WebKitFormBoundarycYENbZ54iQ5KRTpz

koa-static 静态服务的使用和实现

此时我们对 /form 做了处理,如果访问 /form 我们会返回一个表单的页面,那如果我想访问另一个文件,比如 /server.js,就需要再来一套这样的代码了,这个很明显就是静态服务的功能,koa 里面提供了 koa-static 的包。

const static = require('koa-static');

// 可以声明多个静态文件
app.use(static(__dirname));
app.use(static(path.resolve(__dirname, 'middlewares')));

此时访问 /server.js 就能展示 js 内代码了,而且我访问 /bodyparser.js,也可以直接拿到,说明当前文件夹和 middlewares 文件夹都变成了一个静态目录。

让我们来手动实现它,新建 middlewares/static.js

  1. 如果基于静态目录的当前访问路径是文件夹,则默认返回文件夹内部的 index.html,否则返回该文件
  2. 如果文件不存在,则 next 往下走,去匹配其他中间件,如果匹配不到,返回 Not Found
static.js
const path = require('path')
const fs = require('fs').promises;

function static(staticPath) {
  // 这个 staticPath 在每个闭包中缓存
  return async(ctx, next) => {
    try {
      let filePath = path.join(staticPath, ctx.path);
      let statobj = await fs.stat(filePath); // 文件不存在则会报错哦

      if (statobj.isDirectory()) {
        // 如果访问的是文件夹 默认返回 index.html
        filePath = path.join(filePath, 'index.html');
      }

      ctx.body = await fs.readFile(filePath, 'utf-8');
    } catch(e) { 
      return next();
    }
  }
}

module.exports = static;

使用方式不变哦

const static = require('./middlewares/static');

// 可以声明多个静态文件
app.use(static(__dirname));
app.use(static(path.resolve(__dirname, 'middlewares')));

现在我们就可以通过 http://localhost:3000/form.html 访问 form 表单页面啦。

koa-router路由使用与实现

我们在中间件内使用路径去匹配处理逻辑,这样的路由书写方式很 low,也不利于扩展,如果我们能按以下方式书写则会舒服很多.

app.post('/login', async() => {});

我们可以借助 koa-router 来实现这种效果,用法如下:

const Router = require('koa-router');
const router = new Router();

router.get('/hello', async (ctx, next) => {
  console.log('hello 1');
  ctx.body = 111;

  await next();
});

router.get('/hello', async (ctx, next) => {
  ctx.body = 222;
  console.log('hello 2');
});

// 请注意路由中间件和静态目录中间件的顺序,决定着先匹配文件还是先匹配路由
app.use(router.routes());

访问 localhost:3000/hello,输出

// node 控制台打印如下
hello 1
hello 2

// 页面输出如下
222

让我们来手动实现它,新建 middlewares/router.js

  1. Router 原型链挂载 get,post 方法(这里只实现了 get,post),调用该方法时,会收集请求的路径和方式、callback 并记录到 layer 对象中,该对象提供匹配路由功能。
  2. Router 提供 routes 方法,该方法返回一个中间件,内部会筛选已被记录的符合条件的请求,通过 compose 方法控制依次执行,通过返回 promise 对象来决定执行顺序。
  3. 全部执行完毕后,调用 next 方法,执行权从路由中间件移交给其他中间件。
router.js
// 辅助类
class Layer {
  constructor(path, method, callback) {
    this.path = path;
    this.method = method;
    this.callback = callback;
  }

  // 根据请求路径和请求方式匹配是否为当前路由
  match(path, method) {
    return this.path == path && this.method == method.toLowerCase()
  }
}

class Router {
  constructor() {
    this.stack = [];
  }

  // 组合,满足条件的路由函数依次执行
  compose(layers, ctx, next) {
    let dispatch = (i) => {
      // 执行完毕后,继续往下执行,下面可能有全局的中间件,比如 all
      if (i == layers.length) return next();
      let callback = layers[i].callback;
      return Promise.resolve(callback(ctx, () => dispatch(i + 1)))
    }
    return dispatch(0);
  }

  // 因为 routes() 可以被 use,所以它必然返回一个中间件
  routes() {
    return async (ctx, next) => { // app.routes()
      let path = ctx.path;
      let method = ctx.method;
      
      // 筛选符合条件的请求
      let layers = this.stack.filter(layer => layer.match(path, method));

      this.compose(layers, ctx, next);
    }
  }
};

// 收集 get,post 请求
['get', 'post'].forEach(method => [
  Router.prototype[method] = function (path, callback) {
    let layer = new Layer(path, method, callback);
    this.stack.push(layer);
  }
])

module.exports = Router;

使用方式如下:

const Router = require('./middlewares/router');
const router = new Router();

router.get('/hello', async (ctx, next) => {
  console.log('hello 1');
  ctx.body = 111;

  await next();
});

router.get('/hello', async (ctx, next) => {
  ctx.body = 222;
  console.log('hello 2');
});

// 请注意路由中间件和静态目录中间件的顺序,决定着先匹配文件还是先匹配路由
app.use(router.routes());

// 全局兜底
app.use(async (ctx, next) => {
  console.log('all');
  await next();
})

请求 localhost:/hello,输出

// node 控制台输出如下:
hello1
hello2
all

// 页面输出如下:
222