Spring Batch 如何对接远程分页接口

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

    用来标记已经读到哪页数据了。由于知道了当前页码,这样也就知道下一页的页码是什么了。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cab5

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值