Spring Data JPA @Query 注解中的 SpEL 支持
1、概览
SpEL 是 Spring Expression Language(Spring 表达式语言)的缩写,它是一种强大的工具,能显著增强与 Spring 的交互,并为配置、属性设置和查询操作提供额外的抽象。
本文将带你了解如何使用该工具使自定义查询更具动态性,通过 @Query
注解,可以使用 JPQL 或原生 SQL 来定制与数据库的交互。
2、访问参数
首先先看看如何使用 SpEL 处理方法参数。
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 operator,即埃尔维斯运算符)来检查是否提供了内容。虽然可以在查询中编写更复杂的逻辑,但应尽量少用,因为这可能会给调试和验证代码带来问题。
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 语法,但还需要使用 #
来将参数名称与应用 Bean 区分开来。如果省略它,Spring 将尝试在 Context 中查找名称为 id
、title
、content
和 language
的 Bean。
总体而言,该版本与不使用 SpEL 的简单方法非常相似。不过,正如上一节所讨论的,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);
可以使用对象的 public
API 来获取其内部信息。这是一种非常有用的技术,因为它可以保持 Repository 签名的整洁,避免暴露太多信息。它甚至还允许访问嵌套对象。比方说,有一个 ArticleWrapper
,如下:
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、引用实体
减少重复代码是一种很好的做法。然而,自定义查询可能会让这一做法变得具有挑战性。即使有类似的逻辑提取到 Base Repository,但表的名称不同,很难重复使用。
SpEL 为实体名称提供了一个占位符,它可以从 Repository 泛型中推导出实体名称。
创建一个 Base 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
。需要用它来排除实例化 Base Repository。由于它没有具体的泛型,尝试创建这样一个的 Repository 将导致 Context 失败。因此,需要将其排除在外。
使用 JPQL 查询非常简单,它使用指定 Repository 的实体名称:
@Query(value = "select e from #{#entityName} e")
List<Article> findAllEntitiesUsingEntityPlaceholder();
但是,原生查询的情况就没那么简单了。在没有任何额外更改和配置的情况下,它会尝试使用实体名称(在本例中为 Article
)来查找表:
@Query(value = "SELECT * FROM #{#entityName}", nativeQuery = true)
List<Article> findAllEntitiesUsingEntityPlaceholderWithNativeQuery();
但是,数据库中并没有这样一个表。在实体定义中,明确指出了表的名称:
@Entity
@Table(name = "articles") // 表名称
public class Article {
// ...
}
要解决这个问题,可以将表名和实体名称设置为一样:
@Entity(name = "articles") // 实体名称
@Table(name = "articles") // 表名称
public class Article {
// ...
}
在这种情况下,JPQL 和原生查询都会推断出正确的实体名称,就可以在应用的所有实体中重复使用相同的 Base 查询。
4、添加 SpEL Context
如前所述,在引用参数或占位符时,必须在其名称前添加 #
。这样做是为了区分 Bean 名称和参数名称。
但是,不能在查询中直接使用 Spring Context 中的 Bean。IDE 通常会提供有关 Context 中 Bean 的提示,但 Context 会失效。出现这种情况是因为 @Value
和类似注解与 @Query
的处理方式不同。可以从前者的 Context 中引用 Bean,但后者却不行。
但是,可以使用 EvaluationContextExtension
在 SpEL Context 中注册 Bean,这样就可以在 @Query
中使用它们。
设想一下下面的情况 - 需要从数据库中查找所有文章,但要根据用户的 locale 设置对它们进行过滤:
@Query(value = "SELECT * FROM articles WHERE language = :#{locale.language}", nativeQuery = true)
List<Article> findAllArticlesUsingLocaleWithNativeQuery();
这种查询会失败,因为默认情况下无法访问 locale。需要提供自定义的 EvaluationContextExtension
,以保存用户的 locale 信息:
@Component
public class LocaleContextHolderExtension implements EvaluationContextExtension {
@Override
public String getExtensionId() {
return "locale";
}
@Override
public Locale getRootObject() {
return LocaleContextHolder.getLocale();
}
}
可以使用 LocaleContextHolder
访问应用中任何地方的当前 locale。唯一需要注意的是,它与用户请求绑定,在此范围外无法访问。我们需要提供 root 对象和名称。还可以选择添加属性和函数,但在本例中我们只使用 root 对象。
在 @Query
中使用 locale 之前,还需要注册 locale Interceptor:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("locale");
registry.addInterceptor(localeChangeInterceptor);
}
}
如上,添加了要跟踪的参数信息,只要请求中包含了 locale 参数,Context 中的 locale 信息就会被更新。
通过在请求中提供 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
注解时。
5、总结
SpEL 是一个功能强大的工具,与所有功能强大的工具一样,人们往往会过度使用它,并试图只用它来解决所有问题。只有在必要的情况下,才合理地使用复杂的表达式。
虽然集 IDE 提供 SpEL 支持和高亮显示,但复杂的逻辑可能会隐藏难以调试和验证的错误。因此,应尽量少用 SpEL,避免使用 “智能代码”,这些代码最好用 Java 来表达,而不是隐藏在 SpEL 中。
Ref:https://www.baeldung.com/spring-data-query-definitions-spel