Java™ 脚本编程 API(Java scripting API)是 Java SE 6 中新增的,它向后兼容 Java SE 5,支持以一种简单且统一的方式在运行时从 Java 应用程序调用数十种脚本语言。本系列的 第 1 部分 介绍了这个 API 的基本特性。第 2 部分进一步讲解它的功能,演示如何在无需停止并重新启动应用程序的情况下,在运行时执行外部 Ruby、Groovy 和 JavaScript 脚本以修改业务逻辑。
Java SE 6 中新增的 Java 脚本编程 API 为运行用各种动态语言编写的外部程序(并与之共享代码和数据)提供了一种统一的方式。Java 应用程序与脚本语言的强大功能和灵活性相结合是非常有意义的,尤其是在脚本语言能够更简洁地执行某些任务的情况下。但是,Java 脚本编程 API 不仅仅能够以一种统一的方式在 Java 程序中添加许多种脚本语言代码,它还支持在运行时寻找、读取和执行脚本。可以利用这些动态功能在程序运行时修改脚本,从而修改应用程序的逻辑。本文演示如何使用 Java 脚本编程 API 调用外部脚本来动态地修改程序逻辑。还要讨论在将一种或多种脚本语言集成到 Java 应用程序中时可能遇到的问题。
第 1 部分 用一个 Hello World 风格的应用程序介绍了 Java 脚本编程 API。这里将展示一个更真实的示例应用程序,这个程序使用脚本编程 API 创建一个动态的规则引擎,它可以以外部 Groovy、JavaScript 和 Ruby 脚本的形式定义规则。这些规则决定申请人是否符合某些抵押产品的住宅贷款条件。如果用脚本语言定义业务规则,规则就更容易编写,也便于非程序员(比如贷款审查员)阅读。通过使用 Java 脚本编程 API 将这些规则放在程序之外,还可以支持在应用程序运行时修改规则和添加新的抵押产品。
真实的应用程序
这个示例应用程序为虚构的 Shaky Ground Financial 公司处理住宅贷款申请。住宅抵押行业不断地推出新的贷款产品,还常常修改对合格申请人的限制规则。Shaky Ground 公司不但希望能够快速地添加和删除抵押产品,还需要快速修改业务规则,从而控制哪些人符合产品的贷款条件。
Java 脚本编程 API 正好能够满足这种需求。这个应用程序由一个 ScriptMortgageQualifier 类组成,这个类负责判断打算购买某一资产的贷款人是否符合给定的抵押贷款产品的条件。清单 1 给出这个类。
清单 1. ScriptMortgageQualifier 类
这个类相当简单,因为它把所有业务决策任务都委派给了外部脚本。每个脚本表示一个抵押产品。每个脚本文件中的代码包含一系列业务规则,这些规则定义了符合这种抵押产品要求的贷款人类型、资产类型和贷款类型。由于采用了这种方式,只需在脚本目录中添加新的脚本文件,就可以添加新的抵押产品。如果某一抵押产品的业务逻辑改变了,那么只需更新脚本来反映规则的变化。
通过用脚本语言编写抵押产品业务规则,可以展示 Java 脚本编程 API 的功能。这个程序还说明有时候脚本语言代码更容易阅读、修改和理解,即使是非程序员也可以掌握脚本代码。
ScriptMortgageQualifier 类的工作方式
ScriptMortgageQualifier 中的主要方法是 qualifyMortgage()。这个方法通过参数接受以下信息:
贷款人
要购买的资产
贷款细节
一个 File 对象,其中包含要执行的脚本
这个方法的任务是用业务实体参数运行脚本文件并返回一个结果对象,这个对象指出贷款人是否符合抵押产品的要求。这里没有给出 Borrower、Property 和 Loan 的代码。它们只是简单的实体类,可以在本文的源代码中找到它们的代码(见 下载)。
为了找到一个 ScriptEngine 来运行脚本文件,qualifyMortgage() 方法使用了 getEngineForFile() 内部 helper 方法。getEngineForFile() 方法使用 scriptEngineManager 实例变量(这个变量在类实例化时被设置为一个 ScriptEngineManager)寻找能够处理具有给定文件扩展名的脚本的脚本引擎。getEngineForFile() 方法使用 ScriptEngineManager.getEngineByExtension() 方法(见 清单 1 中的粗体代码)搜索并返回 ScriptEngine。
找到脚本引擎之后,qualifyMortgage() 将它接收的实体参数绑定到引擎的上下文,从而让脚本能够使用这些参数。前三个 scriptEngine.put() 调用(也是粗体代码)执行这些绑定。第四个 scriptEngine.put() 调用创建一个新的 MortgageQualificationResult Java 对象并通过脚本引擎共享它。脚本可以通过设置这个对象的属性将它的运行结果返回给 Java 应用程序,qualifyMortgage() 将返回这个共享对象。脚本使用 result 全局变量访问这个 Java 对象。每个脚本负责使用这个共享对象将自己的结果返回给 Java 应用程序。
最后一个 scriptEngine.put() 调用让脚本可以通过 scriptExit 变量使用一个内部 helper 类(ScriptEarlyExit,见 清单 1)的实例。ScriptEarlyExit 定义了两个简单的方法 —— withMessage() 和 noMessage(),它们惟一的作用是抛出一个异常。如果脚本调用 scriptExit.withMessage() 或 scriptExit.noMessage(),那么方法抛出一个 ScriptEarlyExitException 异常。脚本引擎会捕捉这个异常、终止脚本处理并向调用脚本的 eval() 方法抛出一个 ScriptException 异常。
通过以这种迂回的方式提前退出脚本,就可以以一致的方式从函数或方法外的脚本处理过程返回。并非所有脚本语言都提供了这种方式所需的语句。例如,在 JavaScript 中,在执行高层代码时(这个示例应用程序中的抵押处理脚本正是采用这种构造方式),无法使用 return 语句。共享对象 scriptExit 解决了这个问题,一旦脚本判断出贷款人不符合抵押产品的要求,用任何语言编写的脚本都可以通过这个对象退出。
在 qualifyMortgage 中,对脚本引擎的 eval 方法的调用(见粗体代码)使用一个 try/catch 块捕捉 ScriptException 异常。catch 块中的代码检查 ScriptException 错误消息,从而判断这个脚本异常是由 ScriptEarlyExitException 造成的,还是由真正的脚本错误造成的。如果错误消息包含名称 ScriptEarlyExitException,那么代码就认为一切正常并忽略这个脚本异常。
这种在 Java 脚本编程 API 的脚本异常错误消息中搜索字符串的技术有点儿笨拙,但这对于本示例中使用的 Groovy、JavaScript 和 Ruby 语言解释器是有效的。如果所有脚本语言实现将从调用的 Java 代码抛出的 Java 异常添加到异常堆栈中,那么会更方便,这样就可以使用 Throwable.getCause() 方法获取这些异常。JRuby 和 Groovy 等解释器会这样做,但是内置的 Rhino JavaScript 解释器并不这样做。
运行代码:ScriptMortgageQualifierRunner
为了测试 ScriptMortgageQualifier 类,将使用测试数据表示四个贷款人、贷款人打算购买的一项资产和一笔抵押贷款。我们将用一个贷款人、资产和贷款运行所有三个脚本,检查贷款人是否满足脚本所代表的抵押产品的业务规则。
清单 2 给出 ScriptMortgageQualifierRunner 程序的部分代码,我们将用这个程序创建测试对象、在一个目录中寻找脚本文件并通过 清单 1 中的 ScriptMortgageQualifier 类运行它们。为了节省篇幅,这里没有给出这个程序的 createGoodBorrower()、createAverageBorrower()、createInvestorBorrower()、createRiskyBorrower()、createProperty() 和 createLoan() helper 方法。这些方法的作用仅仅是创建实体对象并设置测试所需的值。在 下载 一节中可以获得所有方法的完整源代码。
清单 2. ScriptMortgageQualifierRunner 程序
ScriptMortgageQualifierRunner 中的 main() 方法搜索命令行上提供的脚本文件目录,如果这个目录存在,就用目录的 File 对象设置一个静态变量,并调用 run() 方法执行进一步的处理。
run() 方法对 清单 1 中的 ScriptMortgageQualifier 类进行实例化,然后用一个无限循环调用内部方法 runQualifications(),测试四个贷款人/贷款场景。这个无限循环模拟连续的抵押申请处理。这个循环让我们可以在脚本目录中添加或修改脚本文件(抵押贷款产品),这些修改会动态地生效,不需要停止应用程序。因为这个应用程序的业务逻辑放在外部脚本中,所以可以在运行时动态地修改业务逻辑。
对于脚本目录中的每个脚本文件,runQualifications() helper 方法分别调用 ScriptMortgageQualifer.qualifyMortgage 一次。每个调用前面有一系列打印语句,它们输出脚本文件和贷款人的相关信息;调用之后,用打印语句显示结果,即贷款人是否符合抵押产品的要求。脚本代码使用共享的 MortgageQualificationResult Java 对象返回其结果,检查这个对象的属性就可以判断贷款人是否合格。
本文的源代码 ZIP 文件包含三个用 Groovy、JavaScript 和 Ruby 编写的脚本文件示例。它们分别代表一种标准的 30 年期固定利率抵押贷款产品。脚本中的代码判断贷款人是否符合这种抵押类型的要求,然后通过调用脚本引擎 put() 方法中提供的共享全局变量 result 来返回结果。全局变量 result 是 MortgageQualificationResult 类的实例(部分代码见清单 3)。
清单 3. MortgageQualificationResult 类
脚本设置 result 的属性,从而指出贷款人是否符合抵押贷款的要求以及应该采用的利率。脚本可以通过 message 和 productName 属性指出导致贷款人不合格的原因和返回相关的产品名称。
脚本文件
在给出 ScriptMortgageQualifierRunner 的输出之前,我们先看看这个程序运行的 Groovy、JavaScript 和 Ruby 脚本文件。Groovy 脚本中的业务逻辑定义了一种条件相当宽松的抵押产品,同时由于金融风险比较高,因此利率比较高。JavaScript 脚本代表一种政府担保的抵押贷款,这种贷款要求贷款人必须满足最大收入和其他限制。Ruby 脚本定义的抵押产品业务规则要求贷款人有良好的信用记录,这些人要支付足够的首付款,这种抵押贷款的利率比较低。
清单 4 给出 Groovy 脚本,即使您不了解 Groovy,也应该能够看懂这个脚本。
清单 4. Groovy 抵押脚本
请注意全局变量 result、borrower、loan 和 property,脚本使用这些变量访问和设置共享 Java 对象中的值。这些变量名是通过调用 ScriptEngine.put() 方法设置的。
还要注意 result.productName = 'Groovy Mortgage' 这样的 Groovy 语句。这个语句似乎是直接设置 MortgageQualificationResult 对象的字符串属性 productName,但是,清单 3 清楚地说明它是一个私有的实例变量。这并不 表示 Java 脚本编程 API 允许违反封装规则,而是说明通过使用 Java 脚本编程 API,Groovy 和大多数其他脚本语言解释器可以很好地操作共享的 Java 对象。如果一个 Groovy 语句尝试设置或读取 Java 对象的私有属性值,Groovy 就会寻找并使用 JavaBean 风格的公共 setter 或 getter 方法。例如,语句 result.productName = 'Groovy Mortgage' 会自动转换为适当的 Java 语句:result.setProductName("Groovy Mortgage")。这个 Java setter 语句也是有效的 Groovy 代码,可以在脚本中使用,但是直接使用属性赋值语句更符合 Groovy 的风格。
现在看看清单 5 中的 JavaScript 抵押产品脚本。这个 JavaScript 脚本代表一种政府担保的贷款,政府支持这种贷款是为了提高公民的住宅拥有率。所以,业务规则要求这是贷款人购买的第一套住宅,而且贷款人打算在此居住,而不是出租获利。
清单 5. JavaScript 抵押脚本
注意,JavaScript 代码不能像 Groovy 脚本那样使用 Java scriptExit.withMessage() 方法在一个语句中设置不合格消息并退出脚本。这是因为 Rhino JavaScript 解释器并不把抛出的 Java 异常在 ScriptException 堆栈跟踪中作为嵌入的 “错误原因” 向上传递。因此,在堆栈跟踪中更难找到 Java 代码抛出的脚本异常消息。所以 清单 5 中的 JavaScript 代码需要单独设置结果消息,然后再调用 scriptExit.noMessage() 来产生异常,从而终止脚本处理。
第三个抵押产品脚本是用 Ruby 编写的,见清单 6。这种抵押产品要求贷款人具有良好的信用记录,他们可以支付百分之二十的首付款。
清单 6. Ruby 抵押脚本
在 JRuby 1.0 中不要忘记 $ 符号
在 Ruby 脚本中访问共享的 Java 对象时,一定要记住 Ruby 的全局变量语法。如果省略了全局变量前面的 $ 符号,那么 JRuby 1.0 和当前的 JRuby 1.0.1 二进制版本会抛出一个 RaiseException,而且不提供错误的相关信息。JRuby 源代码存储库中已经纠正了这个 bug,所以在以后的二进制版本中应该不会出现这个问题。
如清单 6 所示,在 Ruby 脚本中,需要在变量名前面加上 $ 符号,这样才能访问放在脚本引擎范围内的共享 Java 对象。这是 Ruby 的全局变量语法。脚本引擎以全局变量的形式向脚本共享 Java 对象,所以必须使用 Ruby 的全局变量语法。
还要注意,在调用共享的 Java 对象时,JRuby 会自动地将 Ruby 式代码转换为 Java 式代码。例如,如果 JRuby 发现代码按照 Ruby 命名约定(即以下划线分隔单词)调用 Java 对象上的方法,比如 $result.product_name = 'Ruby Mortgage',那么 JRuby 会寻找不带下划线的大小写混合式方法名。因此,Ruby 式方法名 product_name= 会正确地转换为 Java 调用 result.setProductName("Ruby Mortgage")。
程序输出
现在用这三个抵押产品脚本文件运行 ScriptMortgageQualifierRunner 程序,看看它的输出。可以使用源代码下载文件中的 Ant 脚本运行这个程序。如果喜欢使用 Maven,那么可以按照 ZIP 文件中的 README.txt 文件中的说明用 Maven 构建并运行这个程序。Ant 命令是 ant run。run 任务确保脚本引擎和语言 JAR 文件在类路径中。清单 7 给出 Ant 的输出。
清单 7. Ant 产生的程序输出
这个输出共有 12 个部分,这是因为程序将四个贷款人示例提交给三个脚本,检查这 12 种组合中贷款人是否符合抵押产品的要求。为了演示本文解释的技术,这个程序会等待一分钟,然后重复处理抵押脚本。在这段停顿期间,可以编辑脚本文件来修改业务规则,还可以在脚本目录中添加新的脚本文件来表示新的抵押产品。在每次重复运行时,程序会扫描脚本目录并处理它找到的所有脚本文件。
例如,假设您希望提高贷款所需的最低信用分数。在一分钟的停顿期间,可以编辑 src/main/scripts/mortgage-products 目录中的 JavaScriptFirstTimeMortgage.js 脚本(见 清单 5),将第 23 行上的业务规则由 if (borrower.creditScore < 500) { 改为 if (borrower.creditScore < 550) {。在下次运行规则时,Risk E. Borrower 就不再符合 JavaScript FirstTime Mortgage 的要求。这个贷款人的信用分数是 520,这个分数低于目前的条件。错误消息现在是 “Your credit score of 520 does not meet the 500 requirement”,但是同样可以在程序运行时纠正这个错误的消息。
避免动态脚本风险
在运行时修改程序的功能是非常强大的,同样也可能导致风险。可以在正在运行的应用程序中添加新的功能和新的业务规则,而无需停止并重新启动应用程序。同样,也很容易引入新的 bug,甚至是严重的 bug。
但是,动态地修改正在运行的应用程序并不比修改停止运行的应用程序更危险。静态技术仅仅意味着必须重新启动应用程序,然后才能发现那些新的错误。良好的软件开发实践表明,对生产性应用程序的任何修改(无论是动态的,还是静态的)都应该先接受测试,然后才能引入生产环境中。Java 脚本编程 API 并未改变这一规则。
外部脚本文件可以在开发期间进行常规的单元测试。可以使用 JUnit 或其他测试工具和模拟 Java 对象来测试脚本,确保脚本在运行时不会出现错误并产生所期望的结果。将应用程序逻辑放在外部非 Java 脚本文件中并不意味着无法测试这些脚本。
如果您当过 Web CGI 脚本程序员,那么一定知道必须注意传递给 ScriptEngine 的 eval() 方法的东西。脚本引擎会立即执行传递给 eval 方法的代码。因此,绝不要把来自不可信来源的字符串或 Reader 对象传递给脚本引擎。
例如,假设我们使用脚本编程 API 远程监视一个 Web 应用程序。我们让脚本引擎能够访问关键的 Java 对象,这些对象提供 Web 应用程序的状态信息。还创建一个简单的 Web 页面,这个页面接受任意脚本表达式,它将这些表达式传递给脚本引擎进行计算并在 Web 页面上显示输出。这样就可以对正在运行的 Java 对象进行查询并执行对象上的方法,从而帮助判断应用程序的状态。
但是,在这种情况下,能够访问这个 Web 页面的任何人都可以执行任意脚本语句,可以访问任意共享 Java 对象。编程时的失误、错误的配置和安全漏洞会把机密信息泄露给未授权用户,或者让应用程序遭遇拒绝服务攻击(例如,攻击者可以执行与 System.exit 或 /bin/rm -fr / 等效的脚本语句)。与任何强大的工具一样,Java 脚本编程 API 要求您保持谨慎,注意安全。
Java SE 6 中新增的 Java 脚本编程 API 为运行用各种动态语言编写的外部程序(并与之共享代码和数据)提供了一种统一的方式。Java 应用程序与脚本语言的强大功能和灵活性相结合是非常有意义的,尤其是在脚本语言能够更简洁地执行某些任务的情况下。但是,Java 脚本编程 API 不仅仅能够以一种统一的方式在 Java 程序中添加许多种脚本语言代码,它还支持在运行时寻找、读取和执行脚本。可以利用这些动态功能在程序运行时修改脚本,从而修改应用程序的逻辑。本文演示如何使用 Java 脚本编程 API 调用外部脚本来动态地修改程序逻辑。还要讨论在将一种或多种脚本语言集成到 Java 应用程序中时可能遇到的问题。
第 1 部分 用一个 Hello World 风格的应用程序介绍了 Java 脚本编程 API。这里将展示一个更真实的示例应用程序,这个程序使用脚本编程 API 创建一个动态的规则引擎,它可以以外部 Groovy、JavaScript 和 Ruby 脚本的形式定义规则。这些规则决定申请人是否符合某些抵押产品的住宅贷款条件。如果用脚本语言定义业务规则,规则就更容易编写,也便于非程序员(比如贷款审查员)阅读。通过使用 Java 脚本编程 API 将这些规则放在程序之外,还可以支持在应用程序运行时修改规则和添加新的抵押产品。
真实的应用程序
这个示例应用程序为虚构的 Shaky Ground Financial 公司处理住宅贷款申请。住宅抵押行业不断地推出新的贷款产品,还常常修改对合格申请人的限制规则。Shaky Ground 公司不但希望能够快速地添加和删除抵押产品,还需要快速修改业务规则,从而控制哪些人符合产品的贷款条件。
Java 脚本编程 API 正好能够满足这种需求。这个应用程序由一个 ScriptMortgageQualifier 类组成,这个类负责判断打算购买某一资产的贷款人是否符合给定的抵押贷款产品的条件。清单 1 给出这个类。
清单 1. ScriptMortgageQualifier 类
// Imports and Javadoc not shown.
public class ScriptMortgageQualifier {
private ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
public MortgageQualificationResult qualifyMortgage(
Borrower borrower,
Property property,
Loan loan,
File mortgageRulesFile
) throws FileNotFoundException, IllegalArgumentException, ScriptException
{
ScriptEngine scriptEngine = getEngineForFile(mortgageRulesFile);
if (scriptEngine == null) {
throw new IllegalArgumentException(
"No script engine on classpath to handle file: " + mortgageRulesFile
);
}
// Make params accessible to scripts by adding to engine's context.
scriptEngine.put("borrower", borrower);
scriptEngine.put("property", property);
scriptEngine.put("loan", loan);
// Make return-value object available to scripts.
MortgageQualificationResult scriptResult = new MortgageQualificationResult();
scriptEngine.put("result", scriptResult);
// Add an object scripts can call to exit early from processing.
scriptEngine.put("scriptExit", new ScriptEarlyExit());
try {
scriptEngine.eval(new FileReader(mortgageRulesFile));
} catch (ScriptException se) {
// Re-throw exception unless it's our early-exit exception.
if (se.getMessage() == null ||
!se.getMessage().contains("ScriptEarlyExitException")
) {
throw se;
}
// Set script result message if early-exit exception embedded.
Throwable t = se.getCause();
while (t != null) {
if (t instanceof ScriptEarlyExitException) {
scriptResult.setMessage(t.getMessage());
break;
}
t = t.getCause();
}
}
return scriptResult;
}
/** Returns a script engine based on the extension of the given file. */
private ScriptEngine getEngineForFile(File f) {
String fileExtension = getFileExtension(f);
return scriptEngineManager.getEngineByExtension(fileExtension);
}
/** Returns the file's extension, or "" if the file has no extension */
private String getFileExtension(File file) {
String scriptName = file.getName();
int dotIndex = scriptName.lastIndexOf('.');
if (dotIndex != -1) {
return scriptName.substring(dotIndex + 1);
} else {
return "";
}
}
/** Internal exception so ScriptEarlyExit.exit can exit scripts early */
private static class ScriptEarlyExitException extends Exception {
public ScriptEarlyExitException(String msg) {
super(msg);
}
}
/** Object passed to all scripts so they can indicate an early exit. */
private static class ScriptEarlyExit {
public void noMessage() throws ScriptEarlyExitException {
throw new ScriptEarlyExitException(null);
}
public void withMessage(String msg) throws ScriptEarlyExitException {
throw new ScriptEarlyExitException(msg);
}
}
}
这个类相当简单,因为它把所有业务决策任务都委派给了外部脚本。每个脚本表示一个抵押产品。每个脚本文件中的代码包含一系列业务规则,这些规则定义了符合这种抵押产品要求的贷款人类型、资产类型和贷款类型。由于采用了这种方式,只需在脚本目录中添加新的脚本文件,就可以添加新的抵押产品。如果某一抵押产品的业务逻辑改变了,那么只需更新脚本来反映规则的变化。
通过用脚本语言编写抵押产品业务规则,可以展示 Java 脚本编程 API 的功能。这个程序还说明有时候脚本语言代码更容易阅读、修改和理解,即使是非程序员也可以掌握脚本代码。
ScriptMortgageQualifier 类的工作方式
ScriptMortgageQualifier 中的主要方法是 qualifyMortgage()。这个方法通过参数接受以下信息:
贷款人
要购买的资产
贷款细节
一个 File 对象,其中包含要执行的脚本
这个方法的任务是用业务实体参数运行脚本文件并返回一个结果对象,这个对象指出贷款人是否符合抵押产品的要求。这里没有给出 Borrower、Property 和 Loan 的代码。它们只是简单的实体类,可以在本文的源代码中找到它们的代码(见 下载)。
为了找到一个 ScriptEngine 来运行脚本文件,qualifyMortgage() 方法使用了 getEngineForFile() 内部 helper 方法。getEngineForFile() 方法使用 scriptEngineManager 实例变量(这个变量在类实例化时被设置为一个 ScriptEngineManager)寻找能够处理具有给定文件扩展名的脚本的脚本引擎。getEngineForFile() 方法使用 ScriptEngineManager.getEngineByExtension() 方法(见 清单 1 中的粗体代码)搜索并返回 ScriptEngine。
找到脚本引擎之后,qualifyMortgage() 将它接收的实体参数绑定到引擎的上下文,从而让脚本能够使用这些参数。前三个 scriptEngine.put() 调用(也是粗体代码)执行这些绑定。第四个 scriptEngine.put() 调用创建一个新的 MortgageQualificationResult Java 对象并通过脚本引擎共享它。脚本可以通过设置这个对象的属性将它的运行结果返回给 Java 应用程序,qualifyMortgage() 将返回这个共享对象。脚本使用 result 全局变量访问这个 Java 对象。每个脚本负责使用这个共享对象将自己的结果返回给 Java 应用程序。
最后一个 scriptEngine.put() 调用让脚本可以通过 scriptExit 变量使用一个内部 helper 类(ScriptEarlyExit,见 清单 1)的实例。ScriptEarlyExit 定义了两个简单的方法 —— withMessage() 和 noMessage(),它们惟一的作用是抛出一个异常。如果脚本调用 scriptExit.withMessage() 或 scriptExit.noMessage(),那么方法抛出一个 ScriptEarlyExitException 异常。脚本引擎会捕捉这个异常、终止脚本处理并向调用脚本的 eval() 方法抛出一个 ScriptException 异常。
通过以这种迂回的方式提前退出脚本,就可以以一致的方式从函数或方法外的脚本处理过程返回。并非所有脚本语言都提供了这种方式所需的语句。例如,在 JavaScript 中,在执行高层代码时(这个示例应用程序中的抵押处理脚本正是采用这种构造方式),无法使用 return 语句。共享对象 scriptExit 解决了这个问题,一旦脚本判断出贷款人不符合抵押产品的要求,用任何语言编写的脚本都可以通过这个对象退出。
在 qualifyMortgage 中,对脚本引擎的 eval 方法的调用(见粗体代码)使用一个 try/catch 块捕捉 ScriptException 异常。catch 块中的代码检查 ScriptException 错误消息,从而判断这个脚本异常是由 ScriptEarlyExitException 造成的,还是由真正的脚本错误造成的。如果错误消息包含名称 ScriptEarlyExitException,那么代码就认为一切正常并忽略这个脚本异常。
这种在 Java 脚本编程 API 的脚本异常错误消息中搜索字符串的技术有点儿笨拙,但这对于本示例中使用的 Groovy、JavaScript 和 Ruby 语言解释器是有效的。如果所有脚本语言实现将从调用的 Java 代码抛出的 Java 异常添加到异常堆栈中,那么会更方便,这样就可以使用 Throwable.getCause() 方法获取这些异常。JRuby 和 Groovy 等解释器会这样做,但是内置的 Rhino JavaScript 解释器并不这样做。
运行代码:ScriptMortgageQualifierRunner
为了测试 ScriptMortgageQualifier 类,将使用测试数据表示四个贷款人、贷款人打算购买的一项资产和一笔抵押贷款。我们将用一个贷款人、资产和贷款运行所有三个脚本,检查贷款人是否满足脚本所代表的抵押产品的业务规则。
清单 2 给出 ScriptMortgageQualifierRunner 程序的部分代码,我们将用这个程序创建测试对象、在一个目录中寻找脚本文件并通过 清单 1 中的 ScriptMortgageQualifier 类运行它们。为了节省篇幅,这里没有给出这个程序的 createGoodBorrower()、createAverageBorrower()、createInvestorBorrower()、createRiskyBorrower()、createProperty() 和 createLoan() helper 方法。这些方法的作用仅仅是创建实体对象并设置测试所需的值。在 下载 一节中可以获得所有方法的完整源代码。
清单 2. ScriptMortgageQualifierRunner 程序
// Imports and some helper methods not shown.
public class ScriptMortgageQualifierRunner {
private static File scriptDirectory;
private static Borrower goodBorrower = createGoodBorrower();
private static Borrower averageBorrower = createAverageBorrower();
private static Borrower investorBorrower = createInvestorBorrower();
private static Borrower riskyBorrower = createRiskyBorrower();
private static Property property = createProperty();
private static Loan loan = createLoan();
/**
* Main method to create a File for the directory name on the command line,
* then call the run method if that directory exists.
*/
public static void main(String[] args) {
if (args.length > 0 && args[0].contains("-help")) {
printUsageAndExit();
}
String dirName;
if (args.length == 0) {
dirName = "."; // Current directory.
} else {
dirName = args[0];
}
scriptDirectory = new File(dirName);
if (!scriptDirectory.exists() || !scriptDirectory.isDirectory()) {
printUsageAndExit();
}
run();
}
/**
* Determines mortgage loan-qualification status for four test borrowers by
* processing all script files in the given directory. Each script will determine
* whether the given borrower is qualified for a particular mortgage type
*/
public static void run() {
ScriptMortgageQualifier mortgageQualifier = new ScriptMortgageQualifier();
for(;;) { // Requires Ctrl-C to exit
runQualifications(mortgageQualifier, goodBorrower, loan, property);
runQualifications(mortgageQualifier, averageBorrower, loan, property);
loan.setDownPayment(30000.0); // Reduce down payment to 10%
runQualifications(mortgageQualifier, investorBorrower, loan, property);
loan.setDownPayment(10000.0); // Reduce down payment to 3 1/3%
runQualifications(mortgageQualifier, riskyBorrower, loan, property);
waitOneMinute();
}
}
/**
* Reads all script files in the scriptDirectory and runs them with this borrower's
* information to see if he/she qualifies for each mortgage product.
*/
private static void runQualifications(
ScriptMortgageQualifier mortgageQualifier,
Borrower borrower,
Loan loan,
Property property
) {
for (File scriptFile : getScriptFiles(scriptDirectory)) {
// Print info about the borrower, loan and property.
System.out.println("Processing file: " + scriptFile.getName());
System.out.println(" Borrower: " + borrower.getName());
System.out.println(" Credit score: " + borrower.getCreditScore());
System.out.println(" Sales price: " + property.getSalesPrice());
System.out.println(" Down payment: " + loan.getDownPayment());
MortgageQualificationResult result = null;
try {
// Run the script rules for this borrower on the loan product.
result = mortgageQualifier.qualifyMortgage(
borrower, property, loan, scriptFile
);
} catch (FileNotFoundException fnfe) {
System.out.println(
"Can't read script file: " + fnfe.getMessage()
);
} catch (IllegalArgumentException e) {
System.out.println(
"No script engine available to handle file: " +
scriptFile.getName()
);
} catch (ScriptException e) {
System.out.println(
"Script '" + scriptFile.getName() +
"' encountered an error: " + e.getMessage()
);
}
if (result == null) continue; // Must have hit exception.
// Print results.
System.out.println(
"* Mortgage product: " + result.getProductName() +
", Qualified? " + result.isQualified() +
"\n* Interest rate: " + result.getInterestRate() +
"\n* Message: " + result.getMessage()
);
System.out.println();
}
}
/** Returns files with a '.' other than as the first or last character. */
private static File[] getScriptFiles(File directory) {
return directory.listFiles(new FilenameFilter() {
public boolean accept(File dir, String name) {
int indexOfDot = name.indexOf('.');
// Ignore files w/o a dot, or with dot as first or last char.
if (indexOfDot < 1 || indexOfDot == (name.length() - 1)) {
return false;
} else {
return true;
}
}
});
}
private static void waitOneMinute() {
System.out.println(
"\nSleeping for one minute before reprocessing files." +
"\nUse Ctrl-C to exit..."
);
System.out.flush();
try {
Thread.sleep(1000 * 60);
} catch (InterruptedException e) {
System.exit(1);
}
}
}
ScriptMortgageQualifierRunner 中的 main() 方法搜索命令行上提供的脚本文件目录,如果这个目录存在,就用目录的 File 对象设置一个静态变量,并调用 run() 方法执行进一步的处理。
run() 方法对 清单 1 中的 ScriptMortgageQualifier 类进行实例化,然后用一个无限循环调用内部方法 runQualifications(),测试四个贷款人/贷款场景。这个无限循环模拟连续的抵押申请处理。这个循环让我们可以在脚本目录中添加或修改脚本文件(抵押贷款产品),这些修改会动态地生效,不需要停止应用程序。因为这个应用程序的业务逻辑放在外部脚本中,所以可以在运行时动态地修改业务逻辑。
对于脚本目录中的每个脚本文件,runQualifications() helper 方法分别调用 ScriptMortgageQualifer.qualifyMortgage 一次。每个调用前面有一系列打印语句,它们输出脚本文件和贷款人的相关信息;调用之后,用打印语句显示结果,即贷款人是否符合抵押产品的要求。脚本代码使用共享的 MortgageQualificationResult Java 对象返回其结果,检查这个对象的属性就可以判断贷款人是否合格。
本文的源代码 ZIP 文件包含三个用 Groovy、JavaScript 和 Ruby 编写的脚本文件示例。它们分别代表一种标准的 30 年期固定利率抵押贷款产品。脚本中的代码判断贷款人是否符合这种抵押类型的要求,然后通过调用脚本引擎 put() 方法中提供的共享全局变量 result 来返回结果。全局变量 result 是 MortgageQualificationResult 类的实例(部分代码见清单 3)。
清单 3. MortgageQualificationResult 类
public class MortgageQualificationResult {
private boolean qualified;
private double interestRate;
private String message;
private String productName;
// .. Standard setters and getters not shown.
}
脚本设置 result 的属性,从而指出贷款人是否符合抵押贷款的要求以及应该采用的利率。脚本可以通过 message 和 productName 属性指出导致贷款人不合格的原因和返回相关的产品名称。
脚本文件
在给出 ScriptMortgageQualifierRunner 的输出之前,我们先看看这个程序运行的 Groovy、JavaScript 和 Ruby 脚本文件。Groovy 脚本中的业务逻辑定义了一种条件相当宽松的抵押产品,同时由于金融风险比较高,因此利率比较高。JavaScript 脚本代表一种政府担保的抵押贷款,这种贷款要求贷款人必须满足最大收入和其他限制。Ruby 脚本定义的抵押产品业务规则要求贷款人有良好的信用记录,这些人要支付足够的首付款,这种抵押贷款的利率比较低。
清单 4 给出 Groovy 脚本,即使您不了解 Groovy,也应该能够看懂这个脚本。
清单 4. Groovy 抵押脚本
/*
This Groovy script defines the "Groovy Mortgage" product.
This product is relaxed in its requirements of borrowers.
There is a higher interest rate to make up for the looser standard.
All borrowers will be approved if their credit history is good, they can
make a down payment of at least 5%, and they either earn more than
$2,000/month or have a net worth (assets minus liabilities) of $25,000.
*/
// Our product name.
result.productName = 'Groovy Mortgage'
// Check for the minimum income and net worth
def netWorth = borrower.totalAssets - borrower.totalLiabilities
if (borrower.monthlyIncome < 2000 && netWorth < 25000) {
scriptExit.withMessage "Low monthly income of ${borrower.monthlyIncome}" +
' requires a net worth of at least $25,000.'
}
def downPaymentPercent = loan.downPayment / property.salesPrice * 100
if (downPaymentPercent < 5) {
scriptExit.withMessage 'Down payment of ' +
"${String.format('%1$.2f', downPaymentPercent)}% is insufficient." +
' 5% minimum required.'
}
if (borrower.creditScore < 600) {
scriptExit.withMessage 'Credit score of 600 required.'
}
// Everyone else qualifies. Find interest rate based on down payment percent.
result.qualified = true
result.message = 'Groovy! You qualify.'
switch (downPaymentPercent) {
case 0..5: result.interestRate = 0.08; break
case 6..10: result.interestRate = 0.075; break
case 11..15: result.interestRate = 0.07; break
case 16..20: result.interestRate = 0.065; break
default: result.interestRate = 0.06; break
}
请注意全局变量 result、borrower、loan 和 property,脚本使用这些变量访问和设置共享 Java 对象中的值。这些变量名是通过调用 ScriptEngine.put() 方法设置的。
还要注意 result.productName = 'Groovy Mortgage' 这样的 Groovy 语句。这个语句似乎是直接设置 MortgageQualificationResult 对象的字符串属性 productName,但是,清单 3 清楚地说明它是一个私有的实例变量。这并不 表示 Java 脚本编程 API 允许违反封装规则,而是说明通过使用 Java 脚本编程 API,Groovy 和大多数其他脚本语言解释器可以很好地操作共享的 Java 对象。如果一个 Groovy 语句尝试设置或读取 Java 对象的私有属性值,Groovy 就会寻找并使用 JavaBean 风格的公共 setter 或 getter 方法。例如,语句 result.productName = 'Groovy Mortgage' 会自动转换为适当的 Java 语句:result.setProductName("Groovy Mortgage")。这个 Java setter 语句也是有效的 Groovy 代码,可以在脚本中使用,但是直接使用属性赋值语句更符合 Groovy 的风格。
现在看看清单 5 中的 JavaScript 抵押产品脚本。这个 JavaScript 脚本代表一种政府担保的贷款,政府支持这种贷款是为了提高公民的住宅拥有率。所以,业务规则要求这是贷款人购买的第一套住宅,而且贷款人打算在此居住,而不是出租获利。
清单 5. JavaScript 抵押脚本
/**
* This script defines the "JavaScript FirstTime Mortgage" product.
* It is a government-sponsored mortgage intended for low-income, first-time
* home buyers without a lot of assets who intend to live in the home.
* Bankruptcies and bad (but not terrible!) credit are OK.
*/
result.productName = 'JavaScript FirstTime Mortgage'
if (!borrower.intendsToOccupy) {
result.message = 'This mortgage is not intended for investors.'
scriptExit.noMessage()
}
if (!borrower.firstTimeBuyer) {
result.message = 'Only first-time home buyers qualify for this mortgage.'
scriptExit.noMessage()
}
if (borrower.monthlyIncome > 4000) {
result.message = 'Monthly salary of $' + borrower.monthlyIncome +
' exceeds the $4,000 maximum.'
scriptExit.noMessage()
}
if (borrower.creditScore < 500) {
result.message = 'Your credit score of ' + borrower.creditScore +
' does not meet the 500 requirement.'
scriptExit.noMessage()
}
// Qualifies. Determine interest rate based on loan amount and credit score.
result.qualified = true
result.message = 'Congratulations, you qualify.'
if (loan.loanAmount > 450000) {
result.interestRate = 0.08 // Big loans and poor credit require higher rate.
} else if (borrower.creditScore < 550) {
result.interestRate = 0.08
} else if (borrower.creditScore < 600) {
result.interestRate = 0.07
} else if (borrower.creditScore < 700) {
result.interestRate = 0.065
} else { // Good credit gets best rate.
result.interestRate = 0.06
}
注意,JavaScript 代码不能像 Groovy 脚本那样使用 Java scriptExit.withMessage() 方法在一个语句中设置不合格消息并退出脚本。这是因为 Rhino JavaScript 解释器并不把抛出的 Java 异常在 ScriptException 堆栈跟踪中作为嵌入的 “错误原因” 向上传递。因此,在堆栈跟踪中更难找到 Java 代码抛出的脚本异常消息。所以 清单 5 中的 JavaScript 代码需要单独设置结果消息,然后再调用 scriptExit.noMessage() 来产生异常,从而终止脚本处理。
第三个抵押产品脚本是用 Ruby 编写的,见清单 6。这种抵押产品要求贷款人具有良好的信用记录,他们可以支付百分之二十的首付款。
清单 6. Ruby 抵押脚本
# This Ruby script defines the "Ruby Mortgage" product.
# It is intended for premium borrowers with its low interest rate
# and 20% down payment requirement.
# Our product name
$result.product_name = 'Ruby Mortgage'
# Borrowers with credit unworthiness do not qualify.
if $borrower.credit_score < 700
$scriptExit.with_message "Credit score of #{$borrower.credit_score}" +
" is lower than 700 minimum"
end
$scriptExit.with_message 'No bankruptcies allowed' if $borrower.hasDeclaredBankruptcy
# Check other negatives
down_payment_percent = $loan.down_payment / $property.sales_price * 100
if down_payment_percent < 20
$scriptExit.with_message 'Down payment must be at least 20% of sale price.'
end
# Borrower qualifies. Determine interest rate of loan
$result.message = "Qualified!"
$result.qualified = true
# Give the best interest rate to the best credit risks.
if $borrower.credit_score > 750 || down_payment_percent > 25
$result.interestRate = 0.06
elsif $borrower.credit_score > 700 && $borrower.totalAssets > 100000
$result.interestRate = 0.062
else
$result.interestRate = 0.065
end
在 JRuby 1.0 中不要忘记 $ 符号
在 Ruby 脚本中访问共享的 Java 对象时,一定要记住 Ruby 的全局变量语法。如果省略了全局变量前面的 $ 符号,那么 JRuby 1.0 和当前的 JRuby 1.0.1 二进制版本会抛出一个 RaiseException,而且不提供错误的相关信息。JRuby 源代码存储库中已经纠正了这个 bug,所以在以后的二进制版本中应该不会出现这个问题。
如清单 6 所示,在 Ruby 脚本中,需要在变量名前面加上 $ 符号,这样才能访问放在脚本引擎范围内的共享 Java 对象。这是 Ruby 的全局变量语法。脚本引擎以全局变量的形式向脚本共享 Java 对象,所以必须使用 Ruby 的全局变量语法。
还要注意,在调用共享的 Java 对象时,JRuby 会自动地将 Ruby 式代码转换为 Java 式代码。例如,如果 JRuby 发现代码按照 Ruby 命名约定(即以下划线分隔单词)调用 Java 对象上的方法,比如 $result.product_name = 'Ruby Mortgage',那么 JRuby 会寻找不带下划线的大小写混合式方法名。因此,Ruby 式方法名 product_name= 会正确地转换为 Java 调用 result.setProductName("Ruby Mortgage")。
程序输出
现在用这三个抵押产品脚本文件运行 ScriptMortgageQualifierRunner 程序,看看它的输出。可以使用源代码下载文件中的 Ant 脚本运行这个程序。如果喜欢使用 Maven,那么可以按照 ZIP 文件中的 README.txt 文件中的说明用 Maven 构建并运行这个程序。Ant 命令是 ant run。run 任务确保脚本引擎和语言 JAR 文件在类路径中。清单 7 给出 Ant 的输出。
清单 7. Ant 产生的程序输出
> ant run
Buildfile: build.xml
compile:
[mkdir] Created dir: C:\temp\script-article\build-main\classes
[javac] Compiling 10 source files to C:\temp\script-article\build-main\classes
run:
[java] Processing file: GroovyMortgage.groovy
[java] Borrower: Good Borrower
[java] Credit score: 800
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: Groovy Mortgage, Qualified? true
[java] * Interest rate: 0.06
[java] * Message: Groovy! You qualify.
[java] Processing file: JavaScriptFirstTimeMortgage.js
[java] Borrower: Good Borrower
[java] Credit score: 800
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: JavaScript FirstTime Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: Only first-time home buyers qualify for this mortgage.
[java] Processing file: RubyPrimeMortgage.rb
[java] Borrower: Good Borrower
[java] Credit score: 800
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: Ruby Mortgage, Qualified? true
[java] * Interest rate: 0.06
[java] * Message: Qualified!
[java] Processing file: GroovyMortgage.groovy
[java] Borrower: Average Borrower
[java] Credit score: 700
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: Groovy Mortgage, Qualified? true
[java] * Interest rate: 0.06
[java] * Message: Groovy! You qualify.
[java] Processing file: JavaScriptFirstTimeMortgage.js
[java] Borrower: Average Borrower
[java] Credit score: 700
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: JavaScript FirstTime Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: Monthly salary of $4500 exceeds the $4,000 maximum.
[java] Processing file: RubyPrimeMortgage.rb
[java] Borrower: Average Borrower
[java] Credit score: 700
[java] Sales price: 300000.0
[java] Down payment: 60000.0
[java] * Mortgage product: Ruby Mortgage, Qualified? true
[java] * Interest rate: 0.065
[java] * Message: Qualified!
[java] Processing file: GroovyMortgage.groovy
[java] Borrower: Investor Borrower
[java] Credit score: 720
[java] Sales price: 300000.0
[java] Down payment: 30000.0
[java] * Mortgage product: Groovy Mortgage, Qualified? true
[java] * Interest rate: 0.06
[java] * Message: Groovy! You qualify.
[java] Processing file: JavaScriptFirstTimeMortgage.js
[java] Borrower: Investor Borrower
[java] Credit score: 720
[java] Sales price: 300000.0
[java] Down payment: 30000.0
[java] * Mortgage product: JavaScript FirstTime Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: This mortgage is not intended for investors.
[java] Processing file: RubyPrimeMortgage.rb
[java] Borrower: Investor Borrower
[java] Credit score: 720
[java] Sales price: 300000.0
[java] Down payment: 30000.0
[java] * Mortgage product: Ruby Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: Down payment must be at least 20% of sale price.
[java] Processing file: GroovyMortgage.groovy
[java] Borrower: Risk E. Borrower
[java] Credit score: 520
[java] Sales price: 300000.0
[java] Down payment: 10000.0
[java] * Mortgage product: Groovy Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: Down payment of 3.33% is insufficient. 5% minimum required.
[java] Processing file: JavaScriptFirstTimeMortgage.js
[java] Borrower: Risk E. Borrower
[java] Credit score: 520
[java] Sales price: 300000.0
[java] Down payment: 10000.0
[java] * Mortgage product: JavaScript FirstTime Mortgage, Qualified? true
[java] * Interest rate: 0.08
[java] * Message: Congratulations, you qualify.
[java] Processing file: RubyPrimeMortgage.rb
[java] Borrower: Risk E. Borrower
[java] Credit score: 520
[java] Sales price: 300000.0
[java] Down payment: 10000.0
[java] * Mortgage product: Ruby Mortgage, Qualified? false
[java] * Interest rate: 0.0
[java] * Message: Credit score of 520 is lower than 700 minimum
[java] Sleeping for one minute before reprocessing files.
[java] Use Ctrl-C to exit...
这个输出共有 12 个部分,这是因为程序将四个贷款人示例提交给三个脚本,检查这 12 种组合中贷款人是否符合抵押产品的要求。为了演示本文解释的技术,这个程序会等待一分钟,然后重复处理抵押脚本。在这段停顿期间,可以编辑脚本文件来修改业务规则,还可以在脚本目录中添加新的脚本文件来表示新的抵押产品。在每次重复运行时,程序会扫描脚本目录并处理它找到的所有脚本文件。
例如,假设您希望提高贷款所需的最低信用分数。在一分钟的停顿期间,可以编辑 src/main/scripts/mortgage-products 目录中的 JavaScriptFirstTimeMortgage.js 脚本(见 清单 5),将第 23 行上的业务规则由 if (borrower.creditScore < 500) { 改为 if (borrower.creditScore < 550) {。在下次运行规则时,Risk E. Borrower 就不再符合 JavaScript FirstTime Mortgage 的要求。这个贷款人的信用分数是 520,这个分数低于目前的条件。错误消息现在是 “Your credit score of 520 does not meet the 500 requirement”,但是同样可以在程序运行时纠正这个错误的消息。
避免动态脚本风险
在运行时修改程序的功能是非常强大的,同样也可能导致风险。可以在正在运行的应用程序中添加新的功能和新的业务规则,而无需停止并重新启动应用程序。同样,也很容易引入新的 bug,甚至是严重的 bug。
但是,动态地修改正在运行的应用程序并不比修改停止运行的应用程序更危险。静态技术仅仅意味着必须重新启动应用程序,然后才能发现那些新的错误。良好的软件开发实践表明,对生产性应用程序的任何修改(无论是动态的,还是静态的)都应该先接受测试,然后才能引入生产环境中。Java 脚本编程 API 并未改变这一规则。
外部脚本文件可以在开发期间进行常规的单元测试。可以使用 JUnit 或其他测试工具和模拟 Java 对象来测试脚本,确保脚本在运行时不会出现错误并产生所期望的结果。将应用程序逻辑放在外部非 Java 脚本文件中并不意味着无法测试这些脚本。
如果您当过 Web CGI 脚本程序员,那么一定知道必须注意传递给 ScriptEngine 的 eval() 方法的东西。脚本引擎会立即执行传递给 eval 方法的代码。因此,绝不要把来自不可信来源的字符串或 Reader 对象传递给脚本引擎。
例如,假设我们使用脚本编程 API 远程监视一个 Web 应用程序。我们让脚本引擎能够访问关键的 Java 对象,这些对象提供 Web 应用程序的状态信息。还创建一个简单的 Web 页面,这个页面接受任意脚本表达式,它将这些表达式传递给脚本引擎进行计算并在 Web 页面上显示输出。这样就可以对正在运行的 Java 对象进行查询并执行对象上的方法,从而帮助判断应用程序的状态。
但是,在这种情况下,能够访问这个 Web 页面的任何人都可以执行任意脚本语句,可以访问任意共享 Java 对象。编程时的失误、错误的配置和安全漏洞会把机密信息泄露给未授权用户,或者让应用程序遭遇拒绝服务攻击(例如,攻击者可以执行与 System.exit 或 /bin/rm -fr / 等效的脚本语句)。与任何强大的工具一样,Java 脚本编程 API 要求您保持谨慎,注意安全。