Skip to content

Commit

Permalink
feature : 매일 04시에 회원 별 미션 업데이트 batch 기능 개발 (#90)
Browse files Browse the repository at this point in the history
* build : spring batch 의존성 추가

* fix : batch 사용 시 DataSource Bean 이름 기본 값 dataSource

* refactor : 생성자 접근 제한자 변경

* feature : Batch 를 위한 설정

* feature : Batch 실행 시 발생할 수 있는 공통 Exception

* feature : mission batch 개발

* feature : mission batch database 개발

* test : mission batch 테스트 (미완)

* test : mission batch 테스트 미완 (임시 주석 처리)

* refactor : @configuration -> @TestConfiguration 으로 변경 및 테스트 용 embedded datasource 추가

* test : MemberMissionBatch 테스트
- memberMissionReSettingBatchStep 실행 안되는 오류 있음

* refactor : memberJpaEntityRowMapper -> memberBatchEntityRowMapper 로 변경

* chore

* refactor : Step 간 데이터 공유 리팩토링

* test : MemberMissionReSettingStepExecutionListener 클래스 로딩 추가
  • Loading branch information
oownahcohc authored Sep 4, 2024
1 parent 4a1fd06 commit 0481f14
Show file tree
Hide file tree
Showing 27 changed files with 910 additions and 5 deletions.
12 changes: 9 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ dependencies {
// + implementation project(":core:domain")
// + implementation project(":core:auth")

// ========== event ==========
// compileOnly 'org.springframework:spring-context' // Kafka 또는 RabbitMQ 등 교체 가능

// ========== batch ==========
implementation 'org.springframework.boot:spring-boot-starter-batch'
testImplementation 'org.springframework.batch:spring-batch-test'
// + implementation project(":database:core")
// + implementation project(":support:logging")

// ========== external:oauth ==========
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

Expand All @@ -94,9 +103,6 @@ dependencies {
testImplementation 'org.testcontainers:junit-jupiter'
// implementation project(":event")

// ========== event ==========
// compileOnly 'org.springframework:spring-context' // Kafka 또는 RabbitMQ 등 교체 가능

// ========== external:notification ==========
implementation 'com.google.firebase:firebase-admin:9.2.0'

Expand Down
22 changes: 22 additions & 0 deletions src/main/java/univ/earthbreaker/namu/batch/BatchConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package univ.earthbreaker.namu.batch;

import java.time.Clock;
import java.time.ZoneId;

import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
@EnableBatchProcessing
public class BatchConfig {

private static final ZoneId KOREA_TIME_ZONE = ZoneId.of("Asia/Seoul");

@Bean("koreaTimeClockInBatch")
public Clock clock() {
return Clock.system(KOREA_TIME_ZONE);
}
}
22 changes: 22 additions & 0 deletions src/main/java/univ/earthbreaker/namu/batch/BatchException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package univ.earthbreaker.namu.batch;

import org.jetbrains.annotations.NotNull;

public class BatchException extends RuntimeException {

private BatchException(String message) {
super(message);
}

public static @NotNull BatchException isStepExecutionNull() {
return new BatchException("[Mission 세팅 JOB] : StepExecution 가 null 값으로, 제대로 설정되지 않았습니다");
}

public static @NotNull BatchException pagingQueryCreationFailed(String message) {
return new BatchException(String.format("[Mission 세팅 JOB] : Paging Query 생성에 실패했습니다 - %s", message));
}

public static @NotNull BatchException retrieveStepExecutionDataFailed() {
return new BatchException("@BeforeStep 이 정상 동작하지 않았습니다");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package univ.earthbreaker.namu.batch.mission;

import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.item.ExecutionContext;

import univ.earthbreaker.namu.batch.BatchException;

public abstract class AbstractExecutionContextManager<T> {

static final String MISSIONS_PROMOTION_KEY = "SHARED_MISSIONS";

private StepExecution stepExecution;

protected AbstractExecutionContextManager() {
}

protected void putDataToStepExecutionContext(String key, T value) {
validateStepExecutionIsNull();
ExecutionContext stepExecutionContext = stepExecution.getExecutionContext();
stepExecutionContext.put(key, value);
}

/**
* ClassCastException 을 발생시킬 가능성이 있는 일반적인 캐스트와 달리,
* AbstractExecutionContextManager 클래스를 상속한 클래스들은
* T 타입으로 제한되기 때문에 해당 캐스트는 안전합니다
* @param key ExecutionContext 에 저장한 <b>공유 데이터</b>의 key 값
* @return 공유 데이터
*/
@SuppressWarnings("unchecked")
protected T getDataFromJobExecutionContext(String key) {
validateStepExecutionIsNull();
JobExecution jobExecution = stepExecution.getJobExecution();
ExecutionContext jobExecutionContext = jobExecution.getExecutionContext();
return (T)jobExecutionContext.get(key);
}

private void validateStepExecutionIsNull() {
if (stepExecution == null) {
throw BatchException.isStepExecutionNull();
}
}

protected void setCurrentStepExecution(StepExecution stepExecution) {
this.stepExecution = stepExecution;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package univ.earthbreaker.namu.batch.mission;

import java.io.Serializable;

public record FixMissionBatchEntity(
Long missionNo,
String missionActivity,
String missionType
) implements Serializable {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package univ.earthbreaker.namu.batch.mission;

import org.jetbrains.annotations.NotNull;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.stereotype.Component;

@Component
public class LoadMissionStepDecider implements JobExecutionDecider {

static final String SPECIAL_STEP = "SPECIAL_STEP";
static final String NORMAL_STEP = "NORMAL_STEP";

private final MissionDateSupport missionDateSupport;

public LoadMissionStepDecider(MissionDateSupport missionDateSupport) {
this.missionDateSupport = missionDateSupport;
}

@Override
public @NotNull FlowExecutionStatus decide(
@NotNull JobExecution jobExecution,
StepExecution stepExecution
) {
if (missionDateSupport.isSpecialDate()) {
return new FlowExecutionStatus(SPECIAL_STEP);
} else {
return new FlowExecutionStatus(NORMAL_STEP);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package univ.earthbreaker.namu.batch.mission;

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

import org.jetbrains.annotations.NotNull;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;

import univ.earthbreaker.namu.database.core.mission.FixMissionJpaEntity;
import univ.earthbreaker.namu.database.core.mission.FixMissionJpaRepository;

@Component
@StepScope
public class LoadNormalMissionTasklet extends AbstractExecutionContextManager<SharedFixMissions> implements Tasklet {

private final FixMissionJpaRepository fixMissionJpaRepository;

public LoadNormalMissionTasklet(FixMissionJpaRepository fixMissionJpaRepository) {
super();
this.fixMissionJpaRepository = fixMissionJpaRepository;
}

@Override
public RepeatStatus execute(
@NotNull StepContribution contribution,
@NotNull ChunkContext chunkContext
) {
List<FixMissionJpaEntity> defaultMissions = fixMissionJpaRepository.findDefaultMissionsByRandom();
List<FixMissionJpaEntity> todayMissions = fixMissionJpaRepository.findTodayMissionsByRandom();

List<FixMissionJpaEntity> fixMissionJpaEntities = new ArrayList<>();
fixMissionJpaEntities.addAll(defaultMissions);
fixMissionJpaEntities.addAll(todayMissions);

super.setCurrentStepExecution(chunkContext.getStepContext().getStepExecution());
super.putDataToStepExecutionContext(MISSIONS_PROMOTION_KEY, SharedFixMissions.from(fixMissionJpaEntities));

return RepeatStatus.FINISHED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package univ.earthbreaker.namu.batch.mission;

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

import org.jetbrains.annotations.NotNull;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;

import univ.earthbreaker.namu.database.core.mission.FixMissionJpaEntity;
import univ.earthbreaker.namu.database.core.mission.FixMissionJpaRepository;

@Component
@StepScope
public class LoadSpecialMissionTasklet extends AbstractExecutionContextManager<SharedFixMissions> implements Tasklet {

private final FixMissionJpaRepository fixMissionJpaRepository;

public LoadSpecialMissionTasklet(FixMissionJpaRepository fixMissionJpaRepository) {
super();
this.fixMissionJpaRepository = fixMissionJpaRepository;
}

@Override
public RepeatStatus execute(
@NotNull StepContribution contribution,
@NotNull ChunkContext chunkContext
) {
List<FixMissionJpaEntity> defaultMissions = fixMissionJpaRepository.findDefaultMissionsByRandom();
List<FixMissionJpaEntity> todayMissions = fixMissionJpaRepository.findTodayMissionsByRandom();
List<FixMissionJpaEntity> specialMissions = fixMissionJpaRepository.findSpecialMissions();

List<FixMissionJpaEntity> fixMissionJpaEntities = new ArrayList<>();
fixMissionJpaEntities.addAll(defaultMissions);
fixMissionJpaEntities.addAll(todayMissions);
fixMissionJpaEntities.addAll(specialMissions);

super.setCurrentStepExecution(chunkContext.getStepContext().getStepExecution());
super.putDataToStepExecutionContext(MISSIONS_PROMOTION_KEY, SharedFixMissions.from(fixMissionJpaEntities));

return RepeatStatus.FINISHED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package univ.earthbreaker.namu.batch.mission;

public record MemberBatchEntity(
long memberNo,
String nickname,
int level,
String status
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package univ.earthbreaker.namu.batch.mission;

import java.time.LocalDate;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.batch.core.listener.ExecutionContextPromotionListener;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class MemberMissionBatchConfig {

private final JobRepository jobRepository;
private final JobExecutionDecider jobExecutionDecider;

public MemberMissionBatchConfig(
JobRepository jobRepository,
JobExecutionDecider jobExecutionDecider
) {
this.jobRepository = jobRepository;
this.jobExecutionDecider = jobExecutionDecider;
}

@Bean("memberMissionBatch")
public Job memberMissionBatch() {
return new JobBuilder("memberMissionBatch", jobRepository)
.start(jobExecutionDecider)
.from(jobExecutionDecider)
.on(LoadMissionStepDecider.NORMAL_STEP)
.to(normalStep(null, null))
.next(memberMissionReSettingBatchStep(null, null, null, null, null))
.from(jobExecutionDecider)
.on(LoadMissionStepDecider.SPECIAL_STEP)
.to(specialStep(null, null))
.next(memberMissionReSettingBatchStep(null, null, null, null, null))
.end()
.build();
}

@Bean("normalStep")
@JobScope
public Step normalStep(
@Qualifier("loadNormalMissionTasklet") Tasklet loadNormalMissionTasklet,
PlatformTransactionManager transactionManager
) {
return new StepBuilder("normalStep", jobRepository)
.tasklet(loadNormalMissionTasklet, transactionManager)
.listener(contextPromotionListener())
.build();
}

@Bean("specialStep")
@JobScope
public Step specialStep(
@Qualifier("loadSpecialMissionTasklet") Tasklet loadSpecialMissionTasklet,
PlatformTransactionManager transactionManager
) {
return new StepBuilder("specialStep", jobRepository)
.tasklet(loadSpecialMissionTasklet, transactionManager)
.listener(contextPromotionListener())
.build();
}

@Bean("memberMissionReSettingBatchStep")
@JobScope
public Step memberMissionReSettingBatchStep(
@Value("#{jobParameters[chunkSize]}") Integer chunkSize,
JdbcPagingItemReader<MemberBatchEntity> memberItemReader,
MemberMissionItemWriter memberMissionItemWriter,
MemberMissionReSettingStepExecutionListener memberMissionReSettingStepExecutionListener,
PlatformTransactionManager transactionManager
) {
return new StepBuilder("memberMissionReSettingBatchStep", jobRepository)
.<MemberBatchEntity, MemberBatchEntity>chunk(chunkSize, transactionManager)
.reader(memberItemReader)
.writer(memberMissionItemWriter)
.listener(memberMissionReSettingStepExecutionListener)
.build();
}

@Bean
public ExecutionContextPromotionListener contextPromotionListener() {
ExecutionContextPromotionListener promotionListener = new ExecutionContextPromotionListener();
promotionListener.setKeys(new String[] {AbstractExecutionContextManager.MISSIONS_PROMOTION_KEY});
return promotionListener;
}

@Bean
@JobScope
public MissionDateSupport missionDateSupport(
@Value("#{jobParameters[batchDate]}") LocalDate batchDate
) {
return new MissionDateSupport(batchDate);
}
}
Loading

0 comments on commit 0481f14

Please sign in to comment.