👻

Spring Boot と QueryDSL を用いて検索クエリ、ページングを実装してみる

2024/09/30に公開

今回は小さい掲示板機能がある Web Application を作りながら Spring Boot と QueryDSL 学んでいきたいと思います。

準備物

  • Java21
  • Spring Boot 3.x.x
  • JPA
  • QueryDSL 5.x.x

QueryDSL を使う理由

QueryDSL は RDB と Spring Data JPA を使う時は必ず使用される技術になります。

下記はChatGPTに聞いたQueryDSLを使用する理由とメリットです。

  • 型安全なクエリの作成: QueryDSLを使用すると、クエリを文字列ではなく、Javaの型安全なDSL (Domain Specific Language) を使って記述することができます。これにより、コンパイル時にクエリのタイポやシンタックスエラーを検出しやすくなります。

  • IDEのサポート: QueryDSLはIDEによるサポートがあり、コード補完やリファクタリング、シンタックスハイライトなどの恩恵を受けることができます。これにより、開発者は効率的にクエリを記述できます。

  • クエリの再利用性と保守性の向上: QueryDSLを使用すると、クエリを再利用しやすくなります。また、クエリの変更があった場合でも、コンパイル時にエラーが検出されるため、保守性が向上します。

  • SQLインジェクションの防止: QueryDSLはパラメータ化されたクエリを生成するため、SQLインジェクション攻撃からアプリケーションを保護するのに役立ちます。

  • Spring Data JPAとの統合: Spring Bootと組み合わせて使用する場合、Spring Data JPAとシームレスに統合できます。これにより、JPAリポジトリを使用する場合と同様の簡潔なコードで、型安全なクエリを実行できます。

  • リファクタリングのサポート: エンティティやフィールドの名前を変更した場合でも、QueryDSLは自動的に生成されたクエリを対応する名前に更新します。これにより、リファクタリングが容易になります。

検索機能の要件

今回実装しようとすする検索機能の要件です。

  • ページングが可能で、1ページのサイズは基本20とする
  • 技術スタックを複数指定できる
  • 応募ポジションを複数指定できる
  • 進め方を1つ指定できる
  • タイトル名を含んで検索可能とする

QueryDSL を設定する

build.gradleの設定

build.gradle に QueryDSL を追加してください。JPA と一緒に使いますので JPA も追加してください。

dependencies {
    // spring boot
    ...
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    ...

    // query dsl
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

    ...

}

Configクラスの作成

QueryDSLのための構成クラスを作成します。

@Configuration
@RequiredArgsConstructor
public class QueryDslConfig {
    private final EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

機能実装

技術スタックを指定できる

最初は技術スタックを指定して検索できるようにしてみます。

まずはテストを書く

@DisplayName("技術スタック、応募ポジションを選択して検索すると選択した技術スタックと応募ポジションに関連する応募ポストのリストを確認することができる。") 
@Test
void getSearchResultsPositionsOrTechStacks() throws Exception {
    // given
    var recruitments = IntStream.range(0, 50)
            .mapToObj(i ->
                    createRecruitment(TechStack.from("Java" + i), Position.from("position" + i), null, null))
            .toList();
    recruitmentsRepository.saveAll(recruitments);

    var searchString = "positions=position1,position3,position7&techStacks=Java8,Java17,Java21";

    // expected
    mockMvc.perform(MockMvcRequestBuilders.get("/recruitments/query?" + searchString))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.size()", is(6)))
            .andExpect(MockMvcResultMatchers.jsonPath("$[0].techStacks[0]", is("Java21")))
            .andExpect(MockMvcResultMatchers.jsonPath("$[1].techStacks[0]", is("Java17")))
            .andExpect(MockMvcResultMatchers.jsonPath("$[2].techStacks[0]", is("Java8")))
            .andExpect(MockMvcResultMatchers.jsonPath("$[3].positions[0]", is("position7")))
            .andExpect(MockMvcResultMatchers.jsonPath("$[4].positions[0]", is("position3")))
            .andExpect(MockMvcResultMatchers.jsonPath("$[5].positions[0]", is("position1")))
            .andDo(print());
}

テストを動かし Red を見ます。

Red を見ましたので Green を見れるように機能実装します。

Controllerを作成

controller を作成します。

@RequestMapping("/recruitments")
@RestController
@RequiredArgsConstructor
public class RecruitmentsController {

  ...
  private final RecruitmentsService recruitmentsService;

  @GetMapping("/query")
  public List<RecruitmentIndexPageResponse> getRecruitmentsForIndexPage(@ModelAttribute RecruitmentSearch recruitmentSearch) {
      return recruitmentsService.getRecruitments(recruitmentSearch);
  }

  ...

}

request dto である RecruitmentSearch を作成します。

/**
 * expect
 * 
 * ?page=1&size=20&techStacks=kotlin,java,spring&positions=backend,frontend&progressMethods=ALL&search=募集します。
 */

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RecruitmentSearch {

    @Builder.Default
    private Integer page = 1;

    @Builder.Default
    private Integer size = 20;

    private List<String> techStacks;
    private List<String> positions;
    private ProgressMethods progressMethods;
    private String search;

}

Serviceを作成

service を生成します。

@Service
@RequiredArgsConstructor
public class RecruitmentsService {

    private final RecruitmentsRepository recruitmentsRepository;
    ...

    public List<RecruitmentIndexPageResponse> getRecruitments(RecruitmentSearch recruitmentSearch) {
        var recruitments = recruitmentsRepository.getSearchResult(recruitmentSearch);

        return recruitments.stream()
                .map(RecruitmentIndexPageResponse::from)
                .toList();
    }

    ...

}

次は Repository 層を作っていきたいと思いますがQueryDSL を使用しますのでここからが重要です!

まずはクラスダイアグラムを見ていきましょう。

クラスダイアグラム

  1. QClassファイルを作成します
  2. RecruitmetsRepositoryCustom interfaceを作成して QueryDSL を用いて作成したいメソッドを RecruitmetsRepositoryCustom 内に宣言します。
  3. RecruitmentsRepositoryImpl を作成して RecruitmetsRepositoryCustom を implements します。
  4. RecruitmetsRepositoryCustomRecruitmentsRepository に extends させます。

QClass ファイルを作成

@Entity がついたクラスが必要です。

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Recruitment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...

}

Gradle タブまたは terminal で compileJava を実行すると QueryDSL が @Entity を見てQClass ファイルを作成してくれます。

作成されたファイルは build/generated/sources/annotationProcessor/java/main 中の@Entity があるパッケージに生成されます。

後で作成されたこの QClass ファイルに対してクエリを作成します。

RecruitmetsRepositoryCustom を作成

RecruitmetsRepositoryCustom を interface で作成します。

後ろに (使用している JpaRepository の名前) + Custom をつけるのが慣例です。

  • ex : RecruitmentsRepositryCustom
public interface RecruitmentsRepositoryCustom {

    List<Recruitment> getSearchResult(RecruitmentSearch search);

}

RecruitmentsRepositoryImpl を作成

名前に要注意です。
クラス名は必ず JpaRepository をextendsしているinteraceの名前 + Impl である必要があります。

  • ex : RecruitmentsRepositoryImpl

ここでクエリを作成します。

@RequiredArgsConstructor
public class RecruitmentsRepositoryImpl implements RecruitmentsRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Recruitment> getSearchResult(RecruitmentSearch searchParams) {
        var positionNames = searchParams.getPositions();
        var techStackNames = searchParams.getTechStacks();
        var progressMethods = searchParams.getProgressMethods();
        var subject = searchParams.getSearch();

        return queryFactory.selectFrom(recruitment)
                .join(recruitmentTechStack).on(recruitmentTechStack.recruitment.id.eq(recruitment.id))
                .join(techStack).on(recruitmentTechStack.techStack.id.eq(techStack.id))
                .join(recruitmentPosition).on(recruitmentPosition.recruitment.id.eq(recruitment.id))
                .join(position).on(recruitmentPosition.position.id.eq(position.id))
                .fetchJoin()
                .where(or(eqPositions(positionNames), eqTechStacks(techStackNames)),
                        eqProgressMethods(progressMethods),
                        eqSubject(subject))
                .offset((long) (searchParams.getPage() - 1) * searchParams.getSize())
                .limit(searchParams.getSize())
                .orderBy(recruitment.createdAt.desc())
                .fetch();
    }

    private BooleanBuilder eqPositions(List<String> positionNames) {
        if (positionNames == null || positionNames.isEmpty()) {
            return null;
        }
        BooleanBuilder builder = new BooleanBuilder();
        positionNames.forEach(positionName ->
                builder.or(recruitmentPosition.position.positionName.eq(positionName)));

        return builder;
    }

    private BooleanBuilder eqTechStacks(List<String> techStackNames) {
        if (techStackNames == null || techStackNames.isEmpty()) {
            return null;
        }
        BooleanBuilder builder = new BooleanBuilder();
        techStackNames.forEach(techStackName ->
                builder.or(recruitmentTechStack.techStack.technologyName.eq(techStackName)));

        return builder;
    }

    private BooleanExpression eqProgressMethods(ProgressMethods progressMethods) {
        if (progressMethods == null) {
            return null;
        }
        return recruitment.progressMethods.eq(progressMethods);
    }

    private BooleanExpression eqSubject(String title) {
        if (title == null || title.isEmpty()) {
            return null;
        }
        return recruitment.subject.containsIgnoreCase(title);
    }

}

RecruitmentsRepositoryに拡張させる

public interface RecruitmentsRepository extends JpaRepository<Recruitment, Long>, RecruitmentsRepositoryCustom {
}

結論

QueryDSL を使いましょう。

Discussion