首页 / 资讯中心 / 文章详情

bsh2.0源码拆解:从Java脚本引擎到受控执行器

bsh2.0源码拆解:从Java脚本引擎到受控执行器 ★ FEATURED ARTICLE
简介bsh2.0 源码是一份面向 Java 开发者与脚本引擎学习者的 BeanShell 2.0 完整源代码包可用于理解轻量级动态脚本解释器的内部实现也适合在应用中嵌入脚本能力或做二次开发时参考。压缩包共 233 个文件、约 347KB以 147 个 java 源文件和 57 个 bsh 脚本为主体另含 html 文档、template 模板、txt 说明及少量 gif、jpg 图示覆盖解释器核心代码、脚本示例与构建配置。目前已有 197 人学习下载。读者可从中研读脚本解析、命令注册与执行流程的实现细节结合示例脚本快速验证语法特性并通过版本变更记录和说明文档了解项目演进与使用方式对教学研究、工具定制和排错分析都有较高参考价值。1. 一次发版引发的拆包bsh2.0 源码能替你省掉多少次重启有一年做风控规则判定逻辑全写在 Java 里业务方一天改三次阈值每次都要走一遍编译、打包、灰度一条if的代价是 40 分钟。后来我把规则表达式挪进脚本宿主只提供对象和数据逻辑交给脚本执行情况才好转。这套思路的底层就是bsh2.0 源码——一份纯 Java 实现的解释型脚本引擎代码包名bsh2.0 分支。它不是新东西也不是玩具完整的词法语法解析、AST 节点体系、命名空间、反射调用、类型包装全都在里面能直接在你的 JVM 里eval一段文本脚本里可以写循环、方法、try/catch还能直接new宿主的类、调宿主的方法。适合两类人想在 Java 服务里塞动态逻辑又不想引入重型引擎的从业者以及想读懂一个解释器到底怎么把字符串变成方法调用的人。下面按我实际拆包和改造的顺序讲。2. 结构拆解bsh2.0 源码里一次 eval 走过的六层调用链拿到源码包先别急着编译先搞清楚一条语句从字符串走到方法调用经过了哪些类。这条链路弄明白后面所有改造和排错都有坐标否则调起问题来就是纯玄学。2.1 先定位入口Main、Interpreter 与脚本的生命周期源码根目录下先找入口类这是整份代码的骨架# 找主类和核心目录先看包结构再动手 find . -name Main.java -o -name Interpreter.java | head -20 # 看文法文件在哪决定你要不要装 JavaCC find . -name *.jj -o -name *.jjtbsh.Main是命令行入口做的事情很薄解析参数、拼一个Interpreter、把脚本文件读进来交给eval。真正的主体是bsh.Interpreter它持有三样东西——一个NameSpace全局变量表、一个Parser语法分析器、一个BshClassManager类解析器。构造Interpreter的时候这三样一起初始化所以你在业务里new Interpreter()一次等于同时开了一个独立作用域和一套类加载上下文。eval(String)的流程是固定的四步拿字符串喂给Parser得到SimpleNode根节点压一个CallStack帧调根节点的eval(callstack, this)最后弹栈。source(String fileName)和eval的区别只在于前者会先做一次文件读取并把文件名记进节点信息里出错时堆栈能报出行号。需要重点看的是Interpreter里的几个开关它们决定脚本的运行姿态setStrictJava(true)让脚本必须像 Java 一样先声明变量拼错变量名直接抛错而不是悄悄返回nullsetExitOnEOF(false)防止脚本读到流末尾就把整个 JVM 关掉——这个默认值在嵌入式场景下翻车过不止一次setOut/setErr把脚本里的print输出重定向到你的日志管道别让它直接打到标准输出污染容器日志。还有一点容易被忽略Interpreter本身不是线程安全的。它的CallStack和NameSpace都是实例字段多个线程共享一个实例跑脚本变量会互相覆盖。这是后面线程模型那一节的前提。2.2 文法与 ASTjj 文件、生成的 Parser 和 BSH* 节点bsh2.0 用的是 JavaCC 那套路线不是手写递归下降。目录里通常能看到一个.jj文法文件以及与之配套的一批生成产物Parser、ParserTokenManager、SimpleCharStream、ParseException、TokenMgrError。发行出来的源码包一般已经带好了生成后的Parser.java只有当目录里只剩.jj没有.java时你才需要自己装 JavaCC 重新生成。文法的输出形态是一棵SimpleNode树。2.0 分支里每种语法结构都对应一个BSH前缀的节点类比如代码块、赋值、方法调用、二元表达式、循环语句、返回语句各自一个类都继承自SimpleNode。这个设计的好处是节点自己知道怎么求值。每个节点实现eval(CallStack, Interpreter)父节点在自己的eval里依次调用子节点的eval整棵树的求值就是一次自上而下的递归。SimpleNode还实现了jjtAccept(Visitor, Object)和一组jjtGetNumChildren()/jjtGetChild(i)方法。这意味着你不只能执行这棵树还能遍历它——第 6 章的静态体检就是靠这个机制做的不用改文法只用加一个访问者。对从业者来说这个层次最实用的认知是脚本报错时的行号、列号来自节点上挂的SourceFileInfo。你在SimpleNode里能看到getSourceFile、getLineNumber、getColumnNumber这类方法。做脚本管理平台的时候把这三个值透出到前端用户改错一个括号能自己定位能省掉大量“我脚本没错啊”的来回。2.3 NameSpace脚本变量到底存在哪NameSpace是这套引擎里最值得单独拎出来看的类。它不是一张平的哈希表而是一个带父指针的链每个NameSpace有一个parentgetVariable(name)会先查自己查不到再沿父链往上找一直找到全局setVariable则默认写在自己这一层。这个结构解决了两类问题。一类是方法调用时的局部变量每次进入脚本方法引擎压一个新的NameSpace方法体里的变量写在新层里方法返回时整层丢弃天然就是栈帧语义不需要额外做变量回收。另一类是模块隔离你手动new NameSpace(parent)造一个子空间塞给Interpreter这个脚本里定义的变量、方法、导入的包全都关在这一层里脚本跑完把整个NameSpace扔掉就行不污染宿主。NameSpace上还挂了两类“外来物”是扩展的入口一是importClass/importPackage维护的导入表决定脚本里写类名时能不能解析到二是setCommand(name, command)注册的命令表把宿主实现的 Java 对象注册成一个看起来像脚本函数的可调用物。第 4 章的自定义命令就是往这里塞东西。有一个常见误解要提前说清NameSpace上还有一层This的引用用于脚本里的this语义和方法的绑定。改命名空间的时候如果只换了NameSpace却没处理This脚本里的方法引用可能还指回原来的上下文。折腾这块的时候我一般会先跑一个最小验证脚本把this打成字符串看一眼比读代码快。2.4 Reflect 与 Primitive脚本类型怎么落到 Java 方法签名上脚本要调宿主方法中间隔着两层Reflect负责方法解析和调用Primitive负责类型包装。Primitive的存在感很低但出事最多。脚本里的字面量1被包装成什么这套引擎的做法是把基本类型包成Primitive对象里面记着值和一个类型标记需要落到 Java 侧时再unwrap()出真正的Integer包装对象。所以脚本里的1在反射阶段是Integer不是int。Reflect.resolveJavaMethod拿到候选方法列表后会按参数个数、参数类型做匹配必要时做宽化int→long→double但不会做你想当然的那种“智能”推断。由此带来一个典型翻车场景宿主有个方法void submit(Long id)脚本里写submit(1001)报“找不到方法”。原因不是参数个数对不上而是脚本给的是IntegerInteger不能宽化成Long反射层匹配失败。常见做法是在脚本里写成submit(1001L)或者把宿主方法签名改成long/Number让宽化路径走得通。这类问题在setStrictJava(false)下报错信息很含糊一旦出现“方法不存在”但你确信签名没问题先怀疑类型包装。Reflect层还有一处分叉要留意方法解析结果是被缓存的。缓存键由方法名和参数量构成命中就跳过后面的匹配逻辑。好处是重复调用快坏处是你在运行期动态给BshClassManager换了类加载器、加载了同名同参的新类缓存里可能还留着旧的方法对象。改造过类加载逻辑之后记得把BshClassManager上的缓存清一遍或者干脆重建一个Interpreter。3. 编译与嵌入从源码到业务里一个可 eval 的 Bean弄清楚结构之后第二步是把它变成一个能进依赖的 jar。这一步没什么技术含量坑都在细节上。3.1 构建路线选择脚本构建 vs 现代构建工具源码包年代偏早通常自带 Ant 风格的构建脚本直接跑也能出包。要不要换到现代构建工具取决于你的用途如果只是验证和内部试用用javac直接编最快不引入任何构建工具依赖如果这份代码要在你团队的项目里长期维护我建议把源码目录整体搬进标准目录结构用 Maven 或 Gradle 包一层好处是依赖、编码、JDK 目标版本全部显式声明不会出现“同事机器上编得过、CI 上编不过”。不管走哪条路有两个参数必须先定死。一是-source/-target这份代码的年代决定了它对高版本 JDK 的语法收紧不一定适应用 JDK 8 目标编出来兼容面最宽二是-encoding UTF-8源码里有中文注释和字符串不指定编码在默认 GBK 的机器上编出来就是乱码。这两个不设后面调试时你会怀疑人生。如果目录里的解析器只有.jj没有生成产物那就得先跑一遍 JavaCC。这一步的产物是Parser.java等一批文件生成之后跟其他源码一起编。生成产物不要手改改了下次重新生成就丢要改文法就改.jj文件本身。3.2 用 javac 直接编译一份可用的 jar最省事的做法是收集全部源文件一次性交给javac# 1. 收集源文件清单排掉测试代码避免测试依赖拖进包体 find . -name *.java -not -path */test/* sources.txt # 2. 编译UTF-8 编码 JDK 8 目标输出到 out 目录 javac -encoding UTF-8 -source 8 -target 8 -d out sources.txt # 3. 打成一个带 Main-Class 的可执行 jar方便命令行直接跑脚本 jar cfe bsh2.jar bsh.Main -C out . # 4. 验证跑一段最简脚本确认解析器和解释器都在 echo print(ok (1 2)); /tmp/smoke.bsh java -jar bsh2.jar /tmp/smoke.bshsources.txt是javac的批处理参数文件写法文件里一行一个路径避免命令行过长被 shell 截断。-d out保证包路径按bsh/目录结构展开jar cfe里的c是创建、f是指定文件名、e是设置入口类入口类必须写成全限定名bsh.Main不带.class后缀。最后一步的冒烟脚本别省。它同时验证了三件事解析器能建树、解释器能求值、字符串拼接正常。如果这一步就报ParseException说明文法生成产物和源码不匹配回去重新生成解析器如果报NoClassDefFoundError说明sources.txt漏了文件用wc -l sources.txt对一下源文件总数。3.3 嵌入宿主Interpreter 的三种典型用法jar 出来之后业务里怎么用决定了后面所有的设计。我这几年见过、也用过的姿势基本是三种复杂度和隔离性递增。第一种一次性求值。适合表达式级别的判断比如折扣计算、阈值比较import bsh.Interpreter; import bsh.EvalError; public class ExprEvaluator { public static Object evalOnce(String script, Object ctx) throws EvalError { Interpreter i new Interpreter(); // 开启强类型变量必须先声明拼写错误立刻暴露而不是返回 null i.setStrictJava(true); // 把宿主上下文以固定名字挂进命名空间脚本里按名字取用 i.set(ctx, ctx); // eval 返回最后一个表达式的值直接拿来做结果 return i.eval(script); } }这段的关键在setStrictJava(true)和i.set。前者把脚本从“随便写”变成“接近 Java”能挡掉大部分低级错误后者是宿主与脚本之间唯一的通道只把需要的数据挂进去别把整个Service实例塞进去否则等于把内部 API 全暴露给脚本。eval的返回值是脚本最后一个表达式的值所以规则脚本最后一行写表达式就行不用写return。第二种会话式。同一个Interpreter实例反复eval变量跨次保留适合交互式调试或者“先定义函数、再多次调用”的场景。代价是要自己管状态清理脚本之间可能串变量。第三种预编译复用。先把脚本解析成SimpleNode存起来之后每次执行直接对节点求值跳过词法语法分析。规则数量多、调用频次高的场景必须走这条解析开销在热路径上不可忽视。需要注意的是节点对象本身持有CallStack之外的上下文引用缓存时按“脚本版本”做键脚本一改就得重新解析别拿脚本内容做弱引用缓存容易脏。3.4 参数与开关ClassPath、命名空间隔离、输出重定向嵌入式场景里必须显式设置的开关我列成一张表缺一个都可能在线上出问题开关/动作作用不设的后果setStrictJava(true)变量必须先声明拼错变量名静默返回 null规则静默失效setExitOnEOF(false)脚本读完不退出 JVM引擎读到流末尾直接关进程setOut/setErr重定向脚本输出脚本 print 打进标准输出污染容器日志setClassLoader指定类解析用的加载器脚本 new 不到业务类报 ClassNotFound独立NameSpace脚本变量隔离两次请求的变量互相覆盖独立Interpreter执行上下文隔离并发下 CallStack 串帧异常堆栈错乱类加载器那条要展开一句Interpreter内部通过BshClassManager解析类名BshClassManager默认用的加载器未必和你的业务类一致。容器环境里业务类往往在应用类加载器上而引擎拿到的可能是更上层的加载器于是脚本里new YourService()直接失败。常见做法是把当前线程上下文类加载器显式塞进去让脚本的类解析跟业务对齐。4. 扩展点改造把 bsh2.0 收成一个受控的脚本执行器能跑通只是及格。要敢让业务方写脚本得把能力边界、审计、隔离三件事补上这三件事恰好都有对应的现成扩展点。4.1 自定义命令实现 bsh.Command 注入宿主能力直接在脚本里import业务类再new等于把整个类路径开放给脚本作者风险不可控。更稳的做法是只暴露少量函数走命令机制注册import bsh.CallStack; import bsh.Command; import bsh.Interpreter; import bsh.NameSpace; import bsh.SimpleNode; public class RiskCommand implements Command { Override public String getCommandName() { return riskScore; // 脚本里就当函数名用 } Override public Object invoke(NameSpace namespace, Interpreter interpreter, CallStack callstack, SimpleNode callerInfo) { // 参数从命名空间或调用栈取具体实现藏在宿主里脚本看不到内部类 Object userId namespace.getVariable(userId); return RiskService.score(userId); // 宿主方法脚本无法绕过 } }注册只需要一行挂在脚本可见的命名空间上Interpreter i new Interpreter(); i.getNameSpace().setCommand(riskScore, new RiskCommand()); i.eval(int s riskScore(); if (s 80) { print(\hit\); });这里的设计要点是“函数名与实现分离”。脚本作者只看到一个riskScore不知道它背后查了几张表、调了几个服务你要改实现换一个Command实现类就行脚本一行不用动。getCommandName()的返回值就是脚本里的调用名注意别和脚本里可能定义的同名方法冲突命名上加个前缀会安全些。参数传入方式上命令机制拿不到像普通函数那样规整的形参列表实际项目里我一般约定“参数通过命名空间变量传”脚本里先set再调用比从CallStack里挖参数稳定得多。4.2 审计与耗时继承 Interpreter 做前后钩子谁在什么时候跑了哪段脚本、跑了多久、有没有异常这些必须留痕。最省事的切入点是继承Interpreter把eval包一层import bsh.EvalError; import bsh.Interpreter; public class AuditedInterpreter extends Interpreter { private final String scriptId; public AuditedInterpreter(String scriptId) { this.scriptId scriptId; } Override public Object eval(String script) throws EvalError { long start System.nanoTime(); try { Object r super.eval(script); Metrics.record(scriptId, ok, System.nanoTime() - start); return r; } catch (EvalError e) { // 把脚本自身报错和宿主异常分开记方便区分是谁的问题 Metrics.record(scriptId, eval_error, System.nanoTime() - start); throw e; } finally { // 清理本次执行留下的临时变量避免下一次复用串味 this.getNameSpace().clear(); } } }覆盖eval而不是去改源码里的求值逻辑是因为求值路径太深改一处影响全部节点。钩子放在最外层能同时拿到入参、结果、异常和耗时信息量足够。finally里的clear()是后悔药同一个实例被复用时上一次脚本留下的变量会在下一次可见加上这一行至少保证每次执行从干净状态开始。指标打点别只记成功EvalError单独一个标签线上排查时能立刻分辨是脚本写错还是宿主方法抛异常。要更细的粒度可以在NameSpace上做文章每次执行前换一个新的子NameSpace执行后整层丢弃比clear()更彻底代价是多一次对象分配。4.3 类白名单从 BshClassManager 收口脚本里能new什么是这套引擎最大的风险面。默认情况下它按类名加载只要类路径上有就能用。收口的办法是在BshClassManager这一层做拦截而不是在脚本文本上做字符串匹配——后者绕过的办法太多了。常见的实现路子是继承或包装BshClassManager在“按名取类”的那一步判断类名是否命中白名单不命中就抛错。白名单按包前缀配置比如只允许业务自己的 DTO 包和少数工具类其余一律拒绝。判断的时候要按全限定名匹配别只看简单类名否则同名类换个包就绕过去了。这件事的收益不只是安全白名单同时也是稳定性手段。脚本作者new了一个带静态初始化块的类或者new了一个会起线程的类出问题的排查成本极高。把可用类收敛到几十个等于把这部分不确定性直接消掉。4.4 线程模型为什么一个请求一个 Interpreter前面反复提到Interpreter不是线程安全的这里说清代价和做法。正确姿势是“一次执行一个实例”。请求进来创建Interpreter挂上下文、跑脚本、销毁全程不跨线程共享。对象创建成本不高真正贵的是解析所以高频场景要配合预编译解析结果缓存起来共享执行时仍然用独立实例把“解析”和“求值”的生命周期拆开。这样既拿到了缓存收益又不会因为共享CallStack出并发问题。线程池隔离是第二层。脚本执行放到独立线程池和业务线程池物理隔开避免某段脚本死循环把业务线程吃光。池子大小按脚本平均耗时估队列要有界满了直接拒绝并打点别让请求无限堆积——堆积的后果是整个服务雪崩而不是某条规则失败。5. 避坑与排查五条把线上线程打满的血泪记录下面这五条都是实际踩出来的按“现象 → 原因 → 解决”记能对上号就直接抄。现象一脚本里new业务类报找不到类宿主里明明有这个类。原因在类加载器BshClassManager拿去解析类名的加载器和业务类所在的加载器不是同一个父子关系不覆盖时就找不到。解决办法是显式把上下文类加载器塞进去让两边对齐Interpreter i new Interpreter(); // 让脚本的类解析走业务侧加载器避免 ClassNotFound i.setClassLoader(Thread.currentThread().getContextClassLoader());设置之后如果还有缓存导致的旧结果重建实例或清掉BshClassManager上的方法缓存。这类问题在容器化和热部署环境下出现频率最高。现象二某天告警业务线程池被打满堆栈全停在脚本求值里。原因几乎都是脚本里写了while(true)或者一个退出条件永远不成立的循环而执行侧没有任何超时约束。interrupt对这类纯计算的循环响应并不可靠指望它停下来不现实。落地方案是把执行放进独立线程池用Future.get(timeout)拿结果超时就放弃这个任务并打点告警FutureObject f scriptPool.submit(() - interpreter.eval(script)); try { return f.get(500, TimeUnit.MILLISECONDS); // 超时上限按业务最慢脚本定 } catch (TimeoutException e) { f.cancel(true); // 尽力中断不保证成功 throw new ScriptTimeoutException(script timeout); }cancel(true)只是尽力而为被放弃的线程可能还在跑所以线程池要允许“丢弃”而不是无限等待同时把超时事件记进指标连续超时的脚本要下线。现象三宿主方法签名没错脚本调用却报“方法不存在”。类型包装问题。脚本里的整数字面量是Integer传给Long形参匹配不上int和Integer的重载并存时也容易选错。诊断办法是把setStrictJava(true)打开让类型错误在更早的位置暴露解决上要么在脚本里显式加后缀写成1001L要么把宿主方法签名放宽成long或Number。这类问题没有通用解只能靠约定对外暴露给脚本的方法参数统一用基本类型或String。现象四两个用户跑脚本A 的变量在 B 的脚本里能读到。共用了同一个Interpreter或同一个全局NameSpace。脚本里set的变量默认写在当前层但没做隔离时大家写的是同一层。解决是每次执行新建实例或者至少新建一个子NameSpace挂上去NameSpace scope new NameSpace(interpreter.getNameSpace(), req- reqId); interpreter.setNameSpace(scope); // 本次执行的变量全关在这一层用完把scope丢掉即可不需要逐个变量清理。现象五本地测得好好的脚本上线报语法错。大概率用了 2.0 分支不认识的语法最常见的是 lambda 和方法引用。这套引擎的文法年代早于这些语法-和::在解析阶段就会失败。解决办法是在脚本规范里明确禁止并在提交侧做一次预解析校验把错误挡在保存之前而不是等到执行时才炸。预解析用同一份引擎跑一遍空执行即可成本很低。6. 上线前的静态体检用 Visitor 遍历语法树拦住三类脚本前面所有手段都是“跑起来之后”的防护真正省钱的是“跑之前”就拒掉。SimpleNode上的jjtAccept(Visitor, Object)给了现成的入口不用改文法加一个访问者就能把整棵树扫一遍。做法是在Interpreter.eval里解析出根节点之后、求值之前插一段。既然是源码包直接改这一处最贴合// 改动点解析完成、求值之前先过一遍静态体检 SimpleNode root parser.parse(); ScriptAudit audit new ScriptAudit(); audit.audit(root); if (audit.hasRisk()) { // 拒绝执行把命中的风险项带回给调用方 throw new EvalError(script rejected: audit.getHits(), callerInfo, callstack); } return root.eval(callstack, this);访问者本身很短靠节点类名判断结构递归交给jjtAccept自己做import bsh.SimpleNode; import bsh.Visitor; public class ScriptAudit implements Visitor { private final StringBuilder hits new StringBuilder(); Override public Object visit(SimpleNode node, Object data) { String type node.getClass().getSimpleName(); // 循环语句 无退出条件等价于死循环的前兆 if (BSHWhileStatement.equals(type) || BSHForStatement.equals(type)) { hits.append(loop).append(node.getLineNumber()).append(;); } // 线程与进程操作一律不允许出现在业务脚本里 if (BSHMethodInvocation.equals(type) String.valueOf(node).contains(System.exit)) { hits.append(exit).append(node.getLineNumber()).append(;); } return data; } public void audit(SimpleNode root) { root.jjtAccept(this, null); } public boolean hasRisk() { return hits.length() 0; } public String getHits() { return hits.toString(); } }这段代码的价值不在逻辑多复杂而在于它把“事后救火”变成了“事前拦截”。getLineNumber()能直接告诉脚本作者第几行有问题比抛一个运行期异常友好得多。实际项目里我把检查项收敛成三类命中任何一类直接拒绝保存检查项节点特征处置循环语句BSHWhileStatement/BSHForStatement需人工审批或强制加迭代上限进程与线程操作方法调用里出现退出、启动线程、Runtime直接拒绝反射与类加载调用Class.forName、getClass、newInstance直接拒绝走白名单命令替代注意这套遍历是纯静态的它判断不了循环条件在运行期是否成立所以只能当“风险提示”不能当“正确性证明”。我的做法是把命中的脚本打上标记要求提交人勾选确认同时保留运行期的超时兜底——两道防线都在才敢让业务方自己发脚本。从那以后我每次接脚本引擎的活都强制走一遍这个顺序先用最小脚本验证解析器和求值链路再补隔离和超时最后才敢开放给业务方写。顺序颠倒过来出的问题往往不是“脚本写错了”而是“引擎把宿主带崩了”。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站