背景
负责菜鸟商业中心CRM系统开发已经有1年多时间,过程中发现有一个痛点:业务线特别多,每个业务线对同一个页面都有个性化布局和不同的字段需求,而我所在的团队就3个人,在资源有限的情况下如何支撑好呢?刚开始,我们是为各业务线单独定制页面和业务逻辑,1到2个业务线还能应付过来,目前已经发展有十几业务线,且每个业务线下还有子业务线,这种个性化的开发多了,工作量就大了,系统维护压力就巨大。所以就孕育而生了—— 销售魔方类低代码产品,与其说低代码产品,还不如说是一种解决千人千面的个性化业务搭建的前后端一体的解决方案。
本文就降本的情况下,我是如何低成本构建产品能力去支撑多条业务线、多租户,我先以小实践成果展示,再过度分享我后续升级的设计思路。
什么是LCDP
低代码开发平台(Low-Code Development Platform)是无需编码(0代码)或通过少量代码就可以快速生成应用程序的开发平台。通过可视化进行应用程序开发的方法(参考可视编程语言),使具有不同经验水平的开发人员可以通过图形化的用户界面,使用拖拽组件和模型驱动的逻辑来创建网页和移动应用程序。低代码开发平台(LCDP)的正式名称直到2014年6月才正式确定,整个低代码开发领域却可以追溯到更早前第四代编程语言和快速应用开发工具。
魔方核心能力
产品能力
上图是魔方1.0 MVP版本基本运行原理,以及上线后降本增效的数据,业务开发从60人日缩短到20人日,年省成本180人日。
以上版本基本满足了80%以上的业务个性化需求自闭环开发。
还有一些小问题,基于这个版本,我们又不断的升级,提升产品体验、能力提升和业务覆盖。
后续我们可做到新页面上线,只需5分钟,新增字段无需模型变更和无需java代码发布,复杂页面前端也能做到0代码。
基于我们业务的诉求,所以销售魔方需具有以下几个核心能力:
- 页面的千行千面(千人千面),包含同一个页面不同布局、不同字段、不同样式。
- 数据模块的千行千面(千人千面),根据不同身份执行不同的业务技术逻辑和服务编排。
- page一键创建,在没有新的业务组建和新的module情况无需开发接入,0代码上线,运营同学自行配置页面。
- 前端组件复用,在没有新前端组件,前端无需参与开发,后端只需编写module对应的业务接口。
- 实现module可复用,module数据渲染、数据写入,查询条件、浮层、半推页面、页面操作。
- 新增字段扩展0代码,模型字段可以自定义,动态扩展,可定义来自本地数据库、远程HSF接口数据。
- 环境可隔离,测试、预发、生产。
- 平台和业务代码分离,业务上线只需关注业务逻辑本身的代码。
- DO DTO可定义,动态映射。
- 数据枚举动态定义,动态绑定。
魔方的设计
产品界面
先展现一个实例配置界面,有个体感
通过以上配置可以个性化配置页面输出的布局和字段,动态输出个性化页面
用户
运营:运营可以根据自己的业务身份,定义独有的page实例,自由选择页面的版块和需要展现和编辑的字段
产品:配置初始化页面,module、以及全量字段池
技术:定义元数据,module,编写module group逻辑执行单元代码
产品模块
核心逻辑
一般低代码平台,主要分为两部分,前端页面的渲染和后端服务接口绑定(服务编排等)。
大的逻辑差不多,因为我这个主要还是具有行业特色的类低代码产品,所以是紧扣行业特殊性构建。
前端渲染
因为我负责的是后端,所以前端我就不过多叙述,大概的逻辑如下两张图,大致意思是前端的页面渲染就如同做菜一样,用户根据自己的需求,可选择菜谱、食材和烹饪方式,不用关心烹饪过程,也不用自己亲自烹饪,都叫由厨师烹饪,厨师会根据你提供的菜谱和食材做好,最后将美食给你端上桌。
同理,前端渲染引擎会根据数据协议、组件库、渲染方式,动态渲染成页面,如果有业务数据将会动态绑定。
后端绑定
我们这有个特殊性,页面是通过后端给定schema,由前端根据这个schema进行页面渲染。后端通过识别出用户的身份,通过接口输出给前端千人千面的个性化schema,前端就通过schema配置动态去渲染。
这样就能实现我们说的同一个功能页面,不同业务身份展示不同的布局和字段。
同时,还会会有一个业务数据接口,用于绑定前端页面填充业务数据或提交表单数据。
每一个组件绑定业务数据接口后,就不是单纯的前端组件了,就具有行业业务属性的组件和页面,这样在这个领域是可以被业务系统直接复用,无需重新编写业务代码。
这里有一个难点,每次新增业务线(租户)后就有新增字段需求,而且字段的差异还挺多,约占80%,在不修改模型的前提下,如何做到?
带着这个问题可以先思考下。
一般解决方案有以下几种:
1、通过设计纵表,以扩展字段。缺点就是查询复杂度高。
2、元数据驱动。缺点就是成本高,架构更加复杂
3、通过定义一个特殊feature字段,存储类型为json,约定好协议,扩展字段按照不同行业存储在json内。优点就是轻量。
根据我们自身的定位和资源情况,最终选择的是第3种方案。
这里有个担心就是json数据方便搜索吗?性能如何?
mysql5.7上,在相同数据量的情况下,虚拟索引和普通索引查询效率基本一致 在大数据量情况下不推荐用聚合函数计算json数据,但是如果json key建立了虚拟列,可以对该虚拟列进行聚合操作,效率跟普通列一样。
模型设计
整个模型可以看出,从左往右:
DO的定义、DTO的定义、module定义(等同VO定义)、原始页面定义(originalPage)、实例化页面配置(instancePage)
这个过程基本描述了一个页面是如何定义出来的,以及动态模型的扩展(模型驱动),从模型到页面组件的布局定义。
行业个性化配置则通过对OriginalPage实例化成InstancePage而得。这样就能动态生成千行千面。
OriginalPage到InstancePage这里借鉴了java的思想,类似Class和实例对象。
渲染页面逻辑
页面在渲染的时候,只需两个接口:
1、实例页面数据结构 ;2、业务数据。
前端根据两个接口分成两步渲染:
1、初始化页面布局;2、填充业务数据。
这个借鉴了Spring容器启动时的设计思路,类似实例化、初始化,属性数据填充。
template定义
即是对页面类型的定义
- 列表页面
- 详情页面
- 半开页面
- 表单提交页面
page定义
定义一张前端页面,分成两个阶段:origin;instance。
origin是定义的原始页面,可以理解成java 的Class类,可以构建多个实例页面。
instance page是最终渲染的运行态。
页面结构:一个page 由N个module组成、一个module由N个field组成。如下图
一个实例page由以下维度定义
1、用户传入参数
page_code
custom_dimension
2、系统获取参数
biz_code
sub_biz_code
enviroment
module定义
页面的组成单元,一个页面由多个module组成。代表页面显示区域单元,有多个前端组件组成,是页面容器的布局单元。
module_code 全局唯一,实例化后的module可被复用、重写、实现多态。
modules 数据结构为一个 B+tree,只有叶子节点是有具体的实体数据。
叶子节点是真正包含具体的字段和属性配置
module_type 定义
对module类型的定义
1、主列表查询模块 MAIN_LIST_MODULE
2、导出模块 EXPORT_MODULE
3、弹出页面模块 FLOAT_PAGE_MODULE
4、搜索条件区域 SEARCH_ARE_MODULE
5、子列表查询模块 SUB_LIST_MODULE
6、编辑表单模块 EDIT_MODULE
7、信息平铺呈现 DISPLAY_FLAT_MODULE
McubeContextAware
module容器代码定义
@Component public class McubeContextAware implements ApplicationContextAware { private static volatile ApplicationContext alc; @Resource private ModuleBeanFactory moduleBeanFactory; @Resource private ModuleGroupBeanFactory moduleGroupBeanFactory; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { alc = applicationContext; } @PostConstruct public void init(){ setModuleBeanMap(); setModuleGroupBeanMap(); } private void setModuleBeanMap() { Map<String, McubeModuleExecutor> beanMap = alc.getBeansOfType(McubeModuleExecutor.class); if (beanMap != null) { beanMap.values().stream().forEach(m -> { McubeModule module = AnnotationUtils.findAnnotation(m.getClass(), McubeModule.class); if (module != null) { String code = module.code(); String name = module.name(); if (code != null) { moduleBeanFactory.getMcubeBeanMap().put(code, m); } } }); } } private void setModuleGroupBeanMap() { Map<String, McubeModuleExecutor> beanMap = alc.getBeansOfType(McubeModuleExecutor.class); if (beanMap != null) { beanMap.values().stream().forEach(m -> { McubeModuleGroup module = AnnotationUtils.findAnnotation(m.getClass(), McubeModuleGroup.class); if (module != null) { String code = module.code(); String name = module.name(); moduleGroupBeanFactory.getMcubeBeanMap().put(code,m); } }); } } }
执行单元(moduleGroup executor)
为了保证页面的数据填充效率,所以并不是一个module绑定一个服务接口,
而是一个执行单元对应一个或多个module,负责多个module的数据渲染和数据写入,moduleGroup executor是一个页面计算单元。
通过moduleCode动态路由对应的module group,执行相应的计算单元。
每个module 执行单至少都包含读、新增、编辑和删除接口。
页面上的每一个module就自动绑定了后端的业务接口,实现了前后端一体化搭建。
/** * Created by hzliuxuan on 2022/5/27. * @author hzliuxuan * 模块接口 */ public interface McubeModuleExecutor<T,V> { /** * 填充数据,页面渲染,一般是read接口 * @param value * @return */ T populate(V value); /** * 编辑模块 * @param value * @return */ void edit(V value); /** * 写接口 * @param value * @return */ void add(V value); /** * 删除接口 * @param value * @return */ void delete(V value); }
McubeModuleGroup
module执行组注解定义
field定义
一个module对应多个field。
如果要支持动态扩展,module需要对应一个实体模型。
module只代表一个VO层的部分显示片段,要想达到字段可以动态扩展需要定义一层实体模型的映射关系,这样才能找到统一的feature对象去解析,完成DO、DTO、VO的相互自动转换。
当module需要动态扩展的时候,从实体模型中去选择已经定义好的field。
因为我们的VO也是动态生成的,这样就不需要因为新增一个字段而进行模型变更或者代码发布。即实现0代码上线。
field数据结构定义
page 数据结构
public class McubePageBeanDTO { /** * 页面编码 */ @CrmOperateLogBizCode private String pageCode; /** * 业务线 */ private String bizCode; /** * 配置类型 */ private TemplateTypeEnum templateType; /** * 配置模块 */ private List<McubeModuleBeanDTO> originalModules; /** * 配置字段 */ private Map<String, List<McubeField>> originalFields; /** * 实例的模块 */ private List<McubeModuleBeanDTO> instanceModules; private List<String> instanceModulesList; /** * 实例的字段 */ private Map<String, List<McubeField>> instanceFields; private String subBizCode; /** * 元页面version */ private Byte originVersion; /** * 实例version */ private Byte instanceVersion; /** * module version */ private Byte moduleVersion; /** * 属性集合 */ private List<Property> properties; ///** // * 显示的模块 // */ //private List<String> instanceModulesList; private Boolean isCache; @Data public static class Property { /** * property */ private Boolean checkable; private Boolean isEdit; private Boolean selectable; private Boolean isLeaf; private Boolean isAdd; private Boolean isDelete; private String showType; private Integer level; private String extendedField; } }
page渲染运行时序图
运行时类设计图
每一个模块背后都会绑定一个 moduleGroup executor ,业务开发只需通过对这个executor实现,即可快速完成开发上线,整个过程无需前端参与。简单的字段添加也无需发布上线,我们会通过动态扩展映射背后的DO扩展。
总结
好了,整个魔方的产品设计到这里基本描述完。
整个产品我理解更多的是贴近业务而产生的一种前后端一体化,低成本快速构建方案。如果要做大,做全,可以参考salesforce元数据驱动模型。
模型关系图:
可参考:https://www.infoq.cn/article/rwstpgujoxxuw9tlm88t
salesforce官方架构文档:https://www.developerforce.com/media/ForcedotcomBookLibrary/Force.com_Multitenancy_WP_101508.pdf
salesforce 已经超脱了模型驱动,下探到元数据模型驱动架构 Metadata-driven Architectures。
优点很明显,就是真的强大,业务都不需要建任何表,想怎么扩展模型就怎么扩展,此架构一般适用于与SAAS产品。
缺点也很明显,完全失去业务语义,开发成本和维护成本高,需要一套强大的sql管理和解析、实时的ETL数据架构、检索能力,后期成本也非常大。
故不适用我这个业务团队采纳的方案,但这套设计方案也给我打开了些思路。
魔方这套方案搭建工时约为50人日,两个后端加一个前端,解决了十几个业务线多租户的个性化接入,一定程度实现了模型驱动,千人千面的能力,我认为是典型小投入大产出吧,希望对正遇到同样问题的同学有一定的帮助和启发,限于个人能力,最终要搞大的需要有专业的团队支撑,也欢迎指正和探讨。