Groovy
- 1、需求的提出
- 2、为什么是Groovy
- 3、设计参考
- 1_引入Maven依赖
- 2_GroovyEngineUtils工具类
- 3_GroovyScriptVar类
- 4_脚本规则表设计
- 5_对应的实体类
- 6_数据库访问层
- 7_GroovyExecService通用接口
- 4、测试
- 5、其他的注意事项
- 6、总结
1、需求的提出
在我们日常的开发过程中,经常会遇到一些功能的逻辑变更很频繁的需求,相信大部分小伙伴都会通过加 if 判断来解决(毕竟这样最简单,也可能是因为项目赶时间等一系列客观因素)。长此以往,我们项目代码中可能充斥着大量的 if 代码段,可读性不高。当然网上有很多方式消除 if…else… 代码的方法,比如说使用恰当的设计模式、使用规则引擎等等。
上述所说的可读性不高,不是本文分享内容解决的重点问题,逻辑变更很频繁的需求,还带来的另一个问题就是需要经常重启服务。今天我们分享的就是利用Groovy脚本在Spring Boot项目中实现 动态编程 动态编程 动态编程解决这一问题,使业务逻辑的动态化,极大地提升了开发效率和灵活性。
2、为什么是Groovy
Groovy语言作为一种基于JVM的动态语言,它可以编译为与Java相同的字节码,然后将字节码文件交给JVM去执行,并且可以与Java类无缝地互操作。
Groovy可以透明地与Java库和代码交互,可以 使用 J a v a 所有的库 使用Java所有的库 使用Java所有的库,并且有着简洁灵活的语法、动态类型和闭包等特性。
Groovy无缝地集成了Java的强大功能,并提供了许多额外的特性,如DSL(领域特定语言)的支持和元编程能力,使得开发者能够以更加简洁和优雅的方式表达复杂的逻辑。
Groovy也可以直接将源文件解释执行。它还极大地清理了Java中许多冗长的代码格式。
Groovy尚未成为主流的开发语言,但是它已经在测试(由于其简化的语法和元编程功能)和构建系统中占据了一席之地。
既支持 面向对象 编程也支持 面向过程 编程,即可以作为 编程语言 也可以作为 脚本语言 。
D S L DSL DSL 其实是 Domain Specific Language 的缩写,中文翻译为领域特定语言(下简称 DSL);
而与 DSL 相对的就是 G P L GPL GPL,是General Purpose Language 的简称,即通用编程语言,也就是我们非常熟悉的Java、Python 以及 C 语言等等。
3、设计参考
在与我们项目整合时,可以这样进行设计:基于简单的 CURD 功能将 G r o o v y 代码片 Groovy代码片 Groovy代码片存入数据库中进行管理,通过切换对应的 G r o o v y 代码片 Groovy代码片 Groovy代码片达到动态变更规则的特性。下面给个模版进行参考,如果有需要再根据自己的需求进行相应的修改(前端也可以使用模版引擎比如 F r e e m a r k Freemark Freemark):
1_引入Maven依赖
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.5</version>
</dependency>
2_GroovyEngineUtils工具类
package org.example.utils;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.codehaus.groovy.jsr223.GroovyScriptEngineImpl;
import javax.script.*;
import java.util.Map;
import java.util.Objects;
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class GroovyEngineUtils{
private static final GroovyScriptEngineImpl GROOVY_ENGINE = (GroovyScriptEngineImpl) new ScriptEngineManager().getEngineByName("groovy");
/**
* 编译脚本
* @param script
* @return
* @throws ScriptException
*/
public static CompiledScript compile(String script) throws ScriptException {
return GROOVY_ENGINE.compile(script);
}
/**
* 执行脚本
* @param compiledScript
* @param args 脚本参数
* @return
*/
public static Object eval(CompiledScript compiledScript, Map<String, Object> args) {
try {
return compiledScript.eval(getScriptContext(args));
} catch (ScriptException e) {
log.error(" exec GroovyEngineUtils.eval error!!!", e);
}
return null;
}
/**
* 执行脚本
* @param script 脚本
* @return
*/
public static Object eval(String script) {
try {
return GROOVY_ENGINE.eval(script);
} catch (ScriptException e) {
log.error(" exec GroovyEngineUtils.eval error!!!", e);
}
return null;
}
/**
* 执行脚本
* @param script 脚本
* @param args 脚本参数
* @return
*/
public static Object eval(String script, Map<String, Object> args) {
try {
return GROOVY_ENGINE.eval(script, getScriptContext(args));
} catch (ScriptException e) {
log.error(" exec GroovyEngineUtils.eval error!!!", e);
}
return null;
}
private static ScriptContext getScriptContext(Map<String, Object> args) {
ScriptContext scriptContext = new SimpleScriptContext();
if (Objects.nonNull(args)) {
args.forEach((k, v) -> scriptContext.setAttribute(k, v, ScriptContext.ENGINE_SCOPE));
}
return scriptContext;
}
}
3_GroovyScriptVar类
GroovyScriptVar 类主要作用是在项目启动时,缓存编译后的脚本,也提供了刷新脚本的功能,这样在业务逻辑变更的时候,我们可以通过刷新功能,重新加载脚本。防止了我们重启服务。
package org.example.config;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.example.mapper.CommonScriptMapper;
import org.example.entity.CommonScriptEntity;
import org.example.utils.GroovyEngineUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.script.CompiledScript;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class GroovyScriptVar {
// 缓存编译后的脚本
private static final Map<String, CompiledScript> SCRIPT_MAP = new ConcurrentHashMap<>();
@Resource
public CommonScriptMapper commonScriptMapper;
@PostConstruct
public void init() {
refresh();
}
@SneakyThrows
public void refresh() {
synchronized (GroovyScriptVar.class) {
// 从数据库中加载脚本
List<CommonScriptEntity> list = commonScriptMapper.findAll();
SCRIPT_MAP.clear();
if(CollectionUtils.isEmpty(list)) return;
for (CommonScriptEntity script : list) {
SCRIPT_MAP.put(script.getUniqueKey(), GroovyEngineUtils.compile(script.getScript()));
}
log.info(" Groovy脚本初始化,加载数量:{}",list.size());
}
}
public CompiledScript get(String uniqueKey){
return SCRIPT_MAP.get(uniqueKey);
}
}
4_脚本规则表设计
create table common_script
(
id int auto_increment comment '主键标识'
primary key,
unique_key varchar(32) null comment '唯一标识',
script mediumtext null comment '脚本',
creator varchar(128) null comment '创建人名称',
create_date datetime null comment '创建时间',
last_update_date datetime null comment '更新时间'
)
comment '脚本规则';
5_对应的实体类
与上述的表结构对应即可
package org.example.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@TableName("common_script") // 指定表名
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CommonScriptEntity {
@TableId(value="id",type = IdType.AUTO)//字段不一致时,通过value值指定table中主键字段名称
private Long id;
private String uniqueKey;
private String script;
private String creator;
private LocalDateTime createDate;
private LocalDateTime lastUpdateDate;
}
6_数据库访问层
当然,这里 mapper 实现是基于MybatisPlus 的:
package org.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Select;
import org.example.entity.CommonScriptEntity;
import java.util.List;
public interface CommonScriptMapper extends BaseMapper<CommonScriptEntity> {
@Select("select * from common_script")
List<CommonScriptEntity> findAll();
}
7_GroovyExecService通用接口
所有的 Groovy 脚本执行服务按照如下接口作为规范进行实现:
package org.example.service;
public interface GroovyExecService {
/**
* 执行脚本的方法
*/
void exec();
/**
* 重新加载脚本引擎
*/
void refresh();
}
Groovy 脚本在项目中使用核心逻辑到这基本就已经完成了。
4、测试
测试脚本,定义一个非常简单的脚本根据不同状态,打印不同返回值:
if ("COMPLETED" == status) {
// 如果条件为COMPLETED
return "条件为已完成"
}else{
return "条件为未完成"
}
测试service:
package org.example.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.example.config.GroovyScriptVar;
import org.example.service.GroovyExecService;
import org.example.utils.GroovyEngineUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.script.CompiledScript;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
public class GroovyExecServiceImpl implements GroovyExecService {
@Resource
private GroovyScriptVar groovyScriptVar;
@Override
public void exec() {
Map<String, Object> args=new HashMap<>();
args.put("status","COMPLETED");
CompiledScript compiledScript = groovyScriptVar.get("test");
Object result = GroovyEngineUtils.eval(compiledScript, args);
log.info("/// 脚本执行结果{}",(String)result);
}
@Override
public void refresh() {
groovyScriptVar.refresh();
}
}
执行效果:可以看到项目在启动过程中已经加载了一个脚本并且成功调用了exec方法:
5、其他的注意事项
如果Groovy脚本没有做好权限控制,将会成为攻击你系统最有力的武器!!!
如果Groovy脚本用不好,还会导致 O O M OOM OOM,最终服务器宕机——毕竟Groovy脚本中创建的对象也是在JVM堆中的。
下面附上一个 Groovy 脚本通用的封装方法方法,可以通过参数传递配置。为了使这个方法更加通用和健壮,对代码进行一些优化和封装。以下是一个改进后的通用封装方法,并附带详细的注释说明:
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
/**
* 执行 Groovy 脚本中的指定方法并返回结果。
*
* @param templateScript 要执行的 Groovy 脚本字符串
* @param methodName 要调用的方法名
* @param params 传递给方法的参数数组
* @return 方法执行的结果
* @throws Exception 如果方法调用失败或其他异常
*/
public static Object invokeGroovyMethod(String templateScript, String methodName, Object... params) throws Exception {
// 创建 Groovy 脚本的变量绑定
Binding groovyBinding = new Binding();
// 使用 GroovyShell 解析脚本字符串
GroovyShell groovyShell = new GroovyShell(groovyBinding);
Script script = groovyShell.parse(templateScript);
// 检查方法是否存在
if (!script.getMetaClass().respondsTo(script, methodName).isEmpty()) {
// 调用指定的方法并传递参数
Object result = script.invokeMethod(methodName, params);
// 清除 Groovy 脚本缓存
groovyShell.getClassLoader().clearCache();
return result;
} else {
throw new NoSuchMethodException("方法 " + methodName + " 不存在于脚本中。");
}
}
步骤说明:
1. Binding 对象:
Binding groovyBinding = new Binding();
:创建一个Binding
对象,用于将变量绑定到 Groovy 脚本中。
2. GroovyShell 对象:
GroovyShell groovyShell = new GroovyShell(groovyBinding);
:创建一个GroovyShell
对象,用于解析和执行 Groovy 脚本。
3.解析 Groovy 脚本:
Script script = groovyShell.parse(templateScript);
:将传入的 Groovy 脚本字符串解析为 Groovy 脚本对象。
4. 检查方法是否存在:
if (!script.getMetaClass().respondsTo(script, methodName).isEmpty()) {
:检查脚本中是否存在指定名称的方法。如果方法存在,则继续执行;否则抛出NoSuchMethodException
异常。
5. 调用方法:
Object result = script.invokeMethod(methodName, params);
:调用 Groovy 脚本中的指定方法,并传递参数。
6. 清除 Groovy 缓存:
groovyShell.getClassLoader().clearCache();
:清除 Groovy 脚本的类加载器缓存,以避免内存泄漏问题。
7. 返回结果:
return result;
:返回方法执行的结果。
使用示例:
public static void main(String[] args) {
String groovyScript = "def methodName(config) { return '执行结果: ' + config }";
String methodName = "methodName";
String configParam = "配置参数";
try {
Object result = invokeGroovyMethod(groovyScript, methodName, configParam);
System.out.println(result); // 输出: 执行结果: 配置参数
} catch (Exception e) {
e.printStackTrace();
}
}
通过这种方式,可以将 Groovy 脚本执行的逻辑封装到一个通用的方法中,并且在调用时更加简洁和安全。
本文的所有示例都是基于JDK8来操作的,如果你使用了8以上的版本可能会出现异常——因为JDK9模块化之后类加载器也进行了改变,而加载脚本引擎还是需要相应的 C l a s s L o a d e r ClassLoader ClassLoader的,所以需要更高版本的Groovy依赖。
6、总结
通过Groovy脚本在Spring Boot项目中实现动态编程,将Groovy脚本存储在数据库等中间件中,我们可以实现诸如动态配置、动态路由以及动态业务逻辑的功能,极大地提高了项目的可扩展性和可维护性。