通过编写一个路由中间件来学习 Koa

475 阅读11分钟
原文链接: zhuanlan.zhihu.com

混了四年的大学生活结束了,校招没有找到工作的我还面临着失业。没办法,只有临时抱抱佛脚看看能不能找个工作了。据说最近前端圈里不会 NodeJs 是不可能找到工作的,于是抱起了 NodeJs 里比较流行的一个框架 Koa 学了起来。Koa 是由开发 Express 的团队开发的一个极简的 Web 应用框架。极简有多简呢,他的核心就只有一个中间件系统,甚至官方连 Web 应用最关键的路由功能都没有提供。虽然官方没有提供路由功能,但是是有第三方路由中间件可用的。而且不考虑细节的话,自己写一个路由中间件也是极其简单的。本文就介绍了如何实现一个 Koa 路由中间件,虽然不能用于生产环境,但最终成果是完全可以实现路由功能的。

为了简单易懂,本示例仅引入了 koa 依赖,添加路由时也仅能通过正则表达式进行注册。为方便测试,引入了 mochachaisupertest 三个开发时依赖。完整的代码可到 这里 查看。package.json 依赖部分配置如下:

"dependencies": {
    "koa": "^2.5.1"
  },
  "devDependencies": {
    "chai": "^4.1.2",
    "mocha": "^5.2.0",
    "supertest": "^3.1.0"
  }

Koa 官网 的文档分为4个部分:

  • Application 介绍了如何创建 Koa 应用及激活中间件。
  • Context 为传给中间件函数的第一参数,用于中间件之间的数据共享。在本示例中用于传递路径参数。
  • Request 保存了与用户请求相关的一些数据,其对原始 NodeJs 的 Request 对象进行了一定封装,可通过 Context.request 获取该对象。在本示例中用于判断请求地址与请求方法。
  • Response 保存了与用户响应相关的一些数据,对原始的 NodeJs 的 Response 进行了一定的封装,可通过 Context.response 获取该对象。

想详细地了解 Koa 地 API 请自行去官网查看相关部分。

项目初始化

开始前请确保主机中已安装 NodeJs 及 NPM 且 NodeJs 版本大于 8.0,然后通过如下命令初始化项目:

# 新建并进入项目目录
mkdir koa-router-demo
cd koa-router-demo

# 回答相应问题以初始化 npm
npm init

# 添加 koa 依赖
npm install --save koa

# 添加用于测试的开发环境依赖
npm install --dev-dev mocha chai supertest
如项目无需推送到 Npm 官方源,则执行 npm init 时的问题随便回答即可。

Koa 初探

首先看看官网上是如何新建一个 Koa 应用并使用一个中间件的:

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'hello world';
});

app.listen(3000);

上述代码中通过 app.use 函数启用一个中间件,该中间件接收了一个参数 ctx,该参数为一个 Context 对象。 ctx.bodyctx.response.body 的别名,用于向用户响应数据。async 为一个异步函数的声明,与之相对应的还有一个 await 关键字。 这两个关键字配合使用可以使得编写异步代码看起来十分像同步代码,远离了回调函数和Promise嵌套的噩梦。更多介绍请浏览 MDN async function

将上面的代码保存到一个 js 文件并运行,然后再浏览器里访问 http://localhost:3000 就能看到熟悉的 hello world 了:

# 新建一个文件,写入上面的代码,可自行用其他方式新建该文件
vim demo.js

# 运行该脚本
node demo

# 访问 `localhost:3000`, 输出为 `hello world`
curl loaclhost:3000

实际上 Koa 的中间件能接收的参数有两个: ctxnext, next 参数为一个异步函数(调用时须在之前添加 await 关键字,以挂起当前正在运行的中间件),用于转移中间件的运行权:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('Before calling next()');
  ctx.foo = 'hello!';
  await next();
  console.log('After calling next()');
})

app.use(async ctx => {
  console.log('on response');
  console.log(ctx.foo);
  ctx.body = 'hello world';
});

app.listen(3000);

如运行上面代码后在浏览器中请求 localhost:3000,则服务器端将会输出如下信息:

Before calling next()
on response
hello
After calling next()

首先 Koa 会按照中间件的添加顺序以队列的方式运行(先添加先运行)所有中间件中调用 await next() 之前的代码,直到所有中间件的最末端(第一个没有调用 next 函数的中间件)运行完成后,再以栈的方式运行(先添加后运行)所有中间件中调用 await next() 之后的代码。如下图所示,按顺序分别添加 1、2、3 三个中间件。当有用户发起请求时,中间件的执行顺序为:

  1. 开始执行中间件1中的代码
  2. 当执行到 await next() 时,挂起中间件1,开始执行中间件2
  3. 当执行到中间件2中的 await next() 时,挂起中间件2,执行中间件3
  4. 因为中间件3中没有调用 await next(),则识别为最后一个中间件
  5. 中间件3中的代码执行完后,返回到中间件2执行调用 await next() 之后的代码
  6. 中间件2中的代码执行完后,返回到中间件1执行调用 await next() 之后的代码
切记调用 next() 函数时,必须添加 await 关键字。
next() 函数在一个中间件中只能调用一次,多次调用会发生 next() called multiple times 错误

中间件差不多就是整个 Koa 的核心功能了。除了中间件的使用外,还需了解如下信息才能上手 Koa:

  • 同一个请求中所有中间件中的 ctx 参数为同一个对象,因此可以通过该对象实现在多个中间件中传递数据。
  • ctx.request 为一个 Koa Request 对象,该对象保存有一些与用户请求相关的数据,如 Cookie、查询字符串和请求头等。
  • ctx.response 为一个 Koa Response 对象,该对象保存一些与用户响应相关的数据,如 响应头、响应状态、响应数据等。
  • ctx.reqctx.res 分别为原生的 NodeJs Request 与 NodeJs Response 对象。

详细的 API 请查看 Koa 官网

编写 Koa 路由中间件

先试想一下我们要编写的路由中间件的执行流程:

  1. 首先要从用户请求中判断HTTP请求方法(GET/POST/PUT/DELETE)
  2. 再判断所请求的地址
  3. 根据请求的方法与地址查找是否已经注册与之匹配的路由处理器
  4. 如果存在以注册的路由处理器则执行该处理器,否则向用户响应404错误代码
  5. 调用 next() 函数执行后续中间件

程序流程图如下所示:

根据流程图我们可以确定,需要对每一个HTTP方法生成一个 Map 来存储地址与处理器的映射。而我们最终的结果想要像 Express 使用 router.get()router.post() 这样的方式来为不同的HTTP方法注册路由。为了增强程序的扩展性,首先需要定义一个包含 HTTP 方法的数组列表,在项目根目录中新建一个 router.js 文件并写入如下内容:

const methods = ['GET', 'POST', 'PUT', 'DELETE'];

定义了该数组后,我们就可以通过该数组来动态生成不同HTTP方法的 Map 与注册函数。

HTTP 方法远不止这4种,但这里为了简单只定义了这4个,实际中可以通过给 Router 构造函数传递参数的方式来确定有哪些方法。

现在正式开始编写 Router 的代码,先编写 Router 的构造函数:

// ...

class Router {
  constructor() {
    this.routesMap = new Map();
    const rm = this.routesMap;

    // 根据 HTTP 方法列表动态生成 Map 以存储地址与处理器的映射
    methods.map((method) => {
      rm.set(method, new Map());
    });
  }
}
这里使用了 JS 的 class 关键字来定义构造函数并且使用了 ES6 中一种新的数据结构 Map ,详细用法请访问 ES6 classES6 Map

在构造函数中,同过之前定义的 HTTP 方法列表分别为每一种 HTTP 方法生成一个 Map ,并使用方法名作为键存入 routesMap 属性中。

接着编写一个通用的路由注册函数,该函数接收三个参数:

  • method: HTTP 方法名
  • pattern: 路由匹配模式(正则表达式)
  • handler: 路由处理函数

并以路由匹配模式作为键将路由处理函数添加到相应的 Map 里:

// ...

class Router {
  // ...

  register(method, pattern, handler) {
    let routes = this.routesMap.get(method);
    if (!routes) {
      throw new Error('该HTTP方法不受支持!');
    }
    routes.set(pattern, handler);
  }
}

继续为每一个 HTTP 方法生成一个注册函数,在这些函数的内部调用之前编写的 register 函数进行注册:

// ...

class Router {
  // ...
}

// 为每一个 HTTP 方法生成相应的函数
methods.map((method) => {
  Router.prototype[method.toLowerCase()] = function(pattern, handler) {
    this.register(method, pattern, handler)
  }
});

接着编写匹配路由处理函数的函数,该函数接收三个参数:

  • method: HTTP 方法
  • url: 要匹配的 URL
  • ctx: 中间件上下文,用于传递正则表达式捕获块

其中通过 method 来确定在哪一个 Map 中进行查找, url 用于匹配 Map 中的键, ctx 用于传递正则表达式的捕获块以达到路径参数的效果。具体代码如下:

// ...

class Router {
  // ...

  matchHandler(method, url, ctx) {
    let routes = this.routesMap.get(method);

    // 路由映射不存在(没实现该HTTP方法)
    if (!routes) {
      return null;
    }
    for (let [key, value] of routes) {
      let matchs;
      if (matchs = key.exec(url)) {
        // 将匹配到的路径参数添加到`ctx`的`params`属性,以便路由处理函数使用
        ctx.params = matchs.slice(1);
        return value;
      }
    }
    return null;
  }
}

// ...

然后编写返回 Koa 中间件的函数,该函数返回一个异步函数,在返回的函数内部获取到用户请求的 HTTP 方法和 URL 地址,然后调用 matchHandler 函数匹配一个路由处理函数,如匹配到路由处理函数,则执行该执行该函数,否则向用户响应 404 状态码,最后调用 next() 函数继续执行后续中间件:

// ...

class Router {
  // ...

  middleware() {
    const self = this;

    // 返回一个供Koa使用的中间件函数
    return async (ctx, next) => {
      const method = ctx.request.method;
      const url = ctx.request.url;
      const handler = self.matchHandler(method, url, ctx);
      if (handler) {
        await handler(ctx);
      } else {
        // handler对象为空,则说明没有匹配的路由,响应404状态
        ctx.status = 404;
        ctx.body = '404 Not Found!\n';
      }
      // 调用next函数以继续执行其他中间件
      await next();
    }
  }
}
// ...

最后将 Router 构造函数导出,以供其他 NodeJs 模块使用:

// ...

class Router {
  // ...
}

// ...

module.exports = Router;

测试

先来看看如何使用我们刚刚编写的模块。首先通过 require('./router') 导入我们刚刚编写的构造函数, 通过该构造函数生成一个对象后,可以使用类似 Express 的路由使用方式向对象注册路由,然后调用对象中的 middleware() 函数来返回一个可供 Koa 使用的中间件函数。 具体使用如下所示:

const Koa = require('koa');
const Router = require('./router');

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

router.get(/^\/$/, ctx => {
  ctx.body = 'home';
});

router.post(/^\/$/, ctx => {
  ctx.body = 'posted';
});

app.use(router.middleware());
app.listen(3000);

然后就可以愉快地开始编写测试用例了,在项目根目录下新建一个 test.js 文件,并写入如下内容:

const http = require('http');

const Koa = require('koa');
const Router = require('./router');

const request = require('supertest');
const chai = require('chai');
const expect = chai.expect;

describe('测试开始', () => {

  it('测试路由工作是否正常', (done) => {
    const app = new Koa();
    const router = new Router();
    router.get(/^\/$/, async (ctx) => {
      ctx.body = { msg: 'home' };
    });
    app.use(router.middleware());

    request(http.createServer(app.callback()))
      .get('/')
      .expect(200)
      .end((err, res) => {
        if (err) done(err);
        expect(res.body.msg).to.equal('home');
        done();
      });
  });

  it('测试GET方法请求是否正常', (done) => {
    const app = new Koa();
    const router = new Router();
    router.get(/^\/hello$/, async (ctx) => {
      ctx.body = { msg: 'hello' };
    });
    app.use(router.middleware());

    request(http.createServer(app.callback()))
      .get('/hello')
      .expect(200)
      .end((err, res) => {
        if (err) done(err);
        expect(res.body.msg).to.equal('hello');
        done();
      });
  });

  it('测试POST方法请求是否正常', (done) => {
    const app = new Koa();
    const router = new Router();
    router.post(/^\/hello$/, async (ctx) => {
      ctx.body = { msg: 'hello' };
    });
    app.use(router.middleware());

    request(http.createServer(app.callback()))
      .post('/hello')
      .expect(200)
      .end((err, res) => {
        if(err) done(err);
        expect(res.body.msg).to.equal('hello');
        done();
      });
  });

  it('测试路径参数是否传递', (done) => {
    const app = new Koa();
    const router = new Router();
    router.get(/^\/param\/(.+)/, async (ctx) => {
      ctx.body = { msg: ctx.params[0] };
    });
    app.use(router.middleware());

    request(http.createServer(app.callback()))
      .get('/param/hello')
      .expect(200)
      .end((err, res) => {
        if (err) done(err);
        expect(res.body.msg).to.equal('hello');
        done();
      });
  });

  it('测试请求未定义的路由', (done) => {
    const app = new Koa();
    const router = new Router();
    app.use(router.middleware());

    request(http.createServer(app.callback()))
      .get('/404')
      .expect(404)
      .end((err) => {
        if (err) done(err);
        done();
      });
  });

});
完整地测试用例请查看 这里

其中 supertest 模块用于模拟请求, chai 模块用于断言。保存好该文件后,在终端运行一下命令开始测试:

mocha test.js

# 如果正确无误,将会输出如下内容:
#
# 测试开始
#    √ 测试路由工作是否正常 (68ms)
#    √ 测试GET方法请求是否正常
#    √ 测试POST方法请求是否正常
#    √ 测试路径参数是否传递
#    √ 测试请求未定义的路由
#
#  5 passing (164ms)

总结

本文简单描述了如何编写一个具有基本路由功能的 Koa 路由中间件,并了解了 Koa 的简单使用。 Koa 使用起来确实是极其地简单,核心只提供能了一个中间件系统,但可以通过使用大量官方和第三方的中间件来扩展其功能。

最终结果虽然能运行,但还是存在大量性能和功能上的不足。比如一个路由只能匹配一个处理方法从而不能对部分请求进行拦截、只能通过正则表达式注册路由、没有子路由功能等等。所以本程序仅供演示,请不要用于生产环境,在实际项目中,还是推荐使用用户量大的第三方库。

浏览完整代码