如何提升微前端场景下的研发效能?- 微前端管理平台的设计与实践

106 阅读14分钟

术语说明

  • 子系统:产品内部的应用,一般有自己的代码仓库,且业务相对独立,如网易七鱼内部的呼叫系统、在线系统、工单系统等。
  • 主应用:微前端应用的主体框架,负责子应用的注册、配置下发、加载和切换等。也被称为基座应用。
  • 子应用:微前端应用的主体内容,属于特殊的子系统,一般与主应用协同运行。

项目背景

网易七鱼是网易旗下的一款SaaS模式的云客服产品,致力于通过自主研发的客服机器人为企业客户降低企业管理成本,提高客户满意度,随时随地解决客户问题。

同步数据与权限

网易七鱼的应用整体采用 Java + FreeMarker + React/NEJ 的方案落地。前端的渲染依赖后端注入到FreeMarker页面模板中的同步数据(同步数据中包含企业权限、角色权限、个人信息等),前端拿到同步数据后需要对它们做进一步处理,如数据格式化、默认值兜底等。 因为每个子系统都需要维护一套自己的同步数据(虽然有的业务场景微前端子应用使用主应用下发的同步数据,但这些同步数据的增删改归根结底还是需要自己在主应用维护),导致同步数据分散在各处维护困难,主应用与子应用数据冗余,改动时的影响范围大、测试成本高等问题。

应用的维护成本

一个主应用下可以注册多个子应用,子应用的注册信息、部分权限数据等需要在主应用中手动维护。 因此每次修改子应用的这些信息时,都需要对主应用进行修改、构建、发布、测试,导致相关需求交付效率低。如何不侵入代码就能实现注册/注销子应用、维护子应用的信息?如何高效控制应用所需的权限? 随着应用数量的增多,如何了解每个主应用下注册了哪些子应用,应用间的关联关系是什么?

跨业务域的应用组合

目前网易七鱼的一个子系统对应一个应用,而这个应用可能包含不同业务域的业务,业务需求涉及到跨组协作,如跨组提MR、代码合并冲突检查、CodeReview、协调不同组的发版计划等,增加了维护成本和沟通成本。 例如网易七鱼下的坐席辅助子系统,被当做一个SKU售卖,这个子系统有在线相关辅助和呼叫相关辅助组成,这两个业务域分别有不同业务组的同学负责开发和需求迭代,把它们耦合到同一个子系统中就会出现上述问题。 如何降低以上成本?最好可以把应用按不同业务域解耦,各个业务组对自己业务域的需求负责。那怎样才能做到把多个不同业务域的应用组合成一个子系统呢?(因iframe方案缺点较多,本文不考虑此方案)

总体设计与思考

将应用的数据层(应用基本信息、关联关系和权限数据)与UI层分离,在平台层集中处理这些数据。 前端应用所需的数据全部通过接口获取,去除FreeMarker同步数据层。应用的基本信息、关联关系、菜单权限等均可在平台层维护和查看。

平台层支持前端应用的接入,为接入的应用提供中心化的治理能力。那么它能解决当前业务中的哪些问题?能给整体的业务带来哪些价值?

解决的业务问题

应用管理

  1. 应用的注册/注销成本大,需要重新修改、构建、发布和测试主应用

  2. 应用没有统一管理的地方,不能方便的查看和操作,包括管理应用的基本信息(路由、分组、代码仓库等)、应用间的关联关系、负责人等

  3. 跨业务域的组合的应用维护成本高

权限管理

  1. 前端应用依赖由后端通过FreeMarker注入的同步数据,导致无法做彻底的前后端分离、前端静态化部署等优化

  2. 应用的权限使用不规范,主应用和子应用会分别从后端同步数据获取权限,会有部分数据冗余,数据量大时会导致页面加载时间长

菜单编排 一般菜单的展示受权限控制,需要将菜单与权限数据共同管理。而且在应用组合的场景下,需要解决菜单数据冗余、维护成本大的问题。

带来的业务价值

  1. 提升研发效能:针对应用信息变更、菜单编排、权限控制等需求,可以减少资源投入。为将来的前后端彻底解耦、静态化部署等打下基础
  2. 提升用户体验:子应用渲染不再需要等待同步数据,可以只请求所需的数据,页面加载时间变短
  3. 赋能部门其他业务:为保证其他业务低成本接入平台,需要平台支持API配置,以获取接入业务的权限数据,接入方只需要实现自己的权限数据接口即可使用
  4. 未来能力扩展:基于平台还可以做应用的健康检查、发布/回滚、日志、资源管理等

实践方案

上文介绍了项目背景和设计思路,这里主要讲具体的设计与实现方案。主要包括以下几个方面:

  1. 产品管理
  2. 应用管理
  3. 菜单编排与权限配置

架构图

微前端管理平台只能通过内网访问,外部用户无法直接访问。这样设计的好处是,用户请求的流量不会走到微前端管理平台的服务,因此不需要把它做成P0服务,维护成本和风险都比较低。

整体流程

  1. 开发者通过管理平台配置应用信息、关联关系、菜单编排数据与菜单权限
  2. 主应用通过后端接口获取平台下发的配置数据(包含应用信息、菜单编排及菜单权限等),以及与当前企业相关的权限数据,然后通过解析企业权限数据获取菜单权限的值
  3. 主应用将解析后的数据下发给子应用,下发数据的方式有:
  4. 将数据挂到全局变量上,这种方式对子应用的侵入最小,方便接入和调试,但需要添加命名空间,降低被污染的风险
  5. 通过props传递,这种方式最安全,但对子应用的侵入较大,尤其是utils类的代码
  6. 子应用拿到数据后走正常的渲染流程

产品管理

产品管理比较简单,主要是满足以下特性:

  • 一个产品内可以包含一个或多个应用
  • 不同产品之间的数据相互隔离,对应用设置前必须先选择一个产品
  • 每个产品需要支持设置业务分组,以标识业务与组织的对应关系

如何支持其他业务产品的接入?

  1. 添加产品时,支持相关权限数据接口对接
  2. 微前端管理平台的后端服务提供openAPI,供接入方获取配置数据
  3. 接入方的产品弱依赖微前端管理平台的后端服务,读取数据后记到缓存,定时刷新
  4. 后端服务需支持鉴权功能、熔断策略

应用管理

应用管理包括主应用及子应用的添加/删除、关联关系、应用分组、负责人、创建时间等。 应用的增删就是常规的CURD,需要注意的是对关键数据的操作最好做二次弹窗提醒。 应用的基本信息 应用信息需要包含应用名、基础路由(一般为应用内各页面的公共前缀)、仓库地址、创建人、维护人、所属组(添加产品时设置的业务分组)、应用简介等。 下图为主应用的基本信息配置:

子应用的基础信息在主应用的基础上增加了应用入口、容器选择器这两个字段,应用入口是子应用的HTML entry,容器选择器为子应用在主应用中渲染的位置。

主应用与子应用的关联 一个主应用可以关联多个子应用,一个子应用也可以关联多个主应用。子应用关联主应用后就会被注册到该主应用,不能重复注册。因此主应用下关联的子应用必须是唯一的,反过来也一样,我们以基础路由来区分各个应用。 新增应用关联关系这里在设计时有一个需要考虑的问题,这个新增的操作是放到主应用侧还是子应用侧?还是两边都放? 我们知道主应用关联子应用后,意味着子应用会马上被注册到主应用,此时如果子应用的信息不完成或者填写错误,到线上就可能出现异常,而在主应用侧校验子应用的信息比较麻烦。 相比在子应用侧添加关联主应用时,主应用基本不会出现异常,子应用的基本信息也可以方便查看,所以我们选择把新增关联关系的操作放在子应用侧。 主应用详情页:

子应用详情页:

子应用间的关联/组合 因为关联的子应用也会被注册到主应用,所以同一个主应用下关联的子应用必须是唯一的。 子应用关联时的列表:

子应用关联后的详情: 相互关联的子应用会复用关联的主应用、菜单编排与权限设置等数据。它们的唯一区别是基础信息不同。

菜单编排与权限设置

菜单的展示一般会受到权限控制,因此菜单与权限有比较强的耦合关系。 因为平台层是无法获取具体企业信息的,所以我们在平台配置的菜单权限是与企业无关的,具体来说,我们配置的只是一个获取权限值的表达式,而不是计算好的权限值。 这就后端接口提供的是当前业务下完整的权限字段(前端只读就可以),平台不需要关心权限字段对应的value。 菜单编排 菜单项支持新增、删除,以及通过拖拽调整层级和顺序。 问题来了,子应用的菜单编排放到主应用内还是子应用内? 应该放到子应用,因为子应用可能关联多个主应用,子应用自己的菜单编排内聚到应用内部更方便维护和迁移。

除手动编排菜单项外,还支持复制和导入菜单数据,以便快速生成从其他复制的菜单数据:

编排完成后,也支持复制出mock数据方便在开发环境使用。 权限设置 权限设置这里在设计时有一个需要考虑的问题,是采用用户自由输入的配置方式还是可视化的配置方式?这里采用的是可视化的配置方式,原因如下:

  • 便于对配置数据做正确性校验,避免配置出错
  • 可以通过工具约束用户的输入,提高可维护性
  • 解析时可以将获取权限的代码的执行上下文限制在权限数据对象内,没有潜在的安全性问题

要实现权限数据的可视化配置,首先需要设计一套满足需求的JSON schema,用它来表达对应的权限逻辑。JSON schema对应的Typescipt声明如下:

import type {MergeExclusive} from "type-fest";

// eq等于、ne不等于、gt大于、lt小于、ge大于等于、le小于等于
type valueOperator = 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le';
type boolOperator = 'falsy' | 'truthy';

// 当前条件与同级条件的关系:and为同时满足,or为满足任一
// empty 空节点, 覆盖(setting.b || setting.c > 2) && (!setting.a || setting.d)的情况
type relationType = 'and' | 'or' | 'empty';

interface IPermNoConditionNoValue {
  type?: relationType;
  expr: string;  // 表达式,例如'setting.a'
  operator?: boolOperator; // 默认值是真值truthy
}

interface IPermNoConditionWithValue {
  type?: relationType;
  expr: string;
  operator: valueOperator;
  value: string;
}

// 处理数组的包含关系
interface IPermNoConditionArrayIncl {
  type?: relationType;
  expr: string;
  arrOperator: 'incl' | 'excl'; // incl包括、excl不包括
  value: string;
}

// 处理数组的长度
interface IPermNoConditionArrayLen {
  type?: relationType;
  expr: string;
  arrOperator: 'len'; // len长度
  operator: valueOperator;
  value: string;
}

/**
 * condition 目前最多 4 层
 */
interface IPermWithCondition {
  type?: relationType; // 描述当前节点与上一个相邻节点的逻辑关系。对首个根节点或首个condition节点禁止选择type,对其他节点必须选择type。
  operator?: boolOperator; // 表达式的操作符,默认值是真值「truthy」
  condition: IPermission[]; // condition与父级节点的逻辑关系是「and」
}

// 菜单项的权限
export type IPermission = MergeExclusive<MergeExclusive<
    MergeExclusive<IPermWithCondition, IPermNoConditionWithValue>,
    MergeExclusive<IPermNoConditionArrayIncl, IPermNoConditionArrayLen>
  >, IPermNoConditionNoValue>;

需要注意的是,不同的配置方式会产生不同的数据结构,表达式和数据结构是一对多的关系。 如表达式:(setting.b || setting.c > 2) && (!setting.a || setting.d) 对应的JSON schema可以是:

[
  {
    expr: 'setting.a',
    operator: 'falsy',
    condition: [
      {
        expr: 'setting.b',
        operator: 'truthy'
      },
      {
        type: 'or',
        expr: 'setting.c',
        operator: 'gt',
        value: 2,
      },
    ]
  },
]

也可以是:

[
  {
    expr: 'setting.a',
    operator: 'falsy',
  },
  {
    type: 'and',
    condition: [
      {
        expr: 'setting.b',
        operator: 'truthy'
      },
      {
        type: 'or',
        expr: 'setting.c',
        operator: 'gt',
        value: 2,
      },
    ]
  },
]

配置菜单项时的UI如下:

新增权限项:

配置菜单所需的权限字段是从接口获取的,字段可能较多,为了提升配置效率,需要支持展示完整权限数据,并提供关键字搜索,UI如下:

降级方案

微前端管理平台相关服务对用户是无感知的,用户侧的请求流量也无法直接走到平台侧。因此应用接入微前端管理的风险点主要在于以下接口的可靠性:

  1. 接口1:获取平台侧下发的配置数据
  2. 接口2:获取企业相关权限数据

整两个接口正常情况下都是由主应用调用,走http请求。 当接口1挂掉时,主应用无法获取平台下发配置,也就是无法执行最基本的应用注册流程,此时的降级方案是使用主应用内的应用信息兜底数据,保障核心服务的注册流程能跑通。 当接口2挂掉时,主应用无法获取当前企业的权限数据,相当于此前的FreeMarker内同步数据异常,页面内所有依赖权限的数据都可能是错误的,此时的降级方案是使用兜底的权限数据默认值,避免出现页面白屏等严重影响用户体验的问题。

环境隔离

平台需要支持的环境包括回归、预发、线上(区分灰度和全量)。 回归、预发、线上可以通过域名来区分。 线上的灰度和全量通过新建产品的属性来区分,好处是不需要再搞一套域名,添加产品时后端只需要加一个字段,实现成本较低。 切换环境时自动在产品名称后加对应的后缀以作标识。

此外,不同环境使用水印提醒,以免对数据的误操作。水印由环境名及用户名组成。

其他工作

  1. 成员管理:支持角色权限,包括普通成员、管理员、超管。需要限制管理员及以上角色才能执行重要的操作,如果删除应用、解除应用关联关系等
  2. 操作日志:记录关键操作,如登录/登出、数据的增删改等
  3. 内网openID登录
  4. 网关路由配置
  5. 快速上手文档

效果展示

image.png

总结

本文从满足网易七鱼需求的单点出发,介绍了从0到1构建微前端管理平台的过程。从需求出发反向思考如何应对未来业务的变化,使系统架构更加符合现有的组织架构(康威定律),从而提升研发效能。

一般一个系统的实现会有多种技术方案,而系统设计就是围绕这些实现方案不断作权衡和取舍的过程,中间需要考虑各种因素,如可行性、安全性、实现成本、可维护性、可扩展性等,最终将方案收敛到一个ROI最高的版本。每种技术方案都是既有利又有弊,适合业务的才是最好的。