经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » JS/JS库/框架 » Node.js » 查看文章
如何从头实现一个node.js的koa框架
来源:jb51  时间:2019/6/17 10:56:23  对本文有异议

前言

koa.js是最流行的node.js后端框架之一,有很多网站都使用koa进行开发,同时社区也涌现出了一大批基于koa封装的企业级框架。然而,在这些亮眼的成绩背后,作为核心引擎的koa代码库本身,却非常的精简,不得不让人惊叹于其巧妙的设计。

在平时的工作开发中,笔者是koa的重度用户,因此对其背后的原理自然也是非常感兴趣,因此在闲暇之余进行了研究。不过本篇文章,并不是源码分析,而是从相反的角度,向大家展示如何从头开发实现一个koa框架,在这个过程中,koa中最重要的几个概念和原理都会得到展现。相信大家在看完本文之后,会对koa有一个更深入的理解,同时在阅读本文之后再去阅读koa源码,思路也将非常的顺畅。

首先放出笔者实现的这个koa框架代码库地址:simpleKoa

需要说明的是,本文实现的koa是koa 2版本,也就是基于async/await的,因此需要node版本在7.6以上。如果读者的node版本较低,建议升级,或者安装babel-cli,利用其中的babel-node来运行例子。

四条主线

笔者认为,理解koa,主要需要搞懂四条主线,其实也是实现koa的四个步骤,分别是

  • 封装node http Server
  • 构造resquest, response, context对象
  • 中间件机制
  • 错误处理

下面就一一进行分析。

主线一:封装node http Server: 从hello world说起

首先,不考虑框架,如果使用原生http模块来实现一个返回hello world的后端app,代码如下:

  1. let http = require('http');
  2. let server = http.createServer((req, res) => {
  3. res.writeHead(200);
  4. res.end('hello world');
  5. });
  6. server.listen(3000, () => {
  7. console.log('listenning on 3000');
  8. });

实现koa的第一步,就是对这个原生的过程进行封装,为此,我们首先创建application.js实现一个Application对象:

  1. // application.js
  2. let http = require('http');
  3. class Application {
  4. /**
  5. * 构造函数
  6. */
  7. constructor() {
  8. this.callbackFunc;
  9. }
  10. /**
  11. * 开启http server并传入callback
  12. */
  13. listen(...args) {
  14. let server = http.createServer(this.callback());
  15. server.listen(...args);
  16. }
  17. /**
  18. * 挂载回调函数
  19. * @param {Function} fn 回调处理函数
  20. */
  21. use(fn) {
  22. this.callbackFunc = fn;
  23. }
  24. /**
  25. * 获取http server所需的callback函数
  26. * @return {Function} fn
  27. */
  28. callback() {
  29. return (req, res) => {
  30. this.callbackFunc(req, res);
  31. };
  32. }
  33. }
  34. module.exports = Application;

然后创建example.js:

  1. let simpleKoa = require('./application');
  2. let app = new simpleKoa();
  3. app.use((req, res) => {
  4. res.writeHead(200);
  5. res.end('hello world');
  6. });
  7. app.listen(3000, () => {
  8. console.log('listening on 3000');
  9. });

可以看到,我们已经初步完成了对于http server的封装,主要实现了app.use注册回调函数,app.listen语法糖开启server并传入回调函数了,典型的koa风格。

但是美中不足的是,我们传入的回调函数,参数依然使用的是req和res,也就是node原生的request和response对象,这些原生对象和api提供的方法不够便捷,不符合一个框架需要提供的易用性。因此,我们需要进入第二条主线了。

主线二:构造request, response, context对象

如果阅读koa文档,会发现koa有三个重要的对象,分别是request, response, context。其中request是对node原生的request的封装,response是对node原生response对象的封装,context对象则是回调函数上下文对象,挂载了koa request和response对象。下面我们一一来说明。

首先要明确的是,对于koa的request和response对象,只是提供了对node原生request和response对象的一些方法的封装,明确了这一点,我们的思路是,使用js的getter和setter属性,基于node的对象req/res对象封装koa的request/response对象。

规划一下我们要封装哪些易用的方法。这里在文章中为了易懂,姑且只实现以下方法:

对于simpleKoa request对象,实现query读取方法,能够读取到url中的参数,返回一个对象。

对于simpleKoa response对象,实现status读写方法,分别是读取和设置http response的状态码,以及body方法,用于构造返回信息。

而simpleKoa context对象,则挂载了request和response对象,并对一些常用方法进行了代理。

首先创建request.js:

  1. // request.js
  2. let url = require('url');
  3. module.exports = {
  4. get query() {
  5. return url.parse(this.req.url, true).query;
  6. }
  7. };

很简单,就是导出了一个对象,其中包含了一个query的读取方法,通过url.parse方法解析url中的参数,并以对象的形式返回。需要注意的是,代码中的this.req代表的是node的原生request对象,this.req.url就是node原生request中获取url的方法。稍后我们修改application.js的时候,会为koa的request对象挂载这个req。

然后创建response.js:

  1. // response.js
  2. module.exports = {
  3. get body() {
  4. return this._body;
  5. },
  6. /**
  7. * 设置返回给客户端的body内容
  8. *
  9. * @param {mixed} data body内容
  10. */
  11. set body(data) {
  12. this._body = data;
  13. },
  14. get status() {
  15. return this.res.statusCode;
  16. },
  17. /**
  18. * 设置返回给客户端的stausCode
  19. *
  20. * @param {number} statusCode 状态码
  21. */
  22. set status(statusCode) {
  23. if (typeof statusCode !== 'number') {
  24. throw new Error('statusCode must be a number!');
  25. }
  26. this.res.statusCode = statusCode;
  27. }
  28. };

也很简单。status读写方法分别设置或读取this.res.statusCode。同样的,这个this.res是挂载的node原生response对象。而body读写方法分别设置、读取一个名为this._body的属性。这里设置body的时候并没有直接调用this.res.end来返回信息,这是考虑到koa当中我们可能会多次调用response的body方法覆盖性设置数据。真正的返回消息操作会在application.js中存在。

然后我们创建context.js文件,构造context对象的原型:

  1. // context.js
  2. module.exports = {
  3. get query() {
  4. return this.request.query;
  5. },
  6. get body() {
  7. return this.response.body;
  8. },
  9. set body(data) {
  10. this.response.body = data;
  11. },
  12. get status() {
  13. return this.response.status;
  14. },
  15. set status(statusCode) {
  16. this.response.status = statusCode;
  17. }
  18. };

可以看到主要是做一些常用方法的代理,通过context.query直接代理了context.request.query,context.body和context.status代理了context.response.body与context.response.status。而context.request,context.response则会在application.js中挂载。

由于context对象定义比较简单并且规范,当实现更多代理方法时候,这样一个一个通过声明的方式显然有点笨,js中,设置setter/getter,可以通过对象的__defineSetter__和__defineSetter__来实现。为此,我们精简了上面的context.js实现方法,精简版本如下:

  1. let proto = {};
  2. // 为proto名为property的属性设置setter
  3. function delegateSet(property, name) {
  4. proto.__defineSetter__(name, function (val) {
  5. this[property][name] = val;
  6. });
  7. }
  8. // 为proto名为property的属性设置getter
  9. function delegateGet(property, name) {
  10. proto.__defineGetter__(name, function () {
  11. return this[property][name];
  12. });
  13. }
  14. // 定义request中要代理的setter和getter
  15. let requestSet = [];
  16. let requestGet = ['query'];
  17. // 定义response中要代理的setter和getter
  18. let responseSet = ['body', 'status'];
  19. let responseGet = responseSet;
  20. requestSet.forEach(ele => {
  21. delegateSet('request', ele);
  22. });
  23. requestGet.forEach(ele => {
  24. delegateGet('request', ele);
  25. });
  26. responseSet.forEach(ele => {
  27. delegateSet('response', ele);
  28. });
  29. responseGet.forEach(ele => {
  30. delegateGet('response', ele);
  31. });
  32. module.exports = proto;

这样,当我们希望代理更多request和response方法的时候,可以直接向requestGet/requestSet/responseGet/responseSet数组中添加method的名称即可(前提是在request和response中实现了)。

最后让我们来修改application.js,基于刚才的3个对象原型来创建request, response, context对象:

  1. // application.js
  2. let http = require('http');
  3. let context = require('./context');
  4. let request = require('./request');
  5. let response = require('./response');
  6. class Application {
  7. /**
  8. * 构造函数
  9. */
  10. constructor() {
  11. this.callbackFunc;
  12. this.context = context;
  13. this.request = request;
  14. this.response = response;
  15. }
  16. /**
  17. * 开启http server并传入callback
  18. */
  19. listen(...args) {
  20. let server = http.createServer(this.callback());
  21. server.listen(...args);
  22. }
  23. /**
  24. * 挂载回调函数
  25. * @param {Function} fn 回调处理函数
  26. */
  27. use(fn) {
  28. this.callbackFunc = fn;
  29. }
  30. /**
  31. * 获取http server所需的callback函数
  32. * @return {Function} fn
  33. */
  34. callback() {
  35. return (req, res) => {
  36. let ctx = this.createContext(req, res);
  37. let respond = () => this.responseBody(ctx);
  38. this.callbackFunc(ctx).then(respond);
  39. };
  40. }
  41. /**
  42. * 构造ctx
  43. * @param {Object} req node req实例
  44. * @param {Object} res node res实例
  45. * @return {Object} ctx实例
  46. */
  47. createContext(req, res) {
  48. // 针对每个请求,都要创建ctx对象
  49. let ctx = Object.create(this.context);
  50. ctx.request = Object.create(this.request);
  51. ctx.response = Object.create(this.response);
  52. ctx.req = ctx.request.req = req;
  53. ctx.res = ctx.response.res = res;
  54. return ctx;
  55. }
  56. /**
  57. * 对客户端消息进行回复
  58. * @param {Object} ctx ctx实例
  59. */
  60. responseBody(ctx) {
  61. let content = ctx.body;
  62. if (typeof content === 'string') {
  63. ctx.res.end(content);
  64. }
  65. else if (typeof content === 'object') {
  66. ctx.res.end(JSON.stringify(content));
  67. }
  68. }
  69. }

可以看到,最主要的是增加了createContext方法,基于我们之前创建的context 为原型,使用Object.create(this.context)方法创建了ctx,并同样通过Object.create(this.request)和Object.create(this.response)创建了request/response对象并挂在到了ctx对象上面。此外,还将原生node的req/res对象挂载到了ctx.request.req/ctx.req和ctx.response.res/ctx.res对象上。

回过头去看我们之前的context/request/response.js文件,就能知道当时使用的this.res或者this.response之类的是从哪里来的了,原来是在这个createContext方法中挂载到了对应的实例上。一张图来说明其中的关系:

构建了运行时上下文ctx之后,我们的app.use回调函数参数就都基于ctx了。

下面一张图描述了ctx对象的结构和继承关系:

最后回忆我们的ctx.body方法,并没有直接返回消息体,而是将消息存储在了一个变量属性中。为了每次回调函数处理结束之后返回消息,我们创建了responseBody方法,主要作用就是通过ctx.body读取存储的消息,然后调用ctx.res.end返回消息并关闭连接。从方法中知道,我们的body消息体可以是字符串,也可以是对象(会序列化为字符串返回)。注意这个方法的调用是在回调函数结束之后调用的,而我们的回调函数是一个async函数,其执行结束后会返回一个Promise对象,因此我们只需要在其后通过.then方法调用我们的responseBody即可,这就是this.callbackFunc(ctx).then(respond)的意义。

然后我们来测试一下目前为止的框架。修改example.js如下:

  1. let simpleKoa = require('./application');
  2. let app = new simpleKoa();
  3. app.use(async ctx => {
  4. ctx.body = 'hello ' + ctx.query.name;
  5. });
  6. app.listen(3000, () => {
  7. console.log('listening on 3000');
  8. });

可以看到这个时候我们通过app.use传入的已经不再是原生的function (req, res)回调函数,而是koa2中的async函数,接收ctx作为参数。为了测试,在浏览器访问localhost:3000?name=tom,可以看到返回了'hello tom',符合预期。

这里再插入分析一个知识概念。从刚才的实现中,我们知道了this.context是我们的中间件中上下文ctx对象的原型。因此在实际开发中,我们可以将一些常用的方法挂载到this.context上面,这样,在中间件ctx中,我们也可以方便的使用这些方法了,这个概念就叫做ctx的扩展,一个例子是阿里的egg.js框架已经把这个扩展机制作为一部分,融入到了框架开发中。

下面就展示一个例子,我们写一个echoData的方法作为扩展,传入errno, data, errmsg,能够给客户端返回结构化的消息结果:

  1. let SimpleKoa = require('./application');
  2. let app = new SimpleKoa();
  3. // 对ctx进行扩展
  4. app.context.echoData = function (errno = 0, data = null, errmsg = '') {
  5. this.res.setHeader('Content-Type', 'application/json;charset=utf-8');
  6. this.body = {
  7. errno: errno,
  8. data: data,
  9. errmsg: errmsg
  10. };
  11. };
  12. app.use(async ctx => {
  13. let data = {
  14. name: 'tom',
  15. age: 16,
  16. sex: 'male'
  17. }
  18. // 这里使用扩展,方便的返回utf-8格式编码,带有errno和errmsg的消息体
  19. ctx.echoData(0, data, 'success');
  20. });
  21. app.listen(3000, () => {
  22. console.log('listenning on 3000');
  23. });

主线三:中间件机制

到目前为止,我们成功封装了http server,并构造了context, request, response对象。但最重要的一条主线却还没有实现,那就是koa的中间件机制。

关于koa的中间件洋葱执行模型,koa 1中使用的是generator + co.js执行的方式,koa 2中则使用了async/await。关于koa 1中的中间件原理,我曾写过一篇文章进行解释,请移步:深入探析koa之中间件流程控制篇

这里我们实现的是基于koa 2的,因此再描述一下原理。为了便于理解,假设我们有3个async函数:

  1. async function m1(next) {
  2. console.log('m1');
  3. await next();
  4. }
  5. async function m2(next) {
  6. console.log('m2');
  7. await next();
  8. }
  9. async function m3() {
  10. console.log('m3');
  11. }

我们希望能够构造出一个函数,实现的效果是让三个函数依次执行。首先考虑想让m2执行完毕后,await next()去执行m3函数,那么显然,需要构造一个next函数,作用是调用m3,然后作为参数传给m2

  1. let next1 = async function () {
  2. await m3();
  3. }
  4. m2(next1);
  5. // 输出:m2,m3

进一步,考虑从m1开始执行,那么,m1的next参数需要是一个执行m2的函数,并且给m2传入的参数是m3,下面来模拟:

  1. let next1 = async function () {
  2. await m3();
  3. }
  4. let next2 = async function () {
  5. await m2(next1);
  6. }
  7. m1(next2);
  8. // 输出:m1,m2,m3

那么对于n个async函数,希望他们按顺序依次执行呢?可以看到,产生nextn的过程能够抽象为一个函数:

  1. function createNext(middleware, oldNext) {
  2. return async function () {
  3. await middleware(oldNext);
  4. }
  5. }
  6. let next1 = createNext(m3, null);
  7. let next2 = createNext(m2, next1);
  8. let next3 = createNext(m1, next2);
  9. next3();
  10. // 输出m1, m2, m3

进一步精简:

  1. let middlewares = [m1, m2, m3];
  2. let len = middlewares.length;
  3. // 最后一个中间件的next设置为一个立即resolve的promise函数
  4. let next = async function () {
  5. return Promise.resolve();
  6. }
  7. for (let i = len - 1; i >= 0; i--) {
  8. next = createNext(middlewares[i], next);
  9. }
  10. next();
  11. // 输出m1, m2, m3

至此,我们也有了koa中间件机制实现的思路,新的application.js如下:

  1. /**
  2. * @file simpleKoa application对象
  3. */
  4. let http = require('http');
  5. let context = require('./context');
  6. let request = require('./request');
  7. let response = require('.//response');
  8. class Application {
  9. /**
  10. * 构造函数
  11. */
  12. constructor() {
  13. this.middlewares = [];
  14. this.context = context;
  15. this.request = request;
  16. this.response = response;
  17. }
  18. // ...省略中间
  19. /**
  20. * 中间件挂载
  21. * @param {Function} middleware 中间件函数
  22. */
  23. use(middleware) {
  24. this.middlewares.push(middleware);
  25. }
  26. /**
  27. * 中间件合并方法,将中间件数组合并为一个中间件
  28. * @return {Function}
  29. */
  30. compose() {
  31. // 将middlewares合并为一个函数,该函数接收一个ctx对象
  32. return async ctx => {
  33. function createNext(middleware, oldNext) {
  34. return async () => {
  35. await middleware(ctx, oldNext);
  36. }
  37. }
  38. let len = this.middlewares.length;
  39. let next = async () => {
  40. return Promise.resolve();
  41. };
  42. for (let i = len - 1; i >= 0; i--) {
  43. let currentMiddleware = this.middlewares[i];
  44. next = createNext(currentMiddleware, next);
  45. }
  46. await next();
  47. };
  48. }
  49. /**
  50. * 获取http server所需的callback函数
  51. * @return {Function} fn
  52. */
  53. callback() {
  54. return (req, res) => {
  55. let ctx = this.createContext(req, res);
  56. let respond = () => this.responseBody(ctx);
  57. let fn = this.compose();
  58. return fn(ctx).then(respond);
  59. };
  60. }
  61. // ...省略后面
  62. }
  63. module.exports = Application;

可以看到,首先对app.use进行改造了,每次调用app.use,就向this.middlewares中push一个回调函数。然后增加了一个compose()方法,利用我们前文分析的原理,对middlewares数组中的函数进行组装,返回一个最终的函数。最后,在callback()方法中,调用compose()得到最终回调函数,并执行。

改写example.js验证一下中间件机制:

  1. let simpleKoa = require('./application');
  2. let app = new simpleKoa();
  3. let responseData = {};
  4. app.use(async (ctx, next) => {
  5. responseData.name = 'tom';
  6. await next();
  7. ctx.body = responseData;
  8. });
  9. app.use(async (ctx, next) => {
  10. responseData.age = 16;
  11. await next();
  12. });
  13. app.use(async ctx => {
  14. responseData.sex = 'male';
  15. });
  16. app.listen(3000, () => {
  17. console.log('listening on 3000');
  18. });
  19. // 返回{ name: "tom", age: 16, sex: "male"}

例子中一共三个中间件,分别对responseData增加了name, age, sex属性,最后返回该数据。

至此,一个koa框架基本已经浮出水面了,不过我们还需要进行最后一个主线的分析:错误处理。

主线四:错误处理

一个健壮的框架,必须保证在发生错误的时候,能够捕获错误并有降级方案返回给客户端。但显然现在我们的框架还做不到这一点,假设我们修改一下例子,我们的中间件中,有一个发生错误抛出了异常:

  1. let simpleKoa = require('./application');
  2. let app = new simpleKoa();
  3. let responseData = {};
  4. app.use(async (ctx, next) => {
  5. responseData.name = 'tom';
  6. await next();
  7. ctx.body = responseData;
  8. });
  9. app.use(async (ctx, next) => {
  10. responseData.age = 16;
  11. await next();
  12. });
  13. app.use(async ctx => {
  14. responseData.sex = 'male';
  15. // 这里发生了错误,抛出了异常
  16. throw new Error('oooops');
  17. });
  18. app.listen(3000, () => {
  19. console.log('listening on 3000');
  20. });

这个时候访问浏览器,是得不到任何响应的,这是因为异常并没有被我们的框架捕获并进行降级处理。回顾我们application.js中的中间件执行代码:

  1. // application.js
  2. // ...
  3. callback() {
  4. return (req, res) => {
  5. let ctx = this.createContext(req, res);
  6. let respond = () => this.responseBody(ctx);
  7. let fn = this.compose();
  8. return fn(ctx).then(respond);
  9. };
  10. }
  11. // ...

其中我们知道,fn是一个async函数,执行后返回一个promise,回想promise的错误处理是怎样的?没错,我们只需要定义一个onerror函数,里面进行错误发生时候的降级处理,然后在promise的catch方法中引用这个函数即可。

于此同时,回顾koa框架,我们知道在错误发生的时候,app对象可以通过app.on('error', callback)订阅错误事件,这有助于我们几种处理错误,比如打印日志之类的操作。为此,我们也要对Application对象进行改造,让其继承nodejs中的events对象,然后在onerror方法中emit错误事件。改造后的application.js如下:

  1. /**
  2. * @file simpleKoa application对象
  3. */
  4. let EventEmitter = require('events');
  5. let http = require('http');
  6. let context = require('./context');
  7. let request = require('./request');
  8. let response = require('./response');
  9. class Application extends EventEmitter {
  10. /**
  11. * 构造函数
  12. */
  13. constructor() {
  14. super();
  15. this.middlewares = [];
  16. this.context = context;
  17. this.request = request;
  18. this.response = response;
  19. }
  20. // ...
  21. /**
  22. * 获取http server所需的callback函数
  23. * @return {Function} fn
  24. */
  25. callback() {
  26. return (req, res) => {
  27. let ctx = this.createContext(req, res);
  28. let respond = () => this.responseBody(ctx);
  29. let onerror = (err) => this.onerror(err, ctx);
  30. let fn = this.compose();
  31. // 在这里catch异常,调用onerror方法处理异常
  32. return fn(ctx).then(respond).catch(onerror);
  33. };
  34. }
  35. // ...
  36. /**
  37. * 错误处理
  38. * @param {Object} err Error对象
  39. * @param {Object} ctx ctx实例
  40. */
  41. onerror(err, ctx) {
  42. if (err.code === 'ENOENT') {
  43. ctx.status = 404;
  44. }
  45. else {
  46. ctx.status = 500;
  47. }
  48. let msg = err.message || 'Internal error';
  49. ctx.res.end(msg);
  50. // 触发error事件
  51. this.emit('error', err);
  52. }
  53. }
  54. module.exports = Application;

可以看到,onerror方法的对异常的处理主要是获取异常状态码,当err.code为'ENOENT'的时候,返回的消息头设置为404,否则默认设置为500,然后消息体设置为err.message,如果异常中message属性为空,则默认消息体设置为'Internal error'。此后调用ctx.res.end返回消息,这样就能保证即使异常情况下,客户端也能收到返回值。最后通过this.emit出发error事件。

然后我们写一个example来验证错误处理:

  1. let simpleKoa = require('./application');
  2. let app = new simpleKoa();
  3. app.use(async ctx => {
  4. throw new Error('ooops');
  5. });
  6. app.on('error', (err) => {
  7. console.log(err.stack);
  8. });
  9. app.listen(3000, () => {
  10. console.log('listening on 3000');
  11. });

浏览器访问'localhost:3000'的时候,得到返回'ooops',同时http状态码为500 。同时app.on('error')订阅到了异常事件,在回调函数中打印出了错误栈信息。

关于错误处理,这里多说一点。虽然koa中内置了错误处理机制,但是实际业务开发中,我们往往希望能够自定义错误处理方式,这个时候,比较好的办法是在最开头增加一个错误捕获中间件,然后根据错误进行定制化的处理,比如:

  1. // 错误处理中间件
  2. app.use(async (ctx, next) => {
  3. try {
  4. await next();
  5. }
  6. catch (err) {
  7. // 在这里进行定制化的错误处理
  8. }
  9. });
  10. // ...其他中间件

至此,我们就完整实现了一个轻量版的koa框架。

结语

完整的simpleKoa代码库地址为:simpleKoa,里面还附带了一些example。

理解了这个轻量版koa的实现原理,读者还可以去看看koa的源码,会发现机制和我们实现的框架是非常类似的,无非是多了一些细节,比如说,完整koa的context/request/response方法上面挂载了更多好用的method,或者很多方法中容错处理更好等等。具体在本文中就不展开讲了,留给感兴趣的读者去探索吧~。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持w3xue。

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号