接口聚合,将端上需要请求
多个接口
获取数据的方式变更为使用聚合层对端屏蔽多个接口请求和数据组装
并一次性
向端吐出面向渲染
的结构数据的过程。
为什么要聚合接口
- 微保几乎所有的主营业务都在一个单独的小程序上,页面的渲染/逻辑的处理会线性地增加小程序包体积,而在当前的阶段,包体积已接近红线,将数据的获取和组装逻辑迁移到远端聚合层是非常有必要的
- 微保的业务在承接形态上有小程序/H5/App(开发中),数据的聚合和组装统一到聚合层,大大减少多端间相同逻辑的开发和维护
如何处理接口聚合
业界比较常见的做法大概分为2类,通过网关实现接口聚合(又叫服务编排)和新增接口聚合层。
- 在网关做接口聚合
比如国内的悟空API网关,就内置有服务编排的能力 - 新增接口聚合层
对于稳定的业务而言,冲击现有的网关显然是不太合理的,我们选择使用serverless
作为聚合层的载体。主要的原因是希望能将开发者从琐碎的运维工作中解放出来,释放运维资源,专注于业务。
接口聚合的实现
整体的大概流程如下图
围绕上图,逐一介绍实现的细节和注意事项
DSL(领域特定语言)
DSL的目的是描述数据获取任务及任务间的并行/串行关系,简单理解是一份配置文件。
业界在定义DSL的时候,一些团队使用yaml或者json这类静态配置文件,作为任务描述的载体。比如美团技术团队,使用json
文件描述请求任务。
静态配置文件能够cover绝大部分的应用场景,对于比较常见的数据获取场景基本够用。但对于下列场景就显得力不从心:
- 根据被依赖的接口数据状态决策依赖方是否执行
- 接口级别的降级处理。接口请求失败使用本地默认数据,请求成功使用线上数据
- 接口数据format
基于此,使用js脚本作为DSL的载体,示例如下
/* eslint-disable no-console */
import { IContext, PlainObject, RpcFetcherResult } from '@wesure-scf/types';
import { GetProposalListRequest } from './_types/carCoreService';
type ProposalListParams = Pick<GetProposalListRequest, 'userId' | 'statusFilter' | 'needRiskKind'>;
export default (body: PlainObject, context: IContext) => {
return {
task1: {
// xxx
},
task2: {
deps: ['task1'],
fetcher: (): RpcFetcherResult<ProposalListParams> => {
return {
endpoint: 'rpc/service-xx/GetProposalList',
params: {
x: 1,
y: 2,
},
};
},
defaultValue: {},
cache: {
ttl: 60 * 1000,
key: 'cacheKey',
},
},
};
};
因为云函数开发基本ts
化,相应的脚本也变更为ts
类型。使用ts作为描述文件有下列优势
- 无缝集成到代码。本身是
ts
文件,直接引入即可 - 使用
类json
的写法(js中的对象)描述任务相关特性 - 天然支持脚本编写
- 天然支持类型校验(
ts
的特性)
Parser
解析器有2个重要的任务
- 配置校验
- 生成任务拓扑图
对复杂的配置进行简化,形式如下
const notDapConfig = {
a: {},
b: {
deps: ['a', 'd'],
},
c: {
deps: ['b'],
},
d: {
deps: ['c'],
}
};
稍作变化可以快速转化为一个逆临接表的数据结构。其实也很好理解,接口聚合的本质就是把一系列的任务根据它们之间的依赖关系创建成一个有向无环图(DAG)的过程。
同样的,如何校验配置文件就比较清晰了。配置文件的格式可以交给ts
的类型系统在编写时即可校验,解析器需要校验的是一些动态的规则。校验列表如下:
- 是否引入
不存在的依赖
- 是否形成
循环依赖
校验依赖是否存在相对简单,只需要判断deps
中的依赖id是否在配置文件的属性列表中。
校验循环依赖稍微麻烦点,不过基于上述的讲解,可以转换为是否为DAG的校验。而当前的配置文件,可以轻松转为逆临接表,那么使用拓扑排序的方法就能达到目的。判断的代码可以参考如下:
// 是否为有向无环图
const isDAG = (config: Config): boolean => {
// ApiConfig天然是一个 逆临接表,是以入度为基准的,deps代表了连向当前节点的其他节点,边的度数都为1
const map = new Map<string, { deps: string[] }>();
// 入度为0的队列
const queue: string[] = [];
Object.keys(config).map((k) => {
const deps: string[] = config[k].deps ?? [];
// 入度为0的放到queue中,其他放到非0的map中,减少运算次数
if (deps.length <= 0) {
queue.push(k);
} else {
map.set(k, {
deps,
});
}
});
// 入度为0的节点还在继续处理,map还不为空
while(map.size > 0) {
if (queue.length <= 0) {
break;
}
// 删除入度为0的节点,更新其他节点的入度
const taskKey = queue.shift();
map.forEach((v, k) => {
const { deps } = v;
const index = deps.findIndex(item => item === taskKey);
// 入度减1
if (index >= 0) {
deps.splice(index, 1);
}
// 节点更新后判断是否可以放到入度为0的队列
if (deps.length <= 0) {
map.delete(k);
queue.push(k);
}
});
}
if (map.size > 0) {
const kArr: string[] = [...map.keys()];
const tip = kArr.join(',');
throw new Error(`${tip}存在循环依赖`);
}
return true;
};
Controller
配置解析完成后,接下来需要将其创建为若干个任务单元,并根据配置的拓扑关系和接口的执行状态完成接口聚合。
控制器维护发布/订阅中心,遵循下列原则
- 使用拓扑排序, 启动
入度为0
(deps
为空或空数组)的任务 - 任务完成后,发布一条以自身id命名的消息,并将任务数据写入上下文
- 订阅该名为该id消息的任务
入度减1
(deps
数组中移除掉该字符串) - 控制器判断是否全部任务都完成,否则循环处理上述过程
控制器的本质是一个小型的任务管理系统。
Fetcher
在最初的设计中,所有的任务处理并不是泛型的,默认套上接口请求的外衣。后续的应用中,发现这种设计不仅不优雅,而且不利于扩展。数据的获取广泛来看可能会有多种来源,rpc请求只是其中一种,其他包括本地json文件
/yaml文件
/计算所得
等等。
基于这些场景和扩展地考量,将任务的处理设计为泛型的fetcher
。抽象来看,就是一个函数。
export type FetcherFunction<T = PlainObject> = (deps?: T) => Promise<any>
export type RpcFetcherResult<T = PlainObject, U = PlainObject> = {
endpoint: string,
params: T,
headers?: Headers,
formatReply?: (res: PlainObject) => U,
defaultValue?: PlainObject | (() => PlainObject),
}
export type RpcFetcherFunction<T = PlainObject, U = PlainObject> = (deps?: PlainObject) => RpcFetcherResult<T, U>
但Fetcher
默认提供RpcFecher
,对于常用的请求接口获取数据的场景直接使用内置能力,其他场景开发者可以自己扩展fetcher
。
小结
接口聚合中比较重要的技术点如下
- DSL(包含DSL的设计)
- DAG
- 发布/订阅
抛砖引玉,期望看到更多关于前后端协作的方案探讨。