简介:一份面向Spring开发者的SpEL表达式详解资源,系统梳理了Spring Expression Language的核心语法与典型应用场景,涵盖属性访问、算术与逻辑运算、集合筛选、类型转换、上下文变量及AOP切点中的动态逻辑编写,适合正在学习Spring框架或希望在配置和注解中灵活使用表达式的初中级开发者。压缩包共14个文件,以java源码、class编译产物、xml配置及Eclipse项目描述文件为主,整体仅16KB,属于轻量级代码示例工程,可直接导入IDE查看运行效果。已有2072人学习下载,资源热度较高。配套内容包含spEL表达式解析器的使用示例、安全性注意事项,以及基于Maven和Eclipse环境的完整项目结构,可对照源码快速验证各类表达式的执行结果,帮助读者在实战中掌握SpEL的求值机制与动态逻辑实现方式。
1. SpEL:一段字符串凭什么能在 Spring 里"活"起来
一个最反直觉的事实是:Spring 里到处是字符串,可偏偏这些字符串能取值、能调用方法、能过滤集合,甚至能触发事务。@Value("#{bean.method()}")、@Cacheable(key = "#id")、@PreAuthorize("hasRole('ADMIN')"),这些不是模板引擎的占位符替换,而是一套完整的小型语言在运行期被执行。这就是 SpEL(Spring Expression Language),它解决的核心问题是:把"程序逻辑"从编译期挪到运行期,用字符串描述规则,让配置可以热修改、可以让业务人参与、可以让同一个表达式在不同上下文里算出不同结果。而字符串本身只是表象,藏在背后的是一个解析器、一棵 AST、一套类型转换系统和一个可插拔的求值上下文。搞懂 SpEL 的底层机制,你才能真正明白 Spring 诸多注解的魔法边界在哪,也才能在遇到"表达式注入""缓存 key 偶尔失效""编译模式开了反而变慢"这类玄学问题时,一眼看穿原因。
2. 求值链路:从字符串到对象的四层分工
2.1 解析器、表达式、上下文、求值器四者各管什么
SpEL 设计的第一个关键决策是:解析和求值是分离的。你看到的"#{bean.method()}"只是一段文本,SpelExpressionParser负责把它变成一棵 AST(抽象语法树),得到的是SpelExpression对象;求值时再传入EvaluationContext和一个 root 对象,由表达式对这棵树做一次解释执行。这两步不绑在一起,意味着你可以把解析结果缓存起来反复求值,代价只在第一次。
// 最小可用示例:解析一次,求值两次 ExpressionParser parser = new SpelExpressionParser(); // 注意:#name 是变量引用,需要在上下文中赋值 Expression exp = parser.parseExpression("#name.toUpperCase()"); EvaluationContext ctx = new StandardEvaluationContext(); ctx.setVariable("name", "spring"); // 第一次求值 String result = exp.getValue(ctx, String.class); System.out.println(result); // SPRING // 换一个变量值,同一棵 AST 再求值一次 ctx.setVariable("name", "spel"); String result2 = exp.getValue(ctx, String.class); System.out.println(result2); // SPEL这段代码拆开看:parseExpression完成词法分析和语法分析,把#name.toUpperCase()解析成"变量引用节点 → 方法调用节点"这样一棵树;getValue才是真正遍历树求值。EvaluationContext是求值时的环境,变量、root 对象、类型访问权限都挂在它上面。参数String.class告诉求值器最终要做一次类型转换,把结果转成目标类型再返回。
常见的实现思路是:StandardEvaluationContext内部维护了三个核心 Resolver——ReflectivePropertyAccessor(属性导航)、ReflectiveMethodResolver(方法调用)、ReflectiveConstructorResolver(构造器调用)。当 AST 里的某个节点需要反射某个 Java 成员时,求值器不会直接 new 一个Method对象,而是先问这些 Resolver 能不能处理,处理不了再抛出SpelEvaluationException。这一层间接设计是整个 SpEL 可扩展的根基,也是后面安全限制的入口。
2.2 AST 是怎么被遍历求值的
理解了四层分工,再看SpelExpression.getValue内部做了什么,会发现它本质上是一个递归下降解释器。SpelExpression持有根节点引用,根节点是OpPlus、MethodReference、PropertyOrFieldReference这类SpelNodeImpl的子类。每个节点实现getValueInternal(ExpressionState state),从根开始深度优先遍历,子节点先算完,父节点再组合。
// 模拟表达式 "1 + 2 * 3" 的求值路径(节选核心逻辑) public class OpPlus extends SpelNodeImpl { @Override public TypedValue getValueInternal(ExpressionState state) { // 先求左子节点 Object left = getLeftOperand().getValueInternal(state).getValue(); // 再求右子节点 Object right = getRightOperand().getValueInternal(state).getValue(); // 处理字符串拼接 vs 数值相加的分派 if (left instanceof String || right instanceof String) { return new TypedValue(String.valueOf(left) + right); } // 数值运算走 TypeConverter 统一成 BigDecimal 再算 return new TypedValue(calculateNumbers(left, right, (a, b) -> a.add(b))); } }注意一个细节:getValueInternal返回的是TypedValue,它同时携带值和类型信息,而不是裸的Object。这是因为 SpEL 要支持#list.?[age > 18]这种集合投影,中间每一步都需要知道运行时类型,才能决定下一步调用哪个方法、做哪种类型转换。如果没有TypedValue,字符串"1"和数字1在+运算时的分派逻辑根本无法实现。
另外一个容易忽略的点是:ExpressionState贯穿整棵树,它记录了当前表达式作用域内的局部变量、root 对象、活动的EvaluationContext。一个表达式内部可以再触发子表达式求值(比如#{bean.toString()}调用返回的对象又作为 root 继续求值),这种情况下ExpressionState会创建一个新的 scope 压栈,子表达式求值完再弹栈恢复。这就是为什么 SpEL 支持#{bean.map['key'].substring(1)}这种链式导航——每一段属性访问都在同一个 state 上下文里连续推进,任何一环返回 null 就触发EL1007E异常。
2.3 属性导航的反射缓存,性能的关键
SpEL 性能最容易被诟病的地方是反射调用慢。但实际源码里,ReflectivePropertyAccessor并不是每次都现取Field和Method,它内部维护了一个Map<CachedExecutor, Method>缓存,key 包含目标类和方法签名。同一类、同一方法名第一次访问时做一次完整的反射查找,之后直接命中缓存。这个缓存在StandardEvaluationContext里是跨表达式共享的,所以"解析一次 + 多次求值 + 多表达式共享"是 SpEL 的标准高效用法。
真正影响性能的不是反射本身,而是你反复parseExpression却不复用Expression对象。解析器要做词法切分、语法归约、AST 构建和类型推断,这些开销是求值的几十倍。很多人写工具类每次调用都new SpelExpressionParser().parseExpression(...),把最贵的步骤重复做,然后归咎于 SpEL 慢——这属于用错了姿势。
3. 核心语义机制:root、变量、集合操作是怎么编织成语言的
3.1 root 对象和 #变量:两个完全不同的取值维度
SpEL 里最容易混淆的是 root 对象和变量。getValue(context, rootObject)传入的 root 是表达式的默认取值目标,属性导航的起点;而#variable是绑定在EvaluationContext上的命名值,用#前缀区分。它们的设计差异在于:root 是被"导航"的对象,变量是被"引用"的值。
public class Order { public String status; public List<Item> items; } public class Item { public double price; public String name; } // root 是 Order,变量是折扣率 Order order = new Order(); order.status = "PAID"; order.items = new ArrayList<>(); // ... 填充 items StandardEvaluationContext ctx = new StandardEvaluationContext(); ctx.setVariable("discount", 0.8); Expression exp = parser.parseExpression("status == 'PAID' && items.![price * #discount]"); // 这里的 status、items 都从 root 上取,#discount 从上下文取 Object result = exp.getValue(ctx, order);只看这段代码,status和items是 root 的属性导航,#discount是变量引用。设计上讲,root 解决"对谁操作"的问题,变量解决"用什么参数操作"的问题。Spring 的@Cacheable(key = "#id")就是把方法参数绑定成变量,而@Value("#{demo.order}")是把 Spring 容器里的 bean 当作 root 来导航——一个表达式在 xml 里写和在注解里写,root 的赋值方式不同,但 AST 结构完全一样。
3.2 集合投影与选择:SQL 式思维的嵌入式实现
SpEL 最有价值的特性不在单对象导航,而在集合操作。list.![price * 0.9]是投影(map),list.?[price > 100]是选择(filter),list.^[price > 100]取第一个匹配,list.$[...]取最后一个。实现机制上,投影节点Projection会先对左子节点求值得到一个List,然后针对列表里的每个元素,把当前元素作为新的 root 创建一个子ExpressionState,对选择条件子树求值,最后组装成新列表。
// 模拟 "items.?[price > 100]" 的求值逻辑 public class Selection extends SpelNodeImpl { @Override public TypedValue getValueInternal(ExpressionState state) { // 先拿到被选择的集合 TypedValue tv = getChild(0).getValueInternal(state); List<?> list = (List<?>) tv.getValue(); List<Object> result = new ArrayList<>(); for (Object item : list) { // 关键:把 item 设为新的 active context 起点 ExpressionState childState = new ExpressionState(state, new TypedValue(item)); // 对每个 item 求值选择条件 Boolean match = (Boolean) getChild(1).getValueInternal(childState).getValue(); if (Boolean.TRUE.equals(match)) { result.add(item); } } return new TypedValue(result); } }这里每个元素都会新建一个ExpressionState,意味着选择条件里的属性访问是从元素本身开始的——?[price > 100]实际上等价于对每个元素执行price > 100。这种设计让集合操作和单对象导航共用同一套 AST 节点,语法上非常统一。代价是循环里创建了不少临时对象,所以 SpEL 集合操作适合做配置级过滤,不适合在超高并发热点路径上处理十万级集合。
3.3 运算符与类型转换:为什么1 + '1'不报错
SpEL 的类型系统是宽松的,继承自 Java 的TypeConverter体系,但做了很多隐式转换。1 + '1'会被判定为字符串拼接,null == null返回true,'10' > 5会先把字符串转成数字再比大小。这种宽松不是偷懒,而是配置语言的定位决定的——表达式往往来自外部配置,写配置的人不该关心 Java 的强类型约束。
数值比较的底层实现是在OpEQ、OpLT等节点里调用TypeComparator,这个比较器对 Number 类型的处理方式是:先统一转成 BigDecimal 再比较。如果一方是字符串、一方是数字,TypeConverter会尝试把字符串转成对应数字类型。转换失败时抛出的不是NumberFormatException,而是被包装成SpelEvaluationException,错误信息里会带上原始表达式片段——这是可排查性的关键设计。
方法调用也有类型适配:SpEL 找方法时不是只找精确签名匹配,它会根据实参类型按"精确匹配 → 类型扩展(int 转 long)→ 装箱 → 自动拆箱"的顺序尝试候选方法。同一个类有多个重载方法时,SpEL 会用ReflectiveMethodResolver里的isMatch做多轮筛选,如果多个方法都匹配,再按距离打分选最接近的。这个机制对用户是透明的,但理解它才能解释通为什么#{bean.method(1)}有时调的是method(Integer)而不是method(long)。
4. 安全边界:为什么同一个 SpEL 在两种上下文里命运完全不同
4.1 StandardEvaluationContext 是一把没装保险的枪
SpEL 表达式能做的不只是取值,new、T()类型引用、静态方法调用、字节码执行,理论上都能做。StandardEvaluationContext默认开放了所有能力:T(System).exit(0)可以结束 JVM,new java.io.File("/").deleteOnExit()可以触碰文件系统。这在内部工具里很强大,但一旦表达式来自用户输入、数据库配置或外部请求,就变成了一个 RCE 入口。业内多次爆出的表达式注入漏洞,根因几乎都是同一件事:用户可控的字符串被交给了StandardEvaluationContext求值,而没有做任何能力裁剪。
// 危险示范:用户输入直接进表达式 String userInput = request.getParameter("rule"); Expression exp = new SpelExpressionParser().parseExpression(userInput); // 下面的上下文允许反射任意类型 StandardEvaluationContext ctx = new StandardEvaluationContext(); // 如果 userInput 是 "T(java.lang.Runtime).getRuntime().exec('calc')",后果自负 exp.getValue(ctx);问题不在于 SpEL 本身,而在于"表达式求值"和"数据绑定"被混为一谈。Spring 自己的@Value、@Cacheable注解里用的表达式,是开发者写的、可信的、不会变的。可一旦表达式变成运行时拼出来的字符串,攻击面就完全打开了。设计上的教训是:任何表达式求值器都要区分"可信代码"与"不可信数据"两道门,把不可信数据挡在门外,让可信代码在有限能力内执行。
4.2 SimpleEvaluationContext:只给数据绑定开了个侧窗
SimpleEvaluationContext的出现就是为了填这个坑。它只开放数据绑定需要的最小能力集:属性导航、集合操作、关系运算符、布尔运算,但默认禁用方法调用访问受限制或未经允许的任意 Java 类型。构造时你必须显式指定MethodAccessor白名单,比如只允许ReflectivePropertyAccessor,不允许ReflectiveMethodResolver。
// 数据绑定场景:只读属性的安全上下文 ExpressionParser parser = new SpelExpressionParser(); SimpleEvaluationContext simpleCtx = SimpleEvaluationContext.Builder // 只允许属性读写,不允许任意方法调用 .withReadOnly() .build(); Order order = new Order(); order.status = "PAID"; // 安全:属性导航完全没问题 Expression exp1 = parser.parseExpression("status"); String status = exp1.getValue(simpleCtx, order, String.class); // 危险:这条会在求值时被拒绝,因为没有注册 MethodResolver Expression exp2 = parser.parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')"); try { exp2.getValue(simpleCtx, order); } catch (SpelEvaluationException e) { // 抛出 EL1011E: Method call not allowed in this context }源码里这个限制是怎么实现的?SimpleEvaluationContext内部持有一组List<PropertyAccessor>、List<ConstructorResolver>、List<MethodResolver>,创建 builder 时只放入了ReflectivePropertyAccessor加上DataBindingMethodResolver(默认不注册)。AST 中的MethodReference节点执行时会去 context 里找方法解析器,一个都没有,直接抛异常。这个失败模式是显式的,不是"不能访问"的模糊拒绝——错误信息会明确告诉你当前上下文不允许方法调用。
4.3 沙箱逃逸的本质:反射链总有缝
为什么有人能用SimpleEvaluationContext还是打出 payload?因为 SpEL 表达式节点不只有属性访问和方法调用两类,T()类型引用节点、new节点、索引访问节点都能触发类加载和类型解析。即便禁了方法调用,如果属性访问器(PropertyAccessor)里有反射逻辑,攻击者还是可以通过"访问某个 bean 的某个属性"间接触发危险的 getter。经典案例是访问一个对象的class属性拿到Class对象,再导航到class.module,通过模块系统构造调用链。
所以安全边界的真正结论是:任何运行时表达式求值器都不能只靠上下文收缩来保证安全,必须叠加输入侧校验(表达式白名单)、能力侧最小化、和不可信数据永远走SimpleEvaluationContext且注册最小白名单。把StandardEvaluationContext用在用户可控输入上,不是技术问题,是安全意识问题。我把这视为 SpEL 设计里最值得反思的一课:语言能力越强,默认配置越危险。
5. 避坑指南:SpEL 开发中最常见的五个翻车现场
5.1 每次请求都重新 parse,性能雪崩
现象:接口单次调用耗时不高,压测时 TPS 上不去,火焰图显示SpelExpressionParser.parseExpression占了大片。
原因:解析是完整编译过程,词法分析、语法树构建、节点对象分配全在这里。求值反而只遍历树。把 parse 放在每次请求里等于把编译开销摊到了每一次运行上。
解决:使用SpelExpressionParser实例并缓存Expression对象。Spring 容器里很多组件已经是这么做的,你自己写工具类时也应在静态初始化里完成解析,求值传参即可。
5.2 在@Value里用new结构体,启动直接失败
现象:@Value("#{new java.util.Date().getTime()}")启动时报EL1008E: Property or field 'new' cannot be found on object of type 'org.springframework.expression.spel.support.StandardEvaluationContext'。
原因:@Value的 SpEL 求值默认用的是StandardEvaluationContext没错,但 Spring 对注解里的表达式做了一层包装,#{}内的new会被当作属性名解析,因为构造器引用在注解表达式里需要特殊的起步语法new关键字后面紧跟全限定类名。但更隐蔽的是,部分 Spring 子模块会限制构造器访问权限。
解决:不要在@Value里写构造器逻辑。把构造逻辑放进一个 bean 的方法里,表达式只做方法调用,例如@Value("#{timeService.now()}")。这符合控制反转的原则,也让测试更容易替换实现。
5.3 布尔表达式用了&&,在 XML 配置里被吞掉
现象:Spring XML 里写<entry key="rule" value="#{order.status == 'PAID' && order.amount > 100}"/>,启动时报 XML 解析错误,&&消失了。
原因:XML 中&必须转义为&,&&写成&&才能被解析器正确接收。这是 SpEL 之外的问题,但凡是 SpEL 遇到这个坑的开发者多半先在 XML 里翻车,然后怀疑表达式本身有语法错误。
解决:XML 中使用&&;用 Java 配置或注解时,字符串不需要转义。另外,属性值写#{...}时注意整个表达式只能有一个#{}入口,里面不要再嵌套#{}。
5.4 表达式里访问了null,抛的异常信息看不懂
现象:表达式没问题,但数据某次是 null,抛EL1007E: Property or field 'xxx' cannot be found on null。日志里只有这一句,看不出是表达式的哪一段出了问题。
原因:SpEL 在属性导航链上遇到 null 不会短路,直接抛异常。异常信息在默认情况下不带完整的表达式路径和当前导航进度,排起错来全靠猜。
解决:开启属性访问器的安全导航写法,使用bean?.property,它在左侧为 null 时整体返回 null 而不抛异常。另外,排查时把StandardEvaluationContext换成一个打印state的定制实现,或者在 catch 里用expression.getExpressionString()拼出完整上下文。真正有效的做法是:表达式里每个易变数据都加?.保护,尤其是外部传入的 payload 字段。
5.5 编译模式开了反而方法调用变慢
现象:把SpelCompilerMode.IMMEDIATE打开后,第一次调用确实快,但后续某些表达式反而比解释模式慢,甚至抛SpelEvaluationException提示节点编译失败。
原因:SpEL 的编译模式会把表达式转成字节码,在SpelCompiler里生成一个类并加载。但它也不是对所有表达式都能编译——动态类型、运行时类型不确定的场景(比如反射返回的 Object 类型不停变化),编译后的代码要写死类型判断,导致类型分派逻辑膨胀,或直接放弃编译回退解释。IMMEDIATE 模式是"任何表达式都试图编译",遇到编译不了的就抛异常,而SpelCompilerMode.MIXED是"先解释执行,采集类型信息,达到阈值后再编译"。
解决:默认别开 IMMEDIATE,要用就用 MIXED 并在测试环境充分跑一轮。更合理的做法是:对热点表达式手动预热并用SpelCompiler的compile(expression)指定编译,对不稳定的表达式保持解释执行。类型信息越稳定,编译收益越大;表达式里有大量T()类型引用或集合投影时,先测再决定是否编译。
6. 从表达式语言看 JVM 生态:解释执行与字节码编译的取舍
SpEL 最终没有把表达式编译成原生代码,也没有做成一个"安全沙箱",而是停在了解释执行加可选编译的中间态。这个选择跟 JVM 生态的整体气质一致:优先保证不同的使用场景能各取所需,而不是提供一个全能的运行时。SpelExpression既可以走纯解释,也可以SpelCompiler编译成内部类,还可以SpelCompilerMode.MIXED混合执行。对普通开发者来说,最合适的姿势永远是:表达式数量少、变化频率低的用缓存 + 解释执行;调用频率极高且类型稳定的,手动编译并做好回退。
从设计取舍的角度看,SpEL 最值得借鉴的是它把"C 端配置灵活"和"B 端执行性能"拧在一起的方式——两者不是二选一,而是同一套 AST 上两个不同的执行路径。解释执行的路径代码直观、可调试,适合开发期;编译执行缩短了调用链路、摊薄了类型判断开销,适合上线后。但代价是维护两套执行引擎的复杂度,以及沙箱能力边界反复被质疑的安全风险。
我个人踩过最大的坑,是把 SpEL 当成了万能钥匙,什么动态规则都想用它拼。后来才意识到:表达式语言的本质是"为可变的规则提供稳定的执行载体",它的价值不在于能写多复杂的逻辑,而在于把变化从代码里抽离出来、放到配置里。从那以后,我每次写parseExpression之前都会强制问一遍自己:这个规则真的会变吗?变了有版本管理吗?用户能改吗?如果三个问题里任何一个回答是肯定的,我才继续用 SpEL,并默认套上SimpleEvaluationContext,绝不给不可信输入开StandardEvaluationContext的口子。希望这份梳理帮你看清 SpEL 的底层机制,也帮你少走一些我用血泪换来的弯路。
本文还有配套的精品资源,点击获取