一种基于动态代理的通用研发提效解决方案 - 阿里技术
( 本文阅读时间:25分钟 )
作为一名研发人员,除了业务开发之外,研发提效是一个永恒的话题,而女娲正是这一话题下进行的一次全面的剖析和实践。
01 女娲是什么
女娲是业务研发同学(开发、测试、运维)在软件迭代的各个阶段(开发、联调、测试、上线、运维)通过女娲控制台对代码进行动态干预(增强、开箱、派生)使程序运行时注入额外的技术能力(缓存、兜底、Mock、灰度等)的一站式平台,旨在提高研发相关工作效率。
女娲的组成为“一个SDK、一个控制台、若干插件、一个生态”,应用于若干场景,形成若干NoCode/LowCode技术解决方案的应用场景
女娲工作示意图:
02 女娲的发展历程
===========
女娲1.0
寓意是“我们不补天,我们只补锅”,最初目标是保障线上的性能和稳定性以及突发应急,希望采用通用的处理模式,将业务中非功能纯技术述求进行提炼,形成热插拔的技术增强插件。业务开发聚焦业务功能需求实现,而非功能需求则由女娲平台动态化组装配置实现,女娲接入“零代码”,达到业务与技术实现分离,提升研发效率。主要的提效场景为兜底、缓存、流控。
女娲2.0
将女娲配置进行界面化,降低女娲使用的复杂度,
女娲3.0
随着女娲从1.0、2.0、3.0的升级,女娲的使命不仅聚焦线上服务保障背锅,而是全面升级为技术人员的工具箱,助力研发过程全方位的工作,达到360°空间的提效。
提效场景包含不限于:
- 开发场景提效:缓存、兜底、灰度、核对、埋点、ABT、动态日志;
- 测试场景提效:单侧、Mock,压测、自动化测试;
- 运维场景提效:流量链路跟踪、链路调用统计分析、流量控制、流量录制与回放、机器人编排服务。
03 女娲产生的背景
3.1 初识提效的几个方面
不管怎么提升自己解决问题的能力和手段,最终的核心目标是提升做事情的效率和质量,这里主要有如下三个方面:
研发提效方面
:采用合理的技术架构、框架、工具、平台使研发过程简化、易用化、智能化,原先一周才能完成的工作现在只有一天。
运营提效方面:
采用合理的业务架构、平台化、体系化、配置化、组件化、流程化、模板化能够灵活支持新的业务,原先新的运营场景只能Case By Case的开发、测试、上线流程,变成了快速配置、落地、检验的自动化、体系化流程,让运营一定程度上有更多的空间,提升整体运营效率。
钱效方面
:采用合理算法模型,通过人群画像、标签体系、分层体系进行精准化投放,提高花钱效率,延长用户生命周期,获得最大ROI,将原先一刀切,粗颗粒的投放精细化,提升用钱效率。
无论哪种提效离都不开“持久”与“稳定”,在此基础上各个提效方面有侧重点:研发要“快”、运营要“活”、花钱要“灵”。
本文聚焦研发提效,研发效率主要包括两大方面:
一是软件的运行质量,也可以说是运行效率,比如程序响应时长,处理吞吐量/并发度、运行的可靠性、安全性。
另一个是软件维护效率:这里的维护是指软件的整个生命周期的维护,维护效率包括开发效率、测试效率,发布效率,运维效率;其中开发效率在代码可读性、易用性、通用性、扩展性和测试性基础上,同时依赖于工具和生态的友好和完善性。
3.2 研发提效的需求矩阵
下面我们进入提效的具体“事”,软件的开发生命周期,实现业务需求是一方面,但为了保障业务需求的很好落地,我们会或很多非功能需求来保障程序运行的稳定,同时我们会辅助大量的测试与验收来检验需求是否正确实施,另外我们也会做很多衍生需求来检验功能需求落地的效果分析。
接下来,我们进一步细化,实实在在走进软件研发工作的点点滴滴中。
比如:
-
需求阶段:产品需求前期配合技术调研方案,趴清现有链路。
-
开发阶段:除功能需求开发外,非功能需求开发,包括:
-
异常处理类:应对各类可能程序异常、业务异常兜底处理、流控处理、数据安全处理,灰度处理,异常回滚处理等。
-
用户体验类:响应耗时优化、长尾处理。
-
效果验证类:用于运维的监控、报警、业务效果统计的埋点统计,用于数据正常与否的数据核对处理。
-
临时处理类:Mock联调,单元测试等。
-
测试阶段:
-
接口测试、功能测试。
-
压测、自动化测试。
-
故障演练
-
发布阶段:白名单验证、灰度发布、回滚、降级等正向与逆向操作。
-
运维阶段:用户动线日志,数据血缘关系,异常根因分析、监控、报警、大盘、智能机器人。
-
效果回收:业务埋点,转化率,DUA,业务看板,业务日报。
-
项目管理:通过敏捷开发、迭代开发等项目开发模式组织各个技术节点高效高质量软件交付工作。
我们通过对上面的工作进行归纳总结、提取通用能力、工具化、平台化既能形成我们的“器”,来提升对应工作的效率。不可否认,大家也是这样做的,每个阶段都有很多工具和方法可以使用,很多团队与个人均有一些自己的沉淀来提升对应的工作效率且大多数运行的好好的。
3.3 研发提效的两大问题
虽然提效已经有成熟的工具平台,但依然存在两个主要问题,也是女娲核心解决和修复的问题。
低集成度导致学习成本高和产生大量胶水代码
大部分是以各种成熟的中间件形式而存在,需要时通过相应的“胶水代码”进行数据链路、系统链路打通。
比如我们需要压测,首先需要接入某近端包上线进行流量录制;我们需要数据效果统计,系统健康监控报警时候,首先我们需要埋点日志打印;需要进行缓存存储时,需要存储SDK引入,Bean注入。
这些独立、模块化的工具、框架、平台在使用时,注定需要不少的“胶水代码”进行串联,每一个都以自己为中心,也带来不少的学习成本和工作量。
rpc接口粒度的最小单元限制很多研发工作开展
而微服务的架构下,我们暴露的服务方法是业务的最小单元,一个接口方法对应一个完整的用户行为,接口是程序对外的最小单元。
但接口方法在程序内视角只是一个入口,内部由更基础的技术处理单元串联构成,比如存在下游防腐处理,数据聚合处理,逻辑加工处理,存储操作等等,甚至每一个业务数据单元的读写都是一个处理单元。绝大多数提效工具、平台、中间件仅针对于的网络调用这一层,而是程序内部处理单元视角。
我们的程序大多是面向对象,一个接口的业务逻辑是有若干内部较为独立的处理单元串联而成,当很多时候我们在研发中需要对内部逻辑处理单元进行处理监听、干预、统计时,所有现成工具变动都不实用了。
比如我们要统计一下某个下游接口在我们应用中被哪个出口方法调用了,我们很难找到内部两个组件的关联分析的工具,另外很多时候为了某个特殊的用途,比如用于单元测试,数据订正,应急的后门,用于智能机器人的查询接口,我们需要将内部的一个处理单元单独包一个接口对外暴露出去。
这些的根因是所有工具和平台的最小处理颗粒度为rpc接口,而女娲聚焦的恰恰是代码层面的业务组件。
3.4 女娲产生的导火索:通用缓存
上面从上向下分析让我们意识到传统的研发工具大部分停留到接口一层,很多时候并不能直接支撑我们的述求,我们是否可以思考一下为什么造成这样的结局,我们是否也可以换个角度,是否这些环节有更加高效的方式?
从业务中来,到业务中去,做女娲的引子是做口碑评价架构升级蚂蚁侧系统迁移入淘,然而依赖服务大部分还在蚂蚁,需要给一堆跨域接口添加缓存,通常我们会逐场景进行code-hard;为进一步提效我们会写缓存的工具类,封装一些缓存读写逻辑,在需要的地方尽量最小侵入的方式调用一下;再再进一步我们会通过声明式注解进行缓存声明实现,业务逻辑几乎无侵入,比如Spring @Cacheable注解或自定义实现一套注解。
虽然上面三种形式在一步一步更高效,只是从代码层面从100行缩短50行,50行缩微20行,从代码的易用性和复用性有一定程度的提升,只是做了一定优化,并没有根本上的差异,无法快速应对风险,不能本质解决问题。
总结如下:
- 缺乏体系化能力:缓存除了读写,往往还伴随着预热、更新、多级处理等,在微服务的架构下,这些动作可能分拆到多个系统进行,每当你添加一个新的业务缓存实例,你均需要考虑使用什么存储,定义什么样的KV,在什么地方读,在什么地方清理与更新,缓存多长时间,是否做预加载,如何做预加载、缓存命中率监控与预警,部分命中数据merge,是否有缓存击穿,穿透,雪崩等处理机制然后在需要的地方添加对应的代码,显然比较分散,维护性不高,但成本很高;同时缺乏立竿见影的数据效果分析,通常中间件只提供资源视角的看板,而业务场景下的缓存命中率,更新率,平均更新时长,部分命中率,缓存准确率等需要自定义效果看板。我们可以看到背后涉及一整套内容,如果考虑不那么全面,就可能带来问题和修修补补或者背锅;
- 缺乏动态调整干预能力(灵活性):当我们通过代码添加缓存时,往往一些参数是写死的,比如未命中的处理逻辑,ttl的取值,然而实际运行时发现ttl可能长了也可能短了,当需要调整时,需要重新走一下需求迭代发布流程,你永远不知道有什么特殊的情况和突发状态,我们可以需要在缓存运行参数上做一些手脚;
- 缺乏快速复制能力:都是在一个一个接口case by case进行缓存这个非功能需求迭代,往往业务比较复杂,我们很难事先预料存在性能瓶颈的接口,如果上线后发现某接口性能不达标,是开发紧急打补丁,重新测试部署与上线;还是先上线,重新排期下次优化;亦或者延期修复后再上线。无论是哪种开发都很被动;
- 易用性:机上通过代码上作文章已经有些优化,但是将技术和业务耦合在一起,很多时候提升了系统复杂性导致不易用。
除了缓存外,其他的兜底、流控,动态埋点,Mock、单侧等等这些研发过程与业务无关的其他非功能需求存在同样的问题。即:
- 每当一次开发,均需要开发一堆配置能力
- 每当有调整,均需要改代码
因此,缺乏完整、不够灵活、复杂度高、烟囱问题致难以快速复制和工作量较大。
04 女娲的建设
=======
4.1 目标:研发提效数倍以上
面对大量的重复工作量、运行时未知风险较弱的调控能力,传统开发模式引发的一定的效率问题,通过一定效率提升手段使:理想情况下以前硬编码2小时~1天的工作量,可以降低到1分钟~30分钟即可完成,效率提升至少n倍以上
4.2 难点:提效场景复杂性和多样性
- 面向不同人群,不同工作内容,不同形式的提效场景,如何在女娲中进行统一表达与实现,形成统一心智,系统化的构成,不至于太零碎;
- 采用什么的架构和处理模式,能有效提升女娲研发效率,新场景扩展效率;
- 场景众多,如何规划模块、定义模块,定义模块实现和扩展标准,如何统一化的术语,形成一个体系化的解决方案。
4.3 核心思路:化功大法七式
那应该采用怎样的效率手段呢,我们分别针对于可做工指标:完整性、适用性、灵活性、易用性、隔离性、复制性进行拆解,最终形成体系化、通用化、动态化、智能化、透明化、规模化、协议化相应的解决方案。
✪ 4.3.1
体系化方案for完整性**
通过体系化方案形成提效目标的完整性,提供体系化的能力全方位保障技术需求的闭环,比如缓存的预热和效果展示做成通用能力,链路直接打通,减去业务特化的胶水代码,保证完整性。
比如缓存,形成完整的缓存操作闭环:
- 首先针对缓存能力本身进行归纳总结,其中包括不限于读能力、写能力、存储能力、预热能力、多级处理能力、统计能力,以及发布与运维时需要降级能力、灰度能力等。
- 其次分别选择不同的载体和扩展方式,将这些能力打通,比如面向代码某个触点的AOP动态代理读写处理,面向通用SPI的预热和调试能力,面向统计的Metric统计模块和配套数据看板,以及用于缓存存储数据源管理模块,缓存压缩的模块等等。
✪ 4.3.2 通用化方案for适用性
由于女娲的目标是研发提效,范畴较为广泛,采用何种技术架构最后在若干提效场景均使用,那么依赖通用化的解决能力来提升女娲在方方面面提效的实用性。
比如下面讲到:
- 三个统一:概念、模型、流程
- 两个体系:插件、页面
- 一个生态:阿里技术体系
原则:使逻辑通用、扩展/配置灵活、避免“复制/粘贴”大法(方案:模块化+组件化+配置化方案)
✪ 4.3.3 动态化方案for灵活性
通过动态化的方案提升提效的灵活性,增强运行时干预能力,如更加灵活的手段创建和修改业务缓存实现,提升灵活性,不需要代码发布即可完成相关工作。
比如缓存:缓存插件通过指令下发驱动,指令下发根据运行的实例差异携带不同的运行参数,这些运行参数,站在我们的视角就是配置,集团内选择Diamond作为配置中心是个常规的选择。
✪ 4.3.4 智能化方案for易用性
通过智能化手段降低复杂度、提升易用性,提供智能的方式降低技术实现的复杂度,降低各种人工设参和构建实例的逻辑实现步骤,从命令式切换为声明式,不用关注技术细节,只需要关注技术目标。
比如缓存,如果要让缓存更智能,通过缓存技术的深入,合理的抽象,成熟的处理模式进行封装,形成一个缓存工具,或者缓存框架,更或者是一个平台,在女娲中则是一个技术插件。
个人理解易用有四重境界:
- 第一重,code-hard,面向命令式编程语言进行编程实现,开发需要具备相应的业务理解和编程技巧;
- 第二重,DSL语言,基于特定领域语言进行逻辑的封装与表达,比如groovy脚本语言,表达式/函数语言以及其他DSL,一定程度进行声明式开发,然后进行运行调试,用户需要具备一定的业务经验+DSL语法操作技能;
- 第三重,配置界面,系统将DSL封装界面化,将技术语言通过业务语言进行表达,通过表单+编排+积木等方式进行逻辑表达和串联,用户只需要具备专业的业务经验通过配置及能实现;
- 第四重,AI智能化,我们接触的很多框架工具都非常通用,因为他们面向用户群体广泛,不通用必然在某些场景不适用,导致引来负反馈,因此通用是他们首先要支持的能力,然而越通用就越开放,越灵活,反而易用性下降。在特定的领域内,通用的内部其实符合二八定律,可以把八做到更加易用和通用,比如缓存配置可以程序计算出用户80%的可能的选择,用户只需要确认或者n选一即可,此时用户只需要具备很少的业务经验及能完成。
这四重境界不是互斥关系,而是包含和依赖关系,比如我们的缓存方案:
- 通过插件进行核心的中枢逻辑封装;
- 通过SPEL等表达式语言作为业务差异化扩展逻辑定义;
- 通过女娲控制台在线表单,对SPEL封装,可以直接进行表单对缓存应用实例配置;
- 通过流量回放+在线学习方式进行缓存键可能组合进行智能推荐,用户只需“点点点”。
原则:优先no-code, 其次low-code,再次hard-cord。
✪ 4.3.5 透明化方案for隔离性
女娲的运行环境是沙箱环境,而业务运行的环境是真实环境,做好环境隔离,对业务使用是透明的,对结果、时效均不产生影响,主要体现如上三点:
- 业务与技术尽量隔离,降低对寄主应用的侵入(方案:SDK+动态代理+控制台)
- 女娲异常和业务异常隔离,避免带来业务故障(方案:封装单独的异常对象)
- 性能尽量最优,避免对原处理增加额外性能负担(方案:预加载+方案按需编排)
✪ 4.3.6 规模化方案for复制性
通过规模化的手段提升提效的快速复制性,提供轻量级接入,构建易用易复制可规模化的解决方案,为了方便缓存方案能快速复制到其他应用场景,所有所有处理的中枢逻辑均封装在女娲SDK中,针对于业务差异化部分均通过女娲控制台来进行配置,应用只需要一次接入,多次使用。就像引入一个第三方lib一样引入即可。
✪ 4.3.7 协议化方案for开放性
通过合适的接口定义开放出去,业务同学可以根据自己的述求实现,甚至是插件,将能力对外开放,甚至是插件生态和共享。
05 关键点设计1:三个统一
=============
三个统一、两个体系、一个生态:
- 三个统一:统一概念 + 统一模型 + 统一流程;
- 两个体系:多样的插件 + 配套的页面;
- 一个生态:丰富的生态。
三个统一
概念、模型、流程三个的统一
- 统一的概念:统一的问题定义;
- 统一的模型:存储模型、消息模型、配置模型;
- 统一的流程:注册流程、代理流程、处理状态机。
5.1 概念统一
✪ 5.1.1
插件和元件**
插件:技术概念,插件是近端包中预置实现技术组件,本质上是完成一个独立的技术目标的代码片段,默认并不会执行,是由单独的指令和配置驱动插件执行以及传递执行时所依赖的参数,女娲提供了多个插件,同时提供了简单的插件注册和实现API。
- 举例:缓存插件、兜底插件、Mock插件,灰度插件,埋点插件、统计插件、流控插件、单侧插件
- 归属模块与对应代码:女娲SDK – NvWaPlugin.java
元件:技术概念,是插件可以使用的原子能力,如果上面是自上向下的设计的产物,那么元件就是自下向上设计的产物。
- 举例:内容组装元件、灰度元件、限流元件,元数据统计元件…
- 归属模块/代码:女娲SDK – xxExecutor.java。
✪ 5.1.2 切点和切面
切点:可以称之为“触点”,技术概念,顾名思义,切入的点,触发扩展逻辑的点,插件是一个Action,那切点这是插件执行的Target,切点分为两类:
- 精确切点:一个切点对应代码中的一个类下的一个方法,即不同的Method对象就是不同的精确切点,插件作用于类下某个方法;
- 模糊切点:通常系统中的精确切点在几百到几千不等,当我们希望将系统相关的若干切点均应用同一个插件时,我们可以定义一个模糊切点,通过正式表达式表达,正则表达式锁匹配的精确切点均共享应用插件实例,而不用每个精确切点都重复配置和实例化一份。
举例:
- 精确切点:com.alsc.comment.kbt.integration.rateplatform.impl.RateQueryServiceClientImpl#queryRates
- 模糊切点:com.alsc.comment.kbt.integration.rateplatform.impl.RateQueryServiceClientImpl#.*
- 归属模块/代码:寄主应用 – IOC容器下类的所有public方法。
切面:Spring的切面(Aspect)是一种用于在应用程序中定义横切关注点的技术。
✪ 5.1.3 场景和方案
场景:业务概念,代表一个完整的技术能力应用,在业务上也对应的一个完整的应用,一个插件作用于一个切点构成一个场景。
即:1个插件+1个切点->1场景,场景是组合Action + Target的一个Trigger。
- 举例:[查询附近好店列表][兜底]场景、[查询店铺信息][缓存]场景、[防腐层][服务调用监控]场景。
- 归属模块/代码:控制台 – 每一个功能菜单下的每一个切点配置集合。
方案:业务概念,方案是一个场景下插件具体需要如何工作的一组定义,一个场景可以有一到多个方案(即多组定义),一个方案包含插件运行时依赖的若干参数,多个方案则可能匹配其中一个方案(排他)或多个方案协同执行(共享),方案是Action工作的方式,一个方案对应一个Executor,一个场景n个方案则有n+1个Executor,其中1个为多个方案Executor协调的Executor。
- 举例:[查询附近好店列表][兜底]场景首屏兜底方案、[查询附近好店列表][兜底]场景非首屏兜底方案。
- 归属模块/代码:女娲SDK – xxExecutor.java。
5.2 模型统一
消息模型:也是领域模型,是驱动插件工作方案元数据模型,将建立存储模型和插件的桥梁,来源于配置模型,同时需要考虑各式各样插件在领域模型的通用和差异化表达,对应Model Object。
配置模型:也是展示模型,指控制台进行场景配置时的结构化配置模型,该模型站在配置人员的视角进行设计,以配置人员体验为优先,同时也能很好转为为存储模型,对应View Object。
存储模型:所有前端配置最终持久化时的存储模型,该模型方便转换为配置模型也方便转为为领域消息模型,对应Persistent Object
无论是哪种模型,都是针对于一个切点的插件应用场景的配置,即模型的实体是女娲场景和方案对象。
✪ 5.2.1 消息模型(DO,归一化)
挑战
由于插件太过于多样化,为了灵活性需要,插件运行时需要支持动态的参数是不一样的,比如:
- 缓存插件依赖的参数有:缓存引擎,缓存键表达式,批量缓存结果智能分拆表达式、是否缓存控制,异步加载策略(采样率、智能学习、上次加载时间间隔等配置)、预热模板、缓存条件表达式、ttl等;
- 兜底插件依赖的参数有:兜底快照存储引擎、兜底快照索引键表达式数组列表(按优先级)、兜底命中条件表达式、兜底数据更新条件、兜底快照ttl、兜底快照更新策略(强制更新策略、弱更新策略、更新检查条件等;
- 流量录制依赖的参数有:总录制条数、计划录制开始、结束时间、录制速度控制、录制采样频率,录制内容设置等。
思路
方案一:无统一的消息模型时
所有的插件均各自定义自己的模型消息,插件内部根据各自的消息模型做相应的处理,即定义:缓存消息模型、兜底消息模型、流量录制消息模型。
优点
- 每个插件对应一个消息模型,比较灵活,语义明确,直接
缺点:
每添加一个新的插件
- 模型层面:均需要自定义对应的消息模型,工作量 + 1;
- 插件实现层面:均需要监听、消费、解析对应的消息模型做响应的事情,工作量 + 1;
- 控制台层面:搭建对应配置页面时,均需要支撑新的消息模型对应的字段,工作量 + 1。
而实际我们发现很多字段都有类似的含义或者同样的作用,比如:useCacheExpr和useSnapshotExpr均表示插件运行时的前置检查,而cacheEngine和snapshotEngine均表示目标的存储实例对象。
是否有更适用的方案呢?我们这里进行各种场景的归纳总结与统一。
方案二:统一的消息模型(元件聚合消息模型)
插件为达到固有的技术目标,通常需要一系列动作组合实现,即内部可拆解若干原子操作,比如我们将缓存插件内部拆解为读缓存、写缓存、独立条件判断、缓存KV计算,统计等原子操作,不同的插件的原子操作可能有类似的,也可以有个性化的,我们所有插件的原子操作进行聚类,同一个类型下的原子能力基本上具备相同的功效和相同的依赖运行参数,我们将通用的原子操作视为系统的“元件”,大体可以归纳为如下几种,比如:
- 读元件
- 写元件
- 灰度元件
- 限流元件
- 存储元件
- ….
而这些元件具有自己的运行时配置参数,比如灰度元件有:分桶键表达式、白名单测试组、灰度比列、灰度类型(手动、自动)、灰度计划等运行配置参数,元件拥有各自的元件配置。
不同插件复用同一套消息模型
优点
有统一的配置模型和扩展机制,添加新插件时:
- 模型层面:直接复用,复用不了在对应元件配置增加扩展字段(工作量<方案一工作量*0.2)
- 插件实现层面:统一的监听、消费、解析,额外只需要针对于扩展字段单独解析(工作量<方案一工作量*0.2)
- 控制台层面:搭建对应配置页面时,统一的模型,大部分配置直接复用即可,扩展字段需要动态渲染(工作量<方案一工作量*0.2)
- 理解层面:统一的模型,不需要每个场景配置都需要独立理解一遍,大部分场景配置都复用
缺点:
- 由于复用统一的模型,语义没那么直接,比如统一模型checkExpr,而非统一模型为canUseCacheExr,后者有明确的用途,前者只是一个能力。
- 由于复用统一的模型,语义没那么直接,有根据有结构化定义和层级定义,报文更复杂。
方案1 vs 方案2
换个角度想,消息模型是给程序使用,语义的明确性和消息的简洁性和实实在在的工作量和复用性比较来并没那么重要。
因此,我们的消息模型毫无疑问选择方案二。
✪ 5.2.2 配置模型(VO,裁剪、拉平、映射、分组、排序)
消息模型是面向程序的,强调通用性、复用性、扩展性;而配置模型是面向用户的,强调简单,直接、明了;展示模型最终是为了转化为消息模型,因此配置模型基于消息模型而设计,如果我们不做加工直接使用存在一些问题。
挑战
缺乏场景语义(语义明确)
统一的消息模型基于技术能力设计,面向插件,采用较为通用的技术化概念,并非用户业务场景场景下业务概念,缺乏对应场景业务语义,并不能满足配置模型简单明了的要求,较为验证影响用户体验。类似于增删改查的数据库技术操作概念,而不是创建,编辑、保存、提交(对应的用户行为)等前端业务概念。
比如我们mock场景,需要针对于某个特殊场景返回某个固定的mock报文,如果按照消息模型直接展示则为内容信息 – 字符串类型内容 – 输入具体字符串值,比如:
而实际展示面向用户应该是这样的,不但能明确知道填写信息是干什么的,还能在这个场景下针对于该字段做特殊的样式和校验逻辑,比如这里的会用json编辑器渲染:
层级较深表达复杂(流程顺畅)
统一的消息模型是一个大而全的模型,由于承载的信息较多,因此会有更多的模块和更深的层级构成,而前端通常是表单、列表、等组件区块进行信息承载,对于层级和模块体现通常会通过子页面、弹窗、分步骤、分Tab进行嵌套,一个页面中如果存在太多的前端区块组件,特别是不同形式的区块组件、更特别的是嵌套的区块组件,不利于信息的表达,显得杂乱,人容易理解的信息流,比如我们常理解的I型、L型,而S型、万花筒式的信息结构更难理解。
比如我们在写入时,我们按层级表达是这样的:
如果基于具体场景打平,我们可以这样表达:
明显下面会更加简洁明了一些,当然例子只展示了冰山一角。
缺乏定制能力(面向程序 or 面向用户)
由于前面我们讲到了消息模型是大而通用,因此具体的场景并不需要所有的属性,因此实际场景编辑中需要进行裁剪;另外为提高配置的效率,我们需要针对于部分字段进行增强配置,增加一些额外的处理能,例如当我们需要对缓存的键值对增加在线自动学习能力;又比如当配置过于复杂时,我们需要让用户step by step来引导用户进行配置。
比如,缓存配置,面向消息模型时,则是缓存键组成部分的录入为:
此处进行展示层的结构化表达为:我们将缓存配置分为缓存基本设置 -> 流量录制 -> 缓存键设置 -> 缓存条件设置 -> 异步加载设置 -> 回放效果分析等步骤,同时针对于缓存键配置时我们增加了选择录制样本进行分析出推荐可选方案,类似如下:
思路
消息模型在不同场景下,增加前端的定制化渲染能力,即场景渲染模板,主要具备如下一些能力:
-
裁剪(简化表达)
消息模型是一个通用的,大的模型,而特化到某个具体的场景,往往不需要所有的配置,因此进行相应的裁剪,形成按需的子集
-
拉平(降层级,简化表达)
将消息模型json结构化数据打平,做为表单field字段的name,形成前端表单容易渲染的数据结构,而不用前端多组件的层级表达。
映射(强化表达)
消息模型的每个字段具备场景下具象化展示名称和展示描述,以及额外的展示能力,方便在不同场景下前端根据配置进行渲染。
正如上面将String的内容,映射为:
分组与排序(模块化,结构化表达)
针对于不同场景可以将消息模型拉平和映射后,可进行分组与排序进行模块化、结构化表达。
正如上面例子,将消息模型的多个模块转化为展示模型的多个模块。
最终分拆为多个表单,多个模块。
结论
增加渲染模板
增加场景配置模板,配置场景下如何对消息模型加工形成单独的前端配置模板,作为配置模型的场景定义,即完成裁剪、拉平、映射、分组、排序等工作。
页面动态搭建:前端组件化和动态渲染
将每一个元件的每一个配置项建立一个前端独立组件,根据渲染模板进行循环渲染
✪ 5.2.3 存储模型(PO,横纵表结合)
如上面介绍,作为工具平台存储模型核心目标就是做配置的持久化,同时方便转化为展示配置模型和领域消息模型,原则上既不面向用户也不面向插件实现。
挑战
存储本质是存储不同女娲插件应用场景下的方案配置,因此配置的主键是场景,场景下配置的是方案。而不同的场景需要配置项是很大不一样的。
站在展示模型,我们可能分步骤进行配置,因此方案下可能分为多个步骤,然后才是该步骤下的表单字段信息。
站在消息模型,我们的同一个方案消息模型存在多个元件构成,每个元件有若干表单字段信息。
是面向消息进行存储,还是面向展示进行存储?如何针对于上面四类信息进行存储,四类信息四张表吗?显然事情没那么粗暴
思路
面向配置进行存储
存储最紧密的关系是增删改查,特别是配置系统,存储是配置的载体,而存储转消息模型只是一锤子买卖,因此根据耦合度来讲,一定是基于配置进行存储的。
面向方案进行配置
前面讲到了1个插件+1个切点=1个场景,场景是Action + Target的一个Trigger,场景本身承载的信息较少,只是切点和插件的应用组合,下挂若干方案, 而方案存在具体描述和生效时间即内容,因此存储模型主体选择方案即可,而一个场景只是切点和插件的方案的聚合。
纵表存储个场景差异化步骤信息
一个方案存在多步配置,对用户来讲可能分段保存,也可能整体保存,用户的每一次操作和数据存储一一关联,因此将每一步的内容存储到纵表的JSON字段中,而步骤唯一code码与方案id作为纵表的联合唯一键组成。
结论
模型的统一是系统的稳定和工作量收拢的重要基础。
5.3 流程统一
✪ 5.3.1 插件开发与注册(开发流程)
开发单插件执行器、定义插件配置文件信息,以及注册插件至插件中心
1.定义插件配置文件信息(约定大于规范):
消息最终需要存储在diamond,在diamond进行如下设计:
- data-group: alsc-nvwa
- data-id:nvwa.{pluginCode}.conf.{appName}
- 归属应用: 对应场景切点归属应用
- 内容:一个应用的一个插件对应一个diamond配置,以切点作为diamond内容的key,方案定义列表作为value
2.插件方案Executor实现
定义统一的Executor接口,待实现方法如下:
- preHandle: 前置处理,插件处理调用下一个节点前会调用该方法
- afterHandle:后置处理,插件处理调用下一个节点后会调用该方法
- around:环绕处理,手动处理,根据自己的需要在调用下一个节点时做前后处理
3.定义插件Schema&插件注册(一行代码)
在com.alsc.content.sdk.nvwa3.schema.PluginSchemas#allPluginSchema 直接add一个插件Schema实例即可,PluginSchema构造函数参数定义如下:
- 插件编码
- 插件名称
- 插件处理类
- 单方案执行器
- 批量方案执行器
✪ 5.3.2 **插件预加载和编排(启动&热加载流程)
插件预加载与编排
配置的若干场景下若干方案的变化,均会由女娲的配置模块进行监听处理.
- 创建配置监听器:通用逻辑,无需额外代码,diamond统一监听,生成一个ConfigHolder;
- 插件Handler构建:通用逻辑,无需额外代码,MethodAspectHandlerRegistry.modifyHandler将插件Handler插入到对应的链表位置中,其中涉及精确切点,模糊切点;
- 方案Executor构建:通用逻辑,无需额外代码。
✪ 5.3.3 请求动态代理(运行处理流程)
代理处理流程(AOP)
- 流量入口:ConfigurationAspect 初始化(Spring Aspect)
女娲统一提供ConfigurationAspect进行流量拦截,在女娲引入时,进行手动注册;
- 代理执行:ConfigurationAspect调用
调用插件处理链进行业务方案执行,即调用MethodAspectHandlerRegistry.getHandler(methodReference);找到插件处理链;
- 插件执行:NvWaPlugin;
- 方案Executor执行:比如GraySolutionExecutor;
- 元件Executor执行:比如LimitAndRateExecutor。
✪ 5.3.4 泛化调用流程(SPI流程)
面向控制台使用的SPI服务:只需要控制台应用初始化才需要配置(com.alsc.content.sdk.nvwa.service.NvWaOpsSpiService)
面向切点的泛化调用SPI服务:通过泛化调用,可以任何一个切到的内部方法进行开箱供外部调用:*(com.alsc.content.sdk.outer.user.service.*)具体实现略
✪ 5.3.5 状态机与发布流程(配置流程)
状态机
版本控制
同一个方案配置在不同的环境中通过版本和环境标进行区分。
- 方案的环境标分别为: DAILY/PROD/PRE
- 方案的版本,日常和预发仅维持一个版本。线上维持多版本并存
- 预发发布线上,如果和上一次存在差异,则生成一个新的线上版本
- 可以直接选择线上任意版本同步回预发当前版本
发布流程
预发和日常只有全量状态才能进行同步至其他环境,线上不允许直接修改配置。
✪ 5.3.6 页面统一配置流程(配置流程)
所有场景均满足如下配置流程,且组件基本上各种复用。
- 定位应用&环境
通过女娲应用顶部banner,选择本次需要操作的应用和环境
- 选择切点
方式1: 包结构 -> 类列表 -> 方法列表 三级选择
方式2: 全部已配置树选择
方式3: 其他
- 创建方案&选择修改方案
- 配置页&详情页
跳转到配置页和详情页,配置页和详情页根据场景的差异(场景渲染模板)进行动态渲染展示和提交保存。
- 统一的状态和发布操作栏
所有场景方案的状态操作,控制通过方案列操作栏进行快捷操作。
06 关键点设计2:两个体系
- 多样的插件:各种提效场景的插件
- 配套的页面:各种提效场景的配套页面
6.1 女娲插件体系
女娲插件是以切点作为组织和调用视角,即:切点 -> 插件链 -> 插件 -> 方案执行器 -> 元件执行器,其中切点对应SpringBean的一个方法;插件链:切点需要动态代理执行处理链。
✪ 6.1.1 插件Handler
一个独立的技术能力组件,主要api为如下:
- public Object handle(MethodHandleContext context) throws Throwable
系统提供通用的插件实现com.alsc.content.sdk.nvwa3.plugins.NvWaPlugin,默认使用该插件即可,特殊情况也可以参考改插件进行实现新的插件。
✪ 6.1.2 方案Executor
方案是每个插件方案执行时的真正逻辑,主要提供如下三个api:
- PreHandleResult preHandle(MethodHandleContext context, NvWaPlugin plugin) throws Throwable;
- AfterHandleResult afterHandle(MethodHandleContext context, NvWaPlugin plugin, PreHandleResult preHandleResult) throws Throwable ;
- default Object aroundHandle(MethodHandleContext context, NvWaPlugin plugin) throws Throwable。
插件方案根据自己场景继承实现对应api即可。通常不需要三个api都实现,比如只实现aroundHandle,或者实现preHandle + afterHandler,或者preHandle与afterHandler之一。
✪ 6.1.3 元件Executor
树形组成关系
元件是一个树型包含关系。
另外,元件分为有生命周期元件和无生命周期元件,上图中含的元件为有生命周期元件,相反为无生命周期元件,其中代表当前元件无效,代表当前元件有效, 代表定时任务激活、失效生命周期元件。
无生命周期元件
无生命周期元件,默认是生效状态,接口定义如下,有一个输入与输出。
有生命周期元件
有生命周期元件,是指带状态的元件,且状态有自己的生命周期,状态决定该元件是否生效,同时该元件的有效性会通过“观察者模式”通知上一个元件是否生效。
其中根据自身元件配置决定会存在定时任务进行状态激活和失效,比如流控元件设置开始结束时间时。
✪ 6.1.4 女娲上下文(插件间通信)
由于存在场景的处理会联动其他切点场景,比如我们切点A命中内部链路监控场景,表明当前请求上所有切点均需要将调用栈打印出来,即其他切点也均需要按照该插件的要求进行默认处理。而如何联动呢,我们通过女娲上下文对象进行串联。
1.上下文对象
女娲上下文对象是一个数据容器对象,包含三部分内容:共享字典、隔离字典、处理钩子,定义如下:
<p></p>
public class NvWaContext {
/**
* 线程上下文
*/
private static ThreadLocal<nvwacontext> contextTL = new ThreadLocal();
/**
* 上下文传值数据对象(隔离字典)
*/
private static ThreadLocal<map object="">> cloneableDataTL = new ThreadLocal();
/**
* 上下文数据字典(共享字典)
*/
private Map<string object=""> data;
/**
* 上下文初始化时预加载对象(处理钩子)
*/
private static List<consumer>> initializedHooks = new ArrayList();
/**
* 上下文初始化时预加载对象(处理钩子)
*/
private static List<aopbeforeconsumer> aopBeforeHooks = new ArrayList();
/**
* 上下文初始化时预加载对象(处理钩子)
*/
private static List<aopafterconsumer> aopHandleAfterHooks = new ArrayList();
..
}
</aopafterconsumer></aopbeforeconsumer></consumer></string></map></nvwacontext>
2.共享字典与隔离字典(数据共享)
上下文对象本质是一个通过线程变量进行持有的数据容器对象,当需要时通过线程变量直接获取使用,而不用调用栈的参数将对象显示的传递下去。
同时,上下文对象通常存储一些通用的数据,多个线程之前需要共享,因此线程之间需要将上下文对象传递,传递分为两种新式
引用传递
我们run新线程任务时,将父线程线程对象赋值给新线程的的线程对象,而女娲上下文在线程传递时,默认以引用进行传递,更多减少对象的开销。
女娲中【共享字典】的所有数据默认采用引用传递。
值传递
然而有些场景,如内部链路监控时,如果通过引用传递则存在线程安全问题。
如我们需要递增计算链路中的spanId时,如果多个线程共享全局SpanId计数器,则最终SpanId就会混乱,此时需要一个线程之间隔离的SpanId计数器,该SpanId计数器继承至父线程的SpanId计数器,但仅线程内部可访问。
女娲中【隔离字典】的所有数据默认采用值传递。
具体应该使用隔离字典还是共享字典取决于要存储的数据是value,还是variable,如果是variable且存在线程安全问题则使用值传递,否则采用引用传递。
无论是共享字典的引用传递还是隔离字典的值传递,通过如果NvWaContext的三个静态方法分别打包提取、传递和清理。
3.上下文钩子(插件间干预)
女娲插件执行需要进行切点之间进行通信时,或者在所有切点,全局进行默认处理时,女娲开放了如下两个级别的钩子,供插件进行链路干实现:
【请求级】钩子
应用内部进入时的处理钩子,包括初始化和处理结束钩子,如List<Consumer<NvWaContext>> initializedHooks会在女娲上下文初始化时执行一次。如果命中内部链路监控插件,需要进入时增加一个链路监控的上下文对象并初始化,类似如下:
<p></p>
NvWaContext.addInitializedHook(nvWaContext -> {
NvWaContext.currentContext().addCloneableInstance(TraceSpanId.NV_WA_CONTEXT_TRACE_SPAN_ID, new TraceSpanId());
TraceSolutionExecutor.initRecordingTraceInfoIfNecessary(
nvWaContext.getEntryMethodReference(), nvWaContext.getEntryStartTime());
});
【切点级】钩子
每个切点处理时钩子,即List<AopBeforeConsumer> aopBeforeHooks,List<AopAfterConsumer> aopHandleAfterHooks,会分别在ConfigurationAspect前置和后置环节处理一次,比如在内部链路监控插件,会在切点处理结束时,继续进行埋点处理。
<p style="margin-bottom: 0px;margin-top: 0px;"><code data-syntax="java" data-theme="default"><span style="font-size: 15px;"> </span></code></p><p></p>
NvWaContext.addAopHandleBeforeHooks((methodReference, startMills, isInitialed) -> {
TraceSpanId spanId = NvWaTraceContext.getSpanId();
if(!isInitialed) {
spanId.enter();
}
});
NvWaContext.addAopHandleAfterHooks((methodReference, method, args, result, e, startMills, isInitialed, context) -> {
try {
TraceSolutionExecutor.recordingTraceInfoIfNecessary(methodReference, method, args, result, e, startMills, context);
} finally {
NvWaTraceContext.getSpanId().exit();
}
});
6.2 页面体系
当我们开发一个插件时,通常需要做如下事情:
- 开发插件相关代码;
- 搭建插件场景配置类页面;
- 搭建插件场景配套工具类页面(如内部链路监控场景,我们需要一个内部链路分析页面:输入一个trace既能查看查看链路的调用信息的);
- 搭建插件运行健康检测类(监控&报警)页面;
- 搭建插件运行的业务效果回收类数据报表。
比如:
前面我们通过“模型的统一” + “流程的统一” + “插件体系”解决了后端实现的烟囱问题,而前端页面如何高效的支撑呢?
针对于4大类页面,梳理“变”与“不变”,尽量复用现有平台或者平台现有组件,其中:
- 采用乐高3.0进行配置页面,配套工具页面开发
- 插件健康检查效果由Sunfire配置支持,最终集成到女娲中
- 业务数据效果数据由FBI独立配置与开发,最终集成到女娲中
✪ 6.2.1 接入乐高3.0深度应用
乐高3.0是集团内的一个低代码开发平台。
1.乐高独立部署
考虑乐高应用在乐高平台集中部署,与服务端http/mtop接口隶属于不同的域名,无法进行直接调用,最有效或者尽量低侵入乐高调用的方式,则采用和Spring Web应用进行集成部署。
2.导航栏定制化渲染
作为全局配置,其中包括应用选择和环境选择,选择后下一跳继承该选择,方便多步骤操作的连贯,其中应用选择范围为自己AONE有权限的应用,接入女娲可选,未接入女娲置灰。
3.统一的配置列表页
所有页面尽量统一风格,统一流程,甚至是复用,详见《页面统一配置模板(配置流程)》介绍,特别是配置页面,由于模型的统一导致配置页面的的复用成为可能。
一个插件一个的配置菜单,菜单对应的插件的配置管理列表页。所有插件均复用改配置管理列表页。
页面的组件、事件、样式完全服务,不同插件该页面仅有如下项几点配置不一样
页面链接不一样
该页面的url链接不一样,但都是以“{插件code}_list”命名
页面全局配置项不一样
- 插件调用服务端的业务类型不一样:即{插件code}
- 插件跳到配置页,详情页的链接不一样,比如跳转到配置均以“{插件code}_cfg”命名
- 是否可以针对于模糊切点配置不一样
配置元数据如下:
<p></p>
{
"businessType":"cache",
"cpLikeMatch": false,
"solutionSlug":"mock_cfg"
}
操作按钮不一样
列表配置项目的操作选项不一样,不同插件配置项对应的状态机,由服务端渲染模板控制,比如Mock的状态图为如下:
- 状态机的“配置中”别名调整为:未生效
- 状态机的“已全量”别名调整为:已生效
- 状态机进行了裁剪
4.动态渲染的配置页
基于前面讲到的场景配置模板,进行动态表单渲染,即使用配置模型驱动。
维护元件配置项组件库
参考消息模型元件设计,系统中存在如下10+个元件构成,每个元件的拥有自己的配置项,系统累计存在元件数 * 元件配置项数约100的配置项,而前端展示最终是通过场景渲染模板将消息模型进行“裁剪”+“拉平”+“映射”+“分组排序”形成每个场景特化的配置模型。
其中,每一个元件的配置项是独立的、较为明确的、不变的;而不同场景下配置项组成和别名是动态的,变化的,因此在乐高3.0中将将元件每一个配置项做成一个前端组件。
同一个元件配置项组件在不同场景下:
相同点:
- 统一的样式
- 统一的事件
不同点:
- 不一样的别名和描述
- 不同的编辑模块和编辑顺序
抽取如下:
分别对应如下红框部分:
使用循环渲染进行动态化表单渲染
乐高3.0对于动态渲染支持不是很友好,这里只能每一个元件配置项组件放在一个循环容器(x1,x2,x3…)中,然后将所有的元件配置项组件循环容器平铺到一个更大的循环容器y中,服务端将渲染模板处理成如下结构:
<p></p>
{
"code": [{
"title":"场景编码",
"desc":"同一个场景同一个方案编码唯一"
}],
"remark": [{
"title":"场景备注",
"desc":"场景备注仅为便于识别作用,无逻辑依赖处理"
}],
"matchExpr_items": [{
"title":"场景条件",
"desc":"无条件或者命中条件时配置才生效"
}],
...
}
然后遍历场景渲染模板的配置项,假设有n项,渲染循环容器y n次;每一次循环容器y渲染,仅渲染模板的同名配置项对应的循环容器x渲染一次,其他循环容器x不被渲染
5.定制化的工具页
由于不同的插件配套工具具备不同特性和功能,无法统一进行模板和组件抽取,这里直接使用乐高3的页面搭建能能力,服务端根据需要进行相关接口提供。
比如内部链路分析页面,和其他页面均不一样:
✪ 6.2.2 接入BUC&ACL&AONE进行权限认证
- BUC登录:BUC是集团内用户登录服务,乐高集中部署默认存在BUC认证,然而独立部署后,由于域名和运行机制的变化乐高的BUC无法生效,因此需要自己应用手动接入;
- ACL权限认证:ACL是权限认知服务,进行功能权限&数据权限配置;
- AONE数据权限认证:AONE是应用管理服务,进行兜底数据权限认证;
- 女娲Ops Spi切点信息拉取:进行页面渲染和操作提示。
✪ 6.2.3 服务端通用和个性化接口
顶部banner(通用)
- 应用列表查询接口
方案管理列表页(通用)
- 切点搜索接口
- 切点包目录接口
- 切点类列表接口
- 切点方法列表接口
- 方案召回接口
- 方案配置树接口
- 方案收藏树接口
- 我的方案树接口
- 方案状态修改接口
方案配置页(通用)
- 初始化接口
- 分步骤提交接口
工具页(个性化)
- 按需开发
07 关键点设计3:一个生态
===============
- 丰富的生态:各种提效场景的对应存储、外联工具
7.1数据源桥接模式(RDB、Cache..)
女娲读写对应的数据源有SLS、Lindorm、Tair、Mysql、File等多种类型的数据源
同时,同一个类型的数据源多租户,多个实例管理,不同的场景按需选用。
- 数据源的配置
所有数据源均配置在diamond上,dataId: nvwa.{dataSoureType}.instance.conf,配置项即为对应的链接元数据。
- 数据源的实现
女娲数据源模块提供统一的消费模式。
- 数据源的应用
StoreEngine实例化存储引擎。
7.2 打通监控链路(Sunfire、SLS..)
说明:Sunfire为监控服务,SLS为日志服务
路径:女娲Logger -> 本地File -> Sunfire,针对于每个插件提供标准的Metric埋点信息,打印到本地日志文件,最终在Sunfire以通用的模式进行监控,可根据不同插件定制,多租户多场景链路共享。
7.3 打通数据链路(ODPS、FBI..)
说明:ODPS为分布式文件服务(等价于HDFS),SLS为日志服务(等价于SLK中ES服务)、Blink流式计算平台(等同Flink), FBI可视化报表服务。
路径:女娲Logger(自定义Appender) -> MQ/SLS -> Blink -> Odps -> FBI -> 钉钉, 针对于各类实时,离线统计,通过现有通路进行加工处理,最终在FBI体现或推送钉钉群,可根据不同插件定制,多租户多场景链路共享。
7.4 打通排障链路(Robet、DingDing..)
路径:钉钉机器人 -> 机器人工厂 -> 女娲控制台API -> 女娲SPI,针对于日常运维各种问题分析链路,通过数据的泛化调用和编排完成信息的结构化组装与输出,可根据不同插件定制,多租户多场景链路共享。
欢迎留言一起参与讨论~