見出し画像

【技術】Spring BatchでJPA使用のPaging

Spring Batchでは、基本的にはSQL全体または条件節を直書きして、ResultSetを操作する実装方法が例として載っていますが、JPAで行う方法を例示します。参考はQiitaのこの記事この記事

まずはRepository

package mitei.mitei.investigate.report.balance.politician.repository;

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;

import mitei.mitei.investigate.report.balance.politician.entity.TaskPlanBalancesheetDetailEntity;

/**
 * task_plan_balancesheet_detail接続用Repository
 */
public interface TaskPlanBalancesheetDetailRepository  extends PagingAndSortingRepository<TaskPlanBalancesheetDetailEntity, Long>,JpaRepository<TaskPlanBalancesheetDetailEntity, Long>{

    // 不要なメソッドは省略

    /**
     * 最新かつ未処理データをすべて抽出する
     *
     * @param saishinKbn 最新区分
     * @param isFinished 終了該否
     * @param pageable ページングプロパティ
     * @return ページ
     */
    Page<TaskPlanBalancesheetDetailEntity> findBySaishinKbnAndIsFinished(Integer saishinKbn,boolean isFinished,Pageable pageable);

}

ポイントは下記です。

1. Spring Bootにレポジトリと認識してもらうために、認識させる系の何らかのinterfaceを継承(extends)すること
2.対象メソッドの引数にorg.springframework.data.domain.Pageableを含むこと
3.返り値はorg.springframework.data.domain.Page<T>であること

今回たまたま2種類のRepositoryを継承していますが、ほとんどの方はJpaRepository継承で何も問題はないと思います。例えばPagingのときにはPagingAndSortingRepositoryを、と記載している記事も見かけますが、JpaRepository単独で動作します(確認済)し、わざわざJpaRepository継承のInterfaceとPagingAndSortingRepository継承のinterfaace2種類作ることもありません。PagingAndSortingRepositoryは公式ドキュメントを見ていただくとわかる通りfindAll(Pageable)、findAll(Sort)しか用意されていなくて(ユーザ皆さんが使う検索条件が統一できるわけがなく、各人カスタマイズするのが当たり前ということを考えれば当然ではある)ほんとに『Repositoryであること、とマークす(してSpring bootに認識させ)る』役割しか負っていないように見えます。
Pageで返すEntityであるTaskPlanBalancesheetDetailEntityの中身は技術説明サンプルにしては無駄に長いので省略。詳細は自作中ソフトウェアのGit

ItemReaderで使用してみる

package mitei.mitei.investigate.report.balance.politician.batch.poli_org.balancesheet.regist;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import org.springframework.batch.item.data.RepositoryItemReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.stereotype.Component;

import mitei.mitei.investigate.report.balance.politician.dto.common_check.DataHistoryStatusConstants;
import mitei.mitei.investigate.report.balance.politician.entity.TaskPlanBalancesheetDetailEntity;
import mitei.mitei.investigate.report.balance.politician.repository.TaskPlanBalancesheetDetailRepository;

/**
 * 政治資金収支報告書タスク詳細テーブルItemReader
 * SQLを直書きしないでRepositoryのメソッドを使用する
 */
@Component
public class TaskPlanBalancesheetDetailItemReader extends RepositoryItemReader<TaskPlanBalancesheetDetailEntity> {

    /**
     * コンストラクタ
     *
     * @param taskPlanBalancesheetDetailRepository 政治資金収支報告書準備詳細テーブルRepository
     */
    public TaskPlanBalancesheetDetailItemReader(
            final @Autowired TaskPlanBalancesheetDetailRepository taskPlanBalancesheetDetailRepository) {

        super();
        super.setRepository(taskPlanBalancesheetDetailRepository);
        super.setSort(new HashMap<String, Direction>()); // NOPMD
        super.setMethodName("findBySaishinKbnAndIsFinished");
        List<Object> listArgs = new ArrayList<>();
        listArgs.add(DataHistoryStatusConstants.INSERT.value()); // saishinKbn = 1:最新
        listArgs.add(false); // isFinished:false
        super.setArguments(listArgs);
    }

}

ItemReaderです。ポイントは下記です。

1.ItemReaderのコンストラクタの引数に動作させるRepositoryを取ること
2.ソートしなくてもソートを設定する必要があるので、空Map<String, Direction>をsetSort(Map<String, Direction>)に設定すること
3.使用するメソッド名をsetMethodNameに設定すること
4.検索条件はsetArgumentにリスト形式で設定すること。

ソート条件は設定しないと落ちます(IllegalStateException: A sort is required)。ItemReaderもコンストラクタの引数でないと落ちます。(IllegalStateException: A PagingAndSortingRepository is required)フィールドで@Autowiredしてもダメです。前の記事でも触れましたがItemReader,ItemWriterはSpring Bootがnewした時点でセットされていない設定(詳細はReder,Writerの継承extendsの内容により異なる)があると落ちるので、そういう設定はコンストラクタの引数に取るかコンストラクタ内でnewするかしないといけないので要注意です。今回は必要な要素がRepositoryである=すなわちboot管理である必要がある→内部でnewできない→コンストラクタの引数一択、という三段論法になります。StackTraceでは冒頭にIllegalStateException: Failed to load ApplicationContext…て表示されるのでBoot全体設定がらみ?と思ってしまいがちですけど(自分はもう慣れた)。なんかJavaだと最初のcausedが主原因なことが多いですけどSpring Bootの場合は最後のcausedが主原因のことが多いですよね。
検索条件は引数の設定順(のはず)。引数の型が異なってもリストの型はObjectにして、個別の値は設定したメソッドの引数の型にして突っ込んでおけば、引数の型と照らし合わせて、よしなにキャストしてくれるはず(そうでないと動作していることとのつじつまが合わない(^^;)。引数Pageableは無視で。それはSpring Bootの管理管轄だから。※Pageableについて追記があります(2025.01.11)

ItemReader単体テストで確認

package mitei.mitei.investigate.report.balance.politician.batch.poli_org.balancesheet.regist;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional;

import mitei.mitei.investigate.report.balance.politician.entity.TaskPlanBalancesheetDetailEntity;

/**
 * TaskPlanBalancesheetDetailItemReader単体テスト
 */
@SpringJUnitConfig
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
class TaskPlanBalancesheetDetailItemReaderTest {
    // CHECKSTYLE:OFF

    /** 単体テスト */
    @Autowired
    private TaskPlanBalancesheetDetailItemReader taskPlanBalancesheetDetailItemReader;

    @Test
    @Transactional
    @Sql("task_plan_balancesheet_detail.sql")
    void test() throws Exception {

        // Step組み立ての際にChnkとして与えられるので外部設定
        taskPlanBalancesheetDetailItemReader.setPageSize(2);
        
        // 8件登録中条件にあう5件を抽出
        // SQLログをオンにしてページサイズ2の時は3回+1回SQLが発行されているのを確認するとベター
        TaskPlanBalancesheetDetailEntity entity01 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity01.getTaskPlanBalancesheetDetailId()).isEqualTo(101L);

        TaskPlanBalancesheetDetailEntity entity02 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity02.getTaskPlanBalancesheetDetailId()).isEqualTo(201L);

        TaskPlanBalancesheetDetailEntity entity03 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity03.getTaskPlanBalancesheetDetailId()).isEqualTo(401L);

        TaskPlanBalancesheetDetailEntity entity04 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity04.getTaskPlanBalancesheetDetailId()).isEqualTo(501L);

        TaskPlanBalancesheetDetailEntity entity05 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity05.getTaskPlanBalancesheetDetailId()).isEqualTo(801L);

        // 件数以上取得しようとしてもnullが戻るだけ
        TaskPlanBalancesheetDetailEntity entity06 = taskPlanBalancesheetDetailItemReader.read();
        assertThat(entity06).isEqualTo(null);

    }

}

ポイントはソースの中のコメントにほぼ言い尽くされていますが…
チャンクサイズ(1回に取得する数)は外から取得しています。バッチのConfigurationでStepを構成するときにStepBuilder("ステップ名", jobRepository).chunk(取得数)とReadrの外から与えているのと、同じ設定にしています。というか、ItemReader内で設定する実装をしてもConfigurationでは上書きされるので無駄…8件テーブルに登録して、そのうち5件を検索するためのテストデータSQLは自作中ソフトウェアのGit(今記事2回目)
テストの時はread()で該当のEntityが1件取得できます。もちろんテストでない場合はSpringBootが内部で実行しているので、実装者が意識することは(readメソッドをOverrideしない限り)ないですけど。小ネタとして件数以上に取得しようとすると、ほんとにないかな?ともう一回SQLを発行して、やっぱりありません、ということでnullを返してきます(ので取得件数の検証に使う)。
Spring Batchだけの特徴ではないかもしれないので、特筆大書すべきことでもないかもしれないのですが…とあるテーブルA(下記例ではTaskPlanBalancesheetDetail)をItemReaderでPageableで読み取りしながら、テーブルAをItemWriterで更新作業を行うと…Spring Batch(Boot)のページ管理が狂うので正常に全件とれません。あるデータを読み取りして、編集登録する、ってあるある作業なので、個人的には結構ショックでした。

package mitei.mitei.investigate.report.balance.politician.batch.poli_org.balancesheet.regist;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import org.springframework.batch.item.data.RepositoryItemReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.stereotype.Component;

import mitei.mitei.investigate.report.balance.politician.dto.common_check.DataHistoryStatusConstants;
import mitei.mitei.investigate.report.balance.politician.entity.TaskPlanBalancesheetDetailEntity;
import mitei.mitei.investigate.report.balance.politician.repository.TaskPlanBalancesheetDetailRepository;

/**
 * 政治資金収支報告書タスク詳細テーブルItemReader
 * SQLを直書きしないでRepositoryのメソッドを使用する
 */
@Component
public class TaskPlanBalancesheetDetailItemReader extends RepositoryItemReader<TaskPlanBalancesheetDetailEntity> {

    /**
     * コンストラクタ
     *
     * @param taskPlanBalancesheetDetailRepository 政治資金収支報告書準備詳細テーブルRepository
     */
    public TaskPlanBalancesheetDetailItemReader(
            final @Autowired TaskPlanBalancesheetDetailRepository taskPlanBalancesheetDetailRepository) {

        super();
        super.setRepository(taskPlanBalancesheetDetailRepository);
        super.setSort(new HashMap<String, Direction>()); // NOPMD
        super.setMethodName("findBySaishinKbnAndIsFinishedAndCharsetIsNotNull");
        List<Object> listArgs = new ArrayList<>();
        listArgs.add(DataHistoryStatusConstants.INSERT.value()); // saishinKbn = 1:最新
        listArgs.add(false); // isFinished:false
        super.setArguments(listArgs);
    }

}

package mitei.mitei.investigate.report.balance.politician.batch.poli_org.balancesheet.regist;

import java.util.Optional;

import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.database.JpaItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import jakarta.persistence.EntityManagerFactory;
import mitei.mitei.investigate.report.balance.politician.dto.common_check.CheckPrivilegeDto;
import mitei.mitei.investigate.report.balance.politician.dto.common_check.DataHistoryStatusConstants;
import mitei.mitei.investigate.report.balance.politician.entity.TaskPlanBalancesheetDetailEntity;
import mitei.mitei.investigate.report.balance.politician.entity.WkTblPoliOrgBalancesheetReportEntity;
import mitei.mitei.investigate.report.balance.politician.repository.TaskPlanBalancesheetDetailRepository;
import mitei.mitei.investigate.report.balance.politician.repository.WkTblPoliOrgBalancesheetReportRepository;
import mitei.mitei.investigate.report.balance.politician.util.SetTableDataHistoryUtil;

/**
 * 政治資金収支報告書ワークテーブルに計画から作業内容を書き込むライタ
 */
@Component
public class WkTblPoliOrgBalancesheetReportItemWriter extends JpaItemWriter<WkTblPoliOrgBalancesheetReportEntity> {

    /**
     * コンストラクタ(EntityManagerFactoryをセットする必要がある)
     *
     * @param entityManagerFactory EntityManagerFactory
     */
    public WkTblPoliOrgBalancesheetReportItemWriter(final @Autowired EntityManagerFactory entityManagerFactory) {
        super();
        super.setEntityManagerFactory(entityManagerFactory);
    }

    /** 政治資金収支報告書登録準備ワークテーブルレポジトリ */
    @Autowired
    private WkTblPoliOrgBalancesheetReportRepository wkTblPoliOrgBalancesheetReportRepository;

    /** 政治資金収支報告書登録準備ワークテーブルレポジトリ */
    @Autowired
    private TaskPlanBalancesheetDetailRepository taskPlanBalancesheetDetailRepository;

    /**
     * 書き込み処理
     */
    @Override
    public void write(final Chunk<? extends WkTblPoliOrgBalancesheetReportEntity> items) {
        
        Integer code = 0;
        Optional<WkTblPoliOrgBalancesheetReportEntity> optional = wkTblPoliOrgBalancesheetReportRepository
                .findFirstByOrderByWkTblPoliOrgBalancesheetReportCodeDesc();
        if (!optional.isEmpty()) {
            code = code + optional.get().getWkTblPoliOrgBalancesheetReportCode();
        }

        CheckPrivilegeDto privilegeDto = new CheckPrivilegeDto();
        privilegeDto.setLoginUserId(items.getItems().get(0).getInsertUserId());
        privilegeDto.setLoginUserCode(items.getItems().get(0).getInsertUserCode());
        privilegeDto.setLoginUserName(items.getItems().get(0).getInsertUserName());

        for (WkTblPoliOrgBalancesheetReportEntity entity : items.getItems()) {
            code++;
            entity.setWkTblPoliOrgBalancesheetReportCode(code);
            
            // このItemRをReaderでを呼び出しているテーブルを更新するコードがダメ
            TaskPlanBalancesheetDetailEntity detailEntity = taskPlanBalancesheetDetailRepository.findById(entity.getTaskPlanBalancesheetDetailId()).get();
            SetTableDataHistoryUtil.practice(privilegeDto, detailEntity, DataHistoryStatusConstants.UPDATE);
            detailEntity.setIsFinished(true);
            taskPlanBalancesheetDetailRepository.save(detailEntity); // ←★こういう形で更新するとPage管理が狂う
            // ダメな例ここまで
        }
        // 登録処理
        wkTblPoliOrgBalancesheetReportRepository.saveAll(items.getItems());
        wkTblPoliOrgBalancesheetReportRepository.flush();
    }

}

こういうことしたい場合はChunkではなくTaskletを使用して、かつReader-Writerのステップから作業を分離して、自分でページングを管理しなければなりません。あるいは管理テーブルをはさんでステップ構成を工夫するとか。 タスクレットがこれで、limitを用いて管理しているRepositoryがこれ複数ステップを直列で連結している例がこれ(Git紹介は記事3-5回目)いい方法がありましたら挙手をいただければとおもいます。

ところで、このPageable、バッチ以外で使えるんでしょうか。
SpringBootがPageableをよしなに管理してくれて使用できる、バッチでないと混線するの2択ですが、まだ実験できていません。って書いているときに実験する方法を思いついたけど、ストップウォッチを持って、にらめっこしてタイミングを合わせて操作しないとダメそうと思ったけど、なんかバッチの想定処理数を爆発的に長くして、処理時間中に該当Repositoryのメソッドを別途動作させたら検証できそうな気がしてきたので、ちょっとテストしてきますので、これにて失礼。

※Pageableについて追記です。上記のテストは意外とてこずってできてません。ごめんなさい。別件ですが、たとえばfrontでユーザさんが指定したページ番号にあわせたデータを取得する、みたいなことは可能でした。

Pageable pageableNotUse = Pageable.unpaged();
Pageable pageable = Pageable.ofSize(50).withPage(3);

のようにPageableのstaticメソッドから、内容を指定したカスタマイズされたインスタンスを生成でき、このインスタンスをRepositoryに押し込みます。上が引数にはPageableを取っているけどPage機能を一時的に消したいとき、下が50件単位(Offset)で3ページ目(100-150件)を取得したいときのPageabbleインスタンスの生成方法となります。これならバッチで作成したメソッドの再利用が可能です。取得できた段階で.toList()とするとPage<取得したいオブジェクト>からList<取得したいオブジェクト>になるので、あとはご随意にいつもの感覚で。なお、どういうわけかSortと一緒に使用することができず頭をひねっています。なぜだ…

いいなと思ったら応援しよう!