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

公孙康
• 阅读 2298

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

洋葱模型

学过或了解过 Node 服务框架 Koa 的,都或许听过洋葱模型和中间件。恩,就是吃的那个洋葱,见下图:
边看边写:基于Fetch仿洋葱模型写一个Http构造类
Koa 是通过洋葱模型实现对 http 封装,中间件就是一层一层的洋葱,这里推荐两个 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 
点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
6个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这
公孙康
公孙康
Lv1
放下屠刀,立地成佛、救人一命,胜造七级浮屠。
文章
4
粉丝
0
获赞
0