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 中查找名称为 idtitlecontentlanguage 的 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,从对象中获取信息,例如 offsetpage 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