Skip to content

表达式评估 (Evaluation)

本节介绍 SpEL 接口及其表达式语言的编程式用法。完整的语言参考可以在语言参考中找到。

以下代码演示了如何使用 SpEL API 来评估字面量字符串表达式 'Hello World'

java
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("'Hello World'"); // (1)
String message = (String) exp.getValue();
kotlin
val parser = SpelExpressionParser()
val exp = parser.parseExpression("'Hello World'") // (1)
val message = exp.value as String
  1. message 变量的值为 "Hello World"

你最可能使用的 SpEL 类和接口位于 org.springframework.expression 包及其子包(如 spel.support)中。

ExpressionParser 接口负责解析表达式字符串。在前面的示例中,表达式字符串是一个由单引号括起来的字符串字面量。Expression 接口负责评估定义的表达式字符串。调用 parser.parseExpression(…)exp.getValue(…) 时可能抛出的两种异常分别是 ParseExceptionEvaluationException

SpEL 支持广泛的特性,例如调用方法、访问属性和调用构造函数。

在以下方法调用示例中,我们在字符串字面量 'Hello World' 上调用了 concat 方法。

java
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("'Hello World'.concat('!')"); // (1)
String message = (String) exp.getValue();
kotlin
val parser = SpelExpressionParser()
val exp = parser.parseExpression("'Hello World'.concat('!')") // (1)
val message = exp.value as String
  1. message 的值现在是 "Hello World!"

以下示例演示了如何访问字符串字面量 'Hello World'Bytes JavaBean 属性。

java
ExpressionParser parser = new SpelExpressionParser();

// 调用 'getBytes()'
Expression exp = parser.parseExpression("'Hello World'.bytes"); // (1)
byte[] bytes = (byte[]) exp.getValue();
kotlin
val parser = SpelExpressionParser()

// 调用 'getBytes()'
val exp = parser.parseExpression("'Hello World'.bytes") // (1)
val bytes = exp.value as ByteArray
  1. 此行将字面量转换为字节数组。

SpEL 还支持通过标准的点标记法(如 prop1.prop2.prop3)访问嵌套属性,以及相应的属性值设置。也可以访问公共字段。

以下示例显示了如何使用点标记法获取字符串字面量的长度。

java
ExpressionParser parser = new SpelExpressionParser();

// 调用 'getBytes().length'
Expression exp = parser.parseExpression("'Hello World'.bytes.length"); // (1)
int length = (Integer) exp.getValue();
kotlin
val parser = SpelExpressionParser()

// 调用 'getBytes().length'
val exp = parser.parseExpression("'Hello World'.bytes.length") // (1)
val length = exp.value as Int
  1. 'Hello World'.bytes.length 给出字面量的长度。

可以调用字符串的构造函数而不是使用字符串字面量,如下例所示。

java
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("new String('hello world').toUpperCase()"); // (1)
String message = exp.getValue(String.class);
kotlin
val parser = SpelExpressionParser()
val exp = parser.parseExpression("new String('hello world').toUpperCase()")  // (1)
val message = exp.getValue(String::class.java)
  1. 从字面量构造一个新的 String 并将其转换为大写。

注意泛型方法的使用:public <T> T getValue(Class<T> desiredResultType)。使用此方法可以避免将表达式的值强制转换为所需的结果类型。如果值无法强制转换为类型 T 或无法通过注册的类型转换器进行转换,则会抛出 EvaluationException

SpEL 更常见的用法是提供一个针对特定对象实例(称为根对象)进行评估的表达式字符串。以下示例显示了如何从 Inventor 类的实例中检索 name 属性,以及如何在布尔表达式中引用 name 属性。

java
// 创建并设置日历
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
kotlin
// 创建并设置日历
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。如下所示:

java
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);
kotlin
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

java
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 个条目的真实集合
// 每个条目都是一个新的空字符串
kotlin
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 类,并使用该类来实现更快的表达式评估。由于表达式周围缺乏类型信息,编译器在执行编译时,会利用在解释执行过程中收集到的信息。例如,它无法仅从表达式中获知属性引用的类型,但在第一次解释评估期间,它会发现其类型。当然,如果各表达式元素的类型随时间发生变化,这种基于派生信息的编译可能会导致问题。因此,编译最适合于其类型信息在重复评估时不会改变的表达式。

考虑以下基础表达式:

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 配置解析器:

java
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);
kotlin
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 为模式值(offimmediatemixed)。

编译器限制 (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(即 autoGrowNullReferencesautoGrowCollections)可以极大地简化代码:

  • 防御 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)现象。

Based on Spring Framework.