找回密码
 会员注册
查看: 45|回复: 0

表达式引擎在转转平台的实践

[复制链接]

8

主题

0

回帖

25

积分

新手上路

积分
25
发表于 2024-9-20 03:54:44 | 显示全部楼层 |阅读模式
一、业务背景介绍二、工程现状三、方案选型四、使用表达式引擎重构架构设计配置重构上线小插曲重构前后对比五、总结和感悟一、业务背景介绍笔者负责了转转APP后端研发工作,主要的模块有首页、列表、页、个人中心等。在负责的页模块中,有这样的一个场景,APP打开不同商品的时候,会根据商品所属的业务,跳转到对应业务所在的M页。APP原生页面仅支持最基础样式的页,其他业务均为M页实现,平台承担着一个路由转发的基础功能。二、工程现状重构前配置方式采用的是Map,Key为拼接的属性条件,Value为跳转的配置实体。重构前的配置示意:{  "label_红布林标签ID": {      "url": "红布林落地页地址",         "name": "红布林配置",      "其他字段": "省略表示"    },  "uid_帮卖用户ID_searchType_2": {      "url": "转转帮卖暗拍落地页地址",         "name": "转转帮卖暗拍配置",  },  "uid_帮卖用户ID": {      "url": "转转帮卖落地页地址",         "name": "转转帮卖配置",  },  "cateId_图书社会科学分类ID": {      "url": "图书科学类落地页地址",      "name": "图书科学类配置",  },  "cateId_图书自然科学分类ID": {      "url": "图书科学类落地页地址",       "name": "图书科学类配置",  }}匹配流程示意:接手此模块的开发工作后,这种开发模式随着需求的迭代暴露出以下问题:缺乏动态扩展性:每次新增一种配置维度,需要上线才能完成Key的拼接逻辑和读取逻辑,且读取的优先级硬编码。已有的属性无法自由组合,如转转帮卖暗拍配置的规则为:卖家ID+搜索类型叠加,只能硬编码的方式进行拼接Key,下次的组合维度发生变化还需要开发。臃肿配置不易维护:相同业务需要维护多条配置,如图书社会科学和自然科学对应的落地页地址都是一个,按照Map的的配置方式需要配置2条、3条、甚至更多。长此以往,Key的拼接和解析逻辑越来越难以维护,配置也越来越庞大。投入产出不成正比:每次上线只是开发拼接Key的逻辑和读取逻辑,都是重复工作,成本高,收益低。通过以上问题分析,我们理想的解决方案应该具备高扩展、低成本、易维护的特点。新需求做到尽可能不上线,通过简单的配置,比如用商品中的属性作为动态配置进行逻辑运算,即可高效率响应业务多变的跳转需求。三、方案选型方案的大体思路把变化的条件和判断通过配置来实现,自然回想到了熟悉的JSP中的EL表达式求值,利用EL表达式可以求出对象中的任意属性值,然后再对这些求值做逻辑运算,得到True和False就可以了。 那么如何实现这个求值和逻辑运算呢,我们选取了几种主表达式引擎作对比如下:SpringElAviatorMVEL简介Spring体系开源开源优点在Spring框架中集成度高,属性求值方便轻量级,高性能动态JIT优化器缺点不明显由于轻量级,部分语法不支持Jar、依赖庞大、社区不活跃扩展支持自定义函数需要完成签名自定义函数、Java函数调用定义脚本较为复杂Aviator虽然缺失部分语法,但考虑到实际场景中不会涉及到,其便捷的自定义函数、Lambda、以及轻量、高性能等优点,最后采用了Aviator作为重构的表达式引擎作为支撑。四、使用表达式引擎重构架构设计开工前,我们对其进行一个小小的封装,作为一个基础组件方便接入以及拓展到更多的场景。img.png封装后提供以下特性(下文出现的规则理解为一条表达式配置):规则配置标准化:提供标准的规则配置存储协议接口,配置实现可以是阿波罗或者自定义后台,根据需求规模自行选择存储实现,环境变量和规则抽象类统一封装,开发者只关心接入逻辑。引擎扩展化:提供快速添加自定义函数的能力,规则切面的扩展能力,开发者可以对规则进行环绕编程。API人性化:提供多种API支持,单个、多个规则目标匹配,自定义拦截扩展匹配。组件需要的配置介质定义为接口,预留原生扩展能力,使用API即可完成规则的快速匹配。配置重构采用表达式引擎重构后由传统的KV结构转变成规则型配置,不同业务的配置按照规则维度区分,有冲突的配置采用优先级和条件叠加的方式,配置示意:[  {      "url": "红布林落地页地址",         "name": "红布林配置",      "ruleEl": "list.contains(labelList, 红布林标签ID)",      "priority": 20,      "其他字段": "省略表示"     },   {      "url": "转转帮卖暗拍落地页地址",      "name": "转转帮卖暗拍配置",      "ruleEl": "帮卖用户ID == product.userId & 2 == product.product.searchType",      "priority": 20    },    {      "url": "转转帮卖落地页地址",      "name": "转转帮卖配置",      "ruleEl": "帮卖用户ID == product.userId",      "priority": 20    },    {      "url": "游戏代练陪玩落地页地址",      "name": "游戏代练陪玩配置",      "ruleEl": "代练分类ID == product.cateId || 陪玩分类ID == product.cateId",      "priority": 20    },]上线小插曲首次采用Aviator表达式引擎重构上线后,企业微信和服务管理平台第一时间收到了页超时的报警消息,因为超时发生在上线后,果断采取了回滚操作,恢复线上服务稳定。通过监控发现案发时刻集群FullGC严重,观察集群监控指标,类加载数量指标一直在飙升,自然联想到应该是使用本次Aviator带来的问题。所以打算本地编写复现代码,进行复现,开启JVM类加载参数:-XX:+TraceClassLoading以便观察类加载的情况,对测试代码进行测试,随着程序的运行可以在控制台发现了大量的类加载信息打印:[Loaded Script_1645773082560_152/982082822 from com.googlecode.aviator.Expression][Loaded Script_1645773082514_151/1163475645 from com.googlecode.aviator.Expression]通过日志可以看到加载类为:com.googlecode.aviator下的Expression类,类名为:Script_当前时间戳_一个ID,那么表达式引擎执行的过程中为什么进行类加载呢,怀着疑惑的态度,阅读源代码,大致可以分析出简略执行逻辑:传入表达式>>是否开启缓存>>每次生成ASM码(否),下面分步解释首先开始执行逻辑,准备将表达式编译public Object execute(final String expression, final Map env,      final boolean cached) {    // 1.编译表达式    Expression compiledExpression = compile(expression, cached);    if (compiledExpression != null) {      return compiledExpression.execute(env);    } else {      throw new ExpressionNotFoundException("Null compiled expression for " + expression);    }  }然后根据是否启用缓存,决定实时编译还是缓存模式public Expression compile(final String expression, final boolean cached) {    if (expression == null || expression.trim().length() == 0) {      throw new CompileExpressionErrorException("Blank expression");    }    if (cached) { // 2.缓存开启与否      FutureTask task = this.cacheExpressions.get(expression);      if (task != null) {        return getCompiledExpression(expression, task);      }      task = new FutureTask(new Callable() {        @Override        public Expression call() throws Exception {          return innerCompile(expression, cached);        }      });      FutureTask existedTask = this.cacheExpressions.putIfAbsent(expression, task);      if (existedTask == null) {        existedTask = task;        existedTask.run();      }      return getCompiledExpression(expression, existedTask);    } else {      // 3.实时编译        return innerCompile(expression, cached);    }  }最后生成字节码逻辑public ASMCodeGenerator(final AviatorEvaluatorInstance instance,final AviatorClassLoader classLoader, final OutputStream traceOut, final boolean trace) {        this.classLoader = classLoader;        this.instance = instance;        this.compileEnv = new Env();        this.compileEnv.setInstance(this.instance);        // 上面打印的生成ASM的类名        this.className = "Script_" + System.currentTimeMillis() + "_" + CLASS_COUNTER.getAndIncrement();        // Auto compute frames        this.classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);                visitClass();}到这里类一直加载的原因也找到了,Aviator默认不开启编译结果缓存,所以导致每次执行的时候都会重新编译ASM,然后加载,最后导致FullGC。开启缓存后重新上线,运行稳定。重构前后对比通过一段时间的需求迭代对比后,采取表达式引擎的面向配置编程的方式,从灵活度、可读性、扩展性、投入成本相对于传统的编码方式效率提升明显,效果理想。封装后的组件大量应用到列表页,首页等场景中,动态灵活的规则配置在大促618,双11期间事半功倍。利用表达式内置的函数和自定义函数,避免同类需求多次开发,避免重复造轮子。常规类配置需求,利用表达式引擎基本做到需求变更无上线。五、总结和感悟通过对历史逻辑的分析,发掘痛点,敢于引入新技术,带来收益。联想到日常工作中也是如此,大多数时候我们总是忙于响应需求,很多时候都是按着老逻辑继续维护,只要能满足功能,哪怕麻烦点,都不会去重构它。殊不知这种习惯在慢慢侵蚀我们的心灵,吞噬我们的斗志,所以鼓励大家大胆走出那一步,相信只要你肯往前走出一步,就会带来满意的收获和成功,所以一起加油吧。关于作者赵天明,转转APP后端负责人。签名:一切觉得麻烦和不爽的问题,一定是打开的方式不对。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 11:56 , Processed in 0.563817 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表