首发于:个人博客:吃饭不洗碗
洋葱模型
学过或了解过 Node 服务框架 Koa 的,都或许听过洋葱模型和中间件。恩,就是吃的那个洋葱,见下图:
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
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。