1. 概述
SpEL(Spring Expression Language)是 Spring 提供的强大表达式语言,能显著增强我们与 Spring 框架的交互能力,为配置、属性设置和查询操作提供额外的抽象层。
本教程将学习如何利用 SpEL 让自定义查询更动态化,并在 Repository 层隐藏数据库特定操作。我们将重点使用 @Query
注解,它允许我们通过 JPQL 或原生 SQL 定制数据库交互逻辑。
2. 访问参数
2.1. 通过索引访问参数
⚠️ 通过索引访问参数不是最佳实践,当参数类型相同时可能引入难以调试的问题。
但在开发阶段参数名频繁变更时,它提供了更高的灵活性——IDE 可能无法正确同步代码和查询中的参数名更新。
JDBC 提供了 ?
占位符标识参数位置,Spring 延续了这一约定:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (?1, ?2, ?3, ?4)",
nativeQuery = true)
void saveWithPositionalArguments(Long id, String title, String content, String language);
目前没有特殊之处,沿用了 JDBC 的传统方式。注意:@Modifying
和 @Transactional
是修改数据库操作的必需注解(如 INSERT),且 INSERT 必须使用原生查询(JPQL 不支持)。
用 SpEL 重写上述查询:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (?#{[0]}, ?#{[1]}, ?#{[2]}, ?#{[3]})",
nativeQuery = true)
void saveWithPositionalSpELArguments(long id, String title, String content, String language);
结果类似但更冗长。不过既然是 SpEL,它提供了更丰富的功能,例如条件判断:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (?#{[0]}, ?#{[1]}, ?#{[2] ?: 'Empty Article'}, ?#{[3]})",
nativeQuery = true)
void saveWithPositionalSpELArgumentsWithEmptyCheck(long id, String title, String content, String isoCode);
这里使用了 Elvis 运算符检查 content 是否为空。虽然可以写更复杂的逻辑,但应谨慎使用——过度使用会带来调试和代码验证的噩梦。
2.2. 通过名称访问参数
✅ 更推荐的方式是使用命名占位符,它通常匹配参数名(非强制)。这也是 JDBC 的约定,命名参数用 :name
标识:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (:id, :title, :content, :language)",
nativeQuery = true)
void saveWithNamedArguments(@Param("id") long id, @Param("title") String title,
@Param("content") String content, @Param("isoCode") String language);
关键点:需确保 Spring 能识别参数名。有两种方式:
- 隐式:编译时使用
-parameters
标志 - 显式:使用
@Param
注解(推荐,避免编译问题)
用 SpEL 重写:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (:#{#id}, :#{#title}, :#{#content}, :#{#language})",
nativeQuery = true)
void saveWithNamedSpELArguments(@Param("id") long id, @Param("title") String title,
@Param("content") String content, @Param("language") String language);
标准 SpEL 语法,但需用 #
区分参数名和 Spring Bean。若省略 #
,Spring 会尝试在上下文中查找名为 id
/title
/content
/language
的 Bean。
虽然与普通方式相似,但 SpEL 提供了更多能力,例如调用对象方法:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (:#{#id}, :#{#title}, :#{#content}, :#{#language.toLowerCase()})",
nativeQuery = true)
void saveWithNamedSpELArgumentsAndLowerCaseLanguage(@Param("id") long id, @Param("title") String title,
@Param("content") String content, @Param("language") String language);
可调用 String.toLowerCase()
等方法,实现条件逻辑、字符串拼接等。但过度使用会让 @Query
变得臃肿,容易把业务逻辑泄露到基础设施代码中。
2.3. 访问对象字段
前两种方式本质是 JDBC 预编译查询的延伸,而本节展示更面向对象的原生查询用法。SpEL 允许直接访问对象字段:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (:#{#article.id}, :#{#article.title}, :#{#article.content}, :#{#article.language})",
nativeQuery = true)
void saveWithSingleObjectSpELArgument(@Param("article") Article article);
通过对象公共 API 获取内部字段。这种技巧能保持 Repository 方法签名整洁,避免暴露过多细节,甚至支持嵌套对象访问。例如定义包装类:
public class ArticleWrapper {
private final Article article;
public ArticleWrapper(Article article) {
this.article = article;
}
public Article getArticle() {
return article;
}
}
在查询中使用:
@Modifying
@Transactional
@Query(value = "INSERT INTO articles (id, title, content, language) "
+ "VALUES (:#{#wrapper.article.id}, :#{#wrapper.article.title}, "
+ ":#{#wrapper.article.content}, :#{#wrapper.article.language})",
nativeQuery = true)
void saveWithSingleWrappedObjectSpELArgument(@Param("wrapper") ArticleWrapper articleWrapper);
SpEL 中可将参数视为 Java 对象,访问任意字段或方法。同样可添加逻辑和方法调用。
额外技巧:结合 Pageable
对象获取分页信息(如 offset/page size)注入原生查询。但 Sort
对象结构复杂,直接使用较困难。
3. 引用实体名称
减少重复代码是良好实践,但自定义查询可能让此目标难以实现——即使提取公共逻辑到基础 Repository,不同表名也会阻碍复用。
SpEL 提供了实体名称占位符 #{#entityName}
,它会根据 Repository 泛型自动推断。创建基础 Repository:
@NoRepositoryBean
public interface BaseNewsApplicationRepository<T, ID> extends JpaRepository<T, ID> {
@Query(value = "select e from #{#entityName} e")
List<Article> findAllEntitiesUsingEntityPlaceholder();
@Query(value = "SELECT * FROM #{#entityName}", nativeQuery = true)
List<Article> findAllEntitiesUsingEntityPlaceholderWithNativeQuery();
}
需添加额外注解:
@NoRepositoryBean
:阻止 Spring 实例化此基础接口(因无具体泛型,实例化会报错)
JPQL 查询直接使用实体名:
@Query(value = "select e from #{#entityName} e")
List<Article> findAllEntitiesUsingEntityPlaceholder();
但原生查询有坑:默认会尝试用实体名(如 Article
)查找表:
@Query(value = "SELECT * FROM #{#entityName}", nativeQuery = true)
List<Article> findAllEntitiesUsingEntityPlaceholderWithNativeQuery();
而数据库中实际表名可能不同(通过 @Table
指定):
@Entity
@Table(name = "articles")
public class Article {
// ...
}
解决方案:让实体名与表名一致:
@Entity(name = "articles")
@Table(name = "articles")
public class Article {
// ...
}
这样 JPQL 和原生查询都能正确推断实体名,实现基础查询复用。
4. 扩展 SpEL 上下文
引用参数或占位符时需添加 #
前缀,以区分 Bean 名称。但 @Query
中无法直接使用 Spring 上下文中的 Bean(IDE 可提示但运行会报错),因为 @Value
和 @Query
的处理机制不同。
可通过 EvaluationContextExtension
注册 Bean 到 SpEL 上下文。假设需根据用户语言环境过滤文章:
@Query(value = "SELECT * FROM articles WHERE language = :#{locale.language}", nativeQuery = true)
List<Article> findAllArticlesUsingLocaleWithNativeQuery();
默认无法访问 locale
。需自定义扩展类:
@Component
public class LocaleContextHolderExtension implements EvaluationContextExtension {
@Override
public String getExtensionId() {
return "locale";
}
@Override
public Locale getRootObject() {
return LocaleContextHolder.getLocale();
}
}
使用 LocaleContextHolder
获取当前语言环境。注意:它绑定到用户请求作用域,超出此范围不可用。
还需注册语言环境拦截器:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("locale");
registry.addInterceptor(localeChangeInterceptor);
}
}
当请求包含 locale
参数时,上下文语言环境会更新。测试用例:
@ParameterizedTest
@CsvSource({"eng,2","fr,2", "esp,2", "deu, 2","jp,0"})
void whenAskForNewsGetAllNewsInSpecificLanguageBasedOnLocale(String language, int expectedResultSize) {
webTestClient.get().uri("/articles?locale=" + language)
.exchange()
.expectStatus().isOk()
.expectBodyList(Article.class)
.hasSize(expectedResultSize);
}
EvaluationContextExtension
能极大增强 SpEL 在 @Query
中的能力,应用场景包括:
- 安全与角色限制
- 功能开关
- 跨 Schema 交互
5. 总结
SpEL 是强大工具,但人们容易过度使用。复杂表达式应合理使用,仅在必要时引入。
尽管 IDE 提供 SpEL 语法高亮,但复杂逻辑可能隐藏难以调试的 Bug。建议:
- ✅ 适度使用 SpEL
- ❌ 避免“炫技代码”——能用 Java 清晰表达的逻辑不要藏在 SpEL 中
本文所有代码可在 GitHub 获取。