该工程(enhance-boot-groovy-engine
)主要是利用【springboot + groovy
】对groovy动态加载脚本功能进行了封装和集成,使得在springboot项目中能够更加简单方便的使用groovy在不重启的情况下来动态的加载外部脚本,可以看做是一个基于groovy封装的轻量级【规则引擎】。
demo-enhance-groovy-engine
中是对该项目的一些使用demoapplication.yml
】参数即可方便使用GroovyClassLoader + InvokerHelper
+ 缓存parse好的Class对象】来解决频繁load groovy脚本为Class从而导致方法区OOM问题。同时通过缓存脚本信息来避免每次执行脚本都需要重新编译而带来的性能消耗,保证脚本执行的高效。EngineExecutor
方便的来执行脚本,同时提供多种执行脚本的方式,仅需要传入能定位脚本的唯一key和脚本里需要的参数即可方便的调用指定的脚本进行执行了caffeine
】来缓存脚本项,并设置过期时间,并且项目里提供了定时刷新(刷新间隔周期可配置)本地缓存里的脚本项的异步线程,可以及时的将【本地缓存的脚本项】和【数据源中的脚本项】进行对比,一旦发现数据源中的脚本发生了变更则及时刷新本地缓存中的脚本项,并且替换原脚本对应的Class。ApplicationContextHelper
】提供了操作spring容器的一些功能,可以借助该helper方便的对IOC容器进行操作RefreshScriptHelper
】提供了动态刷新本地内存中的脚本的能力,可以通过该helper提供的方法来手动的刷新本地内存的脚本(支持单个刷新和批量刷新),比如:新增或修改了某个脚本后想立即让该脚本生效。RegisterScriptHelper
】提供了动态向数据源和本地缓存里注册脚本的能力,可以通过该helper来动态的向数据源和本地缓存中修改脚本或注册新的groovy脚本源码地址:https://gitee.com/mr_wenpan/basis-enhance/blob/master/enhance-boot-data-redis/README.md
执行命令:mvn clean install
(需要切换到该项目目录下执行)
==特别说明==:
enhance-groovy-engine-core
】是核心依赖,必须要引入,其他的按需求选配即可。enhance-groovy-classpath-loader
】即可enhance-groovy-redis-loader
】,由于是从Redis中读取脚本,所以Redis的核心依赖不能少【spring-boot-starter-data-redis
】和 【commons-pool2
】(如果项目中已经有Redis了,那可以不引入这两个)enhance-groovy-mysql-loader
】以及连接MySQL所需要的的依赖<!--核心依赖-->
<dependency>
<artifactId>enhance-groovy-engine-core</artifactId>
<groupId>org.basis.enhance</groupId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--++++++++++++++++++++++++++++++++以下三种loader,按需选配即可++++++++++++++++++++++++++++++++-->
<!--++++++++++++++++++++++++++++++++++第一种:基于Redis的loader++++++++++++++++++++++++++++++++++-->
<!--加载Redis下的groovy脚本loader依赖-->
<dependency>
<artifactId>enhance-groovy-redis-loader</artifactId>
<groupId>org.basis.enhance</groupId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--redis核心依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--Apache的 common-pool2(至少是2.2)提供连接池,供redis客户端使用-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--++++++++++++++++++++++++++++++++++第二种:基于Classpath的loader++++++++++++++++++++++++++++++++++-->
<!--加载classpath下的groovy脚本loader依赖-->
<dependency>
<artifactId>enhance-groovy-classpath-loader</artifactId>
<groupId>org.basis.enhance</groupId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--++++++++++++++++++++++++++++++++++第三种:基于MySQL的loader++++++++++++++++++++++++++++++++++-->
<!--加载MySQL下的groovy脚本loader依赖-->
<dependency>
<artifactId>enhance-groovy-mysql-loader</artifactId>
<groupId>org.basis.enhance</groupId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--以下是mysql相关依赖-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<!--mybatis依赖-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
==以下以从Redis加载脚本为例来演示配置==
server:
port: 1234
spring:
application:
name: customer-console
# redis基础配置
redis:
host: ${SPRING_REDIS_HOST:xxx-host}
port: ${SPRING_REDIS_PORT:6379}
password: ${SPRING_REDIS_PASSWORD:xxxx@123}
database: ${SPRING_REDIS_DATABASE:2}
enhance:
groovy:
engine:
# 脚本检查更新周期(单位:秒),(默认300L)
pollingCycle: 10
# 开启功能
enable: true
# 缓存过期时间(默认600L分钟)
cacheExpireAfterWrite: 10
#缓存初始容量(默认100)
cacheInitialCapacity: 10
# 缓存最大容量(默认500)
cacheMaximumSize: 20
# 开启基于Redis加载groovy脚本
redis-loader:
# 命名空间,可以和应用名称保持一致即可,主要是为了区分不同的应用
namespace: customer-console
# 开启基于Redis的loader
enable: true
RegisterScriptHelper
来注册脚本,这里采用预先加载好脚本到Redis来演示org.enhance.groovy.infra.groovy
目录下,自己按需设定脚本key即可package org.enhance.groovy.api.dto
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class ChangeOrderInfo extends Script {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
Object run() {
// 调用方法
changeOrderInfo();
}
// 修改订单信息
OrderInfoDTO changeOrderInfo() {
String newOrderAmount = "20000";
// 获取参数
OrderInfoDTO orderInfoDTO = orderInfo;
logger.info("即将修改订单金额,原金额为:{}, 修改后的金额为:{}", orderInfoDTO.getOrderAmount(), newOrderAmount);
orderInfoDTO.setOrderAmount("2000");
// 返回修改后的结果
return orderInfoDTO;
}
}
package org.enhance.groovy.api.dto
import org.basis.enhance.groovy.entity.ExecuteParams
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class ChangeProductInfo extends Script {
private final Logger logger = LoggerFactory.getLogger(getClass());
// 修改商品信息
ProductInfoDTO changeProduct(ExecuteParams executeParams) {
// 获取product对象
ProductInfoDTO productInfo = (ProductInfoDTO) executeParams.get("productInfo");
Double newOrderAmount = 20000D;
logger.info("即将修改商品金额,原金额为:{}, 修改后的金额为:{}", productInfo.getPrice(), newOrderAmount);
// 商品价格修改为newOrderAmount
productInfo.setPrice(newOrderAmount);
// 返回修改后的对象
return productInfo;
}
@Override
Object run() {
return null
}
}
package org.enhance.groovy.infra.groovy
import org.enhance.groovy.api.dto.ProductInfoDTO
import org.enhance.groovy.app.service.ProductService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
/**测试从spring ioc容器中获取bean,并调用bean的方法*/
class GetApplicationContext extends Script {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
Object run() {
// 调用方法
ApplicationContext context = getContext();
// 获取容器中的bean
ProductService productService = context.getBean(ProductService.class);
// 调用bean的方法
Random random = new Random();
ProductInfoDTO productInfoDTO = productService.getProductById(random.nextInt(1000));
logger.info("productInfoDTO is : {}", productInfoDTO);
// 调用bean 的修改方法
productService.updateProduct(productInfoDTO);
logger.info("updated productInfoDTO is : {}", productInfoDTO);
return productInfoDTO;
}
// 获取spring容器
ApplicationContext getContext() {
// 获取spring IOC容器
ApplicationContext context = applicationContext;
return context;
}
}
/**
* scriptName只要能唯一定位到脚本即可
* 测试{@link EngineExecutor#execute(ScriptQuery, ExecuteParams)}
* 请求URL:http://localhost:1234/v1/load-from-redis/change-order?scriptName=change-order
*/
@GetMapping("/change-order")
public String changeOrderInfo(String scriptName) {
// 构建参数
OrderInfoDTO orderInfoDTO = new OrderInfoDTO();
orderInfoDTO.setOrderAmount("1000");
orderInfoDTO.setOrderName("测试订单");
orderInfoDTO.setOrderNumber("BG-123987");
ExecuteParams executeParams = new ExecuteParams();
executeParams.put("orderInfo", orderInfoDTO);
// 执行脚本
EngineExecutorResult executorResult = engineExecutor.execute(new ScriptQuery(scriptName), executeParams);
String statusCode = executorResult.getExecutionStatus().getCode();
if("200".equals(statusCode)){
log.info("脚本执行成功......");
} else {
log.info("脚本执行失败......");
}
log.info("changeOrderInfo=========>>>>>>>>>>>执行结果:{}", executorResult);
return "success";
}
org.enhance.groovy.api.controller.TestPerformanceController#simpleTest
org.enhance.groovy.api.controller.TestPerformanceController#testCompileDirect
GroovyCompiler
】,==每次加载脚本为Class时都会使用新的类加载器(便于回收),当某个Class不在被引用并且不在有任何对象存活并且他的classLoader已经回收时,该Class也会相应的被回收掉==。所以即便是每次都编译脚本执行也不会导致方法区Class对象不断增加。ClassLoader
】来加载,那么ClassLoader持有着该Class的引用,只要该ClassLoader没有被回收,那么所有由该【ClassLoader
】加载的Class也无法完成回收,那么就会导致方法区中Class越来越多,进而导致方法区OOM。GroovyClassLoader
】对象(注意这里是同一个GroovyClassLoader对象,而不是其他ClassLoader),那么方法区的Class个数会不会持续上升呢?方法区内存会不会持续上升呢?==看后面的验证!!!==
org.basis.enhance.groovy.compiler.impl.GroovyCompiler
org.enhance.groovy.api.controller.TestPerformanceController#userCache
项目提供了很好的扩展点,如果项目提供的loader不适合某些应用场景,可以通过实现【ScriptLoader
】接口来自定义脚本加载源(比如:从MongoDB加载脚本、从Oracle加载脚本等)
几乎每个组件都是可以动态替换的,如果有组件不满足需求,那么使用方可以自己实现对应的接口覆写方法,然后注入容器即可替换原有组件。
如果要使用【enhance-groovy-mysql-loader
】从MySQL加载脚本,那么需要新建一张脚本存放表,建表SQL如下:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for enhance_groovy_script
-- ----------------------------
DROP TABLE IF EXISTS `enhance_groovy_script`;
CREATE TABLE `enhance_groovy_script` (
`id` bigint NOT NULL COMMENT '主键id',
`namespace` varchar(128) NOT NULL COMMENT '命名空间',
`platform_code` varchar(128) NOT NULL COMMENT '平台码',
`product_code` varchar(128) NOT NULL COMMENT '产品码',
`channel_code` varchar(128) NOT NULL COMMENT '渠道码',
`business_code` varchar(128) NOT NULL COMMENT '业务码',
`enable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`script_content` longtext NOT NULL COMMENT '脚本内容',
`extend_info` longtext COMMENT '扩展信息',
`talent` varchar(64) NOT NULL DEFAULT 'unknown' COMMENT '租户编码',
`object_version_number` int NOT NULL DEFAULT '0' COMMENT '版本号',
`creation_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建日期',
`latest_modified_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改日期',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_only_key` (`namespace`,`platform_code`,`product_code`,`channel_code`,`business_code`) USING BTREE COMMENT '唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ----------------------------
-- Records of enhance_groovy_script
-- ----------------------------
BEGIN;
INSERT INTO `enhance_groovy_script` VALUES (1, 'customer-console', 'console-manager', 'enhance', 'test', 'change-product', 1, 'package org.enhance.groovy.api.dto \n\nimport org.basis.enhance.groovy.entity.ExecuteParams\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nclass ChangeProductInfo extends Script {\n\n private final Logger logger = LoggerFactory.getLogger(getClass());\n\n // 修改商品信息\n ProductInfoDTO changeProduct(ExecuteParams executeParams) {\n // 获取product对象\n ProductInfoDTO productInfo = (ProductInfoDTO) executeParams.get(\"productInfo\");\n Double newOrderAmount = 20000D;\n logger.info(\"即将修改商品金额,原金额为:{}, 修改后的金额为:{}\", productInfo.getPrice(), newOrderAmount);\n // 商品价格修改为newOrderAmount\n productInfo.setPrice(newOrderAmount);\n // 返回修改后的对象\n return productInfo;\n }\n\n @Override\n Object run() {\n return null\n }\n}', NULL, 'unknown', 0, '2022-10-01 18:52:10', '2022-10-01 18:52:10');
INSERT INTO `enhance_groovy_script` VALUES (2, 'customer-console', 'console-manager', 'enhance', 'test', 'change-order', 1, 'package org.enhance.groovy.api.dto\n\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nclass ChangeOrderInfo extends Script {\n\n private final Logger logger = LoggerFactory.getLogger(getClass());\n\n @Override\n Object run() {\n // 调用方法\n changeOrderInfo();\n }\n\n // 修改订单信息\n OrderInfoDTO changeOrderInfo() {\n String newOrderAmount = \"20000\";\n // 获取参数\n OrderInfoDTO orderInfoDTO = orderInfo;\n logger.info(\"即将修改订单金额,原金额为:{}, 修改后的金额为:{}\", orderInfoDTO.getOrderAmount(), newOrderAmount);\n orderInfoDTO.setOrderAmount(\"2000\");\n // 返回修改后的结果\n return orderInfoDTO;\n }\n}', NULL, 'unknown', 0, '2022-10-01 18:38:25', '2022-10-01 18:38:31');
INSERT INTO `enhance_groovy_script` VALUES (3, 'customer-console', 'console-manager', 'enhance', 'test', 'get-context', 1, 'package org.enhance.groovy.infra.groovy\n\nimport org.enhance.groovy.api.dto.ProductInfoDTO\nimport org.enhance.groovy.app.service.ProductService\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.springframework.context.ApplicationContext\n\n/**测试从spring ioc容器中获取bean,并调用bean的方法*/\nclass GetApplicationContext extends Script {\n\n private final Logger logger = LoggerFactory.getLogger(getClass());\n\n @Override\n Object run() {\n // 调用方法\n ApplicationContext context = getContext();\n // 获取容器中的bean\n ProductService productService = context.getBean(ProductService.class);\n // 调用bean的方法\n Random random = new Random();\n ProductInfoDTO productInfoDTO = productService.getProductById(random.nextInt(1000));\n logger.info(\"productInfoDTO is : {}\", productInfoDTO);\n\n // 调用bean 的修改方法\n productService.updateProduct(productInfoDTO);\n logger.info(\"updated productInfoDTO is : {}\", productInfoDTO);\n return productInfoDTO;\n }\n\n // 获取spring容器\n ApplicationContext getContext() {\n // 获取spring IOC容器\n ApplicationContext context = applicationContext;\n return context;\n }\n}', NULL, 'unknown', 0, '2022-10-01 18:40:17', '2022-10-01 18:40:17');
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
使用同一个GroovyClassLoader
对象来加载groovy脚本,到底会不会导致方法区Class数量增加,并且造成OOM?详细分析,参考:用同一个GroovyClassLoader加载的Class真的无法回收吗
这里每次都使用同一个ClassLoader来加载脚本,验证被该ClassLoader加载的脚本Class不能被卸载出方法区,只需要在org.basis.enhance.groovy.compiler.impl.GroovyCompiler
中做如下更改即可
public class GroovyCompiler implements DynamicCodeCompiler {
private static final Logger LOG = LoggerFactory.getLogger(GroovyCompiler.class);
// 这里新增一个公用的类加载器,每个脚本都通过这个类加载器来加载
private static GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
@Override
public Class<?> compile(String code, String name) {
GroovyClassLoader loader = getGroovyClassLoader();
LOG.warn("Compiling filter: " + name);
return (Class<?>) loader.parseClass(code, name);
}
@Override
public Class<?> compile(ScriptEntry scriptEntry) {
GroovyClassLoader loader = getGroovyClassLoader();
// 以 GroovyCompiler + 脚本的名称作为类名称
return loader.parseClass(scriptEntry.getScriptContext(),
GroovyCompiler.class.getSimpleName() + scriptEntry.getName());
}
public GroovyClassLoader getGroovyClassLoader() {
// 使用同一个加载器来加载
return groovyClassLoader;
}
}
测试用例仍然使用TestPerformanceController#testCompileDirect
即可,URL如下:http://localhost:1234/v1/performance/compile-direct?scriptName=change-order,压测这个接口观察方法区Class个数已经占用内存空间的变化
GroovyClassLoader
】来加载,那么也不会导致方法区Class无法被回收的情况,只是方法区的Class数量会不断的增加,但是当这些Class不在被使用了,这些Class是可以被回收的。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。