axios 和洋葱模型中间件

  • 前言
  • MiddleWare类实现
  • HttpRequest类实现
  • 使用
  • 总结


前言

前段时间阅读了axios的源码,代码量不多,而且相对vue源码来说要简单很多,其中的设计思想也非常巧妙。阅读完之后,我就实现了一个微信小程序版本的axios。然后我将两者进行对比,得出了如下的一些问题:

  • 请求/响应拦截器,取消请求等功能的实现方式,甚至代码几乎是完全一样的
  • 请求适配器会有所差别,主要原因是因为运行的环境不一样,底层的实现会有所不一样
  • 配置参数冗余。首先axios是支持web端node端的,配置参数包含了web端特有的,node端特有的,还有web端node端通用的。这使得我们在使用的时候需要注意哪些是特有配置参数,哪些是通用配置参数。然后自己实现的微信小程序版本的axios也是如此,因为底层是基于wx.requestwx.downloadFilewx.uploadFile进行封装的,所以也包含了一些通用和特有的配置参数

基于如上的一些问题,我在想能否把一些通用的功能抽取出来,比如请求/响应拦截器功能,这些功能都是跟环境无关的。然后把跟环境相关的,比如请求处理函数,交给外部去实现,然后给到内部去使用。同时还要解决配置参数冗余的问题,解决不同环境下包含了一些多余的配置参数。

基于上面的目的,我第一时间想到的是koa2洋葱模型中间件实现方式,洋葱模型中间件主要解决的问题就是请求/响应拦截器请求/响应数据转换等功能。实现方面使用es6的class类实现,在初始化的时候让开发者自己去定义所需要的配置参数

MiddleWare类实现

我们首先来实现中间件MiddleWare类,这个类比较简单,就是准备一个数组,然后将use方法收集到的中间件存放到数组中,然后还需要提供一个exec方法来执行所有中间件。代码如下:

class MiddleWare {
  static cbs = [];
  static use(cb) {
    this.cbs.push(cb);
    return this;
  }
  cbs = [];
  exec(ctx, next) {
    let times = -1;
    const cbs = [...MiddleWare.cbs, ...this.cbs];
    const dispatch = (pointer = 0) => {
      if (cbs.length < pointer) {
        return Promise.resolve();
      }
      if (pointer <= times) {
        throw new Error("next function only can be called once");
      }
      times = pointer;
      const fn = cbs[pointer] || next;
      return fn(ctx, () => dispatch(++pointer));
    };
    return dispatch();
  }
  use(cb) {
    this.cbs.push(cb);
    return this;
  }
}

从上面,我们可以看见,中间件分为 2 种,一种是全局的中间件,使用MiddleWare.use静态方法进行收集,然后存放在MiddleWare.cbs数组中。另外一种就是实例中间件。exec方法在执行中间件的时候,会将全局的中间件和实例的中间件进行合并,然后执行中间件。所以,全局中间件每个实例都会被执行,实例中间件只有在当前实例中才会被执行。中间件分为全局中间件和实例中间件这是一种很常规的做法,很多第三方库设计都是如此的,比方说vue可以注册全局组件和局部组件。

HttpRequest类实现

接着我们就来实现HttpRequest这个类,这个类主要做的就是参数配置的合并,还有就是调用外部传进来的请求函数。代码如下:

function merge(op1 = {}, op2 = {}) {
  return { ...op1, ...op2 };
}

class HttpRequest extends MiddleWare {
  static config = {};
  constructor(adapter, config = {}) {
    super();
    this.adapter = adapter;
    this.config = merge(PreQuest.config, config);
  }

  request(url, config = {}) {
    const opt = typeof url === "string" ? merge({ url }, config) : path;
    const request = merge(this.config, opt);
    const response = {};
    return this.controller({ request, response });
  }

  async controller(ctx) {
    await this.exec(ctx, async (ctx) => {
      ctx.response = await this.adapter(ctx.request);
    });
    return ctx.response;
  }
}
  • HttpRequest继承了MiddleWare这个类,这使得HttpRequest具备了MiddleWare类的所有功能。
  • 配置参数。配置参数分为三种。第一种就是全局的配置参数,存储在HttpRequest.config中,每个初始化出来的HttpRequest实例都会具备所有全局配置参数;第二种就是实例的配置参数,在初始化的时候,传递进来的配置项跟全局的配置参数进行合并,得到一个新的配置参数,这个新的配置参数就是实例的配置参数;第三个中就是当前请求的配置参数,在发送请求的时候,会将实例的配置参数和请求传递进来的配置参数进行合并,得到一个新的配置参数,这个配置参数就是本次请求的配置参数
  • ctx上下文。ctx上下文包含了requestresponse两个字段。ctx上下文贯穿于所有中间件中,用来实现中间件与中间件之间的数据传递,这个主要是借助了引用数据类型的特征,所有中间件在读取或者修改ctx的内容的时候,实际上修改的都是指向同一地址的数据内容
  • adapter请求函数其实也是作为一个中间件去执行的,只是adapter请求函数是作为最后一个中间件,并不存在next参数

使用

最后,我们看一下基本的使用

// 请求处理函数
function adapter(config) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const { url, params = {} } = config;
    const parts = [];
    Object.keys(params).forEach((key) => {
      parts.push(`${key}=${params[key]}`);
    });
    const requestUrl = `${url}?${parts.join("&")}`;
    xhr.open(config.method.toUpperCase(), requestUrl);
    xhr.send();

    xhr.addEventListener("readystatechange", () => {
      if (xhr.readyState === 4) {
        resolve(xhr.responseText);
      }
    });
    xhr.addEventListener("error", resolve);
  });
}

// 初始化请求实例,用户自定义配置参数
const instance = new HttpRequest(adapter, {
  baseUrl: "https://cnodejs.org/api/v1",
});

// 请求/响应拦截器中间件
instance.use(async function (ctx, next) {
  console.log("请求拦截器");
  await next();
  console.log("响应拦截器");
});

// 请求/响应数据转换中间件
instance.use(async function (ctx, next) {
  const { baseUrl, url } = ctx.request;
  ctx.request.url = `${baseUrl}/${url}`;
  await next();
  ctx.response = JSON.parse(ctx.response);
});

// 发起请求
instance
  .request("/topics", {
    params: { page: 1, limit: 2, mdrender: false },
    method: "get",
  })
  .then((res) => {
    console.log("success", res);
  });

我们从上面可以看出来,请求/响应拦截器请求/响应数据转换等功能实际上是可以通过中间件去实现的,这使得我们可以将对应的功能拆分出来。这样子做的好处有:

  • 实现按需加载的功能。请求/响应数据转换功能并不是所有环境下都需要的,比方web端是需要的,但是微信小程序端,因为wx.request内部已经是经过封装的了,并不需要请求/响应数据转换这个功能
  • HttpRequest类只负责把所有的功能串联起来,并不负责具体的功能实现
  • 实现了通用功能的提取

关于洋葱模型中间件的执行顺序。我们先来看如下的代码:

instance.use(async function middleware1(ctx, next) {
  console.log("1");
  await next();
  console.log("2");
});

instance.use(async function middleware2(ctx, next) {
  console.log("3");
  await next();
  console.log("4");
});

instance.use(async function middleware3(ctx, next) {
  console.log("5");
  await next();
  console.log("6");
});

instance.use(async function middleware4(ctx) {
  console.log("7");
});

执行顺序为1->3->5->7->6->4->2,先按顺序把await next()前面的代码执行完毕,然后再倒序执行await next()后面的代码。实际上可以转化为如下代码:

async function middleware1(ctx) {
  console.log("1");
  await (async function middleware2(ctx) {
    console.log("3");
    await (async function middleware3(ctx) {
      console.log("5");
      await (async function middleware4(ctx) {
        console.log("7");
      })(ctx);
      console.log("6");
    })(ctx);
    console.log("4");
  })(ctx);
  console.log("2");
}

总结

最后。借助了koa2洋葱模型中间件的思想,我们实现了一个精简版的http请求库,这个请求库跟外部环境无关,具体请求逻辑需要有开发者自己去实现。同时一些通用的功能也是用来中间件去实现,实现了可插拔的功能。这样子做就可以抹平了不同平台上面的一些差异性。基于这个与平台无关的http请求库,我们可以使用Monorepo多包架构去实现不同平台下的请求处理函数。这样做一方面可以针对某个单独的平台去实现一些特有的功能,而不是使用if-else去兼容多个平台。另一方面就是实现了按需加载的功能,减少代码体积,各平台只需要引入对应的包即可使用。