返回

解决SpEL EL1007E错误:Spring缓存Key生成方案

java

理解 SpEL 表达式中的 EL1007E 错误

在使用 Spring 的缓存注解时,常常会用到 SpEL(Spring Expression Language)表达式来动态生成缓存键。EL1007E 错误表明 SpEL 表达式在评估过程中无法找到指定属性或字段。当方法执行时,缓存系统尝试读取SpEL表达式中指定的变量,但在当前上下文中,该变量的值为空或不可用,从而引发此异常。本案例的错误在于 @CachePut 注解试图访问一个请求对象的属性,但是该对象为 null。

分析原因:

出现 EL1007E: Property or field 'universalId' cannot be found on null 的原因,是 SpEL 表达式 "#createAccountRequest.universalId" 中的 createAccountRequest 变量为空。 这可能并非请求对象本身为空,而是在缓存的拦截器(AOP) 在调用 createAccount() 方法之前或调用时访问该方法参数的createAccountRequest变量时候发生,它试图访问该对象的属性时由于该变量的值未赋值(为null),故找不到 universalId 字段。 这通常发生在以下情况:

  1. 缓存操作拦截器在方法参数传入之前提前触发 : 当缓存切面(aspect)尝试使用 SpEL 解析 key 之前,可能由于参数值尚未传递而提前拦截并解析表达式,造成空指针访问。例如 AOP 拦截顺序的问题,在参数未初始化时尝试缓存切面的AOP方法先一步执行,尝试访问参数的值就会为null。
  2. 参数为 null 或空对象 : 虽然请求对象理论上应该存在,但在某些极端场景中,请求对象传入到方法中时本身就可能为空。
  3. 上下文问题 : Spring AOP 有时在 AOP 织入或拦截过程中的上下文不同于正常执行,参数对象在其代理对象或者增强切面的上下文中并未正确地传播或初始化,导致 SpEL 尝试解析时发现它是 null

解决方案与实践

为了解决 SpEL EL1007E 异常,通常有以下几种策略。选择何种方案取决于引发错误的具体场景。

方案一:确保方法参数的非空判断

核心思路是确保 createAccountRequest 对象在 @CachePut 注解的 SpEL 表达式评估之前不为空
如果 createAccountRequest 是来自请求或者参数传入,可以通过对参数做 非空校验 从而解决问题,或调整 AOP 切面优先级,使得赋值先执行

  1. 显式添加条件(condition):
    修改 @CachePut 注解,添加 condition 属性来避免表达式对 null 对象的调用。condition 在满足条件的情况下才进行缓存操作,如果createAccountRequestnull , key 则不会计算,也不会执行缓存。

     @CachePut(cacheNames = "account", key = "#createAccountRequest?.universalId", cacheManager = "demoAccountCacheManager", condition = "#createAccountRequest != null")
     public AccountDetails createAccount(CreateAccountRequest createAccountRequest) {
      // ... 省略实现代码
     }
    

    解释: condition = "#createAccountRequest != null" 确保了只有当createAccountRequest不为null 时才执行缓存操作,使用安全的 ?. 运算符避免对 null 执行属性访问。

  2. 确保数据有效性:
    在方法调用前,必须保证 CreateAccountRequest 实例不为 null。 在 Controller 中调用时需要判断该变量是否为空

     public  ResponseEntity<AccountDetails>  handleAccountCreation(CreateAccountRequest createAccountRequest) {
             if (createAccountRequest == null) {
             //返回错误提示
              return  ResponseEntity.badRequest().body(null);
           }
        AccountDetails details= accountService.createAccount(createAccountRequest);
          return   ResponseEntity.ok(details);
        }
    

重要提醒 : 确保方法参数从上一层级进行判断,确保业务代码逻辑不存在任何 null 的潜在情况,从上游阻断出现空指针的问题。

方案二:使用方法级别的缓存而不是返回级别的缓存

缓存应该缓存该方法执行完毕之后的结果,可以理解 @CachePut 是用来更新缓存。而不是提前通过参数值更新缓存的

  1. 切换到 @Cacheable 并结合 @CacheEvict: 考虑先缓存方法的返回结果(而不是尝试基于请求体),并在更新数据之后清除旧缓存(通常在数据修改完成后) 。使用 @Cacheable 实现缓存结果
@Cacheable(cacheNames = "account", key = "#result.universalId", cacheManager = "demoAccountCacheManager", condition = "#result != null")
    public AccountDetails createAccount(CreateAccountRequest createAccountRequest) {
        //... 执行业务逻辑

         AccountDetails accountDetails = new AccountDetails.Builder()
             .universalId(createAccountRequest.getUniversalId())
             .accountId(BigDecimal.valueOf(437))
             .clientId(BigDecimal.valueOf(752))
             .fileRolePresent(createAccountRequest.getUniversalId() != Keys.ACCOUNT_WITHOUT_EFILING_ROLE)
             .cardRegistered(true)
             .create();

         this.accountDetailsCache.put(accountDetails);

         return accountDetails;

 }
 @CacheEvict(cacheNames="account", key = "#accountDetails.universalId")
    public void updateAccountDetails(AccountDetails accountDetails){
          //update database ...
       }
 ```
**解释:**    `@Cacheable`  会缓存方法的返回值,这里用  `#result.universalId`  作键,`#result != null` 判断返回值避免 `null` 值缓存。 同时提供了 更新操作后的  `@CacheEvict` 注解进行缓存的清理


**重要提示:**  此方案需仔细考虑返回结果的特性,并且避免更新了数据库数据缓存仍然未被刷新

### 方案三:自定义 Key 生成器

使用 Spring Cache 允许定义自定义 `KeyGenerator` 实例,更加精细的控制 `key`  的生成逻辑

1.  **创建自定义 `KeyGenerator` 实现:** 

 ```java
   import org.springframework.cache.interceptor.KeyGenerator;
 import org.springframework.stereotype.Component;
   import java.lang.reflect.Method;

   @Component("accountKeyGenerator")
   public class AccountKeyGenerator  implements KeyGenerator {

     @Override
     public Object generate(Object target, Method method, Object... params) {
           if(params == null || params.length == 0 ||  !(params[0] instanceof CreateAccountRequest)){
               return KeyGenerator.NO_KEY;
             }
            CreateAccountRequest request =(CreateAccountRequest)params[0];

             if(request == null){
                 return KeyGenerator.NO_KEY;
             }
         
              return request.getUniversalId();

     }
 }
**解释:**   首先确保 `params` 非空且参数为  `CreateAccountRequest`  的实例, 并且做了  `null`  值判断 。避免了表达式在初始化的时候由于找不到请求值而导致的  `EL1007E` 问题
  1. 修改 @CachePut 注解:
    @CachePut(cacheNames = "account", keyGenerator ="accountKeyGenerator", cacheManager = "demoAccountCacheManager")
     public AccountDetails createAccount(CreateAccountRequest createAccountRequest) {

    //... 省略业务逻辑

    }
**解释:**  通过  `keyGenerator = "accountKeyGenerator"`   使用自定义  KeyGenerator ,这样能够更灵活控制键的生成方式

注意: 确保 Spring 配置扫描自定义 keyGenerator bean

额外建议:

  • 日志记录:
    添加详细的日志记录,观察何时调用 createAccount,以及createAccountRequest的值是否按预期设置。
  • 单元测试: 为所有使用 @Cacheable@CachePut 的方法编写测试用例,特别包括当入参为 null 或其他边界条件的情况
  • 切面优先级: 如果是由于 AOP 切面执行顺序造成,考虑调整相关切面的优先级,可以使用 Spring AOP @Order 注解或定义更高优先级的切面来确保在需要的时候获得 createAccountRequest 的实际参数值。

解决 SpEL EL1007E 异常的重点在于保证缓存注解尝试访问方法参数值时,对应对象非空且能取到需要的值。上述方案从不同角度提供了思路,结合项目特点选择适当的解决方案能帮助高效解决问题,并确保应用的健壮性和性能。