SpringBoot 不重启!基于脚本引擎实现评分规则的动态加载与执行

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

场景

  • 最近遇到这么一个需求,就是一个成绩单的每个项目的评分规则可能会发生改变,这样原有的代码就不满足需求了,现在要求在SpringBoot 应用不重启的情况下,能够动态加载和执行变更后的评分规则代码
  • 如果是Python实现这个需求确实会显著更简单,但是Java代码相对复杂一点

解决方案

  • 在这我没有打算用Java的自定义类加载器、动态编译等复杂操作,我打算用脚本引擎来实现这个功能

解决基本需求

  • 这里我们先引入依赖,如果是jdk8则不需要引入该依赖(如果jdk版本比较高可以用另外两个脚本引擎)
<dependency>
    <groupId>org.openjdk.nashorn</groupId>
    <artifactId>nashorn-core</artifactId>
    <version>15.4</version><!--jdk8自带该依赖,jdk9~14需要添加该依赖(代码无需修改),jdk15+需要添加该依赖(需要修改Nashorn类的包路径)-->
</dependency>
  • 我们需要编写一个工具类,该工具类用来运行我们数据库中存储的脚本代码,我们只需要调用这个工具类的方法,传入代码片段所需参数,就可以得到结果
// 该工具类是执行动态代码的工具类
import javax.script.Bindings;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import java.util.Map;

/**
 * 动态计算按照规则执行的工具类(JDK8内置的脚本运行引擎,如果是高版本的jdk需要引入maven依赖)
 * 高版本JDK(9+)需要添加的Maven依赖:
 * <dependency>
 *     <groupId>org.openjdk.nashorn</groupId>
 *     <artifactId>nashorn-core</artifactId>
 *     <version>15.4</version>
 * </dependency>
 */
@Component
public class DynamicCalcUtil{
    private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
    
    // 执行计算规则,param为入参,calcCode为数据库中的javaScript代码片段(用不了java脚本)
    public Object executeCalc(String calcCode,Map<String,Object> params) throws ScriptException{
        // 获取javaScrpit脚本引擎
        ScriptEngine engine = ENGINE_MANAGER.getEngineByName("javascript");
        // 绑定入参到执行上下文
        Bindings bindings = engine.createBindings();
        bindings.put("params",params);
        // 执行代码并且返回结果
        Object result = engine.eval(calcCode,bindings);
        // 返回结果
        return result;
    }
}
  • 该工具类是为了能够让我们在脚本中可以查询数据库数据,让我们脚本功能更加强大
// 该工具类用来在javascript中更简便的使用jdbc执行sql语句
import java.util.List;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class JsDbUtil {
    private final JdbcTemplate jdbcTemplate;

    /**
     * 查询单条数据
     * @param table
     * @param fields
     * @param where
     * @return
     */
    public Map<String,Object> selectOne(String table,String fields,String where){
        // 拼接sql
        StringBuilder sql = getStringBuilder(table,fields,where);
        sql.append(" LIMIT 1 ");
        // 底层JDBC执行,封装好所有细节
        String runSql = sql.toString();
        return jdbcTemplate.queryForMap(runSql);
    }
    
    /**
     * 查询单条数据
     * @param sql 要运行的sql
     * @return
     */
    public Map<String,Object> selectOne(String sql){
        return jdbcTemplate.queryForMap(sql);
    }

    /**
     * 查询多条数据
     * @param table
     * @param fields
     * @param where
     * @return
     */
    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);
    }
    
    /**
     * 查询多条数据
     * @param sql 要运行的sql
     * @return
     */
    public List<Map<String,Object>> selectList(String sql){
        return jdbcTemplate.queryForList(sql);
    }

    /**
     * 拼接基本sql
     * @param table
     * @param fields
     * @param where
     * @return
     */
    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 JdbcTemplate jdbcTemplate;
    private final JsDbUtil jsDbUtil;
    private final DynamicCalcUtil dynamicCalcUtil;
    
    @GetMapping
    public void test(){
        Map<String, Object> params = new HashMap<>();
        params.put("jsDbUtil",jsDbUtil);
        // params.put("jdbcTemplate",jdbcTemplate); // 可以给js直接使用jdbcTemplate,但是不推荐这么做,防止脚本修改数据库数据
        // 然后在params中添加其他业务参数
        params.put("userId",1);
        // 调用SQL拿出存在数据库中的代码(这里是需要业务代码,拿出数据库中存储的动态代码)
        MyPojo pojo = myService.getById(1);
        String code = pojo.getCode(); // 动态代码
        try{
            System.out.println(dynamicCalcUtil.executeCalc(code,params));
        }catch (ScriptException e){
            throw new RuntimeException(e);
        }
    }
}
  • 数据库中的脚本代码如下
// code字段存储的javaScript动态代码(这段代码存在数据库中,可以随时变化,java服务器不需要重启)
// 以下是一个示范,比如假设我这个人员小于多少岁需要怎么样,大于多少岁需要怎么样
// 注意:这里只能用ES5.1以前的写法
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; // 默认数据库里面字段叫什么,这里的属性名就是什么(这里取决后端对jdbcTemplate的分装的字段解析规则)
    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;

/**
 * Nashorn配置类
 */
@Configuration
public class NashornConfig {
    @Bean
    public ScriptEngine nashornEngine() {
        // 直接创建Nashorn工厂,手动获取引擎,绕过ScriptEngineManager的匹配问题
        NashornScriptEngineFactory factory = new NashornScriptEngineFactory();
        // 核心:指定ES6模式
        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 // 添加该注解(Lombok注解,创建必须创建的构造方法)
public class DynamicCalcUtil{
    // 我们的脚本引擎就不可以直接new出来了,我们现在要用配置类中加载好的模板引擎对象
    // private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
    private final ScriptEngine engine; // 修改使用的模板引擎对象
    
    // 执行计算规则,param为入参,calcCode为数据库中的javaScript代码片段(用不了java脚本)
    public Object executeCalc(String calcCode,Map<String,Object> params) throws ScriptException{
        // 获取javaScrpit脚本引擎
        // ScriptEngine engine = ENGINE_MANAGER.getEngineByName("javascript"); // 删除一行代码
        // 绑定入参到执行上下文
        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;

/**
 * JS脚本的JDBC工具类
 * 修复点:
 * 1. 所有查询改为参数化(解决SQL注入+无参数化查询问题)
 * 2. 无数据时返回null/空列表(解决直接抛异常问题)
 */
@Component
@RequiredArgsConstructor
public class JsDbUtil {
    private final JdbcTemplate jdbcTemplate;

    /**
     * 安全查询单条数据(参数化where条件,彻底防SQL注入)
     * @param table 表名(必填)
     * @param fields 查询字段(如 "id,age",默认*)
     * @param where 条件模板(如 "id = ? and status = ?")
     * @param params 条件参数(与where中的?一一对应)
     * @return 单条数据,无数据返回null(解决无数据抛异常)
     */
    public Map<String, Object> selectOne(
            String table,
            String fields,
            String where,
            Object... params) {
        // 基础校验:避免空表名生成非法SQL
        if (table == null || table.trim().isEmpty()) {
            throw new IllegalArgumentException("表名不能为空");
        }
        // 字段默认值:为空则查所有字段
        String actualFields = (fields == null || fields.trim().isEmpty()) ? "*" : fields.trim();

        // 拼接SQL(仅拼接固定部分,条件用?占位符,不拼接参数)
        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");

        // 修复2:无数据时返回null,而非抛异常
        try {
            // 修复3:参数化执行(params是独立数组,JDBC自动转义,防注入)
            return jdbcTemplate.queryForMap(sql.toString(), params);
        } catch (Exception e) {
            // 捕获「无数据」「查询异常」,统一返回null,避免JS脚本崩溃
            return null;
        }
    }

    /**
     * 安全查询多条数据(参数化where条件)
     * @param table 表名
     * @param fields 查询字段
     * @param where 条件模板
     * @param params 条件参数
     * @return 数据列表,无数据返回空列表(解决无数据抛异常)
     */
    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) {
            // 返回空列表,避免JS中遍历null报错
            return Collections.emptyList();
        }
    }

    /**
     * 执行自定义SQL查询单条数据(参数化,防注入)
     * @param sql SQL模板(如 "SELECT * FROM user WHERE id = ?")
     * @param params SQL参数(与?一一对应)
     * @return 单条数据,无数据返回null
     */
    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;
        }
    }

    /**
     * 执行自定义SQL查询多条数据(参数化,防注入)
     * @param sql SQL模板
     * @param params SQL参数
     * @return 数据列表,无数据返回空列表
     */
    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();
        }
    }
}
/**
 * 示例1:基础参数化查询(最常用)
 * 核心:?id = ? + 独立参数,防注入;判空避免无数据时报错
 */
function judgeUserAge(params) {
    // 1. 从上下文获取工具类和业务参数(解构更清晰,ES6语法)
    var jsDbUtil = params.jsDbUtil;
    var userId = params.userId || 0; // 兜底:userId为空时默认0
    
    // 2. 安全查询:参数化where条件(核心!?占位符+独立参数)
    var user = jsDbUtil.selectOne("user", "id,age,name", "id = ?", userId);
    
    // 3. 空值兜底:无数据时直接返回提示,避免rule.age报错
    if (!user) {
        return "用户不存在";
    }
    
    // 4. 业务逻辑(简洁写法)
    var age = user.age || 0; // 兜底:age字段为空时默认0
    return age > 18 ? "成年" : "未成年";
}

// 执行函数(固定写法)
judgeUserAge(params);
  • Nashorn小坑:
    1. js脚本中理论上可以使用ES6语法,但是实际上用不了反引号,用不了filter,会报错
  • 文件汇总:前前后后一共写了六个文件
    • pom.xml(依赖配置)
    • NashornConfig(配置类)
    • DynamicCalcUtil(动态JS脚本执行工具类)
    • JsDbUtil(JS 脚本专用 JDBC 工具类)
    • JS 脚本
    • TestController(接口测试类)

GraalVM JS

  • jdk11+之后,就可以替换掉老掉牙的Nashorn了,因为他从 JDK11 开始就停止维护了,这时候我们可以使用GraalVM JS 引擎,接下来我会在原有代码上进行一个修改(其实只用加两个依赖,更换一下配置类就可以了)(实际上就是更换了加载模板所使用的脚本引擎,其他的工具类完全不用修改)
<!--使用GraalVM JS 引擎,需要添加以下依赖-->
<!--注意GraalVM JS支持的jdk11+-->
<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;

/**
 * GraalVM JS配置类(JDK11+/17+推荐,支持ES6+全特性)
 */
@Configuration
public class GraalJsConfig {
    @Bean
    public ScriptEngine graalJsEngine() {
        ScriptEngineManager manager = new ScriptEngineManager();
        return manager.getEngineByName("graal.js");  // 获取GraalVM JS引擎(自动支持ES6+,无需额外参数)
    }
}

Groovy(推荐)

  • 还可以使用Groovy脚本引擎,他的特点主要是和java代码写法基本完全一致(Groovy在2025年仍在更新,所以大部分jdk版本都可以使用该模板引擎,这其实才是最推荐使用的脚本引擎)
Groovy 主版本支持的 JDK 版本核心说明
Groovy 2.5.xJDK 7、JDK 8最后支持 JDK 7 的版本,JDK 8 完全兼容
Groovy 3.0.xJDK 8、JDK 11首次支持 JDK 11,同时兼容 JDK 8
Groovy 4.0.xJDK 8、JDK 11、JDK 17主流稳定版本,兼容 JDK 8/11/17
Groovy 4.1.x+JDK 8、11、17、21/22新增支持 JDK 21/22,向下兼容 JDK 8
<!-- 使用Groovy的依赖 -->
<!-- Groovy核心依赖(JDK8兼容版本) -->
<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <version>2.5.15</version>
    <type>pom</type>
</dependency>
<!-- Groovy脚本引擎(适配JDK ScriptEngine接口) -->
<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");  // 核心:获取Groovy引擎(替换之前的nashorn)
    }
}
// 其他工具类还是不需要修改,只需要修改一下数据库中脚本的写法就可以了
// Groovy脚本(和Java语法99%一致,不用学新东西)
def judgeUserAge(Map params) {
    // 1. 获取工具类和参数(和Java写法一致)
    JsDbUtil jsDbUtil = params.get("jsDbUtil");
    Long userId = params.get("userId") ?: 0; // 空值兜底,Groovy语法糖
    
    // 2. 调用你封装的JDBC工具类(和Java调用方式完全一样)
    Map<String, Object> user = jsDbUtil.selectOne("user", "id,age,name", "id = ?", userId);
    
    // 3. 空值判断(Java语法)
    if (user == null) {
        return "用户不存在";
    }
    
    // 4. 业务逻辑(和Java一致)
    Integer age = user.get("age") ?: 0;
    return age > 18 ? "成年" : "未成年";
}

// 执行函数(固定入口)
judgeUserAge(params);

项目数据查询

  • 脚本还能够调用项目中服务查询数据(其实不用封装工具类直接把userService当参数交给脚本,脚本也可以只用,但是不建议这么做,防止造成安全问题(比如我在脚本中调用某服务中的删除方法就会把项目的数据删除掉))
/**
 * 脚本专用的用户工具类(仅暴露只读方法,封装Service调用)
 * 核心:脚本只能调用这个工具类,不能直接碰Service(就是让脚本不能直接修改我们数据库的数据,这样更安全)
 */
@Component
@RequiredArgsConstructor
public class ScriptUserUtil {
    private final UserService userService; // 内部调用Service
    
    // 仅暴露只读方法,无任何写操作
    public Map<String, Object> getUserById(Long userId) {
        // 内部调用Service,处理事务、异常
        try {
            UserDO user = userService.getById(userId);
            // 转换为Map返回(避免脚本直接接触实体类)
            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; // 异常兜底,避免脚本崩溃
        }
    }
    
    // 如需扩展,只加只读方法,禁止加update/delete/create等写方法(防止脚本对项目数据的修改)
}
  • 这些脚本引擎都可以使用这个工具类
// Groovy脚本(仅调用ScriptUserUtil,不碰UserService)
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);
/**
 * JS脚本
 * 适配JDK8 Nashorn/GraalVM引擎,兼容ES5/ES6语法
 */
function judgeUserAge(params) {
    // 1. 获取封装好的脚本专用工具类(和Groovy逻辑一致)
    var userUtil = params.scriptUserUtil;
    // 2. 获取用户ID,空值兜底(JS ES5写法,兼容所有引擎)
    var userId = params.userId || 0;
    
    // 3. 调用只读方法(JS调用Java工具类方法,语法和Java一致)
    // 注意:JS会自动适配Java的Long类型,无需手动转换
    var user = userUtil.getUserById(userId);
    
    // 4. 空值判断,避免脚本崩溃
    if (!user) {
        return "用户不存在";
    }
    
    // 5. 获取年龄,空值兜底(兼容数据库age字段为null的情况)
    var age = user.age || 0;
    // 6. 业务逻辑,返回结果
    return age > 18 ? "成年" : "未成年";
}

// 执行函数(固定入口,和Groovy的judgeUserAge(params)等价)
judgeUserAge(params);

总结

  1. 如果比较熟悉javascript,可以使用Nashorn、GraalVM JS脚本引擎
  2. 如果比较熟悉java,可以使用Groovy脚本引擎
  3. 文件:
    1. pom.xml(依赖配置)
    2. xxxxConfig(配置类)
    3. DynamicCalcUtil(动态脚本执行工具类)
    4. JsDbUtil(脚本专用 JDBC 工具类)
    5. TestController(接口测试类)
    6. serviceUtil(服务数据查询工具类)
    7. 脚本代码

您可能感兴趣的与本文相关的镜像

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值