表达式评估 (Evaluation)
本节介绍 SpEL 接口及其表达式语言的编程式用法。完整的语言参考可以在语言参考中找到。
以下代码演示了如何使用 SpEL API 来评估字面量字符串表达式 'Hello World'。
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("'Hello World'"); // (1)
String message = (String) exp.getValue();val parser = SpelExpressionParser()
val exp = parser.parseExpression("'Hello World'") // (1)
val message = exp.value as Stringmessage变量的值为"Hello World"。
你最可能使用的 SpEL 类和接口位于 org.springframework.expression 包及其子包(如 spel.support)中。
ExpressionParser 接口负责解析表达式字符串。在前面的示例中,表达式字符串是一个由单引号括起来的字符串字面量。Expression 接口负责评估定义的表达式字符串。调用 parser.parseExpression(…) 和 exp.getValue(…) 时可能抛出的两种异常分别是 ParseException 和 EvaluationException。
SpEL 支持广泛的特性,例如调用方法、访问属性和调用构造函数。
在以下方法调用示例中,我们在字符串字面量 'Hello World' 上调用了 concat 方法。
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("'Hello World'.concat('!')"); // (1)
String message = (String) exp.getValue();val parser = SpelExpressionParser()
val exp = parser.parseExpression("'Hello World'.concat('!')") // (1)
val message = exp.value as Stringmessage的值现在是"Hello World!"。
以下示例演示了如何访问字符串字面量 'Hello World' 的 Bytes JavaBean 属性。
ExpressionParser parser = new SpelExpressionParser();
// 调用 'getBytes()'
Expression exp = parser.parseExpression("'Hello World'.bytes"); // (1)
byte[] bytes = (byte[]) exp.getValue();val parser = SpelExpressionParser()
// 调用 'getBytes()'
val exp = parser.parseExpression("'Hello World'.bytes") // (1)
val bytes = exp.value as ByteArray- 此行将字面量转换为字节数组。
SpEL 还支持通过标准的点标记法(如 prop1.prop2.prop3)访问嵌套属性,以及相应的属性值设置。也可以访问公共字段。
以下示例显示了如何使用点标记法获取字符串字面量的长度。
ExpressionParser parser = new SpelExpressionParser();
// 调用 'getBytes().length'
Expression exp = parser.parseExpression("'Hello World'.bytes.length"); // (1)
int length = (Integer) exp.getValue();val parser = SpelExpressionParser()
// 调用 'getBytes().length'
val exp = parser.parseExpression("'Hello World'.bytes.length") // (1)
val length = exp.value as Int'Hello World'.bytes.length给出字面量的长度。
可以调用字符串的构造函数而不是使用字符串字面量,如下例所示。
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("new String('hello world').toUpperCase()"); // (1)
String message = exp.getValue(String.class);val parser = SpelExpressionParser()
val exp = parser.parseExpression("new String('hello world').toUpperCase()") // (1)
val message = exp.getValue(String::class.java)- 从字面量构造一个新的
String并将其转换为大写。
注意泛型方法的使用:public <T> T getValue(Class<T> desiredResultType)。使用此方法可以避免将表达式的值强制转换为所需的结果类型。如果值无法强制转换为类型 T 或无法通过注册的类型转换器进行转换,则会抛出 EvaluationException。
SpEL 更常见的用法是提供一个针对特定对象实例(称为根对象)进行评估的表达式字符串。以下示例显示了如何从 Inventor 类的实例中检索 name 属性,以及如何在布尔表达式中引用 name 属性。
// 创建并设置日历
GregorianCalendar c = new GregorianCalendar();
c.set(1856, 7, 9);
// 构造函数参数是姓名、生日和国籍。
Inventor tesla = new Inventor("Nikola Tesla", c.getTime(), "Serbian");
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("name"); // 将 name 解析为一个表达式
String name = (String) exp.getValue(tesla);
// name == "Nikola Tesla"
exp = parser.parseExpression("name == 'Nikola Tesla'");
boolean result = exp.getValue(tesla, Boolean.class);
// result == true// 创建并设置日历
val c = GregorianCalendar()
c.set(1856, 7, 9)
// 构造函数参数是姓名、生日和国籍。
val tesla = Inventor("Nikola Tesla", c.time, "Serbian")
val parser = SpelExpressionParser()
var exp = parser.parseExpression("name") // 将 name 解析为一个表达式
val name = exp.getValue(tesla) as String
// name == "Nikola Tesla"
exp = parser.parseExpression("name == 'Nikola Tesla'")
val result = exp.getValue(tesla, Boolean::class.java)
// result == true理解 EvaluationContext
EvaluationContext API 用于在评估表达式时解析属性、方法或字段,并帮助执行类型转换。Spring 提供了两个实现。
SimpleEvaluationContext
公开了 SpEL 语言特性的子集和配置选项。适用于不需要完整的 SpEL 语法的表达式类别,应当受到有意义的限制。示例包括(但不限于)数据绑定表达式和基于属性的过滤器。
StandardEvaluationContext
公开了 SpEL 语言全套特性和配置选项。你可以使用它指定默认根对象并配置每一个可用的评估策略。
SimpleEvaluationContext 旨在仅支持 SpEL 语言语法的子集。例如,它排除了 Java 类型引用、构造函数和 Bean 引用。它还要求你显式选择在表达式中对属性和方法支持的级别。在创建 SimpleEvaluationContext 时,你需要选择数据绑定支持级别:
- 只读数据绑定
- 读写数据绑定
- 自定义
PropertyAccessor(通常不是基于反射的),可能与DataBindingPropertyAccessor结合使用
为简便起见,SimpleEvaluationContext.forReadOnlyDataBinding() 可以通过 DataBindingPropertyAccessor 启用对属性的只读访问。类似地,SimpleEvaluationContext.forReadWriteDataBinding() 启用对属性的读写访问。或者,通过 SimpleEvaluationContext.forPropertyAccessors(…) 配置自定义访问器,并可以通过 builder 潜在地禁用赋值、激活方法解析和/或类型转换器。
类型转换 (Type Conversion)
默认情况下,SpEL 使用 Spring Core 中提供的转换服务(org.springframework.core.convert.ConversionService)。该转换服务带有许多内置转换器用于常见转换,但也完全可扩展,以便你可以添加类型之间的自定义转换。此外,它是泛型感知的。这意味着,当你在表达式中使用泛型类型时,SpEL 会尝试执行转换以维护它遇到的任何对象的类型正确性。
在实践中这意味着什么?假设正在使用 setValue() 为 List 属性设置值。该属性的类型实际上是 List<Boolean>。SpEL 会识别出列表元素在放入之前需要转换为 Boolean。如下所示:
class Simple {
public List<Boolean> booleanList = new ArrayList<>();
}
Simple simple = new Simple();
simple.booleanList.add(true);
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// "false" 在这里作为字符串传入。SpEL 和转换服务
// 将识别出它需要是 Boolean,并进行相应转换。
parser.parseExpression("booleanList[0]").setValue(context, simple, "false");
// b 是 false
Boolean b = simple.booleanList.get(0);class Simple {
var booleanList: MutableList<Boolean> = ArrayList()
}
val simple = Simple()
simple.booleanList.add(true)
val context = SimpleEvaluationContext.forReadOnlyDataBinding().build()
// "false" 在这里作为字符串传入。SpEL 和转换服务
// 将识别出它需要是 Boolean,并进行相应转换。
parser.parseExpression("booleanList[0]").setValue(context, simple, "false")
// b 是 false
val b = simple.booleanList[0]解析器配置 (Parser Configuration)
可以使用解析器配置对象 (org.springframework.expression.spel.SpelParserConfiguration) 来配置 SpEL 表达式解析器。该配置对象控制某些表达式组件的行为。例如,如果你对集合进行索引操作,且指定索引处的元素为 null,SpEL 可以自动创建该元素。这在由一系列属性引用组成的表达式中非常有用。类似地,如果你对集合进行索引操作,且指定的索引大于集合当前的大小,SpEL 可以自动扩容集合以适应。为了在指定索引处添加元素,SpEL 会在设置指定值之前,尝试使用元素类型的默认构造函数来创建。如果元素类型没有默认构造函数,则会向集合添加 null。如果没有内置或自定义的转换器知道如何设置值,则指定索引处将保留 null。以下示例演示了如何自动扩容 List。
class Demo {
public List<String> list;
}
// 开启:
// - 自动 null 引用初始化
// - 自动集合扩容
SpelParserConfiguration config = new SpelParserConfiguration(true, true);
ExpressionParser parser = new SpelExpressionParser(config);
Expression expression = parser.parseExpression("list[3]");
Demo demo = new Demo();
Object o = expression.getValue(demo);
// demo.list 现在将是一个包含 4 个条目的真实集合
// 每个条目都是一个新的空字符串class Demo {
var list: List<String>? = null
}
// 开启:
// - 自动 null 引用初始化
// - 自动集合扩容
val config = SpelParserConfiguration(true, true)
val parser = SpelExpressionParser(config)
val expression = parser.parseExpression("list[3]")
val demo = Demo()
val o = expression.getValue(demo)
// demo.list 现在将是一个包含 4 个条目的真实集合
// 每个条目都是一个新的空字符串默认情况下,SpEL 表达式不能超过 10,000 个字符;但 maxExpressionLength 是可配置的。如果你以编程式创建 SpelExpressionParser,可以在创建提供给它的 SpelParserConfiguration 时指定自定义的 maxExpressionLength。如果你希望设置用于在 ApplicationContext(例如在 XML Bean 定义、@Value 等处)解析 SpEL 表达式的 maxExpressionLength,可以设置一个名为 spring.context.expression.maxLength 的 JVM 系统属性或 Spring 属性。
SpEL 编译 (SpEL Compilation)
Spring 为 SpEL 表达式提供了一个基础编译器。表达式通常是解释执行的,这在评估期间提供了很大的动态灵活性,但性能不是最优的。对于偶尔使用的表达式,这没问题,但当被其他组件(如 Spring Integration)使用时,性能可能非常重要,而且对动态性的需求并不迫切。
SpEL 编译器旨在解决这一需求。在评估过程中,编译器会生成一个在运行时体现表达式行为的 Java 类,并使用该类来实现更快的表达式评估。由于表达式周围缺乏类型信息,编译器在执行编译时,会利用在解释执行过程中收集到的信息。例如,它无法仅从表达式中获知属性引用的类型,但在第一次解释评估期间,它会发现其类型。当然,如果各表达式元素的类型随时间发生变化,这种基于派生信息的编译可能会导致问题。因此,编译最适合于其类型信息在重复评估时不会改变的表达式。
考虑以下基础表达式:
someArray[0].someProperty.someOtherProperty < 0.1由于涉及数组访问、属性解引和数值运算,性能提升显著。在 50,000 次迭代的微基准测试运行中,解释器评估需要 75ms,而编译版仅需 3ms。
编译器配置 (Compiler Configuration)
编译器默认不开启,但你可以通过两种不同方式开启:使用解析器配置过程(如前所述),或者在 SpEL 嵌入其他组件时使用 Spring 属性。
编译器可以在三种模式下运行(由 org.springframework.expression.spel.SpelCompilerMode 枚举定义):
OFF:编译器关闭,所有表达式解释执行(默认模式)。IMMEDIATE:尽快编译表达式。通常在第一次解释评估后进行。如果编译后的表达式评估失败(例如由于类型改变),调用者将收到异常。MIXED:在解释模式和编译模式之间静默切换。经过若干次成功的解释运行后,表达式被编译。如果编译版本评估失败,系统会自动切回解释模式。稍后编译器可能会再次生成编译形式并切换。这种循环会持续直到系统确定不再值得尝试。
IMMEDIATE 模式的存在是因为 MIXED 模式可能对有副作用的表达式产生问题。如果编译后的表达式在部分成功后崩溃,它可能已经改变了系统状态,由于一部分表达式可能运行了两次,调用者可能不希望它静默地在解释模式下重跑。
选择模式后,使用 SpelParserConfiguration 配置解析器:
SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE,
this.getClass().getClassLoader());
SpelExpressionParser parser = new SpelExpressionParser(config);
Expression expr = parser.parseExpression("payload");
MyMessage message = new MyMessage();
Object payload = expr.getValue(message);val config = SpelParserConfiguration(SpelCompilerMode.IMMEDIATE,
this.javaClass.classLoader)
val parser = SpelExpressionParser(config)
val expr = parser.parseExpression("payload")
val message = MyMessage()
val payload = expr.getValue(message)指定编译器模式时,也可以指定 ClassLoader(允许传递 null)。编译后的表达式定义在生成的子类加载器中。
第二种方式是通过 JVM 系统属性(或 SpringProperties 机制)设置 spring.expression.compiler.mode 为模式值(off、immediate 或 mixed)。
编译器限制 (Compiler Limitations)
并不是所有种类的表达式都支持编译。主要关注点是关键性能上下文中的常见表达式。以下种类不支持编译:
- 包含赋值的表达式
- 依赖于转换服务的表达式
- 使用自定义解析器 (resolvers) 的表达式
- 使用重载操作符的表达式
- 使用数组构造语法的表达式
- 使用选择 (selection) 或投影 (projection) 的表达式
- 使用 Bean 引用的表达式
未来可能会支持更多种类表达式的编译。
补充教学
1. 深度辨析:SimpleEvaluationContext 的“安全底线”
在前面的概览中提到了 SpEL 注入的风险。SimpleEvaluationContext 是防御这种风险的第一道防线。
- 默认黑名单:它默认禁用了对
Runtime.getRuntime()、System.exit()等系统级危险操作的访问(通过禁止类型引用和静态方法)。 - 显式白名单:它不会像
StandardEvaluationContext那样通过反射“读心”式地访问所有成员。你需要通过.withMethodResolvers()或.withPropertyAccessors()显式告诉它你允许访问哪些逻辑。 - 实战忠告:在处理像前端传来的“动态筛选条件”这种不安全输入时,强制要求使用
SimpleEvaluationContext。
2. SpEL 编译器的“热点加速”原理
SpEL 编译器的原理很像 JVM 的 JIT(即时编译):
- 字节码生成:它不是简单的代码翻译,而是利用 ASM 库在内存中直接生成
.class字节码。 - 类型“侦察”:由于表达式本身动态,编译器会在前几次“解释运行”中像侦探一样观察根对象的实际类型。
- 失效重来:如果某天根对象变了(例如从
Dog变成了Cat),生成的字节码会因为类型不匹配抛异常。这就是为什么有了MIXED模式——它可以发现错误并降级回解释模式,动态性与高性能兼得。
3. 处理 null 引用的优雅方案
在解析器配置中开启 true, true(即 autoGrowNullReferences 和 autoGrowCollections)可以极大地简化代码:
- 防御 NPE:原本访问
user.address.city如果 address 为空会报错,开启后 SpEL 会自动帮你new Address()。 - 动态列表:如果你给
list[5]赋值而列表只有 3 个元素,它会自动补齐空位。这在处理复杂的动态表单提交映射时非常有用。
4. 性能监控:什么时候开启编译?
不要盲目全局开启 IMMEDIATE。编译也是有开销的(生成字节码、加载类)。
- 适用场景:在大规模数据处理(如 Spring Batch 读写 100 万行记录)、高频网关协议转换或 Spring Integration 消息路由中开启。
- 监控建议:开启
spring.expression.compiler.mode=mixed,利用 JMX 或日志观察是否有频繁的编译退化(de-compilation)现象。