什么是 SpEL(Spring 表达式语言)?它能做什么、在哪些地方用到?
简化版
**SpEL(Spring Expression Language,Spring 表达式语言)**是 Spring 提供的一套「在运行时求值的表达式语言」——它能在字符串里写「取值、运算、调方法、访问对象属性、集合筛选」等逻辑,由 Spring 在运行时解析并求值。能做什么:① 字面量与运算(#{1 + 2}、#{'Hello' + name});② 访问 Bean 和属性(#{userService.count}、@beanName);③ 调用方法(#{T(java.lang.Math).random()});④ 集合操作(筛选 .?[age>18]、投影 .![name]);⑤ 三元/Elvis 运算(#{cond ? a : b}、#{name ?: 'default'});⑥ 安全导航(#{user?.address?.city} 避免 NPE)。在哪用到:@Value("#{...}") 注入、@Cacheable(key="#id") 定义缓存 key、@PreAuthorize("hasRole('ADMIN')") 安全注解、Spring Security/Spring Data 的各种注解里。注意 #{} 和 ${} 的区别:${} 是属性占位符(从配置文件取值,早于 SpEL 解析),#{} 才是 SpEL 表达式(运行时求值)。
详细版
SpEL 能力速览:
| 能力 | 例子 |
|---|---|
| 字面量/运算 | #{100 * 0.9}、#{'a' + 'b'} |
| 访问属性 | #{someBean.property} |
| 引用 Bean | #{@userService} 或 @userService |
| 调用方法/静态方法 | #{T(java.lang.Math).max(1,2)} |
| 集合筛选(selection) | #{users.?[age > 18]} 选出成年人 |
| 集合投影(projection) | #{users.![name]} 提取所有 name |
| 三元 / Elvis | #{x > 0 ? 'p' : 'n'}、#{name ?: 'anon'} |
| 安全导航 | #{user?.name}(user 为 null 不抛 NPE) |
| 变量引用 | #{#root}、方法参数 #{#id}(如缓存注解) |
// @Value 里用 SpEL
@Value("#{systemProperties['user.timezone']}") // 读系统属性
private String timezone;
@Value("#{T(java.lang.Math).random() * 100}") // 调静态方法
private double randomValue;
// 缓存注解里用 SpEL 定义 key(#id 引用方法参数)
@Cacheable(value = "users", key = "#id")
public User getUser(Long id) { ... }
// 安全注解里用 SpEL 判断权限
@PreAuthorize("hasRole('ADMIN') and #user.id == principal.id")
public void update(User user) { ... }
⚠️
#{}(SpEL)与${}(属性占位符)是两套机制,别混淆:${jdbc.url}是 PropertyPlaceholder,在 Bean 定义阶段就从配置文件/环境变量里取值替换(早期处理);#{...}是 SpEL,在运行时解析求值(能调方法、做运算、访问 Bean)。二者可以嵌套:#{'${app.name}'.toUpperCase()}先由${}取出 app.name 的值,再由 SpEL 转大写。搞混的典型后果:想做运算却用了${}(不会运算,当字符串处理)、或想取配置值却用了#{}(找不到对应的属性)。
完整版教学
一、SpEL 是什么:运行时求值的表达式语言
SpEL 是「能在字符串里写逻辑、运行时求值」的语言:
普通字符串:"1 + 2" → 就是这 5 个字符
SpEL 表达式:#{1 + 2} → Spring 解析求值,得到 3
SpEL 让"配置/注解里的字符串"具备了计算能力:
- 不只是死值,能运算、能取 Bean 属性、能调方法
- 在运行时(用到时)才求值
核心组件:
ExpressionParser:解析表达式字符串成 Expression 对象
EvaluationContext:求值的上下文(提供变量、Bean、root 对象)
Expression.getValue(context):求值,得到结果
Expression exp = new SpelExpressionParser().parseExpression("1+2");
Integer r = exp.getValue(Integer.class); // 3
SpEL 的本质是「给字符串赋予计算能力、运行时求值」——普通字符串是死的,SpEL 表达式能运算、取属性、调方法。它由 ExpressionParser(解析)、EvaluationContext(上下文)、Expression.getValue(求值)三部分构成。理解「SpEL 是运行时求值的表达式语言、让字符串具备计算能力、由 Parser/Context/getValue 构成」,就理解了它的定位。
二、基础能力:运算、属性、方法
SpEL 的基础能力覆盖了「取值、运算、调用」:
① 字面量与运算:
#{100} 数字
#{'hello'} 字符串
#{100 * 0.9} → 90(数学运算)
#{'a' + 'b'} → 'ab'(字符串拼接)
#{5 > 3} → true(比较)
#{true and false} → false(逻辑运算)
② 访问对象属性(点号导航):
#{user.name} 取 user 的 name 属性
#{user.address.city} 多级导航
③ 调用方法(含静态方法):
#{user.getName()} 调实例方法
#{'hello'.toUpperCase()} → 'HELLO'
#{T(java.lang.Math).max(1, 2)} T() 引用类,调静态方法
#{T(java.lang.Integer).MAX_VALUE} 访问静态字段/常量
基础能力三类:① 字面量与运算(数学、字符串、比较、逻辑);② 属性访问(点号多级导航);③ 方法调用(实例方法、T() 引用类调静态方法/访问常量)。T(全限定类名) 是 SpEL 的特色——用它可以调静态方法、访问静态常量。理解「SpEL 基础能力:运算/属性导航/方法调用(T()引用类调静态)」,就掌握了 SpEL 的日常用法。
三、进阶能力:集合筛选与投影
SpEL 处理集合的能力很强大——「筛选(selection)」和「投影(projection)」:
假设 users 是一个 List<User>,每个 User 有 name、age
① 集合筛选(selection).?[条件]:选出满足条件的元素
#{users.?[age > 18]} → 所有成年人(子集)
#{users.^[age > 18]} → 第一个成年人(^ 取第一个)
#{users.$[age > 18]} → 最后一个成年人($ 取最后一个)
② 集合投影(projection).![表达式]:对每个元素求值,收集结果
#{users.![name]} → 所有 name 组成的 List
#{users.![age * 2]} → 每个 age 翻倍的 List
③ 组合使用:
#{users.?[age > 18].![name]} → 成年人的 name 列表
(先筛选出成年人,再提取他们的 name)
④ Map 也支持:
#{map.?[value > 100]} 按 value 筛选 Map 的 entry
集合操作是 SpEL 的亮点——筛选 .?[条件](选出满足条件的子集,^ 取第一个、$ 取最后一个)、投影 .。二者可组合(先筛选再投影),非常像后来的 Stream API。这让 SpEL 能在一个表达式里完成「过滤 + 转换」。理解「SpEL 集合筛选 .?[] 选子集、投影 .![] 转换收集、可组合,类似 Stream」,就掌握了 SpEL 处理集合的进阶能力。
四、健壮性能力:三元、Elvis、安全导航
SpEL 提供了几个「写健壮表达式」的运算符:
① 三元运算符(? :):
#{score >= 60 ? '及格' : '不及格'}
② Elvis 运算符(?:)—— 简化的"取默认值":
#{name ?: '匿名'}
→ 等价于 name != null ? name : '匿名'
→ 常用于"没配置就用默认值"
③ 安全导航(?.)—— 避免 NPE:
#{user?.address?.city}
→ 如果 user 是 null,整个表达式返回 null,不抛 NPE
→ 而 #{user.address.city} 在 user 为 null 时会 NPE
组合例子(配置注入):
@Value("#{config.timeout ?: 3000}") // 没配 timeout 就用 3000
这三个运算符让表达式「更健壮」:三元(条件取值)、Elvis ?:(简化的「为 null 取默认值」,常用于配置默认值)、安全导航 ?.(多级取属性时任一级为 null 就返回 null,避免 NPE)。它们在「注入配置、动态取值」时很实用。理解「三元条件取值、Elvis ?: 取默认值、安全导航 ?. 避 NPE」,就掌握了写健壮 SpEL 表达式的运算符。
五、#{} vs ${}:两套机制别混淆
SpEL 最容易踩的坑是「混淆 #{} 和 ${}」:
${...} 属性占位符(Property Placeholder):
- 从配置文件/环境变量/系统属性取值
- 由 PropertySourcesPlaceholderConfigurer 处理
- 处理时机早(Bean 定义阶段就替换)
- 不能运算、不能调方法,就是"取配置值"
例:@Value("${jdbc.url}") 取配置里的 jdbc.url
#{...} SpEL 表达式:
- 运行时解析求值,能运算/调方法/访问 Bean
- 处理时机是运行时
例:@Value("#{T(java.lang.Math).random()}")
二者可嵌套(先 ${} 后 #{}):
@Value("#{'${app.name}'.toUpperCase()}")
→ ${app.name} 先取出配置值 → SpEL 再转大写
混淆的后果:
想取配置却用 #{jdbc.url} → 当成 SpEL 找名为 jdbc 的 Bean 的 url 属性,报错
想运算却用 ${1+1} → 当成属性名 "1+1" 找配置,找不到
#{} 和 ${} 是两套独立机制:${} 是属性占位符(取配置值,Bean 定义阶段处理,不能运算);#{} 是 SpEL(运行时求值,能运算/调方法/访问 Bean)。二者可嵌套(${} 先执行取配置、#{} 后执行求值)。混淆会导致「取不到配置」或「不做运算」。理解「${} 取配置(早/不运算) vs #{} SpEL(运行时/能运算)、可嵌套、别混淆」,就避开了 SpEL 最常见的坑。
六、SpEL 在框架里的应用
SpEL 不只用于 @Value,它嵌入在 Spring 全家桶的很多注解里:
① @Value:注入时求值
@Value("#{systemProperties['os.name']}")
② Spring Cache:@Cacheable/@CacheEvict 的 key、condition
@Cacheable(key = "#id", condition = "#id > 10")
→ #id 引用方法参数 id(SpEL 的方法参数变量)
③ Spring Security:方法级权限
@PreAuthorize("hasRole('ADMIN') and #user.id == authentication.principal.id")
→ 用 SpEL 表达复杂的权限判断
④ Spring Data:@Query 里的 SpEL
@Query("... WHERE u.tenant = ?#{principal.tenantId}")
⑤ Spring Integration、Spring Batch 等的配置里
为什么框架爱用 SpEL:
它让"注解里能写动态逻辑"——不用写死,能引用参数、Bean、上下文
→ 极大提升了注解的表达力和灵活性
SpEL 的真正威力在于它「嵌入了整个 Spring 生态的注解」——@Cacheable 的 key(#id 引用方法参数)、@PreAuthorize 的权限表达式、@Query 的动态查询等。它让「注解里也能写动态逻辑」(引用方法参数、Bean、上下文),极大提升了注解的表达力。理解注解里的 #id、hasRole('ADMIN') 这些其实都是 SpEL,就理解了它在框架里的核心地位。理解「SpEL 嵌入 @Value/@Cacheable/@PreAuthorize/@Query 等注解、让注解能写动态逻辑引用参数和 Bean」,就理解了 SpEL 的实际价值。
记忆钩子:「SpEL(Spring 表达式语言)= 运行时求值的表达式语言,让字符串有计算能力;能力:运算(#{100*0.9})/属性导航(user.name)/方法调用(T(Math).max)/集合筛选(.?[age>18])投影(.![name])/三元/Elvis(?:取默认)/安全导航(?.避NPE);#{}(SpEL 运行时求值能运算)vs ${}(属性占位符取配置早处理不运算),可嵌套别混淆;嵌入 @Value/@Cacheable(key=#id)/@PreAuthorize/@Query 等注解让注解能写动态逻辑」。
七、常见误区与追问
- 误区:#{} 和 ${} 是一回事。 两套机制——${} 是属性占位符(取配置值,Bean 定义阶段处理,不能运算),#{} 是 SpEL(运行时求值,能运算/调方法/访问 Bean);混淆会导致取不到配置或不做运算。
- 误区:SpEL 只能在 @Value 里用。 SpEL 嵌入了整个 Spring 生态:@Cacheable/@CacheEvict 的 key 和 condition、@PreAuthorize 的权限表达式、@Query 的动态查询、@ConditionalOnExpression 等——注解里那些 #id、hasRole(…) 都是 SpEL。
- 误区:SpEL 是编译期检查的。 SpEL 是运行时解析求值的字符串——写错了(如属性名拼错、方法不存在)编译期不报错,运行到求值时才抛异常;所以要小心测试覆盖。
- 误区:@Cacheable(key=“id”) 能取到方法参数。 缓存注解的 key 是 SpEL,引用方法参数要用 #(
#id);写key="id"会被当成字符串字面量 “id”,所有调用共用同一个 key,导致缓存串数据。 - 追问:SpEL 的集合筛选和投影分别是什么? 筛选
.?[条件]选出满足条件的子集(^取第一个、$取最后一个);投影.![表达式]对每个元素求值并收集结果(类似 map);可组合如users.?[age>18].![name]取成年人的 name 列表——很像 Stream 的 filter+map。 - 追问:安全导航运算符 ?. 解决什么问题? 多级属性访问时避免 NullPointerException——
user?.address?.city中任一级为 null,整个表达式返回 null 而不抛 NPE;相比user.address.city在 user 为 null 时会崩溃。 - 追问:${app.name} 和 #{…} 能一起用吗? 能嵌套,且 ${} 先于 #{} 执行:
#{'${app.name}'.toUpperCase()}中 ${app.name} 先在 Bean 定义阶段被替换成配置值,运行时 SpEL 再对结果调 toUpperCase;利用这个可以「取配置值再做运算处理」。
八、加强记忆
SpEL(Spring Expression Language)是「运行时求值的表达式语言」——让配置/注解里的字符串具备计算能力(由 ExpressionParser 解析、EvaluationContext 提供上下文、Expression.getValue 求值)。能力:① 运算(#{100*0.9}、字符串拼接、比较逻辑);② 属性导航(user.address.city);③ 方法调用(实例方法、T(全限定类名) 引用类调静态方法/访问常量);④ 集合筛选 .?[age>18](选子集,^首$尾)和投影 .;⑤ 三元/Elvis ?:(取默认值)/安全导航 ?.(避 NPE);⑥ 变量引用(#id 引用方法参数、#root)。#{}(SpEL 运行时求值能运算)vs ${}(属性占位符取配置、Bean 定义阶段处理、不能运算)是两套机制别混淆(可嵌套,${} 先执行)。应用:嵌入 @Value、@Cacheable(key="#id")、@PreAuthorize("hasRole('ADMIN')")、@Query 等注解,让注解里能写动态逻辑(引用参数、Bean、上下文),极大提升注解表达力。注意 SpEL 是运行时解析,写错不在编译期报错。一句话「SpEL = 运行时求值的表达式语言,能运算/导航/调方法/集合筛选投影/三元 Elvis 安全导航;#{}(SpEL 能运算) 别和 ${}(取配置) 混;嵌入 @Value/@Cacheable/@PreAuthorize 等注解让注解写动态逻辑」。