首发于:个人博客:吃饭不洗碗
洋葱模型
学过或了解过 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