Spring Batch5 複数ファイル処理

# 内容

Spring Batch5で指定フォルダ上に存在する複数ファイルの値を読み込みつつ、
ファイル名を取得するという内容です。

# ファイル構成

## コード

\demo\src\main\java\com\example\demo\
- chunk\
    - CsvProcessor.java
    - CsvReader.java
    - CustomFlatFileItemReader.java

- model\
    - User.java
    
- BatchConfig.java
- DemoApplication.java

## リソース

\demo\src\main\resources\
- csv\
    - female.csv
    - male.csv
- application.properties

# コード以外のファイル

## csv

読み込み対象となるcsvファイルです。

``` csv:female.csv
id,name
2,hanako
3,hana
```

``` csv:male.csv
id,name
1,taro
```

## build.gradle

``` gradle:build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.4.0'
	id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(23)
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-batch'
	runtimeOnly 'com.h2database:h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.batch:spring-batch-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
	useJUnitPlatform()
}
```

# コード

## Model

csvファイルの値に紐づいたフィールド + ファイル名用のフィールドを用意します。

``` java:User.java
package com.example.demo.model;

import lombok.Data;

@Data
public class User {
  private long id;
  private String name;
  private String fileName;
}
```

セッター機能を持たせておかないとread処理のマッピング時にエラーになります。
上記だと@Dataによりセッターが自動生成されています。
>Caused by: java.lang.IllegalStateException: Method not found: com.example.demo.model.User.<init>()

## Reader

``` java:CsvReader.java
package com.example.demo.chunk;

import com.example.demo.model.User;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.MultiResourceItemReader;
import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper;
import org.springframework.batch.item.file.mapping.DefaultLineMapper;
import org.springframework.batch.item.file.transform.DelimitedLineTokenizer;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
public class CsvReader {

  // csvの一覧を取得
  private Resource[] getCsvFiles() throws IOException {
    return new PathMatchingResourcePatternResolver().getResources("classpath:csv/*.csv");
  }

  public MultiResourceItemReader<User> read() throws IOException {
    // 複数ファイル読み込み
    final MultiResourceItemReader<User> multiReader = new MultiResourceItemReader<>();
    multiReader.setResources(getCsvFiles());
    multiReader.setDelegate(singleFileReader());

    return multiReader;
  }

  private FlatFileItemReader<User> singleFileReader() {
    // ファイル読み込み マッピング
    final CustomFlatFileItemReader<User> reader = new CustomFlatFileItemReader<>();
    reader.setName("csvReader");
    reader.setLinesToSkip(1);
    reader.setEncoding(StandardCharsets.UTF_8.name());
    reader.setLineMapper(new DefaultLineMapper<User>() {
      {
        setLineTokenizer(new DelimitedLineTokenizer() {{
          setNames("id", "name");
        }});
        setFieldSetMapper(new BeanWrapperFieldSetMapper<User>() {
          {
            setTargetType(User.class);
          }
        });

      }
    });
    return reader;
  }
}
```
MultiResourceItemReaderにより複数ファイルを取り込むことができます。
setResourcesにファイル一覧, setDelegateに単体ファイルの処理をセットします。

singleFileReaderにはリソースのセット処理は不要です。
単体ファイルの処理のみの場合は必要ですが、MultiResourceItemReaderのsetResourcesでリソースを渡すことで、Spring Batchがよしなにやってくれるみたいです。

また、singleFileReaderにはFlatFileItemReaderBuilderではなくCustomFlatFileItemReaderを使用しています。ファイルに記載されている値のみをセットするだけであればFlatFileItemReaderBuilderを使用し、ファイル外の値をセットするのであればFlatFileItemReaderBuilderを使いつつ、fieldSetMapperで下記のようにすればOKです。
``` java
  private FlatFileItemReader<User> singleFileReader() {
    final String[] nameArray = new String[]{"id", "name"};
    return new FlatFileItemReaderBuilder<User>()
            .name("csvReader")
            .linesToSkip(1)
            .encoding(StandardCharsets.UTF_8.name())
            .delimited()
            .names(nameArray)
            .fieldSetMapper(new CustomFieldSetMapper())
            .build();

  }

  private static class CustomFieldSetMapper implements FieldSetMapper<User> {
    @Override
    public User mapFieldSet(final FieldSet fieldSet) {
      final User user = new User();
      user.setId(fieldSet.readLong("id"));
      user.setName(fieldSet.readString("name"));
      user.setFileName("test"); // ファイルにない項目を追加
      return user;
    }
  }
```
今回、CustomFlatFileItemReaderを使用している経緯としてはいろいろ苦戦した結果こうなってます。モンハンのプレイ時間削って調査しました。~~ほんとはモンスター調査のほうがしたかった。~~

``` java:CustomFlatFileItemReader.java
package com.example.demo.chunk;

import com.example.demo.model.User;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.core.io.Resource;

import java.lang.reflect.Field;
import java.util.Objects;

public class CustomFlatFileItemReader<T extends User> extends FlatFileItemReader<User> {
  private String fileName;

  @Override
  public void open(final ExecutionContext executionContext) {
    super.open(executionContext);
    fileName = Objects.nonNull(getCurrentResource()) ? getCurrentResource().getFilename() : null;
  }

  @Override
  public User read() throws Exception {
    final User item = this.doRead();
    if (Objects.isNull(item)) return null;
    item.setFileName(fileName); // ファイル読み込み値以外をセット
    return item;
  }

  private Resource getCurrentResource() {
    try {
      final Field resourceField = FlatFileItemReader.class.getDeclaredField("resource");

      // privateフィールドにアクセスする
      // VMでリフレクションの使用が禁止されている場合、SecurityExceptionを投げる
      resourceField.setAccessible(true);
      return (Resource) resourceField.get(this);
    } catch (NoSuchFieldException | IllegalAccessException e) {
      throw new RuntimeException("リソースへのアクセスに失敗", e);
    }
  }

}
```

FlatFileItemReaderを継承したクラスを使用しています。
MultiResourceItemReaderがリソース一覧(ファイル一覧)を渡していましたが、FlatFileItemReader(継承なし)からは何が渡されたのか見れそうにありませんでした。
見た感じリソースはprivateかつセッターのみ。

そのため、渡されたリソースを確認するための機能をCustomFlatFileItemReaderにまとめています。openにより、ファイルが開かれたらgetCurrentResourceが実行されます。
下記処理はFlatFileItemReaderにセットされたリソースを取得するための処理です。

``` java
final Field resourceField = FlatFileItemReader.class.getDeclaredField("resource");
resourceField.setAccessible(true);
return (Resource) resourceField.get(this);
```
リソースはprivateで、ゲッターもありませんでしたね。
上記はスコープを無視して値を無理やり取得するコードです。(リフレクションと呼ばれるもの)処理の切り分けとかスコープとか気にする人ならこれでスタン値溜まりそう。

## Processor

読み込んだUser情報を出力するだけです。

``` java:CsvProcessor.java
package com.example.demo.chunk;

import com.example.demo.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.stereotype.Component;

@Component
@StepScope
@Slf4j
public class CsvProcessor implements ItemProcessor<User, User> {

  @Override
  public User process(final User item) throws Exception {
    try {
      System.out.println("demo");
      System.out.println(item.toString());
    } catch (Exception e) {
      log.warn(e.getMessage(), e);
      return null;
    }
    return item;
  }
}
```

## BatchConfig

Step、Jobを実行するだけです。

``` java:BatchConfig.java
package com.example.demo;

import com.example.demo.chunk.CsvProcessor;
import com.example.demo.chunk.CsvReader;
import com.example.demo.model.User;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

import java.io.IOException;
import java.io.PrintStream;

@Configuration
public class BatchConfig {

  @Autowired
  private CsvReader reader;

  @Autowired
  private CsvProcessor processor;

  @Bean
  @StepScope
  public MultiResourceItemReader<User> read() throws IOException {
    return reader.read();
  }

  private Step demoStep(final JobRepository jobRepository, final PlatformTransactionManager transactionManager) throws IOException {
    return new StepBuilder("demoStep", jobRepository)
            .<User, User>chunk(10, transactionManager)
            .reader(read())
            .processor(processor)
            .writer(items -> {}) // 何もしない。
            .build();
  }

  @Bean
  public Job demoJob(final JobRepository jobRepository, final PlatformTransactionManager transactionManager) throws Exception {
    System.setOut(new PrintStream(System.out, true, "UTF-8"));
    return new JobBuilder("demoJob", jobRepository)
            .incrementer(new RunIdIncrementer())
            .start(demoStep(jobRepository, transactionManager))
            .build();
  }
}
```

read処理は他のtaskletやitem○○を実装していないためか、StepScopeアノテーションを付けてもマルチタイプとして処理されないです。これを解決するためにreadクラスの処理を呼び出すメソッドを作成し、こちらにBean, StepScopeのアノテーションが必要になります。

## Main

``` java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}
```

# 実行結果

``` log
20:02:07:  'bootRun' を実行中...

Starting Gradle Daemon...
Gradle Daemon started in 1 s 818 ms
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :resolveMainClassName UP-TO-DATE

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v3.4.0)

2025-03-02T20:02:30.203+09:00  INFO 24844 --- [demo] [           main] com.example.demo.DemoApplication         : Starting DemoApplication using Java 23.0.1 with PID 24844 (\demo\build\classes\java\main started by ryuya in \demo)
2025-03-02T20:02:30.205+09:00  INFO 24844 --- [demo] [           main] com.example.demo.DemoApplication         : No active profile set, falling back to 1 default profile: "default"
2025-03-02T20:02:30.958+09:00  INFO 24844 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2025-03-02T20:02:31.116+09:00  INFO 24844 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:1ffc8927-57c1-4981-866b-5d128def851a user=SA
2025-03-02T20:02:31.117+09:00  INFO 24844 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2025-03-02T20:02:31.349+09:00  INFO 24844 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.456 seconds (process running for 1.727)
2025-03-02T20:02:31.353+09:00  INFO 24844 --- [demo] [           main] o.s.b.a.b.JobLauncherApplicationRunner   : Running default command line with: []
2025-03-02T20:02:31.387+09:00  INFO 24844 --- [demo] [           main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=demoJob]] launched with the following parameters: [{'run.id':'{value=1, type=class java.lang.Long, identifying=true}'}]
2025-03-02T20:02:31.409+09:00  INFO 24844 --- [demo] [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [demoStep]
demo
User(id=2, name=hanako, fileName=female.csv)
demo
User(id=3, name=hana, fileName=female.csv)
demo
User(id=1, name=taro, fileName=male.csv)
2025-03-02T20:02:31.460+09:00  INFO 24844 --- [demo] [           main] o.s.batch.core.step.AbstractStep         : Step: [demoStep] executed in 51ms
2025-03-02T20:02:31.466+09:00  INFO 24844 --- [demo] [           main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=demoJob]] completed with the following parameters: [{'run.id':'{value=1, type=class java.lang.Long, identifying=true}'}] and the following status: [COMPLETED] in 69ms
2025-03-02T20:02:31.469+09:00  INFO 24844 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2025-03-02T20:02:31.489+09:00  INFO 24844 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

BUILD SUCCESSFUL in 13s
4 actionable tasks: 1 executed, 3 up-to-date
20:02:31:  'bootRun' の実行を完了しました。
```

ちゃんとファイル名が取得できているのが確認できました。
ファイル順は今回は無視します。

# おわり

今作のモンスターはPCスタン攻撃も持ってるのか...

# 参考

ファイル複数読み込みやファイルの読み込み順について

https://formula97.cocolog-nifty.com/der_raum_sieben/2020/04/post-9b802e.html

リフレクションについて

https://qiita.com/KeithYokoma/items/9e692808095acf560bc9