
简介Spring SpEL 表达式详解资料包面向 Java 开发者和 Spring 进阶学习者聚焦运行时对象图查询与操作覆盖属性/字段访问、方法调用、算术与逻辑运算、集合筛选、类型转换等核心语法并延伸到配置注入、注解解析和 AOP 切点中的实际运用可帮助应对动态数据绑定、条件判断和代码可维护性等常见问题。包体共 14 个文件压缩包仅 16KB包含 3 个 Java 源码文件、对应 class 编译产物、XML 配置与 Eclipse 工程描述文件源码与测试目录清晰Maven 配置同步提供适合导入 IDE 后直接阅读和验证表达式效果。目前已有 2072 人学习下载。借助该资料可系统梳理 SpelExpressionParser 解析求值流程、上下文变量的访问方式及安全边界并通过精简示例快速掌握表达式写法和应用思路是快速建立 SpEL 知识框架的实用参考。1. 为什么多数人把SpEL用成了字符串拼接从业务场景看表达式引擎的定位很多团队的规则配置是这样写的一堆if-else嵌套加上一个动态拼接出来的条件字符串再用反射或者脚本引擎去执行。问题是这种方式既不好维护又让性能和安全都成了黑匣子。SpELSpring Expression Language不是Spring的附属品而是一个可以脱离Spring容器独立使用的表达式引擎。它解决的是「把一段动态规则交给引擎执行而不是写死在Java代码里」的问题。适合谁用做规则平台、风控拦截、动态配置中心、报表计算这类需求的人手里正好缺一个能把字符串变成可执行逻辑的工具。这篇文章从核心对象拆到实战落地再把常见的坑挨个说清楚你可以照着直接改到自己项目里。2. SpEL核心三件套Parser、Expression与EvaluationContext的组装顺序2.1 解析器与表达式先弄清谁负责编译、谁负责执行接触SpEL第一件事是先分清楚接口职责很多人把Parser当成了执行器结果翻车。SpelExpressionParser负责把字符串编译成Expression对象这个对象才是后续反复求值的核心。Expression本身不持有业务数据它只保存编译后的语法树所以可以设计成单例复用。下面是最基础的一段用法// 标准解析器线程安全可以定义为静态常量 SpelExpressionParser parser new SpelExpressionParser(); // 编译表达式这一步不做数据绑定 Expression expression parser.parseExpression(Hello name); // 求值阶段才传入数据 String result expression.getValue(ctx, String.class);逻辑上要分清三步第一步创建解析器它只负责语法分析第二步调用parseExpression把字符串编译成Expression对象如果语法有错在这一步就会抛ParseException第三步调用getValue传入上下文和期望返回类型。参数说明里有一个容易忽略的点getValue的第二个参数是期望返回类型SpEL会在返回前做类型转换这里传String.class就会把结果强制转成字符串。另一个容易被误解的地方是Expression能不能复用。它可以复用因为表达式里如果写死了字面量或者用变量占位符解析结果都是同一个语法树。常见的错误是在方法里每次new一个parser语法树不缓存在高并发场景下解析成本会被放大。表达式本身就是那个「后悔药」——最初写错了语法改字符串定义即可不用重新编译Java代码。2.2 求值上下文root对象、变量和函数都挂在谁身上StandardEvaluationContext是SpEL默认提供的上下文实现它同时持有三样东西根对象、变量集合、自定义函数。根对象最常用表达式里所有不带#前缀的属性和方法调用默认都作用在根对象上。变量则是显式用#variableName去访问的那一类。函数是一类特殊的变量对应到静态方法或者某个实例方法上。StandardEvaluationContext context new StandardEvaluationContext(); // 设置根对象表达式可以直接访问userId、deptId这些属性 context.setRootObject(ruleContext); // 设置变量表达式里用 #limit 访问 context.setVariable(limit, 3); // 注册自定义函数表达式里用 #parseTime(...) 调用 context.registerFunction(parseTime, DateUtils.class.getDeclaredMethod(parseTime, String.class, String.class));这里的关键是理解变量名冲突的处理规则。如果根对象上有一个属性叫limit同时变量表里也有一个limit表达式里不带#的limit访问根对象带#的limit访问变量表。我一般建议团队在写表达式时强制约定业务数据全部走根对象配置参数全部走变量函数统一带#前缀这样一眼就能看出表达式引用的是什么。registerFunction还有个坑如果目标方法不是publicgetDeclaredMethod之后调用invoke会抛IllegalAccessException所以要确认方法可见性。StandardEvaluationContext不是线程安全的原因在于它内部有表达式编译器的缓存和TypeLocator状态。所以最佳实践是一个上下文只在一个线程内使用或者每次求值都new一个轻量级上下文。性能敏感场景下可以复用同一个上下文但不要多线程并发往里面写变量。提示对于每次请求变量值都不同的场景把变量通过setVariable传入比修改根对象更干净。3. 在Spring容器里落地SpEL从Value到自定义函数与Bean调用的完整链路3.1 注解驱动Value和PreAuthorize背后的解析时机在Spring项目里很多开发者第一次接触SpEL是从Value注解开始的。Value(${xxx})严格来说是占位符不是SpEL而Value(#{...})才是SpEL表达式。这两者经常混用导致表达式不解析、注入失败的问题。下面是一个标准写法Component public class RuleService { // #{...} 走SpEL引用容器里名为 defaultDept 的Bean的属性 Value(#{systemConfig.defaultLimit}) private Integer defaultLimit; // 也可以引用Bean的方法 Value(#{systemConfig.getTimeout(fast)}) private Long fastTimeout; // 配合占位符做兜底 Value(#{${rule.switch:false}}) private Boolean switchEnabled; }Value里的SpEL表达式在Bean实例化阶段被解析解析器是Spring容器内部自带的StandardBeanExpressionResolver底层还是SpelExpressionParser。这里有一个经常被忽略的点SpEL表达式里访问的systemConfig必须是一个Spring管理的Bean它的字段和方法都需要能被访问到。如果方法不是public或者getter没按规范命名这里就自动注入失败。在Spring方法安全注解里SpEL还能拿到方法参数和返回结果。PreAuthorize里的表达式参数名默认依赖编译器的参数名保留如果没开启-parameters参数就必须用Param显式标注。这是很多团队从Spring Security入门时困惑很久的问题。PreAuthorize(hasRole(ADMIN) and #deptId authentication.details.deptId) public void updateDept(Param(deptId) Long deptId) { // 业务逻辑 }这段表达式的执行时机是在方法调用前AOP切面拦截后通过SecurityExpressionRoot把方法参数暴露给表达式。这里牵扯出一个适用场景问题表达式的求值能力再强也不应该替代业务校验。我自己倾向于把SpEL用在「规则动态可变」的那部分像权限这种最好长期稳定否则每次改权限都要理解表达式语义排查成本会变高。3.2 自定义函数注册把Java静态方法暴露给表达式很多规则字符串需要在表达式里做时间格式化、加密、脱敏这类操作这些逻辑放在C端代码里更安全也便于单测。SpEL允许把静态方法注册成函数在表达式中直接调用。public class RuleFunctions { public static boolean inBlackList(String userId, ListString blackList) { return blackList ! null blackList.contains(userId); } public static String maskMobile(String mobile) { return mobile.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); } }StandardEvaluationContext context new StandardEvaluationContext(); context.setRootObject(ruleInput); context.registerFunction(inBlackList, RuleFunctions.class.getDeclaredMethod(inBlackList, String.class, List.class)); context.registerFunction(maskMobile, RuleFunctions.class.getDeclaredMethod(maskMobile, String.class)); String rule #inBlackList(userId, blackList) ? reject : #maskMobile(mobile); String result parser.parseExpression(rule).getValue(context, String.class);注册函数时有一个坑getDeclaredMethod的第二个参数是可变参数必须把方法形参对应的Class全部传对。如果方法第一个参数是String第二个是List而你传成了String.class、String.class运行期会报找不到方法或者参数类型不匹配。另外表达式里调用函数的参数个数和类型也必须匹配SpEL不做隐式强制转换比如传int进来方法声明是long可能直接报错。注册函数的作用还不止是静态方法。如果想把某个Spring Bean的方法暴露给表达式有两种方式一种是在解析前把Bean对象塞进变量表达式里用#beanName.method()调用另一种是先拿到Bean实例再通过registerFunction绑定它的实例方法。实际项目里我通常优先塞变量因为这样表达式更多依赖变量名而不是函数名后续替换实现也方便。3.3 模板表达式规则里混入静态文本时不要手动拼接业务规则往往需要输出一段话比如「命中黑名单拒绝放行」其中命中的原因是从一个变量里动态取的。如果直接用字符串拼接很容易制造出引号嵌套错误。SpEL提供了模板表达式支持只解析被#{...}包住的部分其余内容按原样输出。String template 用户 #{userId} 命中黑名单原因#{reason}; TemplateParserContext parserContext new TemplateParserContext(); Expression expression parser.parseExpression(template, parserContext); String output expression.getValue(context, String.class); System.out.println(output);TemplateParserContext默认前缀是#{后缀是}。如果你的规则模板里本身就需要输出这个前缀要换自定义前缀可以继承TemplateParserContext改写两个方法。模板表达式看起来像字符串拼接的替代品但它的价值在于解析一次之后可复用并且每个#{...}内部都是完整的SpEL语法支持嵌套方法调用。这个特性的常见误用是试图用它在拼接SQL语句。SpEL不是SQL注入过滤器也不该承担拼接SQL的职责那应该走MyBatis的XML配置或参数绑定。模板表达式适合的是生成提示文案、组装消息内容别越界到数据层。4. 运算符与集合操作让表达式在真实业务里“算得动、算得对”4.1 导航运算符与安全导航空指针在表达式里怎么管SpEL支持.导航运算符比如a.b.c这在Java代码里很容易NPE在SpEL里如果中间某个环节是null默认也会抛异常。解决办法是用安全导航运算符?.它只对紧邻的调用做判空。// 假设user可能为nulluser.address也可能为null Expression expr parser.parseExpression(user?.address?.city); String city expr.getValue(context, String.class);这里要理解?.的传播规则当user为null时整个表达式返回null当user非null但address为null最后也返回null。它不会往后的属性继续使用安全导航因为根本不会访问到city。安全导航适合那些“查到了就取没查到就不取”的规则比如定时任务里取一个可能不存在的扩展配置。和这个相关的是elvis运算符?:它在SpEL里的作用和Java的Optional有点像用于提供默认值。很多人把?:和?三元运算混了。写一个直观的例子// 编译期表达取配置没配就用默认值100 Expression expr parser.parseExpression(config?.timeout ?: 100);组合语义是这样的config不为null且访问timeout成功就取timeout的值如果config为null或者timeout为null就返回100。注意?:两侧的优先级你不需要加括号但最好用空格把运算符和操作数隔开提高规则日志的可读性。4.2 内联集合与Map构造、筛选、投影一站式处理SpEL支持直接用表达式构造列表、Map还支持类似于Stream的过滤和投影操作。这一节最实用的是三个运算符.?过滤、.![投影、.^和.$取首尾。// 内联List Expression listExpr parser.parseExpression({1, 2, 3, 4, 5}); ListInteger numbers (ListInteger) listExpr.getValue(); // 基于根对象里的orderList做过滤 String filterExpr orderList.?[amount 100 status PAID]; ListOrder paidOrders parser.parseExpression(filterExpr).getValue(context, List.class); // 投影把所有订单的订单号取出来 String projectExpr orderList.![orderNo]; ListString orderNos parser.parseExpression(projectExpr).getValue(context, List.class); // 取金额最大的第一个 String maxExpr orderList.^[amount 100]; Order firstMatched parser.parseExpression(maxExpr).getValue(context, Order.class);过滤条件内部是完整SpEL表达式可以访问集合元素自身属性也可以引用#变量。它的性能边界要注意整个集合过滤是在Java内存里做的如果集合有几百万条执行耗时就会明显暴露出来。用过滤的场景应该是内存里几千上万的量级而不是替代数据库做查询。投影会把结果List的元素类型转换为目标类型SpEL会自动包装和解包。Order到orderNo的投影结果是List 如果你的集合是Set或者数组投影结果也都是List这一点容易在类型强转时报错。4.3 类型转换与数学运算别把字符串当数字规则配置里从配置中心读出来的值经常是字符串直接和数字比较会断言失败。SpEL有一个T()运算符可以拿到Class对象还支持显式类型转换方法。String expr T(Integer).parseInt(timeoutStr) 500 ? heavy : light; StandardEvaluationContext ctx new StandardEvaluationContext(); ctx.setVariable(timeoutStr, 800); String result parser.parseExpression(expr).getValue(ctx, String.class);T()运算符能直接引用Java类型表达式里写T(java.math.BigDecimal)也是合法的。这里有个安全提示在不信任的表达式输入场景下T()允许访问任意类这相当于给攻击者开了一个反射入口生产环境务必配合接下来的第5章做白名单校验。数学运算的坑集中在除法和取模上。如果两个Integer类型相除SpEL和Java的行为一致结果是整数而不是小数。想要精确结果需要对操作数做类型提升。一般我会在表达式里写成amount / T(java.math.BigDecimal).valueOf(100)这样语义清晰不会因为类型隐式转换导致计算结果不一致。4.4 List投影和索引访问在真实规则里的几种组合用法有些规则需要对列表按条件取值比如「名单里是否包含当前用户」「前三个分类里是否有命中项」组合使用索引访问和过滤可以写出很紧凑的规则表达式。String rule blackList.contains(userId) || categoryList[0] HIGH ? Y : N; Expression expr parser.parseExpression(rule); MapString, Object root new HashMap(); root.put(blackList, Arrays.asList(u1001, u1002)); root.put(userId, u1002); root.put(categoryList, Arrays.asList(HIGH, MEDIUM)); StandardEvaluationContext ctx new StandardEvaluationContext(); ctx.setRootObject(root); String result expr.getValue(ctx, String.class);这里体现的是SpEL作为规则引擎和Java代码的差异Java要对集合判空、对越界做保护SpEL直接写下来也能执行但越界时会抛异常。最好的做法是在表达式外层再包一层判断比如categoryList?.size() 0 categoryList[0] HIGH。这种组合写法需要你记住SpEL的短路求值特性和Java的一致前置条件不满足时不会执行后面的索引访问。5. 避坑与排查SpEL使用中的五个典型翻车现场5.1 表达式解析成功但求值报错先查变量表结构现象parseExpression没有抛异常但getValue时报SpelEvaluationException提示无法解析属性xxx。原因是表达式里使用了某个变量而这个变量并没有在StandardEvaluationContext的变量表里注册或者注册的名字大小写不一致。解决方法是先打印上下文里的变量列表或者直接把根部对象setRootObject让变量访问走根对象属性。排查时我会在本地把context内部dump出来检查表达式里每个#变量是否都有对应值。5.2 性能骤降解析器每次重建语法树没有缓存现象接口RT从几十毫秒飙到几百毫秒压测时CPU飙升。原因是每个请求都new了一个SpelExpressionParser并且不断调parseExpression。SpEL解析本身不是免费操作带复杂嵌套的表达式一次解析可能消耗数毫秒。解决方法是把parser定义成static final高频使用的Expression对象缓存到本地Map里注意Map需要做并发控制。实践里我会把「表达式字符串 → Expression对象」做成两级缓存第一级是规则版本号第二级是表达式内容这样规则变更时能及时失效。5.3 安全漏洞把用户输入直接当成表达式解析现象系统被恶意请求打穿表达式里构造了T(java.lang.Runtime)之类的反射调用执行了外部命令。原因是未对表达式来源做任何限制StandardEvaluationContext默认开放了部分Java类型访问。解决方法是三步走第一安全的输入源只允许配置中心或管理后台写入第二解析前用自定义白名单策略拦截T()、new等高风险语法第三使用SimpleEvaluationContext代替StandardEvaluationContext后者默认不注册反射类型访问器。SimpleEvaluationContext simpleCtx SimpleEvaluationContext.forReadOnlyDataBinding().build();SimpleEvaluationContext适合只读数据绑定、方法调用受限的场景规则引擎这类场景默认应该用它需要函数时再注册。这条属于血泪经验我见过不止一个项目上线一段时间后被人用SpEL表达式摸到内网信息。想让引擎强大就必须先把口子收窄。5.4 泛型集合取不到元素属性现象过滤表达式报错说找不到status属性但Order实体里明明有。原因是根对象里的orderList被声明成了ListSpEL在运行时看不到泛型类型只能拿元素的运行时类型去解析。解决方法是把rootObject替换成强类型对象或者把orderList先转成List 再由外部设置。如果你是从JSON反序列化得来的列表反序列化时就要指定泛型TypeReference否则元素会是LinkedHashMap属性访问路径就要改成orderList.?[status]换成orderList.?[status]。5.5 字符串单引号与转义问题现象表达式执行时报语法错误提示unexpected token。原因是规则里出现的英文单引号、反斜杠没有转义。SpEL的字符串字面量用单引号括起来字符串里再包含单引号时需要写两个单引号或者用双引号包整个表达式。排查时我一般先把表达式打印出来人眼扫一遍再写一个小的JUnit用例单独解析这一段。还有一类隐蔽问题是规则里带了换行和缩进模板模式下如果换行落在#{...}内部可能被视为非法字符解决办法是保存规则时统一去掉空白字符。提示以上五个坑基本涵盖日常接入SpEL的大部分翻车场景。遇到别慌按「先看表达式语法再看上下文数据最后看权限配置」的顺序排查一般都能快速定位。6. 进阶表达式预编译与安全上下文的落地习惯SpEL默认是解释执行但你可以在求值前调用expression.compileExpression()让SpEL生成字节码显著提升循环内的执行性能。这个编译不是必须的而且编译失败时会回退为解释模式所以我的做法是先让规则在预发环境跑一段时间确认语法和类型都稳定后再开启编译。编译模式下对根对象的字段访问会生成快速路径但每次setRootObject替换不同类时编译结果会失效所以不要把编译和频繁变更根对象类型的逻辑混在一起。还有一个容易被忽略的细节是StandardEvaluationContext里的TypeLocator。如果上下文开启了编译并且表达式访问了T()类型SpEL需要加载对应Class这个动作在安全受限环境里会被拦截。我的习惯是非信任来源的表达式一律用SimpleEvaluationContext并显式允许的方法白名单放在一个静态集合里由统一入口加载。在实际项目中我会把SpEL封装成一个独立的规则服务Component public class RuleEvaluator { private final SpelExpressionParser parser new SpelExpressionParser(); private final ConcurrentHashMapString, Expression cache new ConcurrentHashMap(); public T T evaluate(String rule, Object root, ClassT targetType) { Expression expr cache.computeIfAbsent(rule, parser::parseExpression); StandardEvaluationContext ctx new StandardEvaluationContext(); ctx.setRootObject(root); return expr.getValue(ctx, targetType); } }这段代码常见于中小团队的规则引擎雏形。缓存Map只增不减如果规则频繁变动会造成内存缓慢泄漏我后来会加一个带容量上限的LRU缓存或者直接接Caffeine。从那以后我每次设计新的表达式入口强制走一遍这个清单输入源是否可信、是否用了SimpleEvaluationContext、解析器是否缓存、表达式是否允许T()、异常时是否有人工排查日志。这套习惯帮我挡掉了不少线上事故希望帮到你。本文还有配套的精品资源点击获取