1. 项目概述:为什么需要Janino?
在Java开发中,我们经常遇到一个经典难题:如何动态地执行一段代码?无论是需要实现一个灵活的业务规则引擎,还是想在运行时根据配置生成并执行特定的逻辑,传统的Java编译-打包-部署流程都显得过于笨重。你可能会想到使用Java的 ScriptEngine 调用JavaScript,或者引入Groovy、BeanShell等动态语言,但这些方案要么性能堪忧,要么依赖庞大,要么语法与Java不尽相同,增加了学习和维护成本。
这时,Janino就登场了。它不是一个全新的编程语言,而是一个 小巧、快速、纯Java的编译器 。它的核心价值在于,能够将一段符合Java语法的字符串,在运行时直接编译成Java字节码并加载执行。简单来说,它让你拥有了“在Java程序里写Java代码并立刻运行”的能力。这对于需要高度动态化、插件化或规则可配置的系统来说,简直是“雪中送炭”。比如,在数据处理流水线中动态定义数据转换规则,在报表系统中让用户自定义计算字段,或者在企业级应用中实现低代码平台的核心逻辑引擎。
我第一次接触Janino是在一个风控系统中,业务方需要频繁调整风险评分公式。如果每次修改都走发版流程,效率极低。引入Janino后,我们将评分公式以字符串形式存入数据库,规则引擎在运行时调用Janino动态编译执行,实现了业务规则的“热更新”,响应速度从“天”级别提升到了“秒”级别。这种将静态语言动态化的能力,正是Janino的魅力所在。
2. Janino核心能力与工作原理拆解
要真正用好Janino,不能只停留在API调用的层面,必须理解其内部是如何工作的。这能帮助你在遇到复杂问题时,知道从何处着手排查。
2.1 三大核心模块解析
Janino库主要包含三个核心模块,它们各司其职,共同完成了从源代码到可执行结果的魔法。
-
Commons Compiler (
janino-commons-compiler) : 这是编译器的基础设施。它定义了源代码扫描、词法分析、语法分析、抽象语法树(AST)构建、字节码生成等核心接口和抽象类。你可以把它看作是编译器领域的“Spring Core”,提供了构建编译器的骨架。我们日常直接使用它的机会不多,但它是另外两个模块的基石。 -
Compiler (
janino-compiler) : 这是最常用、功能最完整的模块。它实现了完整的Java编译器,支持几乎所有的Java语法(截至其版本对应的Java语言规范)。它不仅能编译内存中的字符串,还能编译.java源文件。其核心类是org.codehaus.janino.Compiler。这个编译器在后台会调用javac吗?不,它是完全独立实现的,这也是它轻量快速的根源。 -
Expression Evaluator (
janino-expression-evaluator) & Script Evaluator (janino-script-evaluator) : 这两个模块是对Compiler的封装,提供了更易用的高级API。- Expression Evaluator : 用于编译和求值 单个Java表达式 (如
a + b * c,user.getName().toUpperCase())。它非常适合计算场景。 - Script Evaluator : 用于编译和求值 一系列Java语句 (可以包含变量声明、赋值、循环、条件判断等,但不能定义新的方法或类)。它适合执行一小段逻辑脚本。
- Expression Evaluator : 用于编译和求值 单个Java表达式 (如
注意 :
Script Evaluator虽然强大,但它编译的“脚本”本质上是一个匿名类的成员方法。因此,脚本内部无法使用return语句来返回值(除非在最后一行作为表达式结果),也无法定义public等方法。理解这个限制,能避免很多语法错误。
2.2 编译与执行流程揭秘
当你调用 ExpressionEvaluator 或 ScriptEvaluator 的 evaluate 方法时,背后发生了一系列精密的操作:
-
源码封装 :Janino不会直接编译你给的字符串。例如,对于表达式
"price * quantity * (1 - discount)",它会将其包装成一个完整的Java类。对于ExpressionEvaluator,生成的类大概长这样:public class CompiledExpression extends org.codehaus.commons.compiler.ExpressionEvaluator { public Object evaluate() { return price * quantity * (1 - discount); } }对于
ScriptEvaluator,则会将多条语句包装进一个方法里。 -
内存编译 :Janino编译器(
JaninoCompiler)在内存中处理这个生成的类源码。它进行词法分析、语法分析,构建AST,然后进行语义检查(如类型校验),最后直接生成Java字节码(bytecode)。 全程不涉及任何磁盘IO ,这是它快的关键。 -
类加载 :生成的字节码会被传递给一个自定义的
ClassLoader(通常是IClassLoader)。这个类加载器在内存中定义出一个新的Java类(Class对象)。 -
实例化与执行 :通过反射,这个新类被实例化,并转型为
ExpressionEvaluator或ScriptEvaluator接口。最后,调用其evaluate方法,传入你预先绑定的参数值(如price=100, quantity=2, discount=0.1),得到计算结果180.0。
整个流程在毫秒级完成。你可能会问,每次都这么编译,性能吃得消吗?这里就引出一个 最佳实践:缓存 。对于固定的脚本或表达式,一定要缓存编译后的 Evaluator 实例,避免重复编译开销。Janino的API设计也考虑到了这点, createEvaluator 方法通常比较耗时,而 evaluate 方法非常快。
3. 从入门到精通:Janino实战教程
理论讲得再多,不如动手一试。我们通过几个由浅入深的例子,来掌握Janino的核心用法。
3.1 环境准备与基础依赖
首先,在你的Maven项目中引入依赖。通常我们只需要 janino 和 commons-compiler ,后者是前者的传递依赖,但显式声明是个好习惯。
<dependency>
<groupId>org.codehaus.janino</groupId>
<artifactId>janino</artifactId>
<version>3.1.9</version> <!-- 请使用最新稳定版本 -->
</dependency>
<dependency>
<groupId>org.codehaus.janino</groupId>
<artifactId>commons-compiler</artifactId>
<version>3.1.9</version>
</dependency>
如果你用的是Gradle,则添加:
implementation 'org.codehaus.janino:janino:3.1.9'
implementation 'org.codehaus.janino:commons-compiler:3.1.9'
实操心得 :版本选择上,建议使用GitHub或Maven中央仓库上最新的稳定版。Janino社区活跃度中等,但版本稳定。避免使用过旧的版本,可能对Java新语法的支持不全。
3.2 初阶应用:动态表达式求值
假设我们正在构建一个电商促销系统,折扣规则需要动态配置。规则可能是:“如果商品类别是‘电子产品’且价格大于5000,则打9折,否则打95折”。我们可以用Janino的 ExpressionEvaluator 来实现。
import org.codehaus.janino.ExpressionEvaluator;
public class PromotionEngine {
// 缓存Evaluator,避免重复编译
private Map<String, ExpressionEvaluator> evaluatorCache = new ConcurrentHashMap<>();
public double calculateFinalPrice(String ruleExpression, String category, double price) throws Exception {
// 构造一个唯一的缓存键,这里简单用表达式本身
ExpressionEvaluator ee = evaluatorCache.computeIfAbsent(ruleExpression, key -> {
try {
ExpressionEvaluator evaluator = new ExpressionEvaluator();
// 设置表达式本身的返回类型
evaluator.setReturnType(double.class);
// 设置表达式中所用参数的类型和名称
evaluator.setParameters(new String[]{"category", "price"}, new Class[]{String.class, double.class});
// 编译表达式!
evaluator.cook(key);
return evaluator;
} catch (Exception e) {
throw new RuntimeException("编译促销规则失败: " + key, e);
}
});
// 执行表达式,传入参数值
return (Double) ee.evaluate(new Object[]{category, price});
}
public static void main(String[] args) throws Exception {
PromotionEngine engine = new PromotionEngine();
String rule = "category.equals(\"electronics\") && price > 5000 ? price * 0.9 : price * 0.95";
double price1 = engine.calculateFinalPrice(rule, "electronics", 6000);
System.out.println("电子产品6000元折后价: " + price1); // 输出 5400.0
double price2 = engine.calculateFinalPrice(rule, "clothing", 3000);
System.out.println("服装3000元折后价: " + price2); // 输出 2850.0
}
}
关键点解析 :
-
setReturnType和setParameters必须在cook方法之前调用,它们定义了表达式的“函数签名”。 -
cook方法是编译的核心,它接受表达式字符串。如果表达式语法错误或类型不匹配,会在此处抛出编译异常。 -
evaluate方法接受一个Object数组,其元素必须与之前定义的参数顺序和类型严格匹配。 - 缓存
ExpressionEvaluator对象至关重要 。想象一下,每秒有成千上万的订单,如果每个订单都编译一次表达式,系统瞬间就会被压垮。
3.3 中阶应用:执行复杂脚本逻辑
当逻辑无法用单行表达式完成时,就需要 ScriptEvaluator 了。例如,我们需要一个脚本,来清洗一段用户输入的地址文本。
import org.codehaus.janino.ScriptEvaluator;
public class AddressCleaner {
public String cleanAddress(String rawAddress) throws Exception {
String script =
"// 脚本开始:可以写多行Java语句\n" +
"String trimmed = rawAddress.trim();\n" +
"String[] parts = trimmed.split(\"\\\\s+\"); // 按空格分割\n" +
"StringBuilder sb = new StringBuilder();\n" +
"for (String part : parts) {\n" +
" if (!part.isEmpty()) {\n" +
" // 简单的首字母大写(仅为示例,不处理所有情况)\n" +
" if (part.length() > 1) {\n" +
" sb.append(Character.toUpperCase(part.charAt(0)))\n" +
" .append(part.substring(1).toLowerCase());\n" +
" } else {\n" +
" sb.append(part.toUpperCase());\n" +
" }\n" +
" sb.append(' ');\n" +
" }\n" +
"}\n" +
"// 最后一行表达式的值,就是脚本的返回值\n" +
"sb.toString().trim();"; // 注意这里没有分号,因为它是作为返回值的表达式
ScriptEvaluator se = new ScriptEvaluator();
se.setReturnType(String.class);
se.setParameters(new String[]{"rawAddress"}, new Class[]{String.class});
se.cook(script); // 编译脚本
return (String) se.evaluate(new Object[]{rawAddress});
}
public static void main(String[] args) throws Exception {
AddressCleaner cleaner = new AddressCleaner();
String result = cleaner.cleanAddress(" 123 main st, new york ");
System.out.println("清洗后地址: \"" + result + "\""); // 输出 "123 Main St, New York"
}
}
与ExpressionEvaluator的区别 :
- 语法 :支持完整的Java语句,如循环、条件分支、局部变量声明。
- 返回值 :脚本的返回值是 最后一条语句的表达式的值 。如果最后一条语句是
int a = 5;(这是一个声明语句,没有值),那么脚本返回null。因此,通常将需要返回的值放在最后一行,且不加分号。 - 作用域 :脚本中声明的变量(如
trimmed,sb)是局部变量,在脚本外部不可见。
3.4 高阶应用:动态定义完整类与函数
对于更复杂的场景,比如需要动态定义一个包含多个方法的工具类,我们就需要请出最强大的 Compiler 了。下面演示如何动态创建一个实现特定接口的类。
假设我们有一个简单的计算器接口,但运算规则需要从外部配置动态加载。
// 1. 定义标准接口
public interface Calculator {
int calculate(int a, int b);
}
// 2. 动态编译并实现接口
public class DynamicCalculatorFactory {
public Calculator createCalculator(String operation) throws Exception {
// 根据操作符生成不同的类源码
String className = "DynamicCalculatorImpl";
String sourceCode =
"public class " + className + " implements " + Calculator.class.getName() + " {\n" +
" public int calculate(int a, int b) {\n" +
" return a " + operation + " b;\n" + // 例如 operation = “*”
" }\n" +
"}";
// 使用Janino编译器
org.codehaus.janino.Compiler compiler = new org.codehaus.janino.Compiler();
// 编译源码并加载类。第二个参数是生成的.class文件的输出目录,null表示不输出到文件,只存在于内存。
ClassLoader classLoader = compiler.getClassLoader();
// 这里需要一点技巧:Compiler.compileToClassLoader 方法更直接,但示例使用更通用的方式
// 更常见的做法是使用SimpleCompiler或通过IClassLoader
org.codehaus.janino.SimpleCompiler sc = new org.codehaus.janino.SimpleCompiler();
sc.cook(sourceCode);
Class<?> clazz = sc.getClassLoader().loadClass(className);
// 实例化
return (Calculator) clazz.getDeclaredConstructor().newInstance();
}
public static void main(String[] args) throws Exception {
DynamicCalculatorFactory factory = new DynamicCalculatorFactory();
Calculator adder = factory.createCalculator("+");
System.out.println("5 + 3 = " + adder.calculate(5, 3)); // 8
Calculator multiplier = factory.createCalculator("*");
System.out.println("5 * 3 = " + multiplier.calculate(5, 3)); // 15
}
}
高阶技巧与陷阱 :
- 类路径问题 :动态生成的类如果需要引用项目中的其他类(如上面的
Calculator接口),必须确保这些类在编译器的类路径中。SimpleCompiler默认使用当前线程的上下文类加载器,通常没问题。但在复杂的OSGi或自定义类加载器环境中,可能需要显式设置Compiler的类路径。 - 性能与缓存 :编译一个完整的类比编译表达式开销大得多。 必须使用强力的缓存策略 ,通常以生成的源码的MD5值或业务规则ID作为缓存键,缓存编译得到的
Class对象甚至其实例。 - 内存泄漏风险 :每次编译都会生成新的类,由自定义的
ClassLoader加载。如果无限制地编译新类而不卸载旧的ClassLoader,会导致PermGen(Java 8之前)或Metaspace内存溢出。解决方案是:1) 积极缓存,减少编译次数;2) 在可能的情况下,复用同一个ClassLoader;3) 对于生命周期明确的热更新场景,可以定期重启应用或模块。
4. 性能调优与安全加固实战
将代码动态化带来了灵活性,也带来了性能和安全的挑战。直接在生产环境使用原生Janino可能会踩坑,下面分享一些实战经验。
4.1 编译性能优化策略
Janino的编译速度虽然比 javac 快,但在高并发下,编译仍可能成为瓶颈。我们的目标是 将99%的请求命中缓存,让编译只发生在第一次或规则变更时 。
策略一:多层次缓存 不要只缓存 Evaluator 或 Class 对象。可以设计一个三级缓存:
- 源码缓存 :缓存原始规则字符串。如果规则未变化,直接进入下一级。
- 编译结果缓存 :缓存编译后的
Evaluator或Class对象。这是核心缓存。 - 实例缓存 :如果动态类是无状态的(或线程安全的),可以缓存其实例,避免重复实例化开销。
public class OptimizedScriptEngine {
private LoadingCache<String, ScriptEvaluator> evaluatorCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, ScriptEvaluator>() {
@Override
public ScriptEvaluator load(String script) throws Exception {
ScriptEvaluator se = new ScriptEvaluator();
se.setReturnType(Object.class);
// ... 设置参数
se.cook(script);
return se;
}
});
// ... 使用缓存
}
策略二:预编译与预热 在系统启动或低峰期,主动加载并编译常用的规则脚本,填充缓存,避免高峰期的首次编译延迟。
策略三:限制脚本复杂度 与业务方约定,动态脚本应保持简洁,避免在脚本内进行复杂的数据库查询或IO操作。脚本应专注于纯计算逻辑。如果逻辑过于复杂,应考虑将其拆分为预定义的Java方法,由脚本调用。
4.2 安全沙箱:防止恶意代码执行
允许执行动态代码是极其危险的操作。一个恶意的脚本 System.exit(0) 或 while(true){} 就足以让服务瘫痪。 必须为动态脚本构建一个安全的沙箱环境 。
1. 代码白名单校验 在编译前,对脚本源码进行静态扫描,只允许出现安全的语法和类。
public class ScriptSecurityChecker {
private static final Set<String> ALLOWED_KEYWORDS = Set.of("if", "else", "for", "while", "return");
private static final Set<String> ALLOWED_CLASSES = Set.of(
"java.lang.String", "java.lang.Integer", "java.util.List"
);
public boolean isSafe(String script) {
// 1. 检查是否包含危险关键字(如 synchronized, native, Thread)
if (script.contains("System.exit") || script.contains("Runtime.getRuntime")) {
return false;
}
// 2. 使用AST解析器进行更精细的检查(这里简化)
// 可以使用Janino自己的Parser生成AST,然后遍历树,检查所有方法调用和字段访问的合法性。
// 这是一个复杂但更安全的方式。
return true;
}
}
2. 使用SecurityManager和自定义类加载器(已过时但仍有参考价值) Java的 SecurityManager 可以限制代码的权限。你可以为执行动态代码的线程设置一个严格的 AccessControlContext ,禁止文件读写、网络访问、执行外部进程等。
// 注意:Java 17+开始,SecurityManager已被标记为废弃,未来可能移除。
// 更现代的替代方案是使用Java模块化系统(JPMS)的权限控制,或依赖容器级别的隔离。
PermissionCollection noPermissions = new Permissions();
AccessControlContext restrictedContext = new AccessControlContext(new ProtectionDomain[]{
new ProtectionDomain(null, noPermissions)
});
Object result = AccessController.doPrivileged(
(PrivilegedExceptionAction<Object>) () -> scriptEvaluator.evaluate(args),
restrictedContext
);
3. 资源与时间限制 即使代码本身安全,一个死循环或高复杂度计算也会耗尽CPU资源。
- 时间限制 :将脚本执行放在一个单独的线程中,使用
Future和超时机制。ExecutorService executor = Executors.newSingleThreadExecutor(); Future<Object> future = executor.submit(() -> scriptEvaluator.evaluate(args)); try { Object result = future.get(2, TimeUnit.SECONDS); // 超时2秒 return result; } catch (TimeoutException e) { future.cancel(true); throw new ScriptTimeoutException("脚本执行超时"); } - 内存限制 :通过
-Xmx限制整个JVM内存是粗粒度的。更精细的控制需要在脚本中避免创建大对象,或者通过Java Agent等技术在类加载时注入内存检查代码,但这非常复杂。一个务实的做法是,在脚本执行前后采样内存使用,如果增长异常则报警并拒绝服务。
4. 最安全的做法:使用自定义DSL而非完整Java 如果业务允许,最好的安全方案是 不直接执行Java代码 。可以设计一个领域特定语言(DSL),比如只支持加减乘除、字符串拼接、简单比较的表达式语言。然后自己实现这个DSL的解释器或编译器(可以用Janino来编译这个解释器生成的Java代码)。这样,能力范围被严格限定,从根本上杜绝了危险操作。
5. 生产环境集成与问题排查指南
将Janino集成到Spring Boot、若依(RuoYi)、芋道(yudao)等主流框架中,并确保其稳定运行,需要一些工程化考量。
5.1 与Spring Boot集成
在Spring Boot中,我们通常将Janino封装成一个Service,由Spring容器管理其生命周期和依赖。
@Service
@Slf4j
public class DynamicRuleService {
private final Map<String, ExpressionEvaluator> evaluatorCache = new ConcurrentHashMap<>();
@Autowired
private RuleConfigRepository ruleRepository; // 假设从数据库读取规则
public Object executeRule(String ruleId, Map<String, Object> context) {
// 1. 获取规则源码
String ruleExpression = ruleRepository.findExpressionById(ruleId);
if (ruleExpression == null) {
throw new RuleNotFoundException(ruleId);
}
// 2. 获取或编译Evaluator
ExpressionEvaluator evaluator = evaluatorCache.computeIfAbsent(ruleId, id -> {
try {
// 动态解析参数类型(这是一个难点!)
Pair<String[], Class<?>[]> paramInfo = resolveParameterTypes(context);
ExpressionEvaluator ee = new ExpressionEvaluator();
ee.setReturnType(Object.class); // 或更具体的类型
ee.setParameters(paramInfo.getLeft(), paramInfo.getRight());
ee.cook(ruleExpression);
log.info("成功编译规则: {}", ruleId);
return ee;
} catch (Exception e) {
log.error("编译规则失败, ruleId: {}, expression: {}", ruleId, ruleExpression, e);
throw new RuleCompilationException("规则编译失败", e);
}
});
// 3. 准备参数值数组(顺序必须与setParameters时一致)
Object[] argValues = prepareArgumentValues(context, evaluator.getParameterNames());
// 4. 执行
try {
return evaluator.evaluate(argValues);
} catch (Exception e) {
log.error("执行规则失败, ruleId: {}", ruleId, e);
throw new RuleExecutionException("规则执行失败", e);
}
}
// 关键辅助方法:如何从Map<String, Object>的上下文推断出参数类型?
private Pair<String[], Class<?>[]> resolveParameterTypes(Map<String, Object> sampleContext) {
// 这是一个简化示例。实际中,你需要有元数据来描述每个参数的类型。
// 方案1:约定context Map的key就是参数名,value的类型就是参数类型。但这要求value不是null。
// 方案2:单独维护一个规则参数元数据表。
List<String> names = new ArrayList<>();
List<Class<?>> types = new ArrayList<>();
for (Map.Entry<String, Object> entry : sampleContext.entrySet()) {
names.add(entry.getKey());
types.add(entry.getValue() != null ? entry.getValue().getClass() : Object.class);
}
return Pair.of(names.toArray(new String[0]), types.toArray(new Class<?>[0]));
}
@PreDestroy
public void cleanup() {
evaluatorCache.clear();
log.info("清理规则引擎缓存");
}
}
集成要点 :
- 配置化 :将Janino的编译选项(如调试信息生成、编译警告级别)放在
application.yml中。 - 监控 :通过Spring Boot Actuator或自定义指标,暴露缓存命中率、编译耗时、执行耗时等关键指标。
- 优雅关闭 :在
@PreDestroy方法中清理缓存,释放资源。
5.2 典型问题排查与解决方案
在实际使用中,你肯定会遇到各种错误。下面是一个快速排查指南。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
编译错误: ParseException 或 CompileException | 1. 脚本语法不符合Java规范。 2. 使用了Janino不支持的Java语法(如某些新版本的语法特性)。 3. 字符串中的转义字符处理错误。 | 1. 将脚本粘贴到IDE中检查语法。 2. 确认Janino版本支持的Java语言级别。对于Lambda表达式( -> ),需要Janino 3.x以上版本并显式开启支持( Compiler.setCompilerOptions )。 3. 在Java字符串中,反斜杠 \ 需要转义为 \\ 。正则表达式中的反斜杠可能需要四个 \\\\ 。 |
运行时错误: ClassNotFoundException | 脚本中引用了某个类,但该类不在编译器的类路径中。 | 1. 确保被引用的类及其依赖的Jar包在应用类路径里。 2. 通过 Compiler.setClassLoader 或 SimpleCompiler.setParentClassLoader 设置正确的父类加载器。 |
运行时错误: NullPointerException 或类型转换错误 | 1. 传给 evaluate 的参数值为 null ,但脚本中进行了自动拆箱(如 int num = param; )。 2. 脚本返回值类型与 setReturnType 不匹配。 | 1. 在脚本开始处对可能为null的参数进行判空处理。 2. 仔细检查 setReturnType 和实际返回值的类型。使用 Object.class 作为返回类型最安全,但调用方需要自己转型。 |
| 性能问题:执行缓慢 | 1. 没有缓存,每次执行都编译。 2. 脚本本身逻辑复杂,或包含低效操作(如字符串拼接在循环内)。 3. 反射调用开销(对于动态生成的类)。 | 1. 务必实现缓存 。 2. 优化脚本逻辑,将不变的计算提到循环外。对脚本复杂度设限。 3. 对于性能极度敏感的场景,可以考虑将频繁执行的动态类,通过字节码工具(如ASM)生成其静态代理类,减少反射。 |
| 内存泄漏:Metaspace持续增长 | 生成了大量动态类,且加载它们的 ClassLoader 未被回收。 | 1. 加强缓存,减少类的生成数量。 2. 如果业务允许,定期重启服务实例。 3. 考虑使用同一个 ClassLoader 来加载所有动态类(需注意类卸载问题)。监控JVM的Metaspace使用情况。 |
| 脚本执行死循环 | 脚本中包含 while(true) 或递归没有退出条件。 | 必须使用带超时机制的线程来执行脚本,如前面“安全沙箱”部分所述。 |
一个真实的踩坑记录 :在一次线上发布后,规则引擎突然大量报 ClassNotFoundException ,指向一个常用的工具类。排查后发现,新版本中这个工具类被移动到了不同的模块,打包成了一个独立的Jar。而我们的Janino编译器使用的是系统类加载器,它看不到这个新模块的Jar。 解决方案 是显式地为 SimpleCompiler 设置父类加载器为当前线程的上下文类加载器( Thread.currentThread().getContextClassLoader() ),这个加载器通常能看到应用的所有模块。
6. 超越基础:Janino在复杂场景下的应用模式
掌握了基本用法和问题排查后,我们可以看看Janino在一些更复杂、更有趣的场景下的应用模式,这能极大拓展你的技术视野。
6.1 实现轻量级规则引擎(Rule Engine)
很多商业规则引擎(如Drools)功能强大但较重。对于规则不太复杂的场景,用Janino自制一个轻量级引擎非常合适。
核心设计是“条件-动作”对。每个规则包含一个用Janino编译的 条件表达式 和一个 动作脚本 。
public class LightweightRule {
private String ruleId;
private ExpressionEvaluator conditionEvaluator; // 编译后的条件,返回boolean
private ScriptEvaluator actionEvaluator; // 编译后的动作脚本
public boolean evaluate(Map<String, Object> facts) { ... }
public void execute(Map<String, Object> facts) { ... }
}
public class RuleEngine {
private List<LightweightRule> ruleChain;
public void fireRules(Map<String, Object> facts) {
for (LightweightRule rule : ruleChain) {
if (rule.evaluate(facts)) {
rule.execute(facts);
// 可能支持“一旦触发就停止”或“继续执行下一条”等模式
}
}
}
}
你可以在此基础上增加优先级、规则流编排等特性,快速构建一个贴合业务的规则系统。
6.2 动态SQL生成与数据转换
在ETL或数据报表系统中,查询条件或字段转换规则经常变化。与其写死代码,不如用Janino动态生成片段。
// 动态生成SQL WHERE子句片段
String filterScript = "StringBuilder where = new StringBuilder();\n" +
"if (department != null) where.append(\" AND dept = '\").append(department).append(\"'\");\n" +
"if (salaryMin > 0) where.append(\" AND salary >= \").append(salaryMin);\n" +
"where.toString();";
// 执行脚本,得到 WHERE 子句字符串,拼接到主SQL中。
注意 :这种方式有SQL注入风险! 绝不能 直接将用户输入拼接到脚本中生成SQL。所有外部输入必须作为 参数 传入脚本,由脚本进行安全处理(如转义),或者更好的做法是,生成的SQL片段使用PreparedStatement的参数占位符。
6.3 插件化系统与热部署
想象一个数据分析平台,用户想上传自定义的数据分析函数(一个 .java 文件)。你可以用Janino的 Compiler 编译这个上传的Java文件,加载类,并让它实现一个预定义的 DataProcessor 接口。这样,新功能无需重启服务就能上线。
// 用户上传的插件源码
String userSourceCode = "public class MyFilter implements DataProcessor { public Object process(Object input) { /* 自定义逻辑 */ } }";
// 动态编译加载
Class<?> pluginClass = compiler.compileToClass("UserPlugin", userSourceCode);
DataProcessor processor = (DataProcessor) pluginClass.newInstance();
// 注册到插件管理器
pluginManager.register(processor);
这为构建可扩展的SaaS平台或中间件提供了强大的能力。
6.4 与表达式语言(EL)互补
Spring框架有自己的SpEL(Spring Expression Language),它功能强大且与Spring生态无缝集成。那么,何时选Janino,何时选SpEL?
- 选Janino :当你需要执行 多行Java语句 ,逻辑复杂,或者需要定义 新的类和方法 时。Janino更接近完整的Java。
- 选SpEL :当你主要在Spring环境内,需要处理 简单的属性访问、方法调用、集合操作 ,并且希望表达式能方便地访问Spring容器中的Bean时。SpEL的语法更简洁,集成度更高。
两者甚至可以结合使用:用SpEL处理简单的配置化表达式,用Janino处理需要复杂逻辑的脚本模块。
最后,我想分享一点个人体会。Janino这类工具是把双刃剑,它赋予系统前所未有的灵活性,但也引入了复杂性和风险。在决定使用它之前,一定要反复问自己:这个需求是否真的需要动态代码?是否可以用配置、策略模式、规则表等更静态、更安全的方式实现?如果答案依然是肯定的,那么请务必做好前面提到的 缓存、沙箱、监控和限流 。把它关进笼子里,再让它为你跳舞。当你看到业务方能够实时调整核心计算逻辑,而无需工程师介入和发布时,你会觉得这些谨慎都是值得的。

193

被折叠的 条评论
为什么被折叠?



