Skip to content

27SpEL解决了哪些问题?

实际工作中,我们经常会在一些注解中使用 SpEL 表达式,当然在 JPA 里也不例外,如果想知道它在 JPA 中的使用详情,必须要先从了解开始。那么这一讲,我们就来聊聊 SpEL 表达式相关知识。

SpEL 基础语法

SpEL 大纲

SpEL 的全称为 Spring Expression Language,即 Spring 表达式语言,是 Spring framework 里面的核心项目。我们先来看一下 spring-expression 的 jar 包的引用关系,如下图所示。

从核心引用来看,SpEL 贯穿所有 Spring 的核心功能。当然了,SpEL 可以脱离 Spring 工程独立使用,其项目里有三个重要的接口:ExpressionParser、Expression、EvaluationContext,我从官方文档中找了一张图来说明它们之间的关系。

注:图片来自网络

ExpressionParser

它是 SpEL 的处理接口,默认实现类是 SpelExpressionParser,对外提供的只有两个方法,如下述代码所示。

java
public interface ExpressionParser {
   // 根据传入的表达式生成Expression
   Expression parseExpression(String expressionString) throws ParseException;
   // 根据传入的表达式和ParserContext生成Expression对象
   Expression parseExpression(String expressionString, ParserContext context) throws ParseException;
}

我们可以看到,这两个方法的目的都是生成 Expression。

Expression

它默认的实现是 SpELExpression,主要对外提供的接口就是根据表达式获得表达式响应的结果,如下图所示。

而它的这些方法中,最重的一个参数就是 EvaluationContext。

EvaluationContext

表示解析 String 表达式所需要的上下文,例如寻找 ROOT 是谁,反射解析的 Method、Field、Constructor 的解析器和取值所需要的上下文。我们看一下其接口提供的方法,如下图所示。

现在对这三个接口有了初步认识之后,我们通过实例来看一下基本用法。

SpEL 的基本用法

下面是一个 SpEL 基本用法的例子,你可以结合注释来理解。

java
//ExpressionParser是操作SpEL的总入口,创建一个接口ExpressionParser对应的实例SpelExpressionParser
ExpressionParser parser = new SpelExpressionParser();
//通过上面我们讲的parser.parseExpression方法获得一个Expression的实例,里面实现的就是new一个SpelExpression对象;而parseExpression的参数就是SpEL的使用重点,各种表达式的字符串
//1.简单的string类型用'' 引用
Expression exp = parser.parseExpression("'Hello World'");
//2.SpEL支持很多功能特性,如调用方法、访问属性、调用构造函数,我们可以直接调用String对象里面的concat方法进行字符串拼接
Expression exp = parser.parseExpression("'Hello World'.concat('!')");
//通过getValue方法可以得到经过Expresion计算parseExpression方法的字符串参数(符合SpEL语法的表达式)的结果
String message = (String) exp.getValue();

而访问属性值如下所示。

java
//3.invokes getBytes()方法
Expression exp = parser.parseExpression("'Hello World'.bytes");
byte[] bytes = (byte[]) exp.getValue(); //得到 byte[]类型的结果

SpEL 字符串表达式还支持使用"."进行嵌套属性 prop1.prop2.prop3 访问,代码如下。

java
// invokes getBytes().length
Expression exp = parser.parseExpression("'Hello World'.bytes.length");
int length = (Integer) exp.getValue();

访问构造方法,例如字符串的构造方法,如下所示。

java
Expression exp = parser.parseExpression("new String('hello world').toUpperCase()");
String message = exp.getValue(String.class);

我们也可以通过 EvaluationContext 来配置一些根元素,代码如下。

java
//我们通过一个Expression表达式想取name属性对应的值
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("name");
//我们通过EvaluationContext设置rootObject等于我们new的UserInfo对象
UserInfo rootUserInfo = UserInfo.builder().name("jack").build();
EvaluationContext context = new StandardEvaluationContext(rootUserInfo);
//getValue根据我们设置context取值,可以得到jack字符串
String name = (String) exp.getValue(context);
//我们也可以利用SpEL的表达式进行运算,判断名字是否等于字符串Nikola
Expression exp2 = parser.parseExpression("name == 'Nikola'");
boolean result2 = exp2.getValue(context, Boolean.class); // 根据我们UserInfo的rootObject得到false

我们在看 SpelExpressionParser 的构造方法时,会发现其还支持一些配置,例如我们经常遇到空指针异常和下标越界的问题,就可以通过 SpelParserConfiguration 配置:当 Null 的时候自动初始化,当 Collection 越界的时候自动扩容增加。我们看一下例子,如下所示。

java
//构造一个Class,方便测试
class MyUser {
    public List<String> address;
}
//开启自动初始化null和自动扩容collection
SpelParserConfiguration config = new SpelParserConfiguration(true,true);
//利用config生成ExpressionParser的实例
ExpressionParser parser = new SpelExpressionParser(config);
//我们通过表达式取这个用户的第三个地址
Expression expression = parser.parseExpression("address[3]");
MyUser demo = new MyUser(); 
//new一个对象,但是没有初始化MyUser里面的address,由于我们配置了自动初始化和扩容,所以通过下面的计算,没有得到异常,o可以得到一个空的字符串
Object o = expression.getValue(demo);// 空字符串

通过上面的介绍,你大概就知道 SpEL 是什么意思了,也知道了该怎么单独使用它,其实不难理解。不过 SpEL 的功能远不止这么简单,我们通过在 Spring 中常见的应用场景,看一下它还有哪些功能。

SpEL 在 Spring 中常见的使用场景

SpEL 在 @Value 里面的用法最常见,我们通过 @Value 来了解一下。

@Value 的应用场景

新建一个 DemoProperties 对象,用 Spring 装载,测试一下两个语法点:运算符和 Map、List。

**第一个语法:通过 @Value 展示 SpEL 里面支持的各种运算符的写法。**如下面的表格所示。

类型操作符
逻辑运算+, -, *, /, %, ^, div, mod
逻辑比较符号<, >, ==, !=, <=, >=, lt, gt, eq, ne, le, ge
逻辑关系and, or, not, &&, ||, !
三元表达式?:
正则表达式matches

我们通过四部分代码展示一下 SpEL 里面支持的各种运算符,用法如下所示。

java
@Data
@ToString
@Component //通过@Value使用SpEL的地方,一定要将此对象交由Spring进行管理
public class DemoProperties {
//第一部分:逻辑运算操作
    @Value("#{19 + 1}") // 20
    private double add;
    @Value("#{'String1 ' + 'string2'}") // "String1 string2"
    private String addString;
    @Value("#{20 - 1}") // 19
    private double subtract;
    @Value("#{10 * 2}") // 20
    private double multiply;
    @Value("#{36 / 2}") // 19
    private double divide;
    @Value("#{36 div 2}") // 18, the same as for / operator
    private double divideAlphabetic;
    @Value("#{37 % 10}") // 7
    private double modulo;
    @Value("#{37 mod 10}") // 7, the same as for % operator
    private double moduloAlphabetic;
// 第二部分:逻辑比较符号
    @Value("#{1 == 1}") // true
    private boolean equal;
    @Value("#{1 eq 1}") // true
    private boolean equalAlphabetic;
    @Value("#{1 != 1}") // false
    private boolean notEqual;
    @Value("#{1 ne 1}") // false
    private boolean notEqualAlphabetic;
    @Value("#{1 < 1}") // false
    private boolean lessThan;
    @Value("#{1 lt 1}") // false
    private boolean lessThanAlphabetic;
    @Value("#{1 <= 1}") // true
    private boolean lessThanOrEqual;
    @Value("#{1 le 1}") // true
    private boolean lessThanOrEqualAlphabetic;
    @Value("#{1 > 1}") // false
    private boolean greaterThan;
    @Value("#{1 gt 1}") // false
    private boolean greaterThanAlphabetic;
    @Value("#{1 >= 1}") // true
    private boolean greaterThanOrEqual;
    @Value("#{1 ge 1}") // true
    private boolean greaterThanOrEqualAlphabetic;
//第三部分:逻辑关系运算符    
    @Value("#{250 > 200 && 200 < 4000}") // true
    private boolean and;
    @Value("#{250 > 200 and 200 < 4000}") // true
    private boolean andAlphabetic;
    @Value("#{400 > 300 || 150 < 100}") // true
    private boolean or;
    @Value("#{400 > 300 or 150 < 100}") // true
    private boolean orAlphabetic;
    @Value("#{!true}") // false
    private boolean not;
    @Value("#{not true}") // false
    private boolean notAlphabetic;    
    
//第四部分:三元表达式 & Elvis运算符
    @Value("#{2 > 1 ? 'a' : 'b'}") // "b"
    private String ternary;
    //demoProperties就是我们通过spring加载的当前对象,
    //我们取spring容器里面的某个bean的属性,
    //这里我们取的是demoProperties对象里面的someProperty属性,
    //如果不为null就直接用,如果为null返回'default'字符串
   @Value("#{demoProperties.someProperty != null ? demoProperties.someProperty : 'default'}")
    private String ternaryProperty;
    /**
     * Elvis运算符是三元表达式简写的方式,和上面一样的结果。如果someProperty为null则返回default值。
     */
    @Value("#{demoProperties.someProperty ?: 'default'}")
    private String elvis;
    /**
     * 取系统环境的属性,如果系统属性pop3.port已定义会直接注入,如果未定义,则返回默认值25。systemProperties是spring容器里面的systemProperties实体;
     */
    @Value("#{systemProperties['pop3.port'] ?: 25}")
    private Integer port;
    /**
     * 还可以用于安全引用运算符主要为了避免空指针,源于Groovy语言。
     * 很多时候你引用一个对象的方法或者属性时都需要做非空校验。
     * 为了避免此类问题,使用安全引用运算符只会返回null而不是抛出一个异常。
     */
    //@Value("#{demoPropertiesx?:someProperty}") 
    // 如果demoPropertiesx不为null,则返回someProperty值
    private String someProperty;
    
//第五部分:正则表达式的支持
    @Value("#{'100' matches '\\d+' }") // true
    private boolean validNumericStringResult;
    @Value("#{'100fghdjf' matches '\\d+' }") // false
    private boolean invalidNumericStringResult;
    // 利用matches匹配正则表达式,返回true
    @Value("#{'valid alphabetic string' matches '[a-zA-Z\\s]+' }") 
    private boolean validAlphabeticStringResult;
    @Value("#{'invalid alphabetic string #$1' matches '[a-zA-Z\\s]+' }") // false
    private boolean invalidAlphabeticStringResult;
    //如果someValue只有数字
    @Value("#{demoProperties.someValue matches '\\d+'}") // true 
    private boolean validNumericValue;
    //新增一个空的someValue属性方便测试
    private String someValue="";
}

我们可以通过 @Value 测试各种 SpEL 的表达式,这和放在 parser.parseExpression("SpEL 的表达式字符串"); 里面的效果是一样的。我们可以写一个测试用例来看一下,如下所示。

java
@ExtendWith(SpringExtension.class)
@Import(TestConfiguration.class)
@ComponentScan(value = "com.example.jpa.demo.config.DemoProperties")
public class DemoPropertiesTest {
    @Autowired(required = false)
    private DemoProperties demoProperties;
    @Test
    public void testSpel() {
        //通过测试用例就可以测试@Value里面不同表达式的值了
        System.out.println(demoProperties.toString());
    }
    @TestConfiguration
    static class TestConfig {
        @Bean
        public DemoProperties demoProperties () {
            return new DemoProperties();
        }
    }
}

或者你可以启动一下项目,也能看到结果。

下面我们通过源码来分析一下 @Value 的解析原理。Spring 项目启动的时候会根据 @Value 的注解,去加载 SpelExpressionResolver 及算出来需要的 StandardEvaluationContext,然后再调用 Expression 方法进行 getValue 操作,其中计算 StandardEvaluationContext 的关键源码如下面两张图所示。

第二个语法:@Value 展示了 SpEL 可以直接读取 Map 和 List 里面的值,代码如下所示。

java
//我们通过@Component加载一个类,并且给其中的List和Map附上值
@Component("workersHolder")
public class WorkersHolder {
    private List<String> workers = new LinkedList<>();
    private Map<String, Integer> salaryByWorkers = new HashMap<>();
    public WorkersHolder() {
        workers.add("John");
        workers.add("Susie");
        workers.add("Alex");
        workers.add("George");
        salaryByWorkers.put("John", 35000);
        salaryByWorkers.put("Susie", 47000);
        salaryByWorkers.put("Alex", 12000);
        salaryByWorkers.put("George", 14000);
    }
    //Getters and setters ...
}
//SpEL直接读取Map和List里面的值
@Value("#{workersHolder.salaryByWorkers['John']}") // 35000
private Integer johnSalary;
@Value("#{workersHolder.salaryByWorkers['George']}") // 14000
private Integer georgeSalary;
@Value("#{workersHolder.salaryByWorkers['Susie']}") // 47000
private Integer susieSalary;
@Value("#{workersHolder.workers[0]}") // John
private String firstWorker;
@Value("#{workersHolder.workers[3]}") // George
private String lastWorker;
@Value("#{workersHolder.workers.size()}") // 4
private Integer numberOfWorkers;

以上就是 SpEL 的运算符和对 Map、List、SpringBeanFactory 里面的 Bean 的调用情况,不知道你是否掌握了?那么使用 @Value 都有哪些需要注意的呢?

@Value 使用的注意事项 # 与 $ 的区别

SpEL 表达式默认以 # 开始,以大括号进行包住,如 #{expression}。默认规则在 ParserContext 里面设置,我们也可以自定义,但是一般建议不要动。

这里注意要与 Spring 中的 Properties 进行区别,Properties 相关的表达式是以 $ 开始的大括号进行包住的,如 ${property.name}。

也就是说 @Value 的值有两类:

  • ${ property**:**default_value }

  • #{ obj.property**? :**default_value }

第一个注入的是外部参数对应的 Property,第二个则是 SpEL 表达式对应的内容。

而 Property placeholders 不能包含 SpEL 表达式,但是 SpEL 表达式可以包含 Property 的引用。如 #{${someProperty} + 2},如果 someProperty=1,那么效果将是 #{ 1 + 2},最终的结果将是 3。

上面我们通过 @Value 的应用场景讲解了一部分 SpEL 的语法,此外它同样适用于 @Query 注解,那么我们通过 @Query 再学习一些 SpEL 的其他语法。

JPA 中 @Query 的应用场景

SpEL 除了能在 @Value 里面使用外,也能在 @Query 里使用,而在 @Query 里还有一个特殊的地方,就是它可以用来取方法的参数。

通过 SpEL 取被 @Query 注解的方法参数

在 @Query 注解中使用 SpEL 的主要目的是取方法的参数,主要有三种用法,如下所示。

java
//用法一:根据下标取方法里面的参数
@Query("select u from User u where u.age = ?#{[0]}") 
List<User> findUsersByAge(int age);
//用法二:#customer取@Param("customer")里面的参数
@Query("select u from User u where u.firstname = :#{#customer.firstname}")
List<User> findUsersByCustomersFirstname(@Param("customer") Customer customer);
//用法三:用JPA约定的变量entityName取得当前实体的实体名字
@Query("from #{#entityName}")
List<UserInfo> findAllByEntityName();

其中,

  • 方法一可以通过 [0] 的方式,根据下标取到方法的参数;

  • 方法二通过 #customer 可以根据 @Param 注解的参数的名字取到参数,必须通过 ?#{} 和 :#{} 来触发 SpEL 的表达式语法;

  • 方法三通过 #{#entityName} 取约定的实体的名字。

你要注意区别我们在"05 | @Query 解决了什么问题?什么时候应该选择它?"中介绍的取 @Param 的用法:lastname这种方式。

下面我们再来看一个更复杂一点的例子,代码如下。

java
public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
   // JPA约定的变量entityName取得当前实体的实体名字
   @Query("from #{#entityName}")
   List<UserInfo> findAllByEntityName();
   
   //一个查询中既可以支持SpEL也可以支持普通的:ParamName的方式
   @Modifying
   @Query("update #{#entityName} u set u.name = :name where u.id =:id")
   void updateUserActiveState(@Param("name") String name, @Param("id") Long id);
   
   //演示SpEL根据数组下标取参数,和根据普通的Parma的名字:name取参数
   @Query("select u from UserInfo u where u.lastName like %:#{[0]} and u.name like %:name%")
   List<UserInfo> findContainingEscaped(@Param("name") String name);
   
   //SpEL取Parma的名字customer里面的属性
   @Query("select u from UserInfo u where u.name = :#{#customer.name}")
   List<UserInfo> findUsersByCustomersFirstname(@Param("customer") UserInfo customer);
   
   //利用SpEL根据一个写死的'jack'字符串作为参数
   @Query("select u from UserInfo u where u.name = ?#{'jack'}")
   List<UserInfo> findOliverBySpELExpressionWithoutArgumentsWithQuestionmark();
   
   //同时SpEL支持特殊函数escape和escapeCharacter
   @Query("select u from UserInfo u where u.lastName like %?#{escape([0])}% escape ?#{escapeCharacter()}")
   List<UserInfo> findByNameWithSpelExpression(String name);
   
   // #entityName和#[]同时使用
   @Query("select u from #{#entityName} u where u.name = ?#{[0]} and u.lastName = ?#{[1]}")
   List<UserInfo> findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntityExpression(String name, String lastName);
   //对于 native SQL同样适用,并且同样支持取pageable分页里面的属性值
   @Query(value = "select * from (" //
         + "select u.*, rownum() as RN from (" //
         + "select * from user_info ORDER BY ucase(firstname)" //
         + ") u" //
         + ") where RN between ?#{ #pageable.offset +1 } and ?#{#pageable.offset + #pageable.pageSize}", //
         countQuery = "select count(u.id) from user_info u", //
         nativeQuery = true)
   Page<UserInfo> findUsersInNativeQueryWithPagination(Pageable pageable);
}

我个人比较推荐使用 @Param 的方式,这样语义清晰,参数换位置了也不影响执行结果。

关于源码的实现,你可以到 ExpressionBasedStringQuery.class 里面继续研究,关键代码如下图所示。

好了,以上就是 @Query 支持的 SpEL 的基本语法,其他场景我就不多列举了。那么其实 JPA 还支持自定义 rootObject,我们看一下。

spring-security-data 在 @Query 中的用法

在实际工作中,我发现有些同事会用 spring-security 做鉴权,详细的 Spring Secrity 如何集成不是我们的重点,我就不多介绍了,具体怎么集成你可以查看官方文档:https://spring.io/projects/spring-security#learn

我想说的是,当我们用 Spring Secrity 的时候,其实可以额外引入 jai 包 spring-security-data。如果我们使用了 JPA 和 Spring Secrity 的话,build.gradle 最终会变成如下形式,请看代码。

java
//引入spring data jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
//集成spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// 集成spring security data对JPA的支持
implementation 'org.springframework.security:spring-security-data'

我们假设继承 Spring Security 之后,SecurityContextHolder 里面放置的 Authentication 是 UserInfo,代码如下。

java
//应用上下文中设置登录用户信息,此时Authentication类型为UserInfo
SecurityContextHolder.getContext().setAuthentication(authentication);

这样 JPA 里面的 @Query 就可以取到当前的 SecurityContext 信息,其用法如下所示。

java
// 根据当前用户email取当前用户的信息
@Query("select u from UserInfo u where u.emailAddress = ?#{principal.email}")
List<UserInfo> findCurrentUserWithCustomQuery();
//如果当前用户是admin,我们就返回某业务的所有对象;如果不是admin角色,就只给当前用户的某业务数据
@Query("select o from BusinessObject o where o.owner.emailAddress like "+
      "?#{hasRole('ROLE_ADMIN') ? '%' : principal.emailAddress}")
List<BusinessObject> findBusinessObjectsForCurrentUser();

我们通过看源码会发现,spring-security-data 就帮我们做了一件事情:实现 EvaluationContextExtension,设置了 SpEL 所需要的 rootObject 为 SecurityExpressionRoot。关键代码如下图所示。

由于 SecurityExpressionRoot 是 rootObject,根据我们上面介绍的 SpEL 的基本用法,SecurityExpressionRoot 里面的各种属性和方法都可以在 SpEL 中使用,如下图所示。

这其实也给了我们一些启发:当需要自动 rootObject 给 @Query 使用的时候,也可以采用这种方式,这样 @Query 的灵活性会增强很多。

最后我们再看看 SpEL 在 @Cacheable 里面做了哪些支持。

SpEL 在 @Cacheable 中的应用场景

我们在实际工作中还有一个经常用到 SpEL 的场景,就是在 Cache 的时候,也就是 Spring Cache 的相关注解里面,如 @Cacheable、@CachePut、@CacheEvict 等。我们还是通过例子来体会一下,代码如下所示。

java
//缓存key取当前方法名,判断一下只有返回结果不为null或者非empty才进行缓存
@Cacheable(value = "APP", key = "#root.methodName", cacheManager = "redis.cache", unless = "#result == null || #result.isEmpty()")
@Override
public Map<String, Map<String, String>> getAppGlobalSettings() {}
//evict策略的key是当前参数customer里面的name属性
@Caching(evict = {
@CacheEvict(value="directory", key="#customer.name") })
public String getAddress(Customer customer) {...}
//在condition里面使用,当参数里面customer的name属性的值等于字符串Tom才放到缓存里面
@CachePut(value="addresses", condition="#customer.name=='Tom'")
public String getAddress(Customer customer) {...}
//用在unless里面,利用SpEL的条件表达式判断,排除返回的结果地址长度小于64的请求
@CachePut(value="addresses", unless="#result.length()<64")
public String getAddress(Customer customer) {...}

Spring Cache 中 SpEL 支持的上下文语法

Spring Cache 提供了一些供我们使用的 SpEL 上下文数据,如下表所示(摘自 Spring 官方文档)。

支持的属性作用域功能描述使用方法
methodNameroot 对象当前被调用的方法名#root.methodName
methodroot 对象当前被调用的方法#root.method.name
targetroot 对象当前被调用的目标对象#root.target
targetClassroot 对象当前被调用的目标对象类#root.targetClass
argsroot 对象当前被调用的方法的参数列表#root.args[0]
cachesroot 对象当前方法调用使用的缓存列表(如@Cacheable(value={"cache1", "cache2"})),则有两个 cache#root.caches[0].name
argument name执行上下文当前被调用的方法的参数,如 findById(Long id),我们可以通过 #id 拿到参数#user.id 表示参数 user 里面的 id
result执行上下文方法执行后的返回值(仅当方法执行之后的判断有效,如'unless','cache evict'的 beforeInvocation=false)#result

有兴趣的话,你可以看一下 Spring Cache 中 SpEL 的 EvaluationContext 加载方式,关键源码如下图所示。

总结

本讲内容到这里就结束了。这一讲我们通过 SpEL 的基本语法介绍,分别介绍了其在 @Value、@Query、@Cache 注解里面的使用场景和方法,其中 # 和 $ 是容易在 @Value 里面犯错的地方;@Param 的用法 : 和 # 也是 @Query 里面容易犯错的地方,你要注意一下。

其实任何形式的 SpEL 的变化都离不开它基本的三个接口:ExpressionParser、Expression、EvaluationContext,只不过框架提供了不同形式的封装,你也可以根据实际场景自由扩展。

关于这一讲内容,希望你能认真去思考,有问题可以在下方留言,我们一起讨论。下一讲我们来聊聊 Hibernate 中一级缓存的概念,到时见。

点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa