云函数开发 - 接口聚合实践

2021-11-29  本文已影响0人  前端C罗

接口聚合,将端上需要请求多个接口获取数据的方式变更为使用聚合层对端屏蔽多个接口请求和数据组装一次性向端吐出面向渲染的结构数据的过程。

为什么要聚合接口

如何处理接口聚合

业界比较常见的做法大概分为2类,通过网关实现接口聚合(又叫服务编排)和新增接口聚合层。

对于稳定的业务而言,冲击现有的网关显然是不太合理的,我们选择使用serverless作为聚合层的载体。主要的原因是希望能将开发者从琐碎的运维工作中解放出来,释放运维资源,专注于业务。

接口聚合的实现

整体的大概流程如下图


接口聚合核心流程.png

围绕上图,逐一介绍实现的细节和注意事项

DSL(领域特定语言)

DSL的目的是描述数据获取任务及任务间的并行/串行关系,简单理解是一份配置文件。
业界在定义DSL的时候,一些团队使用yaml或者json这类静态配置文件,作为任务描述的载体。比如美团技术团队,使用json文件描述请求任务。
静态配置文件能够cover绝大部分的应用场景,对于比较常见的数据获取场景基本够用。但对于下列场景就显得力不从心:

/* 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作为描述文件有下列优势

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

配置解析完成后,接下来需要将其创建为若干个任务单元,并根据配置的拓扑关系和接口的执行状态完成接口聚合。


控制器.png

控制器维护发布/订阅中心,遵循下列原则

控制器的本质是一个小型的任务管理系统。

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

小结

接口聚合中比较重要的技术点如下

抛砖引玉,期望看到更多关于前后端协作的方案探讨。

上一篇 下一篇

猜你喜欢

热点阅读