场景
- 最近遇到这么一个需求,就是一个成绩单的每个项目的评分规则可能会发生改变,这样原有的代码就不满足需求了,现在要求在SpringBoot 应用不重启的情况下,能够动态加载和执行变更后的评分规则代码
- 如果是Python实现这个需求确实会显著更简单,但是Java代码相对复杂一点
解决方案
- 在这我没有打算用Java的自定义类加载器、动态编译等复杂操作,我打算用脚本引擎来实现这个功能
解决基本需求
- 这里我们先引入依赖,如果是jdk8则不需要引入该依赖(如果jdk版本比较高可以用另外两个脚本引擎)
<dependency>
<groupId>org.openjdk.nashorn</groupId>
<artifactId>nashorn-core</artifactId>
<version>15.4</version>
</dependency>
- 我们需要编写一个工具类,该工具类用来运行我们数据库中存储的脚本代码,我们只需要调用这个工具类的方法,传入代码片段和所需参数,就可以得到结果
import javax.script.Bindings;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import java.util.Map;
@Component
public class DynamicCalcUtil{
private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
public Object executeCalc(String calcCode,Map<String,Object> params) throws ScriptException{
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("javascript");
Bindings bindings = engine.createBindings();
bindings.put("params",params);
Object result = engine.eval(calcCode,bindings);
return result;
}
}
- 该工具类是为了能够让我们在脚本中可以查询数据库数据,让我们脚本功能更加强大
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JsDbUtil {
private final JdbcTemplate jdbcTemplate;
public Map<String,Object> selectOne(String table,String fields,String where){
StringBuilder sql = getStringBuilder(table,fields,where);
sql.append(" LIMIT 1 ");
String runSql = sql.toString();
return jdbcTemplate.queryForMap(runSql);
}
public Map<String,Object> selectOne(String sql){
return jdbcTemplate.queryForMap(sql);
}
public List<Map<String,Object>> selectList(String table, String fields, String where){
StringBuilder sql = getStringBuilder(table,fields,where);
String runSql = sql.toString();
return jdbcTemplate.queryForList(runSql);
}
public List<Map<String,Object>> selectList(String sql){
return jdbcTemplate.queryForList(sql);
}
private StringBuilder getStringBuilder(String table, String fields, String where) {
StringBuilder sql = new StringBuilder();
sql.append("select ");
sql.append(fields);
sql.append(" from ");
sql.append(table);
if (null!=where&&!where.isEmpty()){
sql.append(" where ");
sql.append(where);
}
return sql;
}
}
- 接下来是接口测试类,让我们一起来看看这两个工具类是如何使用的吧
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.script.ScriptException;
import java.util.HashMap;
import java.util.Map;
@RequestMapping("/test")
@RestController
@RequiredArgsConstructor
public class TestController {
private final JsDbUtil jsDbUtil;
private final DynamicCalcUtil dynamicCalcUtil;
@GetMapping
public void test(){
Map<String, Object> params = new HashMap<>();
params.put("jsDbUtil",jsDbUtil);
params.put("userId",1);
MyPojo pojo = myService.getById(1);
String code = pojo.getCode();
try{
System.out.println(dynamicCalcUtil.executeCalc(code,params));
}catch (ScriptException e){
throw new RuntimeException(e);
}
}
}
function judge(params){
var jdbcTemplate = params.jdbcTemplate;
var jsDbUtil = params.jsDbUtil;
var userId = params.userId;
var rule = jsDbUtil.selectOne("user","*","id = " + userId);
var age = rule.age;
if (age>18){
return "成年";
}else{
return "未成年";
}
}
judge(params)
JS脚本版本升级
- 如果写过前端的小伙伴会发现,这个脚本引擎并不好用,脚本经常会报错,这是因为该引擎默认只能使用ES5.1的语法,如果想用ES6语法,我们还需要再加个配置类
import jdk.nashorn.api.scripting.NashornScriptEngine;
import jdk.nashorn.api.scripting.NashornScriptEngineFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.script.ScriptEngine;
@Configuration
public class NashornConfig {
@Bean
public ScriptEngine nashornEngine() {
NashornScriptEngineFactory factory = new NashornScriptEngineFactory();
NashornScriptEngine engine = (NashornScriptEngine) factory.getScriptEngine("--language=es6");
return engine;
}
}
- 我们工具类中使用的模板引擎就需要修改一下了,核心的修改就是,更换所使用的脚本引擎,这样就可以使用ES6的语法了
- 说是能够使用,但实际上用起来还是这不行那不行,已知无法使用的内容如下:
- 模板字符串
- 数组的filter
- … …
import javax.script.Bindings;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class DynamicCalcUtil{
private final ScriptEngine engine;
public Object executeCalc(String calcCode,Map<String,Object> params) throws ScriptException{
Bindings bindings = engine.createBindings();
bindings.put("params",params);
Object result = engine.eval(calcCode,bindings);
return result;
}
}
SQL注入问题
- 以上代码如果不考虑SQL注入的安全问题已经是比较完美了,以上代码直接拼接SQL是为了便于理解,接下来将修改以下工具类中的方法,并且修改JS脚本中工具类的用法,防止实际使用中所遇到SQL注入的安全性问题
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JsDbUtil {
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> selectOne(
String table,
String fields,
String where,
Object... params) {
if (table == null || table.trim().isEmpty()) {
throw new IllegalArgumentException("表名不能为空");
}
String actualFields = (fields == null || fields.trim().isEmpty()) ? "*" : fields.trim();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(actualFields)
.append(" FROM ").append(table);
if (where != null && !where.trim().isEmpty()) {
sql.append(" WHERE ").append(where.trim());
}
sql.append(" LIMIT 1");
try {
return jdbcTemplate.queryForMap(sql.toString(), params);
} catch (Exception e) {
return null;
}
}
public List<Map<String, Object>> selectList(
@Nonnull String table,
String fields,
String where,
Object... params) {
if (table == null || table.trim().isEmpty()) {
throw new IllegalArgumentException("表名不能为空");
}
String actualFields = (fields == null || fields.trim().isEmpty()) ? "*" : fields.trim();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ").append(actualFields)
.append(" FROM ").append(table);
if (where != null && !where.trim().isEmpty()) {
sql.append(" WHERE ").append(where.trim());
}
try {
return jdbcTemplate.queryForList(sql.toString(), params);
} catch (Exception e) {
return Collections.emptyList();
}
}
public Map<String, Object> selectOne(@Nonnull String sql, Object... params) {
if (sql == null || sql.trim().isEmpty()) {
throw new IllegalArgumentException("SQL语句不能为空");
}
try {
return jdbcTemplate.queryForMap(sql, params);
} catch (Exception e) {
return null;
}
}
public List<Map<String, Object>> selectList(@Nonnull String sql, Object... params) {
if (sql == null || sql.trim().isEmpty()) {
throw new IllegalArgumentException("SQL语句不能为空");
}
try {
return jdbcTemplate.queryForList(sql, params);
} catch (Exception e) {
return Collections.emptyList();
}
}
}
function judgeUserAge(params) {
var jsDbUtil = params.jsDbUtil;
var userId = params.userId || 0;
var user = jsDbUtil.selectOne("user", "id,age,name", "id = ?", userId);
if (!user) {
return "用户不存在";
}
var age = user.age || 0;
return age > 18 ? "成年" : "未成年";
}
judgeUserAge(params);
- Nashorn小坑:
- js脚本中理论上可以使用ES6语法,但是实际上用不了反引号,用不了filter,会报错
- 文件汇总:前前后后一共写了六个文件
pom.xml(依赖配置)NashornConfig(配置类)DynamicCalcUtil(动态JS脚本执行工具类)JsDbUtil(JS 脚本专用 JDBC 工具类)- JS 脚本
TestController(接口测试类)
GraalVM JS
- jdk11+之后,就可以替换掉老掉牙的Nashorn了,因为他从 JDK11 开始就停止维护了,这时候我们可以使用GraalVM JS 引擎,接下来我会在原有代码上进行一个修改(其实只用加两个依赖,更换一下配置类就可以了)(实际上就是更换了加载模板所使用的脚本引擎,其他的工具类完全不用修改)
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js</artifactId>
<version>23.1.0</version>
</dependency>
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js-scriptengine</artifactId>
<version>23.1.0</version>
</dependency>
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
@Configuration
public class GraalJsConfig {
@Bean
public ScriptEngine graalJsEngine() {
ScriptEngineManager manager = new ScriptEngineManager();
return manager.getEngineByName("graal.js");
}
}
Groovy(推荐)
- 还可以使用Groovy脚本引擎,他的特点主要是和java代码写法基本完全一致(Groovy在2025年仍在更新,所以大部分jdk版本都可以使用该模板引擎,这其实才是最推荐使用的脚本引擎)
| Groovy 主版本 | 支持的 JDK 版本 | 核心说明 |
|---|
| Groovy 2.5.x | JDK 7、JDK 8 | 最后支持 JDK 7 的版本,JDK 8 完全兼容 |
| Groovy 3.0.x | JDK 8、JDK 11 | 首次支持 JDK 11,同时兼容 JDK 8 |
| Groovy 4.0.x | JDK 8、JDK 11、JDK 17 | 主流稳定版本,兼容 JDK 8/11/17 |
| Groovy 4.1.x+ | JDK 8、11、17、21/22 | 新增支持 JDK 21/22,向下兼容 JDK 8 |
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.5.15</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-jsr223</artifactId>
<version>2.5.15</version>
</dependency>
@Configuration
public class GroovyConfig {
@Bean
public ScriptEngine groovyEngine() {
ScriptEngineManager manager = new ScriptEngineManager();
return manager.getEngineByName("groovy");
}
}
def judgeUserAge(Map params) {
JsDbUtil jsDbUtil = params.get("jsDbUtil");
Long userId = params.get("userId") ?: 0;
Map<String, Object> user = jsDbUtil.selectOne("user", "id,age,name", "id = ?", userId);
if (user == null) {
return "用户不存在";
}
Integer age = user.get("age") ?: 0;
return age > 18 ? "成年" : "未成年";
}
judgeUserAge(params);
项目数据查询
- 脚本还能够调用项目中服务查询数据(其实不用封装工具类直接把userService当参数交给脚本,脚本也可以只用,但是不建议这么做,防止造成安全问题(比如我在脚本中调用某服务中的删除方法就会把项目的数据删除掉))
@Component
@RequiredArgsConstructor
public class ScriptUserUtil {
private final UserService userService;
public Map<String, Object> getUserById(Long userId) {
try {
UserDO user = userService.getById(userId);
Map<String, Object> result = new HashMap<>();
result.put("id", user.getId());
result.put("name", user.getName());
result.put("age", user.getAge());
return result;
} catch (Exception e) {
return null;
}
}
}
def judgeUserAge(Map params) {
ScriptUserUtil userUtil = params.get("scriptUserUtil");
Long userId = params.get("userId") ?: 0;
Map<String, Object> user = userUtil.getUserById(userId);
if (user == null) {
return "用户不存在";
}
Integer age = user.get("age") ?: 0;
return age > 18 ? "成年" : "未成年";
}
judgeUserAge(params);
function judgeUserAge(params) {
var userUtil = params.scriptUserUtil;
var userId = params.userId || 0;
var user = userUtil.getUserById(userId);
if (!user) {
return "用户不存在";
}
var age = user.age || 0;
return age > 18 ? "成年" : "未成年";
}
judgeUserAge(params);
总结
- 如果比较熟悉javascript,可以使用Nashorn、GraalVM JS脚本引擎
- 如果比较熟悉java,可以使用Groovy脚本引擎
- 文件:
pom.xml(依赖配置)xxxxConfig(配置类)DynamicCalcUtil(动态脚本执行工具类)JsDbUtil(脚本专用 JDBC 工具类)TestController(接口测试类)serviceUtil(服务数据查询工具类)- 脚本代码