简介: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 实现的解释型脚本引擎代码,包名bsh,2.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 "*.jjt"bsh.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 一样先声明变量,拼错变量名直接抛错而不是悄悄返回null;setExitOnEOF(false)防止脚本读到流末尾就把整个 JVM 关掉——这个默认值在嵌入式场景下翻车过不止一次;setOut/setErr把脚本里的print输出重定向到你的日志管道,别让它直接打到标准输出污染容器日志。
还有一点容易被忽略:Interpreter本身不是线程安全的。它的CallStack和NameSpace都是实例字段,多个线程共享一个实例跑脚本,变量会互相覆盖。这是后面线程模型那一节的前提。
2.2 文法与 AST:jj 文件、生成的 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有一个parent,getVariable(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),报“找不到方法”。原因不是参数个数对不上,而是脚本给的是Integer,Integer不能宽化成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.bsh@sources.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)拿结果,超时就放弃这个任务并打点告警:
Future<Object> 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 | 直接拒绝,走白名单命令替代 |
注意这套遍历是纯静态的,它判断不了循环条件在运行期是否成立,所以只能当“风险提示”,不能当“正确性证明”。我的做法是把命中的脚本打上标记,要求提交人勾选确认,同时保留运行期的超时兜底——两道防线都在,才敢让业务方自己发脚本。
从那以后我每次接脚本引擎的活,都强制走一遍这个顺序:先用最小脚本验证解析器和求值链路,再补隔离和超时,最后才敢开放给业务方写。顺序颠倒过来,出的问题往往不是“脚本写错了”,而是“引擎把宿主带崩了”。希望帮到你。
本文还有配套的精品资源,点击获取