1. 常见批处理模式
一些批处理作业可以纯粹从 Spring Batch 中的现成组件中组装。例如,可以将ItemReader
和ItemWriter
实现配置为涵盖各种场景。但是,在大多数情况下,必须编写自定义代码。应用程序开发人员的主要 API 入口点是Tasklet
,ItemReader
,ItemWriter
和各种侦听器接口。大多数简单的批处理作业都可以使用 Spring Batch ItemReader
的现成 Importing,但是通常情况下,在处理和编写过程中存在自定义问题,需要开发人员实现ItemWriter
或ItemProcessor
。
在本章中,我们提供了一些自定义业务逻辑中常见模式的示例。这些示例主要具有侦听器接口。应当注意,如果合适,ItemReader
或ItemWriter
也可以实现侦听器接口。
1.1.记录 Item 处理和故障
一个常见的用例是需要逐个步骤对错误进行特殊处理,可能需要登录到特殊通道或将记录插入数据库中。面向块的Step
(从 step factory Bean 创建)使用户可以使用简单的ItemReadListener
来解决read
上的错误,并使用ItemWriteListener
来解决write
上的错误。以下代码段说明了一个记录读取和写入失败的侦听器:
public class ItemFailureLoggerListener extends ItemListenerSupport {
private static Log logger = LogFactory.getLog("item.error");
public void onReadError(Exception ex) {
logger.error("Encountered error on read", e);
}
public void onWriteError(Exception ex, List<? extends Object> items) {
logger.error("Encountered error on write", ex);
}
}
实现此侦听器后,必须向其注册一个步骤,如以下示例所示:
XML Configuration
<step id="simpleStep">
...
<listeners>
<listener>
<bean class="org.example...ItemFailureLoggerListener"/>
</listener>
</listeners>
</step>
Java Configuration
@Bean
public Step simpleStep() {
return this.stepBuilderFactory.get("simpleStep")
...
.listener(new ItemFailureLoggerListener())
.build();
}
Tip
如果您的侦听器以onError()
方法执行任何操作,则它必须位于要回滚的事务内。如果您需要在onError()
方法内部使用事务性资源,例如数据库,请考虑向该方法添加声明性事务(有关详细信息,请参见《 Spring Core 参考指南》),并将其传播属性的值设置为REQUIRES_NEW
。
1.2.由于业务原因手动停止作业
Spring Batch 通过JobLauncher
接口提供了stop()
方法,但这实际上是供操作员而非应用程序程序员使用的。有时,从业务逻辑中停止作业执行更方便或更有意义。
最简单的方法是抛出RuntimeException
(既不会无限期重试,也不会跳过)。例如,可以使用自定义异常类型,如以下示例所示:
public class PoisonPillItemProcessor<T> implements ItemProcessor<T, T> {
@Override
public T process(T item) throws Exception {
if (isPoisonPill(item)) {
throw new PoisonPillException("Poison pill detected: " + item);
}
return item;
}
}
停止执行步骤的另一种简单方法是从ItemReader
返回null
,如以下示例所示:
public class EarlyCompletionItemReader implements ItemReader<T> {
private ItemReader<T> delegate;
public void setDelegate(ItemReader<T> delegate) { ... }
public T read() throws Exception {
T item = delegate.read();
if (isEndItem(item)) {
return null; // end the step here
}
return item;
}
}
前面的示例实际上是基于CompletionPolicy
策略的默认实现的,该默认实现在要处理的物料为null
时发出完整批次的 signal。可以实现更复杂的完成策略,并将其通过SimpleStepFactoryBean
注入到Step
中,如以下示例所示:
XML Configuration
<step id="simpleStep">
<tasklet>
<chunk reader="reader" writer="writer" commit-interval="10"
chunk-completion-policy="completionPolicy"/>
</tasklet>
</step>
<bean id="completionPolicy" class="org.example...SpecialCompletionPolicy"/>
Java Configuration
@Bean
public Step simpleStep() {
return this.stepBuilderFactory.get("simpleStep")
.<String, String>chunk(new SpecialCompletionPolicy())
.reader(reader())
.writer(writer())
.build();
}
另一种方法是在StepExecution
中设置一个标志,该标志由 Item 处理之间的框架中的Step
实现检查。要实现此替代方案,我们需要访问当前的StepExecution
,这可以通过实现StepListener
并将其注册到Step
来实现。以下示例显示了设置标志的侦听器:
public class CustomItemWriter extends ItemListenerSupport implements StepListener {
private StepExecution stepExecution;
public void beforeStep(StepExecution stepExecution) {
this.stepExecution = stepExecution;
}
public void afterRead(Object item) {
if (isPoisonPill(item)) {
stepExecution.setTerminateOnly(true);
}
}
}
设置该标志后,默认行为是该步骤抛出JobInterruptedException
。可以通过StepInterruptionPolicy
控制此行为。但是,唯一的选择是引发或不引发异常,因此这始终是工作的异常结束。
1.3.添加页脚记录
通常,在写入平面文件时,在完成所有处理之后,必须在文件末尾附加“页脚”记录。可以使用 Spring Batch 提供的FlatFileFooterCallback
接口来实现。 FlatFileFooterCallback
(及其对应的FlatFileHeaderCallback
)是FlatFileItemWriter
的可选属性,可以将其添加到 Item 编写器中,如以下示例所示:
XML Configuration
<bean id="itemWriter" class="org.spr...FlatFileItemWriter">
<property name="resource" ref="outputResource" />
<property name="lineAggregator" ref="lineAggregator"/>
<property name="headerCallback" ref="headerCallback" />
<property name="footerCallback" ref="footerCallback" />
</bean>
Java Configuration
@Bean
public FlatFileItemWriter<String> itemWriter(Resource outputResource) {
return new FlatFileItemWriterBuilder<String>()
.name("itemWriter")
.resource(outputResource)
.lineAggregator(lineAggregator())
.headerCallback(headerCallback())
.footerCallback(footerCallback())
.build();
}
页脚回调接口只有一种必须在编写页脚时调用的方法,如以下接口定义所示:
public interface FlatFileFooterCallback {
void writeFooter(Writer writer) throws IOException;
}
1.3.1.编写摘要页脚
涉及页脚记录的常见要求是在输出过程中汇总信息,并将此信息附加到文件末尾。此页脚通常用作文件的摘要或提供校验和。
例如,如果批处理作业正在将Trade
条记录写入平面文件,并且要求将所有Trades
的总数放在页脚中,则可以使用以下ItemWriter
实现:
public class TradeItemWriter implements ItemWriter<Trade>,
FlatFileFooterCallback {
private ItemWriter<Trade> delegate;
private BigDecimal totalAmount = BigDecimal.ZERO;
public void write(List<? extends Trade> items) throws Exception {
BigDecimal chunkTotal = BigDecimal.ZERO;
for (Trade trade : items) {
chunkTotal = chunkTotal.add(trade.getAmount());
}
delegate.write(items);
// After successfully writing all items
totalAmount = totalAmount.add(chunkTotal);
}
public void writeFooter(Writer writer) throws IOException {
writer.write("Total Amount Processed: " + totalAmount);
}
public void setDelegate(ItemWriter delegate) {...}
}
此TradeItemWriter
存储一个totalAmount
值,该值随每个写入的Trade
项中的amount
增加。在处理最后一个Trade
之后,框架调用writeFooter
,这会将totalAmount
放入文件中。请注意,write
方法使用了临时变量chunkTotal
,该临时变量将Trade
总量存储在块中。这样做是为了确保,如果write
方法中发生跳过,则totalAmount
保持不变。只有在write
方法的末尾,一旦确保不引发任何异常,就可以更新totalAmount
。
为了调用writeFooter
方法,必须将TradeItemWriter
(实现FlatFileFooterCallback
)连接到FlatFileItemWriter
作为footerCallback
。以下示例显示了如何执行此操作:
XML Configuration
<bean id="tradeItemWriter" class="..TradeItemWriter">
<property name="delegate" ref="flatFileItemWriter" />
</bean>
<bean id="flatFileItemWriter" class="org.spr...FlatFileItemWriter">
<property name="resource" ref="outputResource" />
<property name="lineAggregator" ref="lineAggregator"/>
<property name="footerCallback" ref="tradeItemWriter" />
</bean>
Java Configuration
@Bean
public TradeItemWriter tradeItemWriter() {
TradeItemWriter itemWriter = new TradeItemWriter();
itemWriter.setDelegate(flatFileItemWriter(null));
return itemWriter;
}
@Bean
public FlatFileItemWriter<String> flatFileItemWriter(Resource outputResource) {
return new FlatFileItemWriterBuilder<String>()
.name("itemWriter")
.resource(outputResource)
.lineAggregator(lineAggregator())
.footerCallback(tradeItemWriter())
.build();
}
到目前为止,只有在无法重新启动Step
的情况下,TradeItemWriter
的写入方式才能正确运行。这是因为该类是有状态的(因为它存储了totalAmount
),但是totalAmount
没有持久化到数据库中。因此,在重新启动的情况下无法检索到它。为了使此类可重启,应将ItemStream
接口与方法open
和update
一起实现,如以下示例所示:
public void open(ExecutionContext executionContext) {
if (executionContext.containsKey("total.amount") {
totalAmount = (BigDecimal) executionContext.get("total.amount");
}
}
public void update(ExecutionContext executionContext) {
executionContext.put("total.amount", totalAmount);
}
在该对象持久保存到数据库之前,更新方法将totalAmount
的最新版本存储到ExecutionContext
。 open 方法从ExecutionContext
中检索任何现有的totalAmount
并将其用作处理的起点,从而允许TradeItemWriter
在重新启动时重新启动,而在上一次运行Step
时它停止了。
1.4.基于驱动查询的 ItemReader
在关于 Reader 和 Writer 的章节中,讨论了使用分页的数据库 Importing。许多数据库供应商,例如 DB2,都具有极其悲观的锁定策略,如果正在读取的表也需要由联机应用程序的其他部分使用,则会导致问题。此外,在非常大的数据集上打开游标会导致某些供应商的数据库出现问题。因此,许多 Item 更喜欢使用“驱动查询”方法来读取数据。这种方法通过遍历键而不是遍历需要返回的整个对象而起作用,如下图所示:
图 1.驱动查询作业
如您所见,上图中显示的示例使用与基于游标的示例相同的“ FOO”表。但是,不是选择整个行,而是在 SQL 语句中选择了 ID。因此,不是从read
返回FOO
对象,而是返回Integer
。然后可以使用该数字查询“详细信息”,它是一个完整的Foo
对象,如下图所示:
图 2.驾驶查询示例
应该使用ItemProcessor
将从驾驶查询中获得的键转换为完整的“ Foo”对象。现有的 DAO 可以用于根据密钥查询完整的对象。
1.5.多行记录
虽然平面文件通常将每个记录限制在一行中,但通常一个文件中的记录可能会跨越具有多种格式的多行。以下文件摘录显示了这种安排的示例:
HEA;0013100345;2007-02-15
NCU;Smith;Peter;;T;20014539;F
BAD;;Oak Street 31/A;;Small Town;00235;IL;US
FOT;2;2;267.34
以“ HEA”开头的行和以“ FOT”开头的行之间的所有内容均被视为一条记录。为了正确处理此情况,必须考虑以下几点:
-
ItemReader
不能一次读取一个记录,而必须将多行记录的每一行作为一个组读取,以便可以将其完整地传递给ItemWriter
。 -
每种线型可能需要不同地标记。
由于一条记录跨越多行,并且由于我们可能不知道有多少行,因此ItemReader
必须小心以始终读取整个记录。为此,应实现自定义ItemReader
作为FlatFileItemReader
的包装,如以下示例所示:
XML Configuration
<bean id="itemReader" class="org.spr...MultiLineTradeItemReader">
<property name="delegate">
<bean class="org.springframework.batch.item.file.FlatFileItemReader">
<property name="resource" value="data/iosample/input/multiLine.txt" />
<property name="lineMapper">
<bean class="org.spr...DefaultLineMapper">
<property name="lineTokenizer" ref="orderFileTokenizer"/>
<property name="fieldSetMapper" ref="orderFieldSetMapper"/>
</bean>
</property>
</bean>
</property>
</bean>
Java Configuration
@Bean
public MultiLineTradeItemReader itemReader() {
MultiLineTradeItemReader itemReader = new MultiLineTradeItemReader();
itemReader.setDelegate(flatFileItemReader());
return itemReader;
}
@Bean
public FlatFileItemReader flatFileItemReader() {
FlatFileItemReader<Trade> reader = new FlatFileItemReaderBuilder<Trade>()
.name("flatFileItemReader")
.resource(new ClassPathResource("data/iosample/input/multiLine.txt"))
.lineTokenizer(orderFileTokenizer())
.fieldSetMapper(orderFieldSetMapper())
.build();
return reader;
}
为了确保正确标记每行,这对于定长 Importing 尤其重要,可以在委托FlatFileItemReader
上使用PatternMatchingCompositeLineTokenizer
。有关更多详细信息,请参见Reader 和 Writer 一章中的 FlatFileItemReader。然后,委托 Reader 使用PassThroughFieldSetMapper
将每行的FieldSet
传递回包装ItemReader
,如以下示例所示:
XML Content
<bean id="orderFileTokenizer" class="org.spr...PatternMatchingCompositeLineTokenizer">
<property name="tokenizers">
<map>
<entry key="HEA*" value-ref="headerRecordTokenizer" />
<entry key="FOT*" value-ref="footerRecordTokenizer" />
<entry key="NCU*" value-ref="customerLineTokenizer" />
<entry key="BAD*" value-ref="billingAddressLineTokenizer" />
</map>
</property>
</bean>
Java Content
@Bean
public PatternMatchingCompositeLineTokenizer orderFileTokenizer() {
PatternMatchingCompositeLineTokenizer tokenizer =
new PatternMatchingCompositeLineTokenizer();
Map<String, LineTokenizer> tokenizers = new HashMap<>(4);
tokenizers.put("HEA*", headerRecordTokenizer());
tokenizers.put("FOT*", footerRecordTokenizer());
tokenizers.put("NCU*", customerLineTokenizer());
tokenizers.put("BAD*", billingAddressLineTokenizer());
tokenizer.setTokenizers(tokenizers);
return tokenizer;
}
此包装器必须能够识别记录的结尾,以便它可以连续地对其委托调用read()
直到到达结尾。对于读取的每一行,包装器应构建要返回的 Item。到达页脚后,可以将商品退回以交付给ItemProcessor
和ItemWriter
,如以下示例所示:
private FlatFileItemReader<FieldSet> delegate;
public Trade read() throws Exception {
Trade t = null;
for (FieldSet line = null; (line = this.delegate.read()) != null;) {
String prefix = line.readString(0);
if (prefix.equals("HEA")) {
t = new Trade(); // Record must start with header
}
else if (prefix.equals("NCU")) {
Assert.notNull(t, "No header was found.");
t.setLast(line.readString(1));
t.setFirst(line.readString(2));
...
}
else if (prefix.equals("BAD")) {
Assert.notNull(t, "No header was found.");
t.setCity(line.readString(4));
t.setState(line.readString(6));
...
}
else if (prefix.equals("FOT")) {
return t; // Record must end with footer
}
}
Assert.isNull(t, "No 'END' was found.");
return null;
}
1.6.执行系统命令
许多批处理作业要求从批处理作业中调用外部命令。调度程序可以单独启动此过程,但是有关运行的通用元数据的优势将丢失。此外,也需要将多步骤作业拆分为多个作业。
由于这种需求非常普遍,因此 Spring Batch 提供了Tasklet
实现来调用系统命令,如以下示例所示:
XML Configuration
<bean class="org.springframework.batch.core.step.tasklet.SystemCommandTasklet">
<property name="command" value="echo hello" />
<!-- 5 second timeout for the command to complete -->
<property name="timeout" value="5000" />
</bean>
Java Configuration
@Bean
public SystemCommandTasklet tasklet() {
SystemCommandTasklet tasklet = new SystemCommandTasklet();
tasklet.setCommand("echo hello");
tasklet.setTimeout(5000);
return tasklet;
}
1.7.未找到 Importing 时处理步骤完成
在许多批处理方案中,在数据库或文件中找不到要处理的行并不是 exception。 Step
仅被视为未找到任何工作,并完成了 0 项读取。 Spring Batch 中提供的所有ItemReader
实现都是默认使用此方法。如果即使在存在 Importing 的情况下也什么也不写的情况下,这可能会导致一些混乱(通常在文件名错误或出现类似问题时发生)。因此,应检查元数据本身,以确定发现框架要处理多少工作。但是,如果找不到任何 Importing 被认为是 exceptions 怎么办?在这种情况下,最好的方法是以编程方式检查元数据中是否有未处理的 Item 并导致失败。因为这是一个常见的用例,所以 Spring Batch 为监听器提供了恰好此功能,如NoWorkFoundStepExecutionListener
的类定义所示:
public class NoWorkFoundStepExecutionListener extends StepExecutionListenerSupport {
public ExitStatus afterStep(StepExecution stepExecution) {
if (stepExecution.getReadCount() == 0) {
return ExitStatus.FAILED;
}
return null;
}
}
前面的StepExecutionListener
在“ afterStep”阶段检查StepExecution
的readCount
属性,以确定是否未读取任何 Item。在这种情况下,将返回退出代码 FAILED,表明Step
应该失败。否则,将返回null
,这不会影响Step
的状态。
1.8.将数据传递到将来的步骤
将信息从一个步骤传递到另一步骤通常很有用。这可以通过ExecutionContext
完成。要注意的是,有两个ExecutionContexts
:一个在Step
级别,一个在Job
级别。 Step
ExecutionContext
仅保留到步长,而Job
ExecutionContext
保留到整个Job
。另一方面,每次Step
提交一个块时Step
ExecutionContext
被更新,而Job
ExecutionContext
仅在每个Step
的末尾被更新。
这种分离的结果是在执行Step
时必须将所有数据都放在Step
ExecutionContext
中。这样做可确保在Step
运行时正确存储数据。如果将数据存储到Job
ExecutionContext
,则在Step
执行期间不会将其保留。如果Step
失败,则该数据将丢失。
public class SavingItemWriter implements ItemWriter<Object> {
private StepExecution stepExecution;
public void write(List<? extends Object> items) throws Exception {
// ...
ExecutionContext stepContext = this.stepExecution.getExecutionContext();
stepContext.put("someKey", someObject);
}
@BeforeStep
public void saveStepExecution(StepExecution stepExecution) {
this.stepExecution = stepExecution;
}
}
为了使数据可供将来的Steps
使用,必须在完成该步骤后将其“提升”到Job
ExecutionContext
。 Spring Batch 为此提供了ExecutionContextPromotionListener
。必须为侦听器配置与必须提升的ExecutionContext
中的数据相关的键。也可以选择为其配置升级代码的退出代码模式列表(默认为COMPLETED
)。与所有侦听器一样,它必须在Step
上注册,如以下示例所示:
XML Configuration
<job id="job1">
<step id="step1">
<tasklet>
<chunk reader="reader" writer="savingWriter" commit-interval="10"/>
</tasklet>
<listeners>
<listener ref="promotionListener"/>
</listeners>
</step>
<step id="step2">
...
</step>
</job>
<beans:bean id="promotionListener" class="org.spr....ExecutionContextPromotionListener">
<beans:property name="keys">
<list>
<value>someKey</value>
</list>
</beans:property>
</beans:bean>
Java Configuration
@Bean
public Job job1() {
return this.jobBuilderFactory.get("job1")
.start(step1())
.next(step1())
.build();
}
@Bean
public Step step1() {
return this.stepBuilderFactory.get("step1")
.<String, String>chunk(10)
.reader(reader())
.writer(savingWriter())
.listener(promotionListener())
.build();
}
@Bean
public ExecutionContextPromotionListener promotionListener() {
ExecutionContextPromotionListener listener = new ExecutionContextPromotionListener();
listener.setKeys(new String[] {"someKey" });
return listener;
}
最后,必须从Job
ExecutionContext
检索保存的值,如以下示例所示:
public class RetrievingItemWriter implements ItemWriter<Object> {
private Object someObject;
public void write(List<? extends Object> items) throws Exception {
// ...
}
@BeforeStep
public void retrieveInterstepData(StepExecution stepExecution) {
JobExecution jobExecution = stepExecution.getJobExecution();
ExecutionContext jobContext = jobExecution.getExecutionContext();
this.someObject = jobContext.get("someKey");
}
}