乐趣区

边看边写基于Fetch仿洋葱模型写一个Http构造类

首发于:个人博客:吃饭不洗碗

洋葱模型

学过或了解过 Node 服务框架 Koa 的,都或许听过洋葱模型和中间件。恩,就是吃的那个洋葱,见下图:

Koa 是通过洋葱模型实现对 http 封装,中间件就是一层一层的洋葱,这里推荐两个 Koa 源码解读的文章,当然其源码本身也很简单,可读性非常高。

  • Koa.js 设计模式 - 学习笔记
  • 从头实现一个 koa 框架

我这里不过多讲关于 Koa 的设计模式与源码,理解 Koa 的中间件引擎源码就行了。写这篇文章的目的,是整理出我参照 Koa 设计一个 Http 构造类的思路,此构造类用于简化及规范日常浏览器端请求的书写:

// Koa 中间件引擎源码
function compose(middlewares = []) {if (!Array.isArray(middlewares))
    throw new TypeError('Middleware stack must be an array!');

  for (const fn of middlewares) {if (typeof fn !== 'function')
      throw new TypeError('Middleware must be composed of functions!');
  }

  const {length} = middlewares;
  return function callback(ctx, next) {
    let index = -1;
    function dispatch(i) {let fn = middlewares[i];
      if (i <= index)
        return Promise.reject(new Error('next() called multiple times'));
      index = i;
      if (i === length) {fn = next;}
      if (!fn) {return Promise.resolve();
      }
      try {return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
      } catch (error) {return Promise.reject(error);
      }
    }
    return dispatch(0);
  };
}

Fetch

  语法:Promise<Response> fetch(input[, init]);
  ** 以下代码展示都是以 input 字段为请求 url 的方式展示
  // get 请求
  fetch('http://server.closertb.site/client/api/user/getList?pn=1&ps=10')
   .then(response => {if(reponse.ok) {return data.json();
      } else {throw Error('服务器繁忙,请稍后再试;\r\nCode:' + response.status)
     }
  })
   .then((data) => {console.log(data); });

  // post 请求
  fetch('http://server.closertb.site/client/api/user/getList',
    {
      method: 'POST',
      body: 'pn=1&ps=10',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'}
    }
  ).then(response => {if(reponse.ok) {return data.json();
      } else {throw Error('服务器繁忙,请稍后再试;\r\nCode:' + response.status)
     }
  })
   .then((data) => {console.log(data); })

从上面的示例, 我们可以感觉到,每一个请求发起,都需要用完整的 url,遇到 post 请求,设置 Request Header 是一个比较大的工作,接收响应都需要判断 respones.ok 是否为 true(如果不清楚,请参见 mdn 链接),然后 response.json()得到返回值,有可能返回值中还包含了 status 与 message,所以要拿到最终的内容,我们还得多码两行代码。如果某一天,我们需要为每个请求加上凭证或版本号,那代码更改量将直接 Double, 所以希望设计一个基于 fetch 封装的,支持中间件的 Http 构造类来简化规范日常前后端的交互,比如像下面这样:

  // 在一个 config.js 配置全站 Http 共有信息, eg:import Http from '@doddle/http';

  const servers = {
    admin: 'server.closertb.site/client',
    permission: 'auth.closertb.site',
  }
  export default Http.create({
    servers,
    contentKey: 'content',
    query() {const token = cookie.get('token');
      return token ? {token: `token:${token}` } : {};},
    ...
  });

  // 在 services.js 中这样使用
  import http from '../configs.js';

  const {get, post} = http.create('admin');
  const params = {pn: 1, ps: 10};

  get('/api/user/getList', params)
    .then((data) => {console.log(data); });


  post('/api/user/getList', params, { contentType: 'form'})
    .then((data) => {console.log(data); });

上面的代码,看起来是不是更直观,明了。

设计分析

从上面的分析,这个 Http 构造类需要包含以下特点:

  • 服务 Url 地址的拼接,支持多个后端服务
  • 请求地址带凭证或其他统一标识
  • 请求状态判断
  • 请求目标内容获取
  • 错误处理
  • 请求语义化,即 get, post, put 这种直接标识请求类型
  • 请求参数格式统一化

Talk is Cheap

Http 类

参照上面的理想化示例,首先尝试去实现 Http.create:

export default class Http {constructor(options) {const { query, servers = {}, contentKey = '', beforeRequest = [], beforeResponse = [],
      errorHandle } = options;
    this.servers = servers;
    this.key = contentKey;
    this.before = beforeRequest;
    this.after = beforeResponse;
    this.query = query;
    this.errorHandle = errorHandle;
    this.create = this.create.bind(this);
    this._middlewareInit();}
  // 静态方法, 语义化实例构造
  static create(options) {return new Http(options);
  }
  // 中间件初始化方法,内部调用
  _middlewareInit() {const defaultBeforeMidd = [addRequestDomain, addRequestQuery];
    const defaultAfterMidd = [responseStatusHandle, responseContentHandle];

    this._middleWares = this._middleWares || defaultBeforeMidd
      .concat(this.before)
      .concat(fetchRequest)
      .concat(defaultAfterMidd)
      .concat(this.after);
      this._handlers = compose(this._middleWares); // compose 即为开头提到的 koa 核心代码
    }
  }
  // 中间件扩展,like Koa
  use() {if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    let _order = order || 0;
    // 插入位置不对,自动纠正
    if (typeof _order !== 'number' || _order > this._middleWares.length) {_order = this._middleWares.length;}
    this._middleware.spicle(order || this._middleWares.length, 0, fn);
    this._middlewareInit();}
  // 请求实例构造方法
  create(service) {
    this._instance = new Instance({domain: this.servers[service], // 服务地址
      key: this.key,
      query: this.query,
      errorHandle: this.errorHandle,
      handlers: this._handlers,
    });
    return requestMethods(this._instance.fetch);  // requestMethods = {get, post, put};
  }
}

直接贴代码,也是一种无赖之举。每个方法功能都非常简单,但从 use 和_middlewareInit 方法,可以看出和 koa 的中间件有所区别,这里采用的中间件是一种尾触发方式(中间件按事先排好的顺序调用),在后面会进一步体现。

requestMethods

关于 requestMethods,其类似于一种策略模式,这里将每一种请示类型,抽象成一个具体的策略,在实例化某个服务的请求时,将得到一系列策略,将 resetful 语义函数化:

// 关于 genHeader 函数,请查看源码,这里的 fetch 是中间件包装后的;export const requestMethods = fetch => ({get(url, params, options = {}) {return fetch(`${url}?${qs.stringify(params)}`, params, options);
  },
  post(url, params, options = {}) {const { type} = options;
    return fetch(`${url}`, genHeader(type, params), options);
  },
});

Instance 类

关于 Instance, 每个实例的服务域名是一致的,所以其作用更多是每个 服务 创建一个执行上下文,用于存储 request, response, 并做错误处理, 实现也非常简单:

export default class Instance {
  // configs 包括 domain,key,query
  constructor({handlers, errorHandle, ...configs}) {
    this.configs = configs;
    this.errorHandle = errorHandle;
    this.handlers = handlers;
    this.fetch = this.fetch.bind(this);
    this.onError = this.onError.bind(this);
  }

  fetch(url, params, options) {
    const configs = this.configs;
    const ctx = Object.assign({}, configs, { url, options, params});
    return this.handlers(ctx)
      .then(() => ctx.data)
      .catch(this._onError);
  }

  _onError(error) {if (this.errorHandle) {this.errorHandle(error);
    } else {defaultErrorHandler(error);
    }
    return Promise.reject({});
  }
}

关于 Object.assign 创建 ctx, 是为了同一个服务多个请求发起时,上下文不相互影响。

默认中间件实现

正如设计分析时提到的,默认中间件包含了请求地址服务域名拼接,凭证携带,状态判断,内容提取,中间件可采用 async/await,也可用常规函数,见示例代码:

export function addRequestDomain(ctx, next) {const { domain} = ctx;
  ctx.url = `${domain}${ctx.url}`;
  return next();}

export function addRequestQuery(ctx, next) {
  const {
    query,
    options: {ignoreQuery = false},
  } = ctx;
  const queryParams = query && query();
  // ignoreQuery 确认忽略,或者 queryParams 为空或压根不存在;ctx.url =
    ignoreQuery || !queryParams
      ? ctx.url
      : `${ctx.url}?${qs.stringify(queryParams)}`;
  return next();}

export async function fetchRequest(ctx, next) {const { url, params} = ctx;
  try {ctx.response = await fetch(url, params);
    return next();} catch (error) {return Promise.reject(error);
  }
}

export async function responseStatusHandle(ctx, next) {const { response = {} } = ctx;
  if (response.ok) {ctx.data = await response.json();
    ctx._response = ctx.data;
    return next();} else {return Promise.reject(response);
  }
}

export function responseContentHandle(ctx, next) {const { key, _response} = ctx;
  ctx.data = key ? _response[key] : _response;
  return next();}

每个中间件代码都非常简单易懂,这也是为什么要采用中间件的设计模型,因为将功能解耦,易于扩展。同时也能看到,next 作为每个中间件的最后执行步骤,这种模式就是传说中的中间件尾调用模式。

写在最后

感谢你读到了这里,开始想写的非常多,但高考语文 89 分,不是偶然出现的。在实现一个用于日常生产的 Http 构造类,过程并不像这里写出来的这么简单,需要考虑和权衡的东西非常多,错误处理是关键。这里留了自己踩过的两个坑(更多是因为自己菜),这里没展开来讲,思考:

  • 为什么每个中间件最后要 return next();
  • query 为什么是在中间件中执行,而不是在 fetch 前执行,然后传参过来;

本文的源码可在此 github 地址下载,分支是 http;
执行用例可在此 github 地址下载, 分支是 dva,或执行脚手架命令:

npx create-doddle dva projectname

如果你有兴趣在你的项目尝试,可查阅 npm 使用指南

npm i @doddle/dva --save 
退出移动版