1、业务场景
通过 「系统A」 提供的 「分页接口」 抓取数据,然后将数据进行处理,最后将数据写入 ES(Elasticsearch),如下图:
(注:本文的重点是如何对接系统A的分页接口实现数据读取)
2、具体实现
这里对基础概念就不做过多的说明了,直接进入具体实现环节:
2.1 pom 文件
在 projecet 的 pom 文件中引入 spring-boot-starter-batch.jar,如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
<version>2.3.5.RELEASE<version>
</dependency>
<dependencies>
2.2 模拟系统A客户端
这里为了方便演示,我们用下边代码来模拟系统A的客户端调用,代码如下:
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Author: eric
* @Date: 2021/8/13 15:30
* @Description: 模拟系统A客户端
*/
@Service
public class DemoFacade {
/**
* 获取一页数据
*
* @param page 页码
* @param pageSize 每页的数据量
* @return 一页数据
*/
public List<Map> getPage(int page, int pageSize) {
List<Map> list = new ArrayList<>();
if (page == 0) {
System.out.println("page : " + (page + 1) + ", pageSize : " + pageSize);
for (int i = 0; i < pageSize; i++) {
Map map = new HashMap();
map.put("value", page + "-" + i);
list.add(map);
}
} else if (page == 1) {
System.out.println("page : " + (page + 1) + ", pageSize : " + pageSize);
for (int i = 0; i < pageSize; i++) {
Map map = new HashMap();
map.put("value", page + "-" + i);
list.add(map);
}
} else if (page == 2) {
System.out.println("page : " + (page + 1) + ", pageSize : " + pageSize);
for (int i = 0; i < 2; i++) {
Map map = new HashMap();
map.put("value", page + "-" + i);
list.add(map);
}
} else {
System.out.println("current page : " + page);
}
return list;
}
}
代码很简单,就是要模拟返回第 1 页的 pageSize 条数据,第 2 页返回 pageSize 条数据,第 3 页只返回 2 条数据。
2.3 数据处理与数据写入
开篇的时候已经交代过了本文的重点在数据抓取过程,所以处理和写入部分都是最简单的实现,要注意的就是 「数据处理」 实现了 ItemProcessor 接口,「数据写入」 实现了 ItemWriter 接口,具体如下:
数据处理:
import com.alibaba.fastjson.JSON;
import org.springframework.batch.item.ItemProcessor;
import java.util.Map;
/**
* @Author: eric
* @Date: 2021/6/30 15:04
* @Description: NIPT的原始数据 转换成 NIPT数据)
*/
public class DemoPagingItemProcessor implements ItemProcessor<Map, Map> {
@Override
public Map process(Map map) throws Exception {
// 将要处理的数据输出到控制台且返回
System.out.println("process : " + JSON.toJSONString(map));
return map;
}
}
数据写入:
import com.alibaba.fastjson.JSON;
import org.springframework.batch.item.ItemWriter;
import java.util.List;
import java.util.Map;
/**
* @Author: eric
* @Date: 2021/6/30 15:05
* @Description:
*/
public class DemoPagingItemWriter implements ItemWriter<Map> {
@Override
public void write(List<? extends Map> items) throws Exception {
// 将要写入的数据输出到控制台
System.out.println("write : " + JSON.toJSONString(items));
}
}
2.4 数据读取
同学们注意啦,本文的重点要来喽~~!!!
虽然 Spring Batch 并没有给我们提供直接的支持,但是我们可以通过实现 AbstractPagingItemReader 抽象类来完成分页读取,代码如下:
import org.springframework.batch.item.database.AbstractPagingItemReader;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* @Author: eric
* @Date: 2021/8/12 11:13
* @Description:
*/
public class DemoPagingItemReader extends AbstractPagingItemReader<Map> {
@Autowired
private DemoFacade demoFacade;
public DemoPagingItemReader(int pageSize) {
this.setPageSize(pageSize);
}
@Override
protected void doReadPage() {
if (this.results == null) {
this.results = new CopyOnWriteArrayList<>();
} else {
this.results.clear();
}
// 调用 系统A模拟客户端
List<Map> list = demoFacade.getPage(this.getPage(), this.getPageSize());
this.results.addAll(list);
}
@Override
protected void doJumpToPage(int i) {
// 这里不涉及到跳转到指定页面,所以这里为空实现
}
}
通过以上代码,我们可以看到,为了实现分页读取,只要实现 doReadPage() 方法即可。由于本例中不涉及直接跳到指定页面,所以,未对 doJumpToPage(int i) 方法进行具体实现。各位同学在自己实现 AbstractPagingItemReader 的时候,还需要注意以下几点:
-
设置 pageSize
如果 pageSize 不设置指定值,默认值为 10。
-
初始化 results
doReadPage() 被第一次调用的时候,results 为 null,为了下边能够正常运行,我们需要对 results 进行初始化。
-
清空 results
当 doReadPage() 被再次调用的时候,说明要获取下一页数据,此时要清空 results 中上一页的数据,否则会出现问题。
-
向 results 中添加读出来的数据
最后要记得把本次获取到的页面数据,放到 results 中。否则,在后续的步骤中(处理、写入),就没有数据了。
至于为啥要注意以上几点,下文会再进一步说明,这里大家就硬性记忆就好了~~!
2.5 封装 Laucher
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
/**
* @Author: eric
* @Date: 2021/6/30 14:48
* @Description:
*/
@Slf4j
@Component
public class DemoPagingSchedulerLauncher {
@Autowired
private JobLauncher jobLauncher;
@Autowired
@Qualifier(DemoPagingBatchProcessConfiguration.JOB_PAGING_DEMO)
private Job job;
@SneakyThrows
public void run() {
JobExecution jobExecution = jobLauncher.run(job, this.createJobParameters());
System.out.println("jobExecution : " + jobExecution);
}
private JobParameters createJobParameters() {
return new JobParametersBuilder().addLong("beginTime", System.currentTimeMillis()).toJobParameters();
}
}
2.6 配置类
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.*;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.launch.support.SimpleJobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
/**
* @Author: eric
* @Date: 2021/6/28 17:19
* @Description:
*/
@Configuration
@EnableBatchProcessing
public class DemoPagingBatchProcessConfiguration {
public static final String JOB_PAGING_DEMO = "job4PagingDemo";
public static final String STEP_PAGING_DEMO = "step4PagingDemo";
@Bean
public SimpleJobLauncher jobLauncher(JobRepository jobRepository) {
SimpleJobLauncher jobLauncher = new SimpleJobLauncher();
jobLauncher.setJobRepository(jobRepository);
return jobLauncher;
}
/**
* 配置 Job
*
* @return
*/
@Bean(name = JOB_PAGING_DEMO)
public Job job(JobBuilderFactory jobBuilderFactory, @Qualifier(STEP_PAGING_DEMO) Step step) {
return jobBuilderFactory.get(JOB_PAGING_DEMO)
.incrementer(new RunIdIncrementer())
.start(step)
.build();
}
/**
* 配置 Step
* @param stepBuilderFactory
* @param reader
* @param writer
* @param processor
* @return
*/
@Bean(name = STEP_PAGING_DEMO)
@JobScope
public Step step(StepBuilderFactory stepBuilderFactory, ItemReader reader, ItemWriter writer, ItemProcessor processor) {
return stepBuilderFactory
.get(STEP_PAGING_DEMO)
.chunk(6) // 读 6 条数据,写 1 次
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
@Bean
@StepScope
DemoPagingItemReader reader() {
// pageSize 为 3(每页取 3 条数据)
return new DemoPagingItemReader(3);
}
@Bean
DemoPagingItemWriter writer() {
return new DemoPagingItemWriter();
}
@Bean
public ItemProcessor<Map, Map> processor() {
return new DemoPagingItemProcessor();
}
}
(注:千万不要忘记在自己的配置类上加上 @EnableBatchProcessing 注解啊!!!!)
2.6 单元测试
最后,让我们调用 DemoPagingSchedulerLauncher 中的 run() 方法来看下效果,如下:
public class PagingDemoSchedulerLauncherTestCase {
@Autowired
private DemoPagingSchedulerLauncher demoPagingSchedulerLauncher;
@SneakyThrows
@Test
public void launch() {
demoPagingSchedulerLauncher.run();
}
}
执行结果如下:
page : 1, pageSize : 3
page : 2, pageSize : 3
process : {"value":"0-0"}
process : {"value":"0-1"}
process : {"value":"0-2"}
process : {"value":"1-0"}
process : {"value":"1-1"}
process : {"value":"1-2"}
write : [{"value":"0-0"},{"value":"0-1"},{"value":"0-2"},{"value":"1-0"},{"value":"1-1"},{"value":"1-2"}]
page : 3, pageSize : 3
process : {"value":"2-0"}
process : {"value":"2-1"}
write : [{"value":"2-0"},{"value":"2-1"}]
通过上边的结果,我们可以看到一共 8 条数据,头 2 页都是返回 3 条数据,最后 1 页返回 2 条数据。因为我们将 chunk 设置为 6, 所以当头 2 页的数据凑够了 6 条的时候,触发一次写入操作(也就是说只要凑够 6 条就触发一次写入),最后 1 页只有 2 条数据,最后写入 1 次。
3、分页读取的实现逻辑
在上文中简单的说了下怎样通过 Spring Batch 的抽象类 AbstractPagingItemReader 来实现分页读取数据,下面将进一步说明其中的实现逻辑。首先,我们先来看下类图:
首先是接口 ItemReader,Spring Batch 基于该接口的 read() 方法将数据逐条读取出来(直至返回 NULL 为止),代码如下:
package org.springframework.batch.item;
import org.springframework.lang.Nullable;
public interface ItemReader<T> {
@Nullable
T read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException;
}
这里有个问题,Spring Batch 是逐条读取的,但是我们的分页接口可是一次性获取多条数据的啊!我们假设,第 1 次调用 read() 的时候,「远程分页接口」返回第 1 页数据 A、B、C、D、E,而 read() 将数据 A 返回(注:这是因为 ItemReader.read()只能返回一条数据 ),那剩下的 B、C、D、E 将会被忽略掉!当第 2 次调用 read() 的时候,按道理来说应该会去获取第 2 页的数据(例如:F、G、H…),和第 1 次调用 read() 一样,我们同样也只能将第二页数据(例如:F、G、H…)中的其中一条返回(其他的均被忽略掉!)。这样最终的结果是,我们只能读取每页中的 1 条数据,其他的数据都被忽略掉了,如下图:
要想知道 Spring Batch 是如何解决上边问题的,那就需要我们来先看下 AbstractPagingItemReader 这个抽象类的具体实现了,如下:
package org.springframework.batch.item.database;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
public abstract class AbstractPagingItemReader<T> extends AbstractItemCountingItemStreamItemReader<T> implements InitializingBean {
protected Log logger = LogFactory.getLog(this.getClass());
private volatile boolean initialized = false;
private int pageSize = 10;
private volatile int current = 0;
private volatile int page = 0;
protected volatile List<T> results;
private Object lock = new Object();
......
@Nullable
protected T doRead() throws Exception {
synchronized(this.lock) {
// current 用来记录当前是取的当前页第几条数据
if (this.results == null || this.current >= this.pageSize) {
/*
* results == null
* 表示初始状态,还没有读过任何数据,下边开始读取第一页数据
* this.current >= this.pageSize
* 表示当前页的数据已经都读完了,需要获取下一页数据了
*/
if (this.logger.isDebugEnabled()) {
this.logger.debug("Reading page " + this.getPage());
}
// 读取第this.page页对应的数据
this.doReadPage();
// 页数累加(下一页)
++this.page;
if (this.current >= this.pageSize) {
/*
* this.current >= this.pageSize 表示当前数据已经是当前页面最后一条数据了,
* 再调用 doRead() 将会获取下一页数据,所以需要将 current 清零,
* 以备读取下一页数据的时候,从头开始计数
*/
this.current = 0;
}
}
// next 为当前页下一条数据
int next = this.current++;
return next < this.results.size() ? this.results.get(next) : null;
}
}
protected abstract void doReadPage();
......
}
(注:以上代码片段中只保留了部分关键代码)
上边这段代码片段中,需要重点关注的有以下几点:
-
results
results 是一个 List,用来存放当前页的数据。当当前页的数据都遍历读取出来后,才会触发调用 doReadPage() 的逻辑,来读取下一页数据。
在这里要对上边 2.4 中出现的,初始化 results,以及清理 results,这两个问题进行说明:
我们从 doRead() 的实现中发现,在去读下一页数据的时候,并没有对 results 进行初始化或者清理的逻辑。也就是说,如果我们在实现 doReadPage() 的时候直接使用 results,就会发生 2 个异常情况:
条件 引发的异常 results 为空 在 doReadPage() 中的实现逻辑中,向 results add 数据的时候会报出空指针异常 results 不为 empty 且未清空 由于在调用 doReadPage() 读取下一页数据之前, results 未被清空,则 results 中就不仅仅只存放当前页面的数据了(而是所有页面的的数据)。最终造成的后果是,永远只能读取到第一页的数据以及读取死循环(不信你自己个儿试试~~) 为了避免以上 2 种异常情况的发生,我的做法是:在 AbstractPagingItemReader 与 DemoPagingItemReader 之间再抽象一层 AbstractMyPagingItemReader ,具体代码如下:
import org.springframework.batch.item.database.AbstractPagingItemReader; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * @Author: eric * @Date: 2021/8/13 16:05 * @Description: */ public abstract class AbstractMyPagingItemReader<T> extends AbstractPagingItemReader<T> { // 返回页面中的数据 protected abstract List<T> getDataList(); public AbstractAnnoroadPagingItemReader() { } public AbstractAnnoroadPagingItemReader(int pageSize) { super.setPageSize(pageSize); } @Override protected void doReadPage() { if (this.results == null) { this.results = new CopyOnWriteArrayList<>(); } else { this.results.clear(); } List<T> dataList = getDataList(); this.results.addAll(dataList); } @Override protected void doJumpToPage(int i) { } }
我们只要继承 AbstractMyPagingItemReader 且实现其中的 getDataList() 方法即可。基于 AbstractMyPagingItemReader,我们将 DemoPagingItemReader 进行如下的改造:
import org.springframework.beans.factory.annotation.Autowired; import java.util.List; import java.util.Map; /** * @Author: eric * @Date: 2021/8/12 11:13 * @Description: */ public class DemoPagingItemReader extends AbstractAnnoroadPagingItemReader<Map> { @Autowired private DemoFacade demoFacade; public DemoPagingItemReader() { super(); } public DemoPagingItemReader(int pageSize) { super(pageSize); } @Override protected List<Map> getDataList() { return demoFacade.getPage(super.getPage(), super.getPageSize()); } }
调整后的类图,如下:
-
current
用来记录已经读到当前页的第几条数据了,如果当前页面的数据都读取完了,再读取下一页数据之前,将 current 清零。
-
page
用来标记已经读到哪页数据了。由于知道了当前页码,这样也就知道下一页的页码是什么了。