1. 概述

在本教程中,我们将探讨如何使用 Spring Data JPA 和 Querydsl 构建一个 REST API 的查询语言。

在本系列前两篇文章中,我们分别使用了 JPA Criteria 和 Spring Data JPA 的 Specifications 实现了类似的搜索/过滤功能。

那么问题来了:为什么需要一个查询语言?

因为对于一个足够复杂的 API 来说,仅仅通过简单的字段进行资源的搜索和过滤是远远不够的。一个查询语言更加灵活,可以让你精确地筛选出你真正需要的资源。


2. Querydsl 配置

首先,我们来看看如何在项目中配置 Querydsl。

你需要在 pom.xml 中添加以下依赖:

<dependency> 
    <groupId>com.querydsl</groupId> 
    <artifactId>querydsl-apt</artifactId> 
    <version>4.2.2</version>
</dependency>
<dependency> 
    <groupId>com.querydsl</groupId> 
    <artifactId>querydsl-jpa</artifactId> 
    <version>4.2.2</version> 
</dependency>

同时,还需要配置 APT(Annotation Processing Tool)插件:

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>target/generated-sources/java</outputDirectory>
                <processor>com.mysema.query.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

✅ 这个插件会在编译时自动生成实体类的 Q-type 类,例如 QMyUser,用于构建类型安全的查询。


3. MyUser 实体类

接下来,我们定义一个用于搜索的实体类 MyUser

@Entity
public class MyUser {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String firstName;
    private String lastName;
    private String email;

    private int age;
}

这个实体类非常简单,包含用户的基本信息,如姓名、邮箱和年龄。


4. 使用 PathBuilder 构建自定义 Predicate

为了实现灵活的动态查询,我们可以使用 PathBuilder 构建通用的 Predicate

下面是一个 MyUserPredicate 类的实现:

public class MyUserPredicate {

    private SearchCriteria criteria;

    public BooleanExpression getPredicate() {
        PathBuilder<MyUser> entityPath = new PathBuilder<>(MyUser.class, "user");

        if (isNumeric(criteria.getValue().toString())) {
            NumberPath<Integer> path = entityPath.getNumber(criteria.getKey(), Integer.class);
            int value = Integer.parseInt(criteria.getValue().toString());
            switch (criteria.getOperation()) {
                case ":":
                    return path.eq(value);
                case ">":
                    return path.goe(value);
                case "<":
                    return path.loe(value);
            }
        } 
        else {
            StringPath path = entityPath.getString(criteria.getKey());
            if (criteria.getOperation().equalsIgnoreCase(":")) {
                return path.containsIgnoreCase(criteria.getValue().toString());
            }
        }
        return null;
    }
}

⚠️ 注意:这里我们没有使用生成的 Q-type,而是使用 PathBuilder 来动态构建路径,这样更适用于抽象和通用的查询场景。

配套的 SearchCriteria 类如下:

public class SearchCriteria {
    private String key;
    private String operation;
    private Object value;
}

字段含义如下:

  • key:字段名,例如 firstName, age
  • operation:操作符,如 :, >, <
  • value:字段值,例如 john, 25

5. MyUserRepository 接口

为了让我们的 Repository 支持 Querydsl 的 Predicate 查询,需要让它继承 QuerydslPredicateExecutor

public interface MyUserRepository extends JpaRepository<MyUser, Long>, 
  QuerydslPredicateExecutor<MyUser>, QuerydslBinderCustomizer<QMyUser> {
    
    @Override
    default public void customize(
      QuerydslBindings bindings, QMyUser root) {
        bindings.bind(String.class)
          .first((SingleValueBinding<StringPath, String>) StringExpression::containsIgnoreCase);
        bindings.excluding(root.email);
    }
}

✅ 通过 customize() 方法,我们可以自定义绑定规则,比如对字符串字段默认进行不区分大小写的模糊匹配,并排除某些字段(如 email)。


6. 组合多个 Predicate

为了支持多个条件的组合查询,我们创建一个 MyUserPredicatesBuilder

public class MyUserPredicatesBuilder {
    private List<SearchCriteria> params;

    public MyUserPredicatesBuilder() {
        params = new ArrayList<>();
    }

    public MyUserPredicatesBuilder with(
      String key, String operation, Object value) {
        params.add(new SearchCriteria(key, operation, value));
        return this;
    }

    public BooleanExpression build() {
        if (params.size() == 0) {
            return null;
        }

        List<BooleanExpression> predicates = params.stream()
          .map(param -> {
              MyUserPredicate predicate = new MyUserPredicate(param);
              return predicate.getPredicate();
          })
          .filter(Objects::nonNull)
          .collect(Collectors.toList());
        
        BooleanExpression result = Expressions.asBoolean(true).isTrue();
        for (BooleanExpression predicate : predicates) {
            result = result.and(predicate);
        }        
        return result;
    }
}

✅ 通过 with() 方法可以链式添加多个查询条件,最终通过 build() 构建出一个组合的 BooleanExpression


7. 测试查询功能

我们通过几个单元测试来验证查询功能是否正常。

初始化数据

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { PersistenceConfig.class })
@Transactional
@Rollback
public class JPAQuerydslIntegrationTest {

    @Autowired
    private MyUserRepository repo;

    private MyUser userJohn;
    private MyUser userTom;

    @Before
    public void init() {
        userJohn = new MyUser();
        userJohn.setFirstName("John");
        userJohn.setLastName("Doe");
        userJohn.setEmail("john.doe@example.com");
        userJohn.setAge(22);
        repo.save(userJohn);

        userTom = new MyUser();
        userTom.setFirstName("Tom");
        userTom.setLastName("Doe");
        userTom.setEmail("tom.doe@example.com");
        userTom.setAge(26);
        repo.save(userTom);
    }
}

测试用例

根据姓氏查询

@Test
public void givenLast_whenGettingListOfUsers_thenCorrect() {
    MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder().with("lastName", ":", "Doe");

    Iterable<MyUser> results = repo.findAll(builder.build());
    assertThat(results, containsInAnyOrder(userJohn, userTom));
}

根据姓氏和名字组合查询

@Test
public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() {
    MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder()
      .with("firstName", ":", "John").with("lastName", ":", "Doe");

    Iterable<MyUser> results = repo.findAll(builder.build());

    assertThat(results, contains(userJohn));
    assertThat(results, not(contains(userTom)));
}

姓氏 + 年龄组合查询

@Test
public void givenLastAndAge_whenGettingListOfUsers_thenCorrect() {
    MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder()
      .with("lastName", ":", "Doe").with("age", ">", "25");

    Iterable<MyUser> results = repo.findAll(builder.build());

    assertThat(results, contains(userTom));
    assertThat(results, not(contains(userJohn)));
}

查询不存在的用户

@Test
public void givenWrongFirstAndLast_whenGettingListOfUsers_thenCorrect() {
    MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder()
      .with("firstName", ":", "Adam").with("lastName", ":", "Fox");

    Iterable<MyUser> results = repo.findAll(builder.build());
    assertThat(results, emptyIterable());
}

模糊匹配名字

@Test
public void givenPartialFirst_whenGettingListOfUsers_thenCorrect() {
    MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder().with("firstName", ":", "jo");

    Iterable<MyUser> results = repo.findAll(builder.build());

    assertThat(results, contains(userJohn));
    assertThat(results, not(contains(userTom)));
}

8. 构建 REST 接口:UserController

最后,我们将所有功能整合进一个 REST 接口:

@Controller
public class UserController {

    @Autowired
    private MyUserRepository myUserRepository;

    @RequestMapping(method = RequestMethod.GET, value = "/myusers")
    @ResponseBody
    public Iterable<MyUser> search(@RequestParam(value = "search") String search) {
        MyUserPredicatesBuilder builder = new MyUserPredicatesBuilder();

        if (search != null) {
            Pattern pattern = Pattern.compile("(\w+?)(:|<|>)(\w+?),");
            Matcher matcher = pattern.matcher(search + ",");
            while (matcher.find()) {
                builder.with(matcher.group(1), matcher.group(2), matcher.group(3));
            }
        }
        BooleanExpression exp = builder.build();
        return myUserRepository.findAll(exp);
    }
}

✅ 这个接口支持如下格式的查询参数:

http://localhost:8080/myusers?search=lastName:doe,age>25

返回结果示例:

[{
    "id":2,
    "firstName":"tom",
    "lastName":"doe",
    "email":"tom.doe@example.com",
    "age":26
}]

9. 总结

本篇文章介绍了如何使用 Spring Data JPA 和 Querydsl 构建一个灵活的 REST 查询语言。

我们通过 SearchCriteriaPathBuilder 实现了动态构建查询条件,并结合 QuerydslPredicateExecutor 实现了多条件组合查询。

虽然当前实现较为基础,但已经具备良好的扩展性,可以轻松支持更多操作符和字段类型。

完整代码可在 GitHub 项目 中找到。项目基于 Maven,可直接导入运行。

✅ 踩坑提醒:

  • 注意 Querydsl 生成 Q-type 的配置是否正确,否则编译时会报找不到类
  • 正则表达式要确保能正确解析查询字符串
  • 注意 containsIgnoreCase 的使用场景和性能问题


原始标题:REST Query Language with Spring Data JPA and Querydsl

« 上一篇: Baeldung周报5