
简介bsh2.0 源码是一份面向 Java 开发者与脚本引擎学习者的 BeanShell 2.0 源代码包可用于理解轻量级动态脚本语言在 JVM 上的解析、解释与执行机制也适合在应用中嵌入脚本能力或做二次开发参考。压缩包共 233 个文件约 347KB以 147 个 Java 源文件为主体配套 57 个 bsh 脚本示例及 html 文档、txt 说明与清单配置等覆盖核心实现、示例脚本和项目元数据。已有 197 人学习下载。读者可从中研读解释器结构、语法解析与运行时处理思路结合示例脚本验证用法并借助说明文档和构建清单快速定位模块为定制扩展、教学演示或集成脚本支持提供较完整的源码参考。1. 拿到 bsh2.0 源码先别急着编译先想清楚它替你解决了什么问题去年接手一个老系统规则配置里允许业务方写一小段类 Java 的表达式运行期动态生效。整个链路在代码里就一行new Interpreter().eval(expr)平时风平浪静一出问题日志里只有一行 stack trace连表达式原文都不打。想定位到底是变量作用域串了、还是类型被隐式转换吃掉精度光看调用方根本看不出来只能把 bsh2.0 源码拉下来逐层读。这份源码就是一个用 Java 写的轻量级脚本解释器不做字节码编译落盘直接解析成语法树后递归求值能在 JVM 里动态执行接近 Java 语法的脚本还能双向访问宿主对象。它适合两类人一类是被嵌入式脚本坑过、需要搞清楚执行模型的后端开发另一类是想给自己的系统加动态规则、动态公式能力又不想引入重型脚本引擎的架构同学。下面按「骨架在哪、怎么跑、怎么改」的顺序拆。2. 源码骨架从目录结构到一次 eval 的完整调用链拿到一份解释器源码最容易犯的错是从第一个 Java 文件开始顺着读。正确的入口是反过来先找到对外暴露的那一个方法再从它往下追。bsh2.0 对外的门面就是Interpreter一次eval会依次穿过解析器、语法树、命名空间三层把字符串变成实际副作用。2.1 目录结构与模块职责源码包解压后大致是下面这个格局不同打包方式略有出入但主体稳定路径职责读它的优先级src/bsh/Interpreter.java门面类eval/source/set/get 全在这里最高先读src/bsh/NameSpace.java作用域链变量与方法的查找表高src/bsh/CallStack.java调用栈帧方法调用与异常回溯高src/bsh/node/*.java语法树节点每个节点自带 eval高但要挑着读src/bsh/BSH*.java具体语句/表达式节点赋值、循环、方法声明中src/bsh/Primitive.java弱类型包装所有运算都过它中src/bsh/Reflect.java反射桥脚本调 Java 方法的最后一公里中src/bsh/BshClassManager.java类解析与动态生成类的缓存低改造时才看src/bsh/parser/*.jjtJavaCC 语法描述生成 Parser需要改语法时看第一层要建立的认知是这里没有「编译成 class 再执行」的路径。脚本进来是字符串出去是副作用中间全部发生在内存里的对象图上。这也解释了它为什么启动快、单次执行慢——省掉了编译开销代价是每次求值都要走一遍树。第二层认知关于node包。所有语法节点都实现同一个接口核心方法签名是Object eval(CallStack callstack, Interpreter interpreter) throws EvalError。整棵树的求值就是对这个方法的一次递归调用父节点负责调度子节点、合并结果。理解了这个签名后面所有代码都是它的变体。2.2 一次 eval 的调用链把调用链画成文字大概是Interpreter.eval(String)→ 懒加载Parser→Parser.parse()返回根节点 →SimpleNode.eval()递归下降 → 叶子节点调NameSpace查变量、调Reflect调方法 → 结果回传到根 → 返回Object。关键点在于Parser是懒加载并且复用的NameSpace也是复用的所以同一个Interpreter实例连续 eval 两次第二次会明显快而且变量是共享的。// Interpreter.eval 的骨架节选并简化保留关键控制流 public Object eval(String statement) throws EvalError { // 1. 解析器懒加载第一次调用才真正 new Parser if (parser null) { parser new Parser(new StringReader()); } try { // 2. 换输入源复用同一个 Parser 实例避免重复初始化语法表 parser.ReInit(new StringReader(statement)); // 3. 解析出根节点这一步只做语法分析不执行 Node node parser.Line(); // 4. 从全局命名空间开始求值 return node.eval(globalNameSpace, this); } catch (ParseException e) { // 解析期异常统一包成 EvalError带上原始脚本便于定位 throw new EvalError(parse error: statement, node, callstack); } }这段代码里有两个参数值得记住globalNameSpace是这个Interpreter持有的根作用域所有顶层变量都挂在它下面this被一路传下去让任意深度的节点都能回头访问解释器比如取配置、取类管理器。ReInit而不是new Parser是为了复用 JavaCC 生成的词法状态表这个细节在做高并发脚本执行时影响不小——频繁 new Parser 会带来额外的初始化成本也会让 GC 压力变大。EvalError里携带了node这是它比普通异常好用的地方出错的语法树节点被保留下来你可以从节点反查行号、反查原始文本把「第几个字符出错」还原出来。很多项目把它直接printStackTrace掉等于把黑匣子扔了血泪经验是至少把e.getErrorNode()的文本打进日志。2.3 构建与最小可跑环境源码包一般自带 Ant 构建脚本语法分析器由 JavaCC 从.jjt生成。第一次构建的顺序不能反先生成 Parser再编译 Java 源码否则会因为找不到生成的类而大面积报错。# 完整构建清理 - 生成解析器 - 编译 - 打包 ant clean ant parser # 由 .jjt 生成 Parser 相关源码 ant jar # 产物通常在 dist/ 或 target/ 下 # 如果没有 Ant手动两步也能跑通 javacc -OUTPUT_DIRECTORYsrc/bsh/parser src/bsh/parser/bsh.jjt javac -encoding UTF-8 -d out $(find src -name *.java)手动javac那条命令要注意三个点-encoding UTF-8不能省源码里有中文注释或特殊字符时会编译失败输出目录out要提前存在find的结果里包含生成代码所以必须先跑javacc。跑完之后写一个五行的验证类能打印出结果就说明整条链路通了。import bsh.Interpreter; public class SmokeTest { public static void main(String[] args) throws Exception { Interpreter i new Interpreter(); i.set(base, 100); // 宿主变量注入脚本作用域 Object r i.eval(base * 2 1); // 脚本里直接用无需声明 System.out.println(r); // 期望输出 201 } }set注入的变量落在全局命名空间脚本里可以直接当普通变量读写这是嵌入式脚本最常用的交互方式。返回值是Object实际类型由脚本决定这里会是Primitive包装过的整数println时走toString才看到201。如果你在业务代码里直接强转成Integer会拿到ClassCastException这一点后面避坑章还会提。3. 解释执行核心Interpreter、NameSpace、CallStack 三件套三个类的分工可以类比成「门面 / 环境 / 栈」Interpreter负责对外接口和生命周期NameSpace负责「这个名字指向谁」CallStack负责「现在执行到哪、出错往回找谁」。把这三者的关系理顺剩下的节点代码基本都是填空。3.1 Interpreter不只是 eval 的壳Interpreter上值得关注的方法不止eval。source用于执行整个脚本文件set/get用于宿主与脚本之间传值还有一组setClassLoader、getNameSpace之类的钩子改造时经常从这些地方下手。Interpreter interp new Interpreter(); // 1. 注入宿主对象脚本里可以直接调用它的公开方法 interp.set(rule, new RuleContext()); // 2. 执行脚本文件注意第二个参数是是否输出到控制台 interp.source(rules/check.bsh); // 3. 取回脚本里定义的变量供 Java 侧继续使用 Object result interp.get(matched); // 4. 取回脚本里定义的方法包装成接口回调 interp.eval(int score(int x) { return x 60 ? 1 : 0; });第三步get的返回值同样是被包装过的对象取基本类型时我一般会走一层工具方法做宽容转换而不是直接强转因为脚本里1和1.0在某些运算后会得到不同类型。第四步是它比较实用的一个能力脚本里定义的方法可以通过getNameSpace().getMethod(...)拿到再通过接口代理暴露给 Java 调用做到「规则用脚本写、调用在 Java 侧」改规则不用重新发版。生命周期上有个容易忽略的点Interpreter不是线程安全的。它内部持有共享的NameSpace和CallStack多线程同时eval会出现变量串味这种玄学问题现象是 A 线程的临时变量莫名出现在 B 线程的结果里。并发场景要么每个线程一个实例要么加锁串行化要么用线程本地变量池化实例。池化时记得每次归还前清空业务变量否则上一个请求的数据会泄漏到下一个请求这条在任何脚本引擎上都成立。3.2 NameSpace作用域链就是一条父指针NameSpace的结构非常朴素一个变量表、一个方法表、一个指向父命名空间的引用。查找规则是从当前往上冒泡直到根节点写入规则默认写当前层除非显式声明为全局。// 变量查找的核心逻辑示意保留语义 public Object getVariable(String name) throws EvalError { NameSpace ns this; while (ns ! null) { Object val ns.variables.get(name); if (val ! null) return val; // 命中就近返回 ns ns.getParent(); // 未命中往上冒泡 } throw new EvalError(undefined variable: name, null, null); }冒泡查找带来了两个直接后果。第一内层可以读到外层变量闭包式的引用在脚本层是天然可用的这对写规则的人很友好。第二内层同名变量会遮蔽外层且方法调用时参数会新建一层子命名空间方法体里对基本类型参数的修改不会影响调用方看到的原变量——这不是 bug是值传递的正常表现但业务方经常当成 bug 来报我在规则文档里专门写了一行说明。对应到具体语句节点上BSHAssignment负责写BSHAmbiguousName负责读两者都走NameSpace。读节点里有个细节如果名字在当前层找不到会尝试当成类名去BshClassManager里解析这是为了支持new ArrayList()这种不 import 也能用的写法。也就是说变量名和类名共享同一个命名空间查找流程这也是为什么你在脚本里定义一个叫String的变量后后面所有new String()都会翻车。3.3 CallStack异常回溯的最后一道保险CallStack是栈帧的链表每个帧记录方法名、参数、调用者。脚本里抛异常时回溯信息就靠它拼出来。// 方法调用节点的关键步骤示意 public Object eval(CallStack callstack, Interpreter interpreter) throws EvalError { // 1. 压栈记录当前调用点便于异常时回溯 CallStack newStack callstack.copy(); newStack.push(this); // this 即方法调用节点含方法名与实参 try { return method.invoke(argValues, newStack, interpreter); } catch (EvalError e) { // 2. 不吞异常补充调用点信息后继续往上抛 e.addCallStack(newStack); throw e; } }这里用copy()而不是直接push是有意的脚本里的递归调用很多共享一个可变栈会导致回溯信息错乱每个调用点复制一份快照代价是多几次对象分配换来的是异常信息可读。实际用的时候建议把EvalError的完整回溯打印出来包括每个帧的方法名和参数值——只打 message 的话一条「undefined variable」在你有一百条规则时毫无定位价值。顺带说下性能。解释执行的常数开销主要在两处节点递归调用以及Primitive的类型提升判断。前者靠减少嵌套层级缓解脚本别写太深后者靠避免在热循环里做混合类型运算int和double混着算会反复走类型提升分支。这两条不是什么高深优化但在每秒几千次求值的规则场景里效果肉眼可见。4. 语法树与反射桥Node.eval 递归到 Reflect 怎么把脚本接到 Java 上到这里骨架清楚了剩下的问题是最关键的落地细节一个1 2 * 3这样的表达式是怎么从树节点变成一个数字的以及list.add(x)这种调用是怎么落到真实 Java 对象上的。4.1 SimpleNode一切节点都是同一个模式SimpleNode是所有语法节点的基类默认实现是「遍历子节点并求值」具体节点覆盖它。以二元运算为例逻辑简单到几乎不需要注释求左、求右、按运算符分派。// 二元运算节点示意 public Object eval(CallStack callstack, Interpreter interpreter) throws EvalError { Object lhs jjtGetChild(0).eval(callstack, interpreter); // 左子树 Object rhs jjtGetChild(1).eval(callstack, interpreter); // 右子树 switch (kind) { case PLUS: // 关键不直接做 Java 的 而是交给 Primitive 统一处理 return Primitive.binaryOperation(lhs, rhs, Primitive.PLUS); case LT: return Primitive.binaryOperation(lhs, rhs, Primitive.LT); default: throw new EvalError(unsupported operator, this, callstack); } }为什么绕一圈走Primitive而不直接(Integer)lhs (Integer)rhs因为脚本是弱类型的a 1要得到字符串拼接1 1.5要得到浮点数null 1要按特定规则处理。这些规则集中在一个类里改起来只有一处比散落在几十个节点里靠谱得多。读源码时建议把Primitive的运算分派表过一遍那是这个引擎语义的「宪法」。另一个值得看的是BSHBlock它代表一段语句块依次 eval 每个子语句遇到ReturnControl、BreakControl这类控制信号就提前跳出。注意这里用的是「控制信号对象」而不是 Java 异常来做流程跳转早期版本用异常做 break/continue 导致性能很差现在这版改成了返回特殊对象。翻到这一段时能明显感受到作者踩过坑。4.2 从脚本调用到 Java 方法Reflect 的参数匹配list.add(1)这行脚本落到Reflect里要做三件事找到目标对象的类、按方法名筛出候选方法、按实参类型选出最匹配的那个。Java 允许重载脚本又是弱类型参数匹配就成了最容易出问题的地方。// 方法解析的简化流程 static Invocable getMethod(Object obj, String name, Object[] args) throws ReflectError { Method[] candidates obj.getClass().getMethods(); ListMethod byName new ArrayList(); for (Method m : candidates) { if (m.getName().equals(name)) byName.add(m); // 1. 先按名字粗筛 } // 2. 再按参数个数与类型兼容性精筛返回可调用包装 return resolveBestMatch(byName, args); }精筛的兼容规则是这套代码里最值得抄的部分基本类型和包装类型视为等价int可以喂给long参数null可以喂给任意引用类型但会优先选更具体的重载。理解这套规则的意义在于当业务方反馈「脚本调我的方法说找不到」时你能立刻判断是参数个数不匹配、类型不兼容还是方法不是 public。九成的「找不到方法」都是这三类不用去翻引擎内部。还有个小坑反射调用异常会被包成EvalError原始异常藏在 cause 里。业务代码里catch (EvalError e)之后必须往下挖一层e.getCause()否则你看到的永远是「方法调用失败」看不到真正的NullPointerException或者参数校验异常。4.3 动态类与缓存的边界脚本里写class Foo { ... }时引擎会在内存里生成字节码并交给自定义类加载器。这条路径由BshClassManager管理它内部维护了名字到类的缓存。缓存的存在意味着同一份源码改完之后重新定义同名类不一定会生效因为缓存命中直接返回旧类。做热更新功能时这就很致命。我的处理方式是在重新加载脚本前通过ClassManager清掉对应条目或者干脆整个Interpreter换新实例。后者更粗暴但更可靠代价是丢失了之前注入的宿主变量需要重新set一遍。改造时如果要做脚本热部署强烈建议把「换实例」作为默认方案缓存精细清理作为后续优化——先保证正确再谈性能。5. 踩坑排查跑通并上线 bsh2.0 之前先看这几条这一章是我自己踩过、也看别人踩过的记录。每条按「现象 → 原因 → 解决」写遇到同类问题可以直接对照。5.1 返回值强转 Integer 报 ClassCastException现象int r (Integer) interp.eval(1 2);抛类型转换异常日志里显示实际类型是bsh.Primitive。原因引擎内部为了支持弱类型把基本类型统一包在Primitive里eval原样返回这个包装对象。它在toString、equals上做了伪装所以println打印出来一切正常一旦强转就露馅。这类问题最阴的地方在于测试时用println验证过就以为没问题上线后走了强转的分支才炸。解决不要直接强转。写一个宽容转换工具按目标类型分派目标int时先取Primitive的数值再转目标String时走toString目标自定义类型时判断instanceof后强转。所有从脚本取值的出口统一走这个工具一处收口就不会散。public static int toInt(Object v) { if (v instanceof Number) return ((Number) v).intValue(); // 覆盖 Primitive 与包装类 if (v instanceof String) return Integer.parseInt((String) v); throw new IllegalArgumentException(not a number: v); }注意Primitive实现了Number的行为走Number分支能同时兼容包装类和脚本返回比instanceof Primitive更稳。5.2 方法内改变量外面看不到变化现象脚本里void inc(int x) { x x 1; }调用后调用方变量没变业务方认定引擎有 bug。原因方法调用会新建子命名空间基本类型实参按值传入方法体里的赋值只改子空间里的副本。这是值传递的正常语义和 Java 一致只是脚本使用者往往按「引用传递」的直觉理解。解决两条路。要么在脚本层改用可变容器承载比如int[] box new int[]{0};传box进去改box[0]要么在规则文档里明确写清「基本类型是值传递需要回传请用返回值」。我一般两个都做文档防一半容器兜底另一半。如果对方坚持要引用语义也可以让方法返回值再赋值回去写法啰嗦但意图最清晰。5.3 首个请求特别慢压测曲线开头有个尖刺现象服务刚启动时前几十次脚本执行耗时是稳态的三五倍之后恢复正常压测图上开头一根尖刺。原因第一次eval才初始化 ParserJavaCC 生成的词法表和状态机要建起来同时反射调用的方法查找也没有缓存每次都要遍历方法列表做匹配。两者叠加冷启动阶段的成本明显高于稳态。解决启动阶段做预热。把常用的规则脚本各跑一遍「空执行」让 Parser 初始化、让方法解析缓存建立起来再开始接流量。预热的次数不用多每种脚本形态跑一两次就够。另外如果是长驻服务别在请求线程里new Interpreter用池子复用实例初始化成本只付一次。// 启动预热用最小成本触发解析器与方法缓存初始化 for (String script : warmupScripts) { try { interp.eval(script); } catch (EvalError ignored) { /* 预热失败不影响启动 */ } }预热失败要吞掉异常别让一条坏规则把服务启动拦下来但吞掉的同时打一条 warn 日志避免坏脚本长期潜伏。5.4 脚本死循环把线程钉死超时控制不生效现象业务方写了个while(true)或者条件永远成立的循环工作线程被占满接口全部超时外部设置的Future.get(timeout)也拉不回来。原因解释执行是纯递归求值循环节点内部没有「指令计数」这样的检查点Thread.interrupt也不会在节点求值过程中被响应。超时只能取消等待取消不了正在跑的任务。解决分三层。第一层脚本入口处做静态检查禁止明显的无条件循环结构第二层在eval外层用独立线程池执行配超时并记录第三层也是真正治本的在节点的eval里插入检查点每隔若干次求值检查一次线程标志位或截止时间超时就抛EvalError中断。第三层要改源码但改动很小一个计数器加一个判断是这份源码最值得做的二次开发之一。// 在 SimpleNode.eval 入口插入检查点二次开发示例 private static final ThreadLocallong[] deadline ThreadLocal.withInitial(() - new long[]{0L}); public Object eval(CallStack callstack, Interpreter interpreter) throws EvalError { long[] d deadline.get(); if (d[0] 0 System.currentTimeMillis() d[0]) { throw new EvalError(script timeout, this, callstack); // 主动中断执行 } return evalNode(callstack, interpreter); }ThreadLocal存截止时间避免给CallStack加参数破坏兼容性d[0] 0表示不设限保持原有行为。这个改动配合同步的时间戳检查开销大概在每次求值多一次比较实测对吞吐影响很小换来的是不会被一条坏规则拖垮整个服务。6. 二次开发给 bsh2.0 加沙箱和自定义能力真要在业务里用它光跑通不够还得回答两个问题怎么限制脚本能干的事怎么让脚本用上业务提供的便利函数。这两块我都动过手下面说具体的改法。沙箱的第一道闸在BshClassManager。脚本里new 某某类()最终都会走到类解析重写或包一层类管理器维护一个允许的包名前缀白名单解析时不在白名单内直接抛EvalError就能挡掉绝大多数越权访问。// 白名单式类解析示意 public class SandboxClassManager extends BshClassManager { private static final ListString ALLOWED Arrays.asList( java.lang., java.util., java.math., com.example.rule.); Override public Class? classForName(String name) throws ClassNotFoundException { boolean ok ALLOWED.stream().anyMatch(name::startsWith); if (!ok) throw new ClassNotFoundException(blocked: name); // 非白名单一律拒绝 return super.classForName(name); } }注意白名单要写成前缀而非全名否则java.util.ArrayList和java.util.HashMap要列几百条。但前缀也有风险java.lang.Runtime就在java.lang.下面所以真实场景我一般用「包前缀白名单 类名黑名单」双保险把反射、运行时、线程这类入口单独禁掉。另一道闸是禁语法脚本里禁止出现import、class定义、以及直接调用Reflect相关能力。这需要在 Parser 层或节点层拦成本比类管理器高按需做。第二块是自定义能力。最省事的做法是把业务工具类注册成全局变量脚本里当普通对象用更贴近「内置函数」体验的做法是在解释器上挂一个包装对象把多个静态方法暴露出去。// 注册业务函数集脚本里可直接 str.trim( x ) interp.set(str, new StringUtil()); interp.set(clock, new ClockUtil()); // 脚本侧写法 // if (str.isBlank(name)) { return clock.now(); }这里有个细节要留意注册的对象方法必须是 public否则反射不可见脚本调用时只会得到「找不到方法」排查时容易误判成名字写错。另外注册对象本身也会暴露它的所有 public 方法不想被调用到的工具方法就别放在这个类里宁可多拆两个类也别为了省事把一个大杂烩工具类整个塞进去。验证改造效果我用的是一套脚本回归集正常脚本若干、越权脚本若干、死循环脚本若干每组断言预期结果正常出值、拒绝执行、超时中断。每改一次源码跑一遍比手工点几下靠谱得多。这套回归集建起来大概半天之后每次升级都能省下大量回归时间属于典型的「后悔药」。改造顺序上我的建议是先做检查点和超时保命再做类白名单防越权最后做内置函数提效率。反过来先加功能等出了问题再补安全往往会发现节点层已经被改得面目全非回头补检查点要重写一遍。从那以后我每次给这类解释器加能力都强制先把「最坏情况下它会怎么把服务搞挂」这条链路跑一遍再开始写业务增强。希望这些能帮你少绕几步。本文还有配套的精品资源点击获取