通过洋葱模型编写一个灵活的Fetch库

1,309 阅读12分钟

背景

在很多项目里,都会自己封装一个自定义的fetch库,用于实现请求之外,实现一些业务特有的登录态校验。我们公司也一样,之前通过硬编码将get/post请求进行封装,然后一把梭哈一股脑子把构造请求体/请求URL、登录态校验的代码都撸进去,如果新建另一个项目的话,就再把整个文件拷贝过去,然后再硬编码强改。

其实这样子在简单的业务下是没有问题,但是存在两个比较严重的问题:

  1. 多人合作

    如果这个项目是多人合作的项目,当别人遇到了一个需求,想通过改造fetch库下手的话,那么很可能他会在原有的这段硬编码长代码里进行深耕,直接导致代码无法直视。

  2. 不够灵活

    每个项目都有自己一套独立的登录态校验逻辑,如果跨了项目的话,就会造成对整个文件的修改,因为所有代码都揉在一起,所以很容易出现意想不到的bug。

    同时,如果在A项目里一套逻辑,突然到了B项目里,新增了一个灰度发布的逻辑,就会导致很多硬编码修改。那么如果C项目来了,我是要拿哪个项目的fetch库进行参考?A项目比较简洁,但是B项目比较新,很可能写了代码没更新到A项目,不过万一C项目不需要灰度发布的逻辑怎么办?是不是还得一段段函数进行检查,然后进行注释或者删改?想想都头疼

洋葱模型

最近写了一个Node中间件,觉得Koa2的中间件洋葱模型很灵活,于是把整个洋葱模型搬过来,将fetch库进行升级。我们把对应的业务需求通过中间件的形式进行实现,这样子在项目迁移的时候,只需要判断我是否需要对应的中间件即可,实现了特定业务需求和请求库的解耦,更加灵活。

而且通过洋葱模型,我们可以把新的功能通过中间件进行扩展,每次遇到特殊的业务需求,不需要对旧代码进行多次的硬编码改造。这也符合软件设计的开闭原则:对扩展开放,对修改关闭。

Koa2洋葱模型

洋葱模型不是koa2特有的中间件机制,redux也有类似的实现。但是在koa2的实现里,特别容易让人理解。下面我们来看看代码:

const compose = (middleware) => {
  // 参数检验
  return function(context, next) {
    // last called middleware #
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      let fn = middleware[i];
      // 最后一个中间件的调用
      if (i === middleware.length) fn = next;
      if (!fn) return Promise.resolve();
      // 用Promise包裹中间件,方便await调用
      try {
        const result = fn(context, function next() {
          return dispatch(i + 1);
        });
        return Promise.resolve(result);
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
};

该模型会遍历中间件数组,然后生成每个中间件对应的fn(context, next)的函数,重点是这个函数的入参,它包含了全局上下文和next函数。我们通过全局上下文可以在不同中间件里传递参数,以及读取外部传入的参数;我们在中间件内部获取的next函数,顾名思义就是可以跳转至下一个中间件。

try {
      const result = fn(context, function next() {
        return dispatch(i + 1);
      });
      return Promise.resolve(result);
    } catch (err) {
      return Promise.reject(err);
}

通过上面那段代码,可以大概了解到洋葱模型会接收一个中间件数组,然后遍历这些中间件,生成一个深度嵌套的执行链很长的函数。它会先逐个执行中间件数组,如果在中间件里执行到next函数,直到最后一个中间件时,再执行传入的next方法。实际运行的代码可以看下面的示例:

function middleware1(ctx, next) {
    console.log("先执行代码1")
	// 此时会进入下一个中间件,在这里直接调next,对应了源码的dispatch(i + 1)方法,但是实际代码对应到你的下一个中间件函数,即middleware2(ctx, next)
    await next()
    console.log("执行结束1")
}
function middleware2(ctx, next) {
	// 从上一个中间件进来
    console.log("先执行代码2")
    next()
    console.log("执行结束2")
}
// 实际代码
function middleware1(ctx, next) {
    console.log("先执行代码")
    // next() -> 因为dispatch(i + 1)在执行时,实际执行的代码是fn(context, next),自动会写入对应的入参,即middleware2(ctx, next)
    middleware2(ctx, next)
    console.log("执行结束")
}

相当于我们写了一段深度嵌套执行的代码,然后将每个函数独立出来作为中间件,那么我们就可以通过next函数订制业务流程,根据业务需求决定我们是要进行拦截处理还是对结果进行加工;同时,还能在中间件里进行切面编程,在一些特殊的业务里,比如统计一个请求耗时时间,或者需要回调下一个中间件的处理结果等场景下很有用。

Fetch请求里的洋葱模型

我们来分析下具体的业务需求,当发起一个Fetch请求时,需要哪些步骤?在只考虑最简单的业务里,至少有三个:构造请求参数、发起请求前的登录态校验、接收响应后的登录态校验。第一个我们可以理解;第二个的话是可能第一次打开或者以及注销之后,在发起请求前希望能校验下登录态,然后对请求进行排队,登陆校验后再发起对应的网络请求。特别是在App里嵌套的webview时,通过jsBridge获取token时,这种情况就特别常见;第三个的话就是常见的页面放置太久导致的登录态超时,然后触发用户进行登录态校验。

我们可以根据实际情况,分成三个中间件,分别是构造请求参数的中间件、发起请求前的登录态校验中间件以及接收响应后的登录态校验中间件。那么我们整个请求库的结构就类似如下:

-fetch
	-index // 主入口文件
	-buildMiddleware // 构建请求参数的中间件
	-validateMiddleware // 登录态的校验中间件
	-utils // 包含工具函数,比如compose函数

入口文件

那么,我们的入口文件就可以改造成如下:

// index.js
import { validateRequest, validateResponse } from "./validateMiddleware";
import buildMiddleware from "./buildMiddleware";
import compose from "./utils";
// 中间件数组
const middlewares = [buildMiddleware, validateRequest, validateResponse];
// 通过组合中间件,生成新的request方法
const composeRequest = compose(middlewares);

const request = (...config) => {
  const [url, params, options] = config;
  const result = composeRequest({ url, params, options }, ({ url, params }) => {
    return window.fetch(url, params).then((res) => {
      return res.json();
    });
  });
  return result;
};
// 导出get方法
export const get = (...config) => {
  const [url, params, options] = config;
  return request(url, { params, method: "GET" }, options);
};
// 导出post方法
export const post = (...config) => {
  const [url, params, options] = config;
  return request(url, { params, method: "POST" }, options);
};
// 对于contentType为json的post请求,单独导出一个方法
export const postJson = (...config) => {
  const [url, params, options] = config;
  return request(url, { params, method: "POST", contentType: "json" }, options);
};

总体来说index.js这个入口文件还是比较好写的,接收中间件数组,组装成composeRequest方法,然后导出对应的get/post方法

中间件

buildMiddleware-构建请求参数中间件
// buildMiddleware.js
import qs from "qs";
import storage from "../storage";

const baseURL = `/api`;

const buildPostParams = ({
  contentType = "form",
  headers = {},
  params = {},
}) => {
  const formCb = {
    headers: {
      ...headers,
      "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
    },
    params: {
      body: qs.stringify(params),
    },
  };
  const formDataCb = {
    headers: {
      ...headers,
      "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
    },
    params: {
      body: Object.keys(params).reduce((total, cur) => {
        total.append(cur, params[cur]);
        return total;
      }, new FormData()),
    },
  };
  const jsonCb = {
    headers: {
      ...headers,
      "content-type": "application/json",
    },
    params: {
      body: JSON.stringify(params),
    },
  };
  const cbMap = { form: formCb, formData: formDataCb, json: jsonCb };
  return cbMap[contentType];
};

const isEmpty = (params) => {
  if (!params) return true;
  return !Object.keys(params).length;
};

const build = async (ctx, next) => {
  const { method, ...restConfig } = ctx.params;
  const token = storage.getToken();
  if (method === "POST") {
    const { headers = {}, params = {} } = buildPostParams(restConfig);
    headers["token"] = token;
    ctx.params = { method, headers, ...params };
    ctx.url = `${baseURL}${ctx.url}`;
  }
  if (method === "GET") {
    const { params } = ctx.params;
    ctx.params.headers = {
      ...ctx.params.headers,
      token,
    };
    ctx.url = `${baseURL}${ctx.url}${
      isEmpty(params) ? "" : "?" + qs.stringify(params)
    }`;
  }
  console.log(ctx.params, ctx.url, "building params");
  return await next();
};

export default build;

buildMiddleware非常简单,通过判断传进来的Method方法,根据GET和POST不同,分别调用不同的逻辑:在GET请求里,通过qs库将参数添加至url并挂载到ctx全局变量上;在POST请求里,通过content-type将对应的参数转换为对应的格式,并挂载到ctx全局变量上

validateMiddleware-校验登录态中间件
request登录态校验中间件
/**
 * @name: 请求中间件
 */
const validateRequest = async (ctx, next) => {
  // 进行网络请求前的登录态校验
  globalLoadCount++ === 0 && Toast.loading("加载中...");
  if (!storage.getToken() && !storage.getTaroToken()) {
    return new Promise(async (resolve, reject) => {
      // 如果正在校验登录态的话,将其存入requestQueue队列
      if (system.isApp) {
        requestQueue.unshift([ctx, next, resolve]);
        if (!validateStatus) {
          // 通过validateStatus字段,保证只会触发验证一次登录态校验
          validateStatus = true;
          // App的登录态是异步加载
          const token = await commonFactory.getInstance().getToken();
          storage.setToken(token);
          const doRequest = async (curRequest) => {
            // 循环取出写入的请求对象,调用对应的next回调,进入下一个中间件
            const [ctx, next, resolve] = curRequest;
            const { headers } = ctx.params || {};
            // 重写token,并进入下一个中间件
            ctx.params = { ...ctx.params, headers: { ...headers, token } };
            resolve(await next());
          }
          for(let i = requestQueue.length - 1; i >= 0; i--) {
            doRequest(requestQueue[i])
          }
          // let curRequest = null;
          // while ((curRequest = requestQueue.pop())) {
          //   // 循环取出写入的请求对象,调用对应的next回调,进入下一个中间件
          //   const [ctx, next, resolve] = curRequest;
          //   const { headers } = ctx.params || {};
          //   // 重写token,并进入下一个中间件
          //   ctx.params = { ...ctx.params, headers: { ...headers, token } };
          //   resolve(await next());
          // }
          validateStatus = false;
        }
      } else {
        if (!validateStatus) {
          // 通过validateStatus字段,保证只会触发验证一次登录态校验
          // 因为H5和小程序的登录态是同步获取,如果没有对应的登录态就直接跳转对应页面
          validateStatus = true;
          return commonFactory.getInstance().login();
        }
      }
    });
  } else {
    // 进行网络请求
    return next();
  }
};

在App环境下,我们是通过bridge来获取token,所以,当遇到没有token的时候,需要调用bridge去获取token。与此同时,将后续的网络请求收入队列里,当获取token成功之后,再批量从队列里取出网络请求参数,排队进行请求。

在这里需要注意一个地方,在Async/Await下,使用for / forEach / while对应的异步行为是不同的。在for里调用await和while里调用await的效果是一样的,表现为串行异步,只会等上一个异步操作执行结束之后,才会执行下一步异步函数。在for里调用async函数和forEach里则是一样的,表现为并行异步,不需要等待上一个异步操作结束才执行下一个。这是因为forEach本质上是每次都生成一个async函数,然后在for循环里执行,实际执行的时候也是转换成例子2,在for里调用async函数

// 例子1:直接在for里调用await
const doRequest = async () {
    for(...) {
        await setTimeout(() => console.log("doing request"), 1000)
    }
}
// 例子2:在for里调用async 函数
const doRequest = async () {
    for(...) {
        asyncFn()
    }
}
// 例子3:直接在forEach里调用await
const doRequest = async () => {
    Array.forEach(...) {
    	await setTimeout(() => console.log("doing request"), 1000)
	}
}
// 例子4:在while里调用await
const doRequest = async () => {
    while(...) {
    	await setTimeout(() => console.log("doing request"), 1000)
	}
}
response登录态校验中间件
/**
 * @name: 响应中间件
 */
const validateResponse = async (ctx, next: () => RequestType) => {
  const response = await next();
  --globalLoadCount === 0 && Toast.hide();
  const { options = {} } = ctx;
  const { code, errorCode, success } = response || {};

  // 如果返回的响应码正确,返回对应的响应
  if (success || isAvailable(code || errorCode)) {
    return formatResponse(response);
  }
  // 如果返回登录超时的验证码
  if (isInvalidLogin(code || errorCode)) {
    return new Promise(async (resolve, reject) => {
      responseQueue.unshift([ctx, next, resolve]);
      if (!reValidateStatus) {
        reValidateStatus = true;
        const login = async () => {
          const token = await commonFactory.getInstance().refreshToken();
          if (token) {
            const reRequest = async (curResponse) => {
              const [ctx, next, resolve] = curResponse;
              const { headers } = ctx.params || {};
              ctx.params = { ...ctx.params, headers: { ...headers, token } };
              console.log("继续发起请求")
              resolve(await next());
            };
            for (let i = responseQueue.length - 1; i >= 0; i--) {
              reRequest(responseQueue[i]);
            }
            reValidateStatus = false;
          } else {
            Toast.fail("登陆异常");
          }
        };
        await login();
      }
    });
  }
  // 根据showMsg参数,决定是否显示对应弹窗
  const { showMsg = true } = options;
  showMsg && toastErrorMsg(response);
  return formatResponse(response);
};

在响应中间件这里,有个地方可能和koa2洋葱模型不一样的地方,就是在登陆超时的时候,我们需要再次调用next方法。在继续发起网络请求之后,整个请求库还会继续执行response中间件继续校验是否登陆超时。这种方式是会存在重复调用中间件的情况,但是本身koa2中间件模型是设置了不能重复调用中间件,因此我们需要改一下源码,将下面这句代码屏蔽掉。

if (i <= index) return Promise.reject(new Error("next() called multiple times"));

对于这种场景,存在一个问题,如果中间一环出现了问题,出现获取了错误的token时,导致整个程序一直处于发起请求 -> 返回请求超时 -> 获取到错误的token -> 发起请求,对于这种情况,我们有两种处理方法:1. 在返回登录超时,直接调用fetch方法,而不是next方法,不走洋葱模型的机制; 2. 使用redux的洋葱模型,直接调用store方法,而非dispatch方法。3. 修改中间件模型,将传入的next方法写入到context,或者作为入参传给中间件

总结

洋葱模型是一个很好的设计模型,使用该模型进行编程,可以灵活地满足复杂的需求。在这里我只是简单地写了一个登录校验的中间件,需要填的坑还很多,比如超时、请求竞态的取消、缓存等。而且整体设计还是挺简单的,如果大家想学习的话,可以参考下 umi-request 这个库。我也是写完之后才发现,原来大厂也用洋葱模型写了一个请求库,不过感觉是为了解决历史包袱,写了一些兼容的代码,如果大家是新项目的话,可以适当造点轮子,毕竟还是自己写的东西比较灵活点,想改动也简单。