Spring Boot と QueryDSL を用いて検索クエリ、ページングを実装してみる
今回は小さい掲示板機能がある 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 を使用しますのでここからが重要です!
まずはクラスダイアグラムを見ていきましょう。
クラスダイアグラム
-
QClass
ファイルを作成します -
RecruitmetsRepositoryCustom
interfaceを作成して QueryDSL を用いて作成したいメソッドをRecruitmetsRepositoryCustom
内に宣言します。 -
RecruitmentsRepositoryImpl
を作成してRecruitmetsRepositoryCustom
を implements します。 -
RecruitmetsRepositoryCustom
をRecruitmentsRepository
に 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