[034][公共模块]基于SpEL的方法参数表达式求值器设计与实现
本文章代码: gitee , gitcode , github
一、概述
在Spring框架驱动的应用开发中,动态表达式求值是一个常见需求,例如权限注解(@PreAuthorize)、缓存注解(@Cacheable)、日志模板等场景,都需要根据当前方法的调用上下文(方法名、参数值、返回值等)计算表达式结果。
本文介绍的 MethodBasedExpressionEvaluator 是一套优雅的抽象设计,其实现 SpelMethodBasedExpressionEvaluator 基于Spring的SpEL(Spring Expression Language)引擎,将目标方法的参数、Bean容器、自定义变量等无缝暴露给表达式,实现高内聚、低耦合的动态求值能力。
二、核心功能
该组件的核心功能可以概括为:
- 方法上下文感知:自动将目标方法的参数名(通过参数名发现器获取)映射为表达式中的变量,例如表达式
#userId可直接访问名为userId的方法参数。 - 占位符解析:支持表达式中的占位符(如
${app.timeout}),通过EmbeddedValueResolverAware机制解析外部配置。 - Bean引用:表达式可引用Spring容器中的Bean,例如
@userService.findName(#id)。 - 额外变量注入:允许调用方传入自定义的变量映射,扩展表达式可访问的上下文。
- 表达式缓存:对解析后的SpEL表达式对象进行缓存,避免重复解析开销。
- 类型安全求值:直接返回指定类型的求值结果,减少强制类型转换。
三、接口设计分析
public interface MethodBasedExpressionEvaluator {
<T> T getValue(Method method, Object[] arguments,
String expression, Class<T> resultType,
@NonNull Map<String, Object> variables);
default <T> T getValue(Method method, Object[] arguments,
String expression, Class<T> resultType) {
return getValue(method, arguments, expression, resultType, Collections.emptyMap());
}
}
接口设计体现了单一职责和开闭原则:
- 核心方法接收完整的求值上下文(
Method+arguments+variables),保证灵活性。 - 默认方法提供简化版本,方便无需额外变量的场景。
- 泛型方法
T getValue(...)确保调用方可直接获得目标类型结果,无需手动转换。
四、实现类深度解析
4.1 类结构与依赖
public class SpelMethodBasedExpressionEvaluator
implements MethodBasedExpressionEvaluator,
EmbeddedValueResolverAware,
BeanFactoryAware
实现类同时实现了两个Spring感知接口:
EmbeddedValueResolverAware:注入StringValueResolver,用于解析${...}占位符。BeanFactoryAware:注入BeanFactory,创建BeanFactoryResolver使表达式支持@bean引用。
核心组件:
| 组件 | 类型 | 作用 |
|---|---|---|
expressionCache |
Map<String, Expression> |
线程安全的表达式缓存(基于 ConcurrentReferenceHashMap,弱引用防止内存泄漏) |
expressionParser |
SpelExpressionParser |
SpEL解析器,线程安全且可复用 |
parameterNameDiscoverer |
DefaultParameterNameDiscoverer |
从方法字节码或本地变量表获取参数名 |
beanResolver |
BeanResolver |
用于表达式中解析Bean引用 |
embeddedValueResolver |
StringValueResolver |
解析占位符 |
4.2 求值主流程
getValue 方法执行步骤:
- 创建求值上下文:调用
createEvaluationContext(method, arguments)构建MethodBasedEvaluationContext。 - 注入额外变量:将
variables映射中的键值对设置到上下文中(context.setVariable(key, value))。 - 解析表达式:调用
parseExpression完成占位符解析 + SpEL解析 + 缓存。 - 求值并返回:
exp.getValue(context, resultType)返回指定类型结果。
4.3 关键实现细节
4.3.1 方法参数的暴露
MethodBasedEvaluationContext 是Spring提供的专用上下文实现,它根据 Method 对象和实际参数数组,自动将每个参数注册为变量,变量名即参数名。参数名的获取依赖于 ParameterNameDiscoverer,默认实现 DefaultParameterNameDiscoverer 会尝试从以下几种途径获取:
- 调试信息中的局部变量表(需编译时保留
-parameters或-g) - 可选的
@ParameterName注解(Spring 4+) - 回退到
arg0,arg1等占位符
4.3.2 表达式缓存机制
protected Expression parseExpression(String expression, ExpressionParser parser) {
return expressionCache.computeIfAbsent(expression, exp -> {
exp = embeddedValueResolver.resolveStringValue(exp);
Assert.notNull(exp, "Expression must not be null");
return parser.parseExpression(exp);
});
}
- 缓存键为原始表达式字符串(未解析占位符之前)。不同表达式可共享解析结果;但若占位符解析结果因外部配置变化而变化,缓存可能导致不一致——通常占位符代表的应用配置是静态的,因此可以接受。
ConcurrentReferenceHashMap使用弱引用键,避免缓存长期持有表达式字符串导致内存无法回收。
4.3.3 属性访问器扩展
context.addPropertyAccessor(MAP_ACCESSOR);
MapAccessor 使得表达式可以像访问对象属性一样访问 Map 的键值,例如 #myMap.keyName。这增强了表达式对非Bean对象的友好性。
4.3.4 BeanFactoryAware 与 BeanResolver
@Override
public void setBeanFactory(@NonNull BeanFactory beanFactory) {
beanResolver = new BeanFactoryResolver(beanFactory);
}
BeanFactoryResolver 实现了 BeanResolver 接口,支持表达式中使用 @ 前缀引用Bean,例如 @userService.getCurrentUser()。这为表达式提供了调用任何Spring Bean方法的能力。
五、使用示例
5.1 基础用法:访问方法参数
假设有一个服务方法:
public User findUser(Long userId, String name) {
... }
使用求值器:
SpelMethodBasedExpressionEvaluator evaluator = new SpelMethodBasedExpressionEvaluator();
Method method = UserService.class.getMethod("findUser", Long.class, String.class);
Object[] args = {
1001L, "张三"};
Long result = evaluator.getValue(method, args, "#userId + 1", Long.class);
// result = 1002
5.2 带占位符的表达式
配置文件中定义 query.limit=100,表达式为 "${query.limit} > #limit ? #limit : ${query.limit}":
evaluator.setEmbeddedValueResolver(new StandardEnvironment().getPlaceholderResolver(...));
Integer limit = evaluator.getValue(method, new Object[]{
50}, "${query.limit} > #limit ? #limit : ${query.limit}", Integer.class);
// 若 limit 参数为 50,结果为 50(取较小值)
5.3 引用Spring Bean
evaluator.setBeanFactory(applicationContext);
// 表达式调用 Bean 方法
Boolean isValid = evaluator.getValue(method, args, "@validationService.check(#userId)", Boolean.class);
5.4 注入额外变量
Map<String, Object> vars = new HashMap<>();
vars.put("tenantId", "tenant_001");
String tenant = evaluator.getValue(method, args, "#tenantId", String.class, vars);
六、适用场景
| 场景 | 说明 |
|---|---|
| 权限控制 | 在方法拦截器中,根据方法参数和当前用户信息计算是否有权执行 |
| 审计日志 | 动态生成日志内容,例如 "用户 #userId 执行了删除操作" |
| 数据权限 | 在查询方法前动态追加过滤条件,根据参数值决定可见数据范围 |
| 缓存Key生成 | SpEL表达式作为 @Cacheable 的 key 属性,灵活组合参数值 |
| 消息模板 | 根据方法参数填充通知内容 |
七、注意事项与最佳实践
- 参数名可用性:确保编译时保留参数名(
-parameters选项)或使用@ParameterName注解,否则会退化为arg0,arg1等不友好名称。 - 表达式安全:当表达式来自不可信源(如用户输入)时,可能引发恶意表达式注入风险。应使用
SimpleEvaluationContext限制功能,但当前实现使用标准MethodBasedEvaluationContext,需注意控制表达式来源。 - 性能考量:表达式解析结果已缓存,但占位符解析未缓存。如果占位符值频繁变化,应考虑更细粒度的缓存策略。
- Null安全:表达式求值结果可能为
null,调用方需做判空处理。 - 线程安全性:
SpelMethodBasedExpressionEvaluator是无状态的(缓存为ConcurrentHashMap,解析器线程安全),可以被多个线程共享使用。
八、总结
SpelMethodBasedExpressionEvaluator 是一个设计精良的表达式求值组件,它充分利用了Spring SpEL的强大能力,并针对方法调用场景做了深度适配。通过内置的参数暴露、占位符解析、Bean引用、表达式缓存等特性,它为上层框架(如权限控制、缓存、日志等)提供了一个通用、高效、可扩展的求值基础设施。
在实际项目中,可以将其作为Bean配置在Spring容器中,并在需要动态表达式解析的地方(如自定义注解处理器、拦截器)注入使用。该组件也体现了Spring设计哲学:约定优于配置,扩展点开放,内部实现可靠。