【nodejs】手写简易版 koa 及常用中间件

什么是koa

概述

  • Koa 是⼀个新的 web 框架, 致⼒于成为 web 应⽤和 API 开发领域中的⼀个更⼩、更富有 表现⼒、更健壮的基⽯。
  • koaExpress 的下⼀代基于 Node.jsweb 框架 。
  • koa2 完全使⽤ Promise 并配合 async 来实现异步。

特点

  • 轻量,⽆捆绑
  • 中间件架构
  • 优雅的 API 设计
  • 增强的错误处理

安装

npm i koa -S
复制代码

koa的简单使用

// 引入 koa
const Koa = require("koa")

// 创建 koa 实例
const app = new Koa()

// 计算请求耗时
app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  const end = Date.now()
  console.log(`??~ 请求${ctx.url}耗时${parseInt(end - start)}ms`);// ??~ 请求/耗时1014ms
})

// 请求
app.use(async (ctx, next) => {
  // 模拟一些耗时的操作
  await imitateDelay(1000);
  ctx.body = {
    name: 'warbler'
  }
})

// 启动服务 监听3000端口
app.listen(3000, () => {
  console.log('??~ 3000:', 3000);
})
复制代码

手写一个简易版koa

⼀个基于 nodejs 的⼊⻔级 http 服务,类似下⾯代码。

const http = require("http")
const fs = require("fs")
const server = http.createServer((req, res) => {
  res.writeHead(200)
  res.end("hello, koa!")
})
server.listen(3000, () => {
  console.log('??~ sever at 3000');
})
复制代码

koa 的⽬标是⽤更简单化、流程化、模块化的⽅式实现回调部分的业务逻辑。

image.png

接下来我们从头实现一个 MyKoa

第一步 实现use和listen方法

首先我们要实现 koauselisten 方法,我们这样使用 MyKoa

// 引入 MyKoa
const MyKoa = require('./mykoa')

// 创建 MyKoa 实例
const app = new MyKoa()

// 调用 use 方法
app.use((req, res) => {
  res.writeHead(200)
  res.end("hello, MyKoa!")
})

// 调用 listen 方法
app.listen(3000, () => {
  console.log('??~ sever at 3000');
})
复制代码

MyKoa 源码如下。

// 引入原生 http 模块
const http = require("http")

// 声明 MyKoa 类
class MyKoa {
  // 实现 listen 方法
  listen(...args) {
    // 调用原生 http.createServer 创建服务
    const server = http.createServer((req, res) => {
      // 调用 callback 实现业务代码
      this.callback(req, res)
    })
    // 调用原生 server.listen 监听端口
    server.listen(...args)
  }
  // 实现 use 方法
  use(callback) {
    // 真正的业务逻辑代码通过 use 方法保存到 this.callback 中。
    this.callback = callback
  }
}

module.exports = MyKoa
复制代码

⽬前为⽌,MyKoa 只是个⻢甲,要真正实现⽬标还需要引⼊ 上下⽂context) 和 中间件机制middleware

第二步 构建上下文(context)

koa 为了能够简化 API,引⼊上下⽂ context 概念,将原始请求对象 req 和响应对象 res 封装并挂载到 context 上,并且在 context 上设置 gettersetter,从⽽简化操作。

一个描述 gettersetter 的小栗子,我们可以通过访问 person.name , 实际访问 person.info.name

const person = {
  info: {
    name: '一尾流莺'
  },
  get name() {
    return this.info.name
  },
  set name(val) {
    this.info.name = val
  }
}
console.log('??~person.name :', person.name);// ??~person.name : 一尾流莺
person.name = "warbler"
console.log('??~person.name :', person.name);// ??~person.name : warbler
复制代码

下面来简单实现一下 MyKoa 的上下文。

// request.js
module.exports = {
  get url() {
    return this.req.url;
  },

  get method() {
    return this.req.method.toLowerCase()
  }
};
复制代码
// response.js
module.exports = {
  get body() {
    return this._body;
  },
  set body(val) {
    this._body = val;
  }
};
复制代码
// context.js
module.exports = {
  get url() {
    return this.request.url;
  },
  get body() {
    return this.response.body;
  },
  set body(val) {
    this.response.body = val;
  },
  get method() {
    return this.request.method
  }
};
复制代码
// myKoa.js
const http = require("http")
const context = require("./context");
const request = require("./request");
const response = require("./response");
class MyKoa {
  listen(...args) {
    const server = http.createServer((req, res) => {
      // 创建上下⽂
      let ctx = this.createContext(req, res);
      this.callback(ctx)
      // 响应
      res.end(ctx.body);
    })
    server.listen(...args)
  }
  use(callback) {
    this.callback = callback
  }
  // 构建上下⽂, 把res和req都挂载到ctx之上,并且在ctx.req和ctx.request.req同时保存
  createContext(req, res) {
    const ctx = Object.create(context);
    ctx.request = Object.create(request);
    ctx.response = Object.create(response);
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
  }
}

module.exports = MyKoa
复制代码

然后就我们就可以像 koa 一样去使用上下文了,当我们访问 ctx.url 的时候,实际上就是在访问ctx.request.req.url ,同理,当我们访问 ctx.body 的时候,就是在访问 ctx.response.body 了。

// index.js
const MyKoa = require('./myKoa')

const app = new MyKoa()

app.use(ctx => {
  ctx.body = 'welcome to MyKoa'
})

app.listen(3000, () => {
  console.log('??~ sever at 3000 ~~~');
})
复制代码

第三步 中间件机制

先来看一下什么是 函数组合 ,就是将⼀组需要顺序执⾏的函数复合为⼀个函数,外层函数的参数实际是内层函数的返回值。

// 计算 x + y
const add = (x, y) => x + y
// 计算 z 的 平方
const square = z => z * z
// 计算 m - 1
const cutOne = (m) => m - 1

// 函数组合 先计算 z = x + y ,再计算 z 的平方
const fn = (x, y) => square(add(x, y))

console.log(fn(1, 2)); //=> 9
复制代码

我们简单的封装一下,结果是一样的。

// 函数组合封装 2 个函数组合
const compose = (fn1, fn2) => (...args) => fn2(fn1(...args))

const fn2 = compose(add, square)

console.log(fn2(1, 2)); //=> 9
复制代码

但是只能适用于两个函数的组合,继续封装一下,这样就满足将任意数量的函数组合起来了。

// 函数组合封装  多个函数组合
const composeMore = (...[first, ...other]) => (...args) => {
  let ret = first(...args)
  other.forEach((fn) => {
    ret = fn(ret)
  })
  return ret
}

const fn3 = composeMore(add, square, cutOne, cutOne, cutOne)

console.log(fn3(1, 2)); //=> 6
复制代码

Koa 中间件机制就是函数式组合概念 Compose 的概念,洋葱圈模型可以形象表示这种机制,是源码中的精髓和难点。

6351c242aaae48a7afa41b894d2789fd_tplv-k3u1fbpfcp-watermark.webp

洋葱圈模型和普通的函数式组合不太一样,普通的是执行完一个函数,再执行下一个函数。洋葱圈模型是执行完一个函数的一半,就执行下一个函数,以此类推直到执行完最后一个函数,返回来再执行前一个函数没有执行完的一半。

// 函数组合
function compose(middleWares) {
  return function() {
    return dispatch(0)
    function dispatch(i) {
      let fn = middleWares[i]
      // 为了支持异步方法,所以需要返回Promise.resolve()
      if (!fn) {
        return Promise.resolve()
      }
      return Promise.resolve(
        fn(function next() {
          return dispatch(i + 1)
        })
      )
    }
  }
}

async function fn1(next) {
  console.log("fn1 begin");
  await next();
  console.log("fn1 end");
}
async function fn2(next) {
  console.log("fn2 begin");
  await delay();
  await next();
  console.log("fn2 end");
}
function fn3(next) {
  console.log("fn3");
}

// 模拟异步方法
function delay() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, 2000);
  });
}

const middleWares = [fn1, fn2, fn3];
const finalFn = compose(middleWares);
finalFn();
复制代码

最终执行的结果是

fn1 begin
fn2 begin 
// 2000ms 后
fn3
fn2 end
fn1 end
复制代码

接下里应用在 MyKoa 中,修改代码如下。

// myKoa.js
class MyKoa {
  constructor() {
    //  存放中间件
    this.middleWares = []
  }

  listen(...args) {
    // 创建原生服务
    const server = http.createServer(async (req, res) => {
      // 创建上下⽂
      let ctx = this.createContext(req, res);
      // 合成函数,形成洋葱圈
      const fn = this.compose(this.middleWares)
      await fn(ctx)
      // 响应
      res.end(ctx.body);
    })
    server.listen(...args)
  }

  use(middleWare) {
    // 把中间件函数存起来
    this.middleWares.push(middleWare)
  }

  // 构建上下⽂, 把res和req都挂载到ctx之上,并且在ctx.req和ctx.request.req同时保存
  createContext(req, res) {
    const ctx = Object.create(context);
    ctx.request = Object.create(request);
    ctx.response = Object.create(response);
    ctx.req = ctx.request.req = req;
    ctx.res = ctx.response.res = res;
    return ctx;
  }

  // 合成函数
  compose(middleWares) {
    return function(ctx) {
      return dispatch(0)
      function dispatch(i) {
        let fn = middleWares[i]
        if (!fn) {
          return Promise.resolve()
        }
        return Promise.resolve(
          // 在这里传入上下文 ctx
          fn(ctx, function next() {
            return dispatch(i + 1)
          })
        )
      }
    }
  }
}
复制代码
// index.js
const MyKoa = require('./myKoa')

// 创建 MyKoa 实例 
const app = new MyKoa()

// 模拟异步操作
const delay = () => new Promise(resolve => setTimeout(() => resolve(), 2000));

app.use(async (ctx, next) => {
  ctx.body = "1";
  await next();
  ctx.body += "5";
});
app.use(async (ctx, next) => {
  ctx.body += "2";
  await delay();
  await next();
  ctx.body += "4";
});
app.use(async (ctx, next) => {
  ctx.body += "3";
});

app.listen(3000, () => {
  console.log('??~ sever at 3000 ~~~');
})
复制代码

第四步 常用中间件

koa 中间件的规范

  • 是⼀个 async 函数
  • 接收 ctxnext 两个参数
  • 任务结束需要执⾏ next
const middleWare = async (ctx, next) => {
  // 来到中间件,洋葱圈左边
  next() // 进⼊其他中间件
  // 再次来到中间件,洋葱圈右边
};
复制代码

中间件常⻅任务

  • 请求拦截
  • 路由
  • ⽇志
  • 静态⽂件服务

路由中间件

路由其实就是对策略模式的一个实现,免去了大量的 if...else

// router.js
class Router {
  constructor() {
    // 策略库
    this.stack = []
  }

  /**
   * 注册策略到策略库中
   * @param {*} path 请求路径
   * @param {*} method 请求方法
   * @param {*} middleWare 中间件
   */
  register(path, method, middleWare) {
    let route = { path, method, middleWare }
    this.stack.push(route)
  }

  // 注册 get 请求
  get(path, middleWare) {
    this.register(path, 'get', middleWare)
  }

  // 注册 post 请求
  post(path, middleWare) {
    this.register(path, 'post', middleWare)
  }

  // 路由中间件
  routes() {
    let _stack = this.stack
    // 返回的是一个中间件
    return async function(ctx, next) {
      // 获取到上下文中的 url
      let currentPath = ctx.url
      // 声明一个策略
      let route
      // 根据上下文中的 method 查找对应的策略
      for (let i = 0; i < _stack.length; i++) {
        const item = _stack[i];
        if (currentPath === item.path && item.method === ctx.method) {
          route = item.middleWare
          break
        }
      }
      // 如果取出的策略是一个函数,执行这个函数
      if (typeof route === 'function') {
        route(ctx, next)
        return
      }
      // 进入下一个中间件
      await next()
    }
  }
}

module.exports = Router
复制代码
// index.js
const MyKoa = require('./myKoa')
const Router = require('./router')

const app = new MyKoa()
const router = new Router();
router.get('/index', async ctx => {
  ctx.body = 'index page';
});
router.get('/post', async ctx => { ctx.body = 'post page'; });
router.get('/list', async ctx => { ctx.body = 'list page'; });
router.post('/index', async ctx => { ctx.body = 'post page'; });


// 路由实例输出⽗中间件 router.routes()
app.use(router.routes());

app.listen(3000, () => {
  console.log('??~ sever at 3000 ~~~');
})
复制代码

静态⽂件服务中间件

处理静态文件的请求。

  • 配置绝对资源⽬录地址,默认为 static
  • 获取⽂件或者⽬录信息
  • 静态⽂件读取
  • 返回
const fs = require("fs");
const path = require("path");
module.exports = (dirPath = "./public") => {
  return async (ctx, next) => {
    if (ctx.url.indexOf("/public") === 0) {
      // public开头 读取⽂件
      const url = path.resolve(__dirname, dirPath);
      const fileBaseName = path.basename(url);
      const filepath = url + ctx.url.replace("/public", "");
      console.log(filepath);
      // console.log(ctx.url,url, filepath, fileBaseName)
      try {
        stats = fs.statSync(filepath);
        if (stats.isDirectory()) {
          const dir = fs.readdirSync(filepath);
          // const
          const ret = ['<div style="padding-left:20px">'];
          dir.forEach(filename => {
            console.log(filename);
            // 简单认为不带⼩数点的格式,就是⽂件夹,实际应该⽤statSync
            if (filename.indexOf(".") > -1) {
              ret.push(
                `<p><a style="color:black" href="https://juejin.cn/post/${ctx.url
                }/${filename}">${filename}</a></p>`
              );
            } else {
              // ⽂件
              ret.push(
                `<p><a href="https://juejin.cn/post/${ctx.url}/${filename}">${filename}</a></p>`
              );
            }
          });
          ret.push("</div>");
          ctx.body = ret.join("");
        } else {
          console.log("⽂件");
          const content = fs.readFileSync(filepath);
          ctx.body = content;
        }
      } catch (e) {
        // 报错了 ⽂件不存在
        ctx.body = "404, not found";
      }
    } else {
      // 否则不是静态资源,直接去下⼀个中间件
      await next();
    }
  };
};
复制代码

请求拦截中间件

请求拦截应⽤⾮常⼴泛:登录状态验证、CORS 头设置,⿊名单等。

本次实现一个⿊名单中存在的 ip 将被拒绝访问的功能。

module.exports = async function(ctx, next) {
  const { res, req } = ctx;
  const blackList = ['127.0.0.1'];
  const ip = getClientIP(req);

  if (blackList.includes(ip)) {//出现在⿊名单中将被拒绝
    ctx.body = "not allowed";
  } else {
    await next();
  }
};

function getClientIP(req) {
  return (
    req.headers["x-forwarded-for"] || // 判断是否有反向代理 IP
    req.connection.remoteAddress || // 判断 connection 的远程 IP
    req.socket.remoteAddress || // 判断后端的 socket 的 IP
    req.connection.socket.remoteAddress
  );
}
复制代码

BodyParser中间件

const middleWare = async (ctx, next) => {
  console.log('??~ body-parser');
  const req = ctx.request.req
  let reqData = [];
  let size = 0;
  await new Promise((resolve, reject) => {
    req.on('data', data => {
      console.log('??~ req on', data);
      reqData.push(data);
      size += data.length
    })
    req.on('end', () => {
      console.log('??~ end');
      const data = Buffer.concat(reqData, size)
      console.log('??~ data:', size, data.toString());
      ctx.request.body = data.toString()
      resolve();
    })
  })
  await next()
};
复制代码

参考资料

开课吧全栈架构师课程

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享