SpringBatch 高级教程(六)
十一、扩容和调整
美国国税局在 2010 年处理了超过 2 . 36 亿份纳税申报表。亚特兰大的哈兹菲尔德-杰克逊机场在 2010 年接待了近 9000 万名乘客。脸书每天有超过 4500 万条状态更新。苹果公司在 iPhone 4ss 上市的前三天售出了超过 170 万部 iPhone 4。世界每天产生的数据量是惊人的。过去,随着数据的增加,处理数据的处理器也会增加。如果你的应用不够快,你可以等一年,买一个新的服务器,一切都会好的。
但现在情况不同了。CPU 的速度不再像以前那样快了。相反,通过向单个内核添加内核而不是晶体管,CPU 在并行处理方面变得更好,而不是在单个任务上变得更快。Spring Batch 背后的开发人员理解这一点,并将并行处理作为框架的主要焦点之一。本章着眼于以下内容:
- *分析批处理作业:*您会看到一个分析批处理作业的过程,这样您做出的优化决策会对您的性能产生积极影响,而不是相反。
- 评估 Spring Batch 中的每个可伸缩性选项: Spring Batch 提供了许多不同的可伸缩性选项,我们将详细介绍每一个选项。
分析您的批处理过程
迈克尔·杰克逊在他 1975 年的书《程序设计的原则》中提出了两个最佳优化规则:
第一条规则。别这么做。
第二条规则(仅适用于专家)。先别做。
这背后的想法很简单。软件在其开发过程中会发生变化。正因为如此,在系统开发出来之前,几乎不可能对如何设计系统做出准确的决定。系统开发完成后,您可以测试它的性能瓶颈,并根据需要解决这些问题。如果不采用这种方法,你可能会被我第二喜欢的关于优化的引用所描述,这个引用来自 W. A. Wulf:
更多的计算犯罪是以效率的名义犯下的(不一定能实现),而不是因为任何其他原因——包括盲目的愚蠢。
要分析任何 Java 应用,有许多选择,从免费到非常昂贵。然而,Java 虚拟机(JVM)中包含了一个最好的免费选项:VisualVM。这是可用于分析批处理作业的工具。在开始分析作业之前,让我们快速浏览一下 VisualVM 工具。
VisualVM 之旅
Oracle 的 VisualVM 是一个工具,可以让您深入了解 JVM 中正在发生的事情。作为 JConsole 的老大哥,VisualVM 不仅像 JConsole 一样提供 JMX 管理,还提供关于 CPU 和内存使用、方法执行时间以及线程管理和垃圾收集的信息。这一部分着眼于 VisualVM 工具的功能。
在你尝试 VisualVM 之前,你必须安装它。如果您运行的是高于版本 6 update 7 的 JDK 版本的 Oracle 发行版,那么您已经安装了它,因为它是 JDK 附带的。如果你运行的是不同版本的 Java,你可以在 visualvm.java.net/download.ht… 直接从甲骨文获得 VisualVM。
安装 VisualVM 后,您可以启动它。VisualVM 用左边的菜单和右边的起始页来迎接你,如图图 11-1 所示。
***图 11-1。*visual VM 的开始屏幕
左侧的菜单分为四个部分:本地和远程是您可以找到的应用,您可以连接到配置文件。当您启动 VisualVM 时,因为它本身是一个 Java 应用,所以它出现在本地部分。在本地和远程部分的下面,您可以加载之前收集的想要分析的 Java VM 核心转储;或快照,是虚拟机在某个时间点的状态,您可以使用 VisualVM 捕获。为了查看 VisualVM 工具的一些功能,让我们将 VisualVM 连接到 Eclipse 的一个实例。
当你第一次连接到一个正在运行的 JVM 时,VisualVM 显示如图图 11-2 所示的屏幕。
***图 11-2。*连接到 Java 进程
屏幕顶部有五个选项卡:
- Overview: 提供正在运行的 Java 应用的概述,包括主类、应用名、进程 id 和启动时传递给 JVM 的参数。
- Monitor: 显示图表,显示 CPU 利用率、内存利用率(堆和 PermGen)、加载的类的数量以及活动线程和守护线程的数量。Monitor 选项卡还允许您执行垃圾收集以及生成堆转储以供以后分析。
- Threads: 显示应用已经启动的所有线程的信息,以及它们正在做什么(运行、睡眠、等待或监控)。这些数据以时间线、表格或详细信息的形式显示。
- Sampler: 允许您对应用的 CPU 利用率和内存分配进行采样,并拍摄快照。CPU 显示哪些方法运行了多长时间。内存利用率显示了哪些类占用了多少内存。
- Profiler: 看起来和感觉上类似于 Sampler 选项卡,但是允许您分析 CPU 和内存的使用情况,以及保存这两个资源当前所处状态的快照。您还可以比较多个快照。
除了选项卡之外,Overview 还显示了正在分析的当前 Java 进程的信息,包括进程 id、运行该进程的主机、JVM 参数以及 JVM 知道的系统属性的完整列表。
第二个页签是监控器页签,如图图 11-3 所示。
***图 11-3。*Eclipse 实例的 Monitor 选项卡
Monitor 选项卡是从内存和 CPU 的角度整体查看 JVM 状态的地方。当您确定 Monitor 选项卡中确定的问题的原因时,其他选项卡会更有用(如果您一直内存不足或 CPU 由于某种原因而停滞)。“监控器”选项卡上的所有图表都可以调整大小,并且可以根据需要隐藏。
VisualVM 中可用的下一个选项卡是线程选项卡,显示在图 11-4 中。
***图 11-4。*visual VM 中的线程标签
所有的 Java 应用都是多线程的。至少,您有一个主执行线程和一个额外的垃圾收集线程。然而,由于各种原因,大多数 Java 应用都会产生许多额外的线程。此选项卡允许您查看应用产生的各种线程的信息以及它们正在做什么。图 11-4 以时间线的形式显示了数据,但是数据也可以以表格和每个线程的详细图表的形式提供。
最后两个选项卡非常相似。如图图 11-5 所示,第一个是采样器选项卡。
图 11-5。 VisualVM 的采样器标签
在这两个选项卡中,你会看到相同的屏幕,包括 CPU 和内存按钮以及停止按钮。要开始按方法对 CPU 执行进行采样或按类对内存占用进行采样,请单击相应的按钮。这些表会根据 VisualVM 正在研究的虚拟机的当前状态定期更新。这两个选项卡的区别在于,Profiler 选项卡可以执行垃圾收集并保存收集到的数据,而 sampler 选项卡则不能。
VisualVM 是一个强大且可扩展的工具。许多插件可用来扩展现成的特性集。您可以添加一些功能,例如使用 Thread Inspector 插件查看当前正在执行的线程的堆栈跟踪,使用 Visual GC 插件进行可视化垃圾收集,以及通过 MBean 浏览器访问 MBean,以扩展 VisualVM 已经非常强大的工具套件。
现在,您已经了解了 Oracle 的 VisualVM 可以做什么,让我们看看如何使用它来分析 Spring 批处理应用。
剖析 Spring 批处理应用
当您分析您的应用时,您通常会查看两件事情中的一件:CPU 的工作强度和工作位置,以及使用了多少内存和使用了哪些内存。第一个问题,CPU 工作有多努力,在哪里工作,与你的 CPU 在做什么有关。你的工作在计算上有困难吗?您的 CPU 是否将大量精力用在了业务逻辑之外的地方——例如,它花在解析文件上的时间是否比实际进行您想要的计算多?第二组问题围绕着记忆。您是否使用了大部分(如果不是全部)可用内存?如果是,是什么占用了所有的内存?您是否有一个 Hibernate 对象没有延迟加载集合,从而导致了这些问题?这一节将介绍如何在 Spring 批处理应用中查看资源的使用情况。
CPU 剖析
在分析应用时,最好有一个简单明了的检查清单。但这并不容易。有时候,剖析应用更像是一门艺术,而不是一门科学。本节将介绍如何获取与应用性能及其 CPU 利用率相关的数据。
当您查看 CPU 在您的应用中的性能时,通常会使用时间度量来确定热点(性能未达到预期的区域)。CPU 工作最多的是哪些领域?例如,如果你的代码中有一个无限循环,在它被触发后,CPU 会在那里花费大量的时间。然而,如果一切都运行良好,您可以预期看到没有瓶颈或者出现了您预期的瓶颈(I/O 通常是大多数现代系统的瓶颈)。
要查看工作中的 CPU 分析功能,让我们使用您在上一章中完成的语句作业。这项工作包括六个步骤,并与互联网、文件和数据库进行交互。图 11-6 从较高的层面显示了当前配置下的作业。
***图 11-6。*声明工作
要执行作业,您可以使用命令java -jar statement-job-1.0.0-SNAPSHOT.jar jobs/statementJob.xml statementJob –next。启动作业后,它会出现在本地下方左侧的 VisualVM 菜单中。要连接到它,你只需要双击它。
现在,您已经连接到运行语句作业,您可以开始查看它是如何工作的。让我们先来看看 Monitor 选项卡,看看 CPU 有多忙。在使用包含 100 个客户和 20,000 多个事务的客户事务文件运行语句作业后,您可以看到该作业的 CPU 利用率极低。图 11-7 显示了作业运行后监控器选项卡中的图表。
***图 11-7。*报表作业的资源利用率
如图 11-7 所示,语句作业不是一个 CPU 密集型的过程。事实上,如果您查看内存配置文件,该作业也不是非常占用内存。然而,你可以很容易地改变这一点。如果你在第 4 步使用的 ItemProcessor】)中添加一个小循环,你可以很快让你的 CPU 忙碌起来。清单 11-1 显示了你添加的循环。
***清单 11-1。*用PricingTiersItemProcessor计算质数
`package com.apress.springbatch.statement.processor;
import java.math.BigInteger;
import org.springframework.batch.item.ItemProcessor;
import com.apress.springbatch.statement.domain.AccountTransactionQuantity; import com.apress.springbatch.statement.domain.PricingTier;
public class PricingTierItemProcessor implements ItemProcessor<AccountTransactionQuantity, AccountTransactionQuantity> {
public AccountTransactionQuantity process(AccountTransactionQuantity atq)
throws Exception { for(int i = 0; i < 1000000; i++){
new BigInteger(String.valueOf(i)).isProbablePrime(0);
}
if(atq.getTransactionCount() <= 1000) { atq.setTier(PricingTier.I); } else if(atq.getTransactionCount() > 1000 && atq.getTransactionCount() <= 100000) { atq.setTier(PricingTier.II); } else if(atq.getTransactionCount() > 100000 && atq.getTransactionCount() <= 1000000) { atq.setTier(PricingTier.III); } else { atq.setTier(PricingTier.IV); }
return atq; } }`
显然,你添加的用于计算 0 到 100 万之间所有质数的循环(如清单 11-1 所示)不太可能出现在你的代码中。但正是这种意外循环可能会在处理数百万个事务的过程中对批处理作业的性能造成灾难性的影响。根据 VirtualVM,图 11-8 显示了这个小循环对 CPU 利用率的影响。
***图 11-8。*更新后的语句作业的 CPU 利用率
对于三行代码来说,这是一个相当大的峰值。这项工作从几乎不使用 CPU 发展到将 CPU 利用率提高到 50%。但是如果你不知道是什么导致了这个峰值,你下一步会去哪里找呢?
像这样识别出一个尖峰后,下一步要看的是采样器标签。通过在相同的条件下重新运行作业,您可以看到哪些单独的方法在作业的执行中显示为热点。在这种情况下,在您开始运行作业后,立即突出的方法是com.mysql.jdbc.util.ReadAheadInputStream.fill()。MySQL 驱动程序使用这个类从数据库中读取数据。正如您之前看到的,I/O 通常是当今业务系统中的主要处理瓶颈,因此看到这个类占用了大部分 CPU 并不奇怪。然而,在 Monitor 选项卡上的峰值开始的同时,一个新的类在使用大量 CPU 的方法列表中快速攀升:com.apress.springbatch.statement.processor.PricingTierItemProcessor.process()。在作业结束时,该方法已经占用了执行该作业所需的全部 CPU 时间的 32.6%,如图图 11-9 所示。
图 11-9。PricingTierItemProcessor占用了相当多的 CPU。
当您遇到这样的场景时,查看是什么在消耗 CPU 执行时间的一个更好的方法是通过您的代码所使用的包名来过滤列表。在这种情况下,您可以过滤com.apress.springbatch.statement上的列表,查看哪些类占用了总 CPU 利用率的多少百分比。在这个过滤器下,这个例子中的罪魁祸首变得非常清楚:这个PricingTierItemProcessor.process() method and the 32.6% of the CPU time it takes up.。第二高的占 0.3% ( com.apress.springbatch.statement.domain.PricingTier.values())。此时,您已经从工具中获得了所有的信息,是时候开始挖掘代码来确定PricingTierItemProcessor.process()中的什么使用了这么多的 CPU 了。
很简单,不是吗?不完全是。虽然这里使用的过程是您在任何系统中用来缩小问题范围的过程,但是问题很少这么容易跟踪。但是,使用 VisualVM,您可以逐步缩小工作中的问题范围。CPU 利用率不是唯一的性能指标。下一节将介绍如何使用 VisualVM 分析内存。
记忆剖析
虽然 CPU 利用率可能看起来像是您最有可能看到问题的地方,但事实是,根据我的经验,内存问题更有可能出现在您的软件中。原因是您使用了许多在幕后做事情的框架。当您不正确地使用这些框架时,可能会创建大量的对象,而没有任何迹象表明它已经发生,直到您完全耗尽内存。本节介绍如何使用 VisualVM 分析内存使用情况。
为了了解如何分析内存,让我们调整一下之前的PricingTierItemProcessor。但是,这次不是占用处理时间,而是对其进行更新,以模拟创建一个失控的集合。虽然这个代码示例可能不是您在现实系统中看到的,但意外创建比您预期的更大的集合是内存问题的一个常见原因。清单 11-2 显示了更新后的PricingTierItemProcessor的代码。
清单 11-2。 PricingTierItemProcessor出现内存泄漏
`package com.apress.springbatch.statement.processor;
import java.util.ArrayList; import java.util.List;
import org.springframework.batch.item.ItemProcessor;
import com.apress.springbatch.statement.domain.AccountTransactionQuantity; import com.apress.springbatch.statement.domain.PricingTier;
public class PricingTierItemProcessor implements ItemProcessor<AccountTransactionQuantity, AccountTransactionQuantity> {
private List accountsProcessed = new ArrayList();
public AccountTransactionQuantity process(AccountTransactionQuantity atq) throws Exception {
if(atq.getTransactionCount() <= 1000) { atq.setTier(PricingTier.I); } else if(atq.getTransactionCount() > 1000 && atq.getTransactionCount() <= 100000) { atq.setTier(PricingTier.II); } else if(atq.getTransactionCount() > 100000 && atq.getTransactionCount() <= 1000000) { atq.setTier(PricingTier.III); } else { atq.setTier(PricingTier.IV); }
for(int i = 0; i <atq.getTransactionCount() * 750; i++) { accountsProcessed.add(atq.getTier()); }
return atq; } }`
在清单 11-2 所示的版本中,您正在创建一个对象的List,它将存在于当前正在处理的块之后。在正常处理下,当块完成时,给定块中涉及的大多数对象都被垃圾收集,从而控制内存占用。通过像本例中这样的操作,您会发现内存占用会失控。
当您带着这个 bug 运行语句作业并使用 VisualVM 对其进行剖析时,您可以看到从内存角度来看事情很快就失控了;在步骤中途抛出一个OutOfMemoryException。图 11-10 显示了带有内存泄漏的语句作业的运行。
***图 11-10。*内存泄漏语句作业的监控结果
注意在图 11-10 右上角的内存图的最末端,内存使用达到峰值,导致OutOfMemoryException。但是你怎么知道是什么导致了峰值呢?如果你不知道,采样器标签可能会透露一些信息。
您之前已经看到过,Sampler 选项卡可以显示哪些方法调用占用了 CPU,但它也可以告诉您哪些对象占用了宝贵的内存。要看到这一点,从像以前一样执行你的工作开始。当它运行时,使用 VisualVM 连接到进程并转到 Sampler 选项卡。要确定内存泄漏的原因,您需要确定随着内存使用量的增加会发生什么变化。例如在图 11-11 中,每个块代表一个类实例。每列中的块堆叠得越高,内存中的实例就越多。每一列代表 JVM 中的一个时间快照。当程序开始时,创建的实例数量很少(本例中只有一个);随着时间的推移,这个数字会慢慢上升,当垃圾收集发生时,这个数字偶尔会下降。最后,它在最后达到 9 个实例。这是您在 VisualVM 中寻找的内存使用增加的类型。
***图 11-11。*程序生命周期中的内存利用率
要在批处理作业中查看这种类型的更改,可以使用 VisualVM 的快照功能。当作业运行时,单击屏幕中间的快照按钮。当您拍摄快照时,VisualVM 会记录 JVM 的确切状态。您可以将此快照与其他快照进行比较,以确定哪些更改。通常,这种变化表示问题的位置。如果它不是确凿的证据,那它绝对是你应该开始寻找的地方。
正如本章前面几节所讨论的,扩展批处理作业的能力并不是解决性能错误的必要条件。相反,无论您做什么,具有上述缺陷的工作通常都不会扩展。相反,在应用 Spring Batch 或任何框架提供的可伸缩性特性之前,您需要解决应用中的问题。当您的系统不存在这些问题时,Spring Batch 提供的超越单线程、单 JVM 方法的特性是所有框架中最强的。在本章的剩余部分,您将了解如何使用 Spring Batch 的可伸缩性特性。
扩容工作
在企业中,当事情进展顺利时,数据就会变大。更多的顾客。更多交易。更多的网站点击率。更多,更多,更多。您的批处理作业需要能够跟上。Spring Batch 从一开始就被设计成高度可伸缩的,以满足小型批处理作业和大型企业级批处理基础设施的需求。本节将介绍 Spring Batch 在扩展默认流程之外的批处理作业时采用的四种不同方法:多线程步骤、并行步骤、远程分块和分区。
多线程步骤
当一个步骤被处理时,默认情况下它是在一个单独的线程中处理的。虽然多线程步骤是并行化作业执行的最简单的方法,但与所有多线程环境一样,使用它时需要考虑一些方面。这一节将介绍 Spring Batch 的多线程步骤,以及如何在批处理作业中安全地使用它。
Spring Batch 的多线程步骤概念允许一个批处理作业使用 Spring 的org.springframework.core.task.TaskExecutor抽象在自己的线程中执行每个块。图 11-12 显示了使用多线程步骤时处理如何工作的一个例子。
***图 11-12。*多线程单步处理
如图 11-12 所示,一个任务中的任何步骤都可以被配置成在一个线程池中执行,独立地处理每个块。在处理程序块时,Spring Batch 会跟踪相应的操作。如果在任何一个线程中出现错误,作业的处理将按照常规的 Spring 批处理功能回滚或终止。
要配置一个以多线程方式执行的步骤,您需要做的就是为给定的步骤配置一个对 TaskExecutor 的引用。如果以语句作业为例,清单 11-3 展示了如何将calculateTransactionFees步骤(步骤 5)配置为多线程步骤。
清单 11-3。 calculateTransactionFees作为多线程步骤
… ` <chunk reader="transactionPricingItemReader" processor="feesItemProcessor" writer="applyFeeWriter" commit-interval="100"/>
<beans:bean id="taskExecutor" class="org.springframework.core.task.SimpleAsyncTaskExecutor"> <beans:property name="concurrencyLimit" value="10"/> </beans:bean> …`
如清单 11-3 所示,将 Spring 的多线程能力添加到工作中的一个步骤所需要的就是定义一个 TaskExecutor 实现(在本例中使用org.springframework.core.task.SimpleAsyncTaskExecutor)并在步骤中引用它。当您执行语句作业时,Spring 会创建一个 10 个线程的线程池,在不同的线程中执行每个块,或者并行执行 10 个块。你可以想象,这对大多数工作来说都是一个强有力的补充。
但是使用多线程步骤时有一个问题。Spring Batch 提供的大多数 ItemReaders 都是有状态的。Spring Batch 在重新启动作业时使用这种状态,因此它知道处理是从哪里停止的。但是,在多线程环境中,以多线程可访问的方式维护状态的对象(不同步等)可能会遇到线程覆盖彼此状态的问题。
为了绕过状态问题,您使用了 staging 在批处理运行中要处理的记录的概念。这个概念很简单。在该步骤开始之前,使用 StepListener 以某种方式标记所有记录,将它们标识为要在当前批处理运行(或 JobInstance)中处理的记录。可以通过更新数据库字段上的一个或多个特殊列或者将记录复制到临时表中来进行标记。然后,ItemReader 正常地读取在步骤开始时被标记为的记录。当每个块完成时,使用 ItemWriteListener 将刚刚处理的记录更新为已被处理。
为了将这个概念应用到语句作业的calculateTransactionFees步骤中,首先向事务表中添加两列:jobId 和 processed。jobId 存储语句作业当前运行的 run.id。第二列是一个布尔值,如果记录已被处理,则值为 true 如果记录未被处理,则值为 false。图 11-13 显示了更新后的表格定义。
***图 11-13。*更新了包含暂存列的交易表的数据模型
为了使用这些列,您需要创建一个 StepListener 来更新您用 jobId 处理的记录,并将您处理的记录的 processed 标志设置为 false。为此,您创建了一个名为StagingStepListener的 StepListener,它更新您配置的任何表上的这些列,并为其他表重用它。清单 11-4 显示了StagingStepListener的代码。
清单 11-4。??StagingStepListener
`package com.apress.springbatch.statement.listener; import org.springframework.batch.core.StepListener; import org.springframework.batch.core.annotation.BeforeStep; import org.springframework.jdbc.core.JdbcTemplate;
public class StagingStepListener extends JdbcTemplate implements StepListener {
private String SQL = " set jobId = ?, processed = false "; private String tableName; private String whereClause = ""; private long jobId;
@BeforeStep public void stageRecords() { update("update " + tableName + SQL + whereClause, new Object [] {jobId}); }
public void setTableName(String tableName) {
this.tableName = tableName;
} public void setJobId(long jobId) {
this.jobId = jobId;
}
public void setWhereClause(String whereClause) { if(whereClause != null) { this.whereClause = whereClause; } } }`
如您所见,清单 11-4 中的 StepListener 更新了您用作业 id 标识的所有记录,这些记录将由您的步骤处理。台阶的另一端是ItemWriteListener。这个侦听器接口在块被写入之前或之后(这里是之后)被调用。方法afterWrite采用 ItemWriter 先前编写的相同的条目列表。使用此功能,您可以更新要标记为已处理的暂存记录。清单 11-5 显示了这个监听器的代码。
清单 11-5。??StagingChunkUpdater
`package com.apress.springbatch.statement.listener;
import java.util.List;
import org.springframework.batch.core.ItemWriteListener; import org.springframework.jdbc.core.JdbcTemplate;
import com.apress.springbatch.statement.domain.AccountTransaction;
public class StagingChunkUpdater extends JdbcTemplate implements ItemWriteListener {
private String SQL = " set processed = true "; private String tableName; private String whereClause = "";
public void beforeWrite(List<? extends AccountTransaction> items) { }
public void afterWrite(List<? extends AccountTransaction> items) { for (AccountTransaction accountTransaction : items) { update("update " + tableName + SQL + whereClause, new Object[] {accountTransaction.getId()}); } }
public void onWriteError(Exception exception, List<? extends AccountTransaction> items) { }
public void setTableName(String tableName) {
this.tableName = tableName; }
public void setWhereClause(String whereClause) { this.whereClause = whereClause; } }`
当块被处理时,不管线程是什么,StagingChunkUpdater更新被标记为已处理的项目。你还需要做两件事。首先,您需要更新配置以使用新的侦听器;其次,您需要更新用于该步骤的 ItemReader 的查询,以便在其标准中包含 jobId 和 processed 标志。清单 11-6 显示了更新后的配置,包括更新后的 ItemReader、新的登台监听器和更新后的calculateTransactionFees步骤。
***清单 11-6。*使用分段监听器的多线程步骤的配置
… `<beans:bean id="transactionPricingItemReader" class="org.springframework.batch.item.database.JdbcCursorItemReader" scope="step"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="sql" value="select a.id as accountId, a.accountNumber, t.id as transactionId, t.qty, tk.ticker, a.tier, t.executedTime, t.dollarAmount from account a inner join transaction t on a.id = t.account_id inner join ticker tk on t.tickerId = tk.id and t.processed = false and t.jobId = #{jobParameters[run.id]} order by t.executedTime"/> <beans:property name="rowMapper" ref="transactionPricingRowMapper"/> </beans:bean>
<beans:bean id="transactionPricingRowMapper" class="com.apress.springbatch.statement.reader.AccountTransactionRowMapper"/>
<beans:bean id="stagingStepListener"
class="com.apress.springbatch.statement.listener.StagingStepListener" scope="step"> <beans:property name="dataSource" ref="dataSource"/>
<beans:property name="tableName" value="transaction"/>
<beans:property name="whereClause"
value="where jobId is null and processed is null"/>
<beans:property name="jobId" value="#{jobParameters[run.id]}"/>
</beans:bean>
<beans:bean id="stagingChunkUpdater" class="com.apress.springbatch.statement.listener.StagingChunkUpdater" scope="step"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="tableName" value="transaction"/> <beans:property name="whereClause" value="where id = ?"/> </beans:bean>
<beans:bean id="taskExecutor" class="org.springframework.core.task.SimpleAsyncTaskExecutor"> <beans:property name="concurrencyLimit" value="10"/> </beans:bean> …`
通过采用分段记录的方法,您可以让 Spring Batch 不必担心步骤的状态,因为它是单独维护的。不幸的是,这种解决方案仍然不完美,因为它只在使用可以这样管理的输入源时才实用(数据库是典型的用例)。平面文件不能以分阶段的方式管理。然而,最终大多数输入情况都可以通过允许多线程处理的方式来解决。
并行步骤
多线程步骤提供了在作业的同一步骤中并行处理大量项目的能力,但有时并行执行整个步骤也很有帮助。以导入多个彼此没有关系的文件为例。一个导入没有理由需要等待另一个导入完成后才开始。Spring Batch 并行执行步骤甚至流程(可重用的步骤组)的能力允许您提高一个任务的总吞吐量。这一节将介绍如何使用 Spring Batch 的并行步骤和流程来提高作业的整体性能。
如果你想在网上提交一份订单,在物品被放入箱子并交给邮递员送到你家门口之前,需要做一些事情。你需要保存订单。付款需要确认。库存需要验证。需要为要从仓库获取并包装的物料生成提货清单。但并非所有这些工作都需要按顺序执行。作为并行处理的一个例子,让我们看一个作业,它接收一个订单,将其导入数据库,然后并行地验证付款和库存。如果两者都可用,则处理订单。图 11-14 显示了该示例作业的流程图。
***图 11-14。*订单处理作业的流程
这是一个分四步走的工作。步骤 1 用步骤 2 中要读取的数据预填充 JMS 队列。 1 虽然可以使用任意数量的输入选项,但是使用 JMS 进行订单交付对于现实世界来说是一个现实的选项;所以,这个例子使用了它。第 2 步从 JMS 队列中读取并保存数据库,这样如果在以后的处理过程中出现问题,您就不会丢失订单。从那里,您并行执行两个不同的步骤。一个步骤是验证资金是否可用于购买。第二种是用库存系统核实库存。如果这两个检查都成功,则处理订单并生成仓库的挑库单。
要开始完成这项工作,让我们看一下对象模型。具体有三个域类:Customer、Order、OrderItem,如图图 11-15 所示。
***图 11-15。*并行处理作业的类图
一个Order,如图图 11-15 所示,由一个Customer、订单特定信息(主要是付款信息)和一个OrderItem列表组成。客户购买的每件商品在列表中都有一个OrderItem条目,包含商品特定信息,包括商品编号和订购数量。
为了完成这项工作,您需要编写少量代码。具体来说:
- 为您的工作生成样本订单的 ItemReader
CreditService/InventoryService:作为项目处理器的服务,用于验证用户的信用,并验证您是否有库存来处理订单PickListFormatter:生成您生成的选择列表所需格式的行聚合器
从全局来看,这第一步可能没什么意义。它显然不会进入你的生产工作。相反,这是在执行作业之前构建测试数据的好方法。
由于没有要处理的订单,你什么也做不了,所以你工作的第一步是生成要处理的订单。下一节将讨论实现这一点所需的代码和配置。
预加载数据进行处理
没有店面卖东西,就需要自己建立订单进行加工。为此,您将创建一个基于硬编码数据生成随机订单的 ItemReader 实现。虽然您不会在生产中这样做,但它将允许您设置测试其余工作所需的数据。在这一节中,我们将对生成测试数据所需的组件进行编码和配置。
让我们从查看OrderGenerator ItemReader 实现开始。为了给本书中的大部分工作生成测试文件,我编写了 Ruby 脚本(包含在本书的源代码中)来生成数据,这比我手工编写要快得多。这个类只不过是那些 Ruby 脚本的 Java 等价物。清单 11-7 显示了OrderGenerator的代码。
清单 11-7。??OrderGenerator
`package com.apress.springbatch.chapter11processor;
import java.math.BigDecimal; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Random;
import org.springframework.batch.item.ItemReader;
import com.apress.springbatch.chapter11.domain.Customer; import com.apress.springbatch.chapter11.domain.Order; import com.apress.springbatch.chapter11.domain.OrderItem;
public class OrderGenerator implements ItemReader {
private static final String [] STREETS = {"Second", "Third", "Fourth", "Park", "Fifth"}; private static final String[] CITIES = {"Franklin", "Clinton", "Springfield", "Greenville"}; private static final String[] FIRST_NAME = {"Jacob", "Ethan", "Michael", "Alexander"}; private static final String[] LAST_NAME = {"Smith", "Jones", "Thompson", "Williams"}; private static final String[] STATES = {"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE"};
private Random generator = new Random(); private DateFormat formatter = new SimpleDateFormat("MM/yy"); private int counter = 0;
public Order read() throws Exception { if(counter < 100) { Order curOrder = new Order();
curOrder.setCreditCardNumber(String.valueOf(generator.nextLong())); curOrder.setCustomer(buildCustomer());
curOrder.setExpirationDate(formatter.format(new Date()));
curOrder.setPlacedOn(new Date());
curOrder.setItems(buildItems(curOrder));
counter ++;
return curOrder; } else { return null; } }
private List buildItems(Order order) { List items = new ArrayList(); int total = 0;
while(total <= 0) { total = generator.nextInt(10); }
for(int i = 0; i < total; i++) { OrderItem item = new OrderItem();
item.setItemNumber(String.format("%09d", generator.nextLong())); item.setPrice(BigDecimal.valueOf(generator.nextDouble())); item.setQty(generator.nextInt(5)); item.setOrder(order);
items.add(item); }
return items; }
private Customer buildCustomer() { Customer customer = new Customer();
customer.setAddress(generator.nextInt(999) + " " + STREETS[counter % STREETS.length]); customer.setCity(CITIES[counter % CITIES.length]); customer.setCustomerName(FIRST_NAME[counter % FIRST_NAME.length] + " " + LAST_NAME[counter % LAST_NAME.length]); customer.setState(STATES[counter % STATES.length]); customer.setZip(String.format("%05d", generator.nextInt(99999)));
return customer; } }`
代码非常简单。您构建一个Order对象,填充Customer并生成几个OrderItem,然后发送它。当您使用 Spring Batch 像这样设置数据时,您可以轻松地运行各种测试场景。
要使用这个类,你需要开始构建你的工作。因为第一步只包括使用OrderGenerator生成Order并将它们写入 JMS 队列,所以您可以将所有这些连接起来并进行测试,而不需要做任何进一步的工作。清单 11-8 显示了作业的配置,它是一个单步作业,生成数据并将其放入队列中,供以后的步骤提取。
**清单 11-8。**配置了第一步的parallelJob.xml中配置的并行作业
` <beans:beans xmlns:beans="www.springframework.org/schema/bean…" xmlns:util="www.springframework.org/schema/bean…" xmlns:xsi="www.w3.org/2001/XMLSch…" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… www.springframework.org/schema/util www.springframework.org/schema/util… www.springframework.org/schema/batc… www.springframework.org/schema/batc…
<beans:import resource="../launch-context.xml"/>
<beans:bean id="jmsWriter" class="org.springframework.batch.item.jms.JmsItemWriter"> <beans:property name="jmsTemplate" ref="jmsTemplate"/> </beans:bean>
<beans:bean id="dataGenerator" class="com.apress.springbatch.chapter11.processor.OrderGenerator"/>
</beans:beans>`
虽然任务本身现在已经配置好了,但是您需要对这个任务的launch-context.xml和您的 POM 文件做一些小的调整。具体来说,您需要在launch-context.xml中配置 JMS 支持和 Hibernate 支持(您使用 Hibernate 来简化对象层次结构的存储),并将适当的依赖项添加到 POM 文件中。 2
让我们首先更新 POM 文件。清单 11-9 显示了 ActiveMQ 和 Spring 的 JMS 支持的附加依赖,以及 Hibernate 依赖和 Spring 的 ORM 支持模块。
参见第七章和第九章,了解更多关于在工作中使用 Hibernate 和 JMS 的信息。
***清单 11-9。*更新 POM 文件以支持 JMS 和 Hibernate
... <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jms</artifactId> <version>${spring.framework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-orm</artifactId> <version>${spring.framework.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> <version>3.3.0.SP1</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-entitymanager</artifactId> <optional>true</optional> <version>3.3.2.GA</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-annotations</artifactId> <optional>true</optional> <version>3.4.0.GA</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-core</artifactId> <version>5.4.2</version> <exclusions> <exclusion> <groupId>org.apache.activemq</groupId> <artifactId>activeio-core</artifactId> </exclusion> </exclusions> </dependency> ...
POM 文件更新后,您可以更新launch-context.xml。所需的更新是 JMS 资源(连接工厂、目的地和JmsTemplate)以及 Hibernate 资源(一个SessionFactory和更新的事务管理器)的配置。清单 11-10 显示了这个任务的launch-context.xml文件。
清单 11-10。??launch-context.xml
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns:p="http://www.springframework.org/schema/p" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans `www.springframework.org/schema/bean…
<bean id="jobOperator" class="org.springframework.batch.core.launch.support.SimpleJobOperator" p:jobLauncher-ref="jobLauncher" p:jobExplorer-ref="jobExplorer" p:jobRepository-ref="jobRepository" p:jobRegistry-ref="jobRegistry" />
<bean id="jobExplorer" class="org.springframework.batch.core.explore.support.JobExplorerFactoryBean" p:dataSource-ref="dataSource" />
<bean id="jobRegistry" class="org.springframework.batch.core.configuration.support.MapJobRegistry" />
<bean id="jobRepository" class="org.springframework.batch.core.repository.support.JobRepositoryFactoryBean" p:dataSource-ref="dataSource" p:transactionManager-ref="transactionManager" />
classpath:hibernate.cfg.xml
<property name="configurationClass">
org.hibernate.cfg.AnnotationConfiguration
false
false
update
org.hibernate.dialect.MySQLDialect
<bean id="transactionManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager" lazy-init="true">
<bean id="placeholderProperties" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"> <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" /> `
如你所见,绝大多数的launch-context.xml是默认的。主要的变化是在最后添加了 JMS 资源和 Hibernate 资源。请注意,默认配置的TransactionManager已经被替换为您在此工作中使用的那个。
为了让这项工作的各个部分协同工作,您需要做的最后一项配置是hibernate.cfg.xml文件。因为您使用注释来进行映射,所以hibernate.cfg.xml文件只不过是被映射的类的列表。清单 11-11 包含了这个例子的文件源。
清单 11-11。hibernate.cfg.xml
`
`当您按原样构建和运行作业时,您可以直接使用 JobRepository 或检查 Spring Batch Admin 来确认 100 个项目被读取,100 个项目被写入(如在OrderGenerator类中指定的)到您的 JMS 队列。无论是哪种情况,您都可以开始构建完成工作的批处理作业。
将订单载入数据库
作业的第一步(实际上是第二步)从 JMS 队列中读取订单,并将它们存储在数据库中以供进一步处理。像以前的许多步骤一样,这一步只包含 XML 配置。您需要配置一个JMSItemReader来从队列中获取订单,配置一个HibernateItemWriter来将对象存储在数据库中。然而,在查看 ItemReader 和 ItemWriter 的配置之前,清单 11-12 中有Customer、Order和OrderItem对象的代码,显示了它们的 Hibernate 映射。
***清单 11-12。*休眠映射为Customer、Order和OrderItem、
`Customer package com.apress.springbatch.chapter11.domain;
import java.io.Serializable;
import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import javax.persistence.Version;
@Entity @Table(name="customers") public class Customer implements Serializable{
private static final long serialVersionUID = 1L;
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
private String customerName;
private String address;
private String city;
private String state;
private String zip;
// Accessors go here ... }
Order package com.apress.springbatch.chapter11.domain;
import java.io.Serializable; import java.util.Date; import java.util.List;
import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToOne; import javax.persistence.OneToMany; import javax.persistence.Table; import javax.persistence.Version;
@Entity @Table(name="orders") public class Order implements Serializable{
private static final long serialVersionUID = 1L;
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Version private long version;
@ManyToOne(cascade = CascadeType.ALL) private Customer customer; private String creditCardNumber; private String expirationDate;
@OneToMany(cascade = CascadeType.ALL, mappedBy="order", fetch = FetchType.LAZY)
private List items; private Date placedOn;
private Boolean creditValidated;
// Accessors go here ... }
OrderItem package com.apress.springbatch.chapter11.domain;
import java.io.Serializable; import java.math.BigDecimal;
import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToOne; import javax.persistence.Table; import javax.persistence.Version;
@Entity @Table(name="orderItems") public class OrderItem implements Serializable{
private static final long serialVersionUID = 1L;
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Version private long version; private String itemNumber; private int qty; private BigDecimal price; private Boolean inventoryValidated; @ManyToOne private Order order;
// Accessors go here ... }`
注释是 JMS 和数据库资源基线配置的最后一部分。现在,您可以配置作业的第 2 步,从 JMS 队列中读取订单,并将它们保存在数据库中。清单 11-13 显示了并行作业第二步的配置。
清单 11-13。ParallelJob的第二步
`... <beans:bean id="jmsReader" class="org.springframework.batch.item.jms.JmsItemReader"> <beans:property name="jmsTemplate" ref="jmsTemplate"/> </beans:bean>
<beans:bean id="orderWriter" class="org.springframework.batch.item.database.HibernateItemWriter"> <beans:property name="sessionFactory" ref="sessionFactory"/> </beans:bean>
...`接下来,您需要验证客户的信用卡是否可以使用,以及您是否有库存来完成订单。因为这些功能并不直接相关,所以您可以并行执行它们来提高作业的整体吞吐量。接下来您将看到这些步骤是如何配置的。
配置并行步骤
为了并行执行步骤,Spring Batch 再次使用 Spring 的 TaskExecutor。在这种情况下,每个流都在自己的线程中执行,从而允许您并行执行多个流。要对此进行配置,可以使用 Spring Batch 的split标签。split标签有三个必需的属性:
id:元素的 id。task-executor:对 Spring Batch 用来管理用于并行处理的线程的 TaskExecutor 实现的引用。next:告诉 Spring Batch 在所有流程成功完成后执行哪个步骤。一个split标签将多个步骤包装成一个伪步骤;如果任何流在执行中失败,同时运行的其他步骤完成,然后当并行处理结束时作业失败。
需要注意的是,使用split的作业的执行顺序类似于常规作业的执行顺序。在常规作业中,一个步骤直到该步骤的所有项目都被处理后才完成,下一个步骤直到上一个步骤完成后才开始。使用split,直到分割中配置的所有流都已完成,才执行分割后的步骤。
注意拆分后的步骤直到一个拆分内的所有流程完成后才执行。
为了做这项工作所需的信用验证和库存检查,您可以开发一些高科技服务来为您执行检查。在您的步骤中,您将这些服务中的每一个都用作项目处理器。首先我们来看CreditServiceImpl,它负责验证客户的信用卡是否会通过。清单 11-14 显示了与这个过程相关的代码。
清单 11-14。??CreditServiceImpl
`package com.apress.springbatch.chapter11.service.impl;
import com.apress.springbatch.chapter11.domain.Order; import com.apress.springbatch.chapter11.service.CreditService;
public class CreditServiceImpl implements CreditService {
@Override public Order validateCharge(Order order) { if(order.getId() % 3 == 0) { order.setCreditValidated(true); } else { order.setCreditValidated(false); }
return order; } }`
因为您在这里实际上不处理订单,所以验证信用卡通过没有多大意义。相反,该服务批准了三分之一的订单。因为CreditServiceImpl有一个简单的方法来验证资金,你可以想象InventoryServiceImpl有一个类似的方法来确保你手头有产品来完成订单。清单 11-15 显示了验证每个OrderItem的库存的代码。
清单 11-15。??InventoryServiceImpl
`package com.apress.springbatch.chapter11.service.impl;
import com.apress.springbatch.chapter11.domain.OrderItem; import com.apress.springbatch.chapter11.service.InventoryService;
public class InventoryServiceImpl implements InventoryService {
@Override
public OrderItem validateInventory(OrderItem item) {
if(item.getId() % 2 == 0) {
item.setInventoryValidated(true);
} else { item.setInventoryValidated(false);
}
return item; } }`
现在已经编写了业务处理,让我们配置这两个服务并行运行。为此,如前所述,您使用 Spring Batch 的split标签和 Spring 提供的SimpleAsyncTaskExecutor来处理线程管理。清单 11-16 显示了您并行运行的步骤的配置。
***清单 11-16。*并行步骤的配置
`… <beans:bean id="taskExecutor" class="org.springframework.core.task.SimpleAsyncTaskExecutor"/>
<beans:bean id="orderItemReader" class="org.springframework.batch.item.database.HibernateCursorItemReader" scope="step"> <beans:property name="sessionFactory" ref="sessionFactory"/> <beans:property name="queryString" value="from OrderItem where inventoryValidated is null"/> </beans:bean>
<beans:bean id="orderReader" class="org.springframework.batch.item.database.HibernateCursorItemReader" scope="step"> <beans:property name="sessionFactory" ref="sessionFactory"/> <beans:property name="queryString" value="from Order where creditValidated is null"/> </beans:bean>
<beans:bean id="orderWriter" class="org.springframework.batch.item.database.HibernateItemWriter"> <beans:property name="sessionFactory" ref="sessionFactory"/> </beans:bean>
<beans:bean id="creditService" class="com.apress.springbatch.chapter11.service.impl.CreditServiceImpl"/>
<beans:bean id="creditVerificationProcessor"
class="org.springframework.batch.item.adapter.ItemProcessorAdapter">
<beans:property name="targetObject" ref="creditService"/> <beans:property name="targetMethod" value="validateCharge"/>
</beans:bean>
<beans:bean id="inventoryService" class="com.apress.springbatch.chapter11.service.impl.InventoryServiceImpl"/>
<beans:bean id="inventoryVerificationProcessor" class="org.springframework.batch.item.adapter.ItemProcessorAdapter"> <beans:property name="targetObject" ref="inventoryService"/> <beans:property name="targetMethod" value="validateInventory"/> </beans:bean>
…`清单 11-16 显示了你所期望的所需的项目阅读器和项目写入器的配置,以及creditService和inventoryService。您使用ItemProcessorAdapter将您的服务转换成项目处理器,并最终连接每个步骤。对于这个例子来说,有趣的是工作本身。
在parallelJob中,您从步骤 1 开始,它指向步骤 2(通过next属性)。然而,步骤 2 并没有指向next属性中的一个步骤。相反,它指向split标签。在split标签中,您定义了两个流:一个用于信用卡验证(使用creditVerificationStep),一个用于库存验证(使用inventoryVerificationStep)。这两个流程同时执行。当两个步骤都完成时,就认为parallelProcessing“步骤”完成了。
这就是作业的并行处理方面。一旦分割伪步骤完成,就执行最后一步,即生成选择列表。在下一节中,您将看到该步骤所需的代码以及该步骤是如何配置的。
构建选择列表
这项工作的最后一个难题是为仓库写出提取商品的选择列表。在这种情况下,您为通过信用验证步骤(creditValidated = true)的每个订单生成一个选择列表,并且订单中的所有OrderItem都通过了库存检查(inventoryValidated = true)。为此,您有一个HibernateCursorItemReader,它只读取适当的订单,并将它们传递给MultiResourceItemWriter,这样每个选择列表都包含在自己的文件中。对于这最后一步,您需要为编写器在LineAggregator中编写少量代码,因为您需要按顺序循环遍历OrderItem。清单 11-17 显示了LineAggregator、PickListFormatter的代码。
清单 11-17。??PickListFormatter
`package com.apress.springbatch.chapter11.writer;
import org.springframework.batch.item.file.transform.LineAggregator;
import com.apress.springbatch.chapter11.domain.Order; import com.apress.springbatch.chapter11.domain.OrderItem;
public class PickListFormatter implements LineAggregator {
public String aggregate(Order order) { StringBuilder builder = new StringBuilder();
builder.append("Items to pick\n");
if(order.getItems() != null) { for (OrderItem item : order.getItems()) { builder.append(item.getItemNumber() + ":" + item.getQty() + "\n"); } } else { builder.append("No items to pick"); }
return builder.toString(); } }`
因为您需要做的只是写一个小标题(“要挑选的物品”),然后列出物品编号和要挑选的数量,所以这个LineAggregator的编码非常简单。最后一个步骤的配置包括向作业添加新步骤,并在两个流程完成后将split标记指向该步骤。清单 11-18 显示了最后一步的配置和完成的工作。
***清单 11-18。*已完成的parallelJob配置
… <beans:bean id="validatedOrderItemReader" class="org.springframework.batch.item.database.HibernateCursorItemReader" scope="step"> <beans:property name="sessionFactory" ref="sessionFactory"/> <beans:property name="queryString" value="from Order as o where o.creditValidated = true and not exists (from OrderItem oi where oi.order = o and oi.inventoryValidated = false)"/> <beans:property name="useStatelessSession" value="false"/> </beans:bean>
`<beans:bean id="outputFile" class="org.springframework.core.io.FileSystemResource"
scope="step">
<beans:constructor-arg value="#{jobParameters[outputFile]}"/>
</beans:bean>
<beans:bean id="pickListFormatter" class="com.apress.springbatch.chapter11.writer.PickListFormatter"/>
<beans:bean id="pickListOutputWriter" class="org.springframework.batch.item.file.FlatFileItemWriter"> <beans:property name="lineAggregator" ref="pickListFormatter"/> </beans:bean>
<beans:bean id="pickListWriter" class="org.springframework.batch.item.file.MultiResourceItemWriter"> <beans:property name="resource" ref="outputFile"/> <beans:property name="delegate" ref="pickListOutputWriter"/> <beans:property name="itemCountLimitPerResource" value="1"/> </beans:bean>
…`显然,当您运行这个作业时,由于订单是随机生成的,所以输出会有所不同。但是,对于任何给定的运行,都会生成几个选项列表。清单 11-19 显示了批处理作业生成的选择列表的输出。
***清单 11-19。*选项列表输出
Items to pick 5837232417899987867:1
如您所见,使用 Spring Batch 开发使用并行处理的作业通常就像更新一些 XML 一样简单。然而,这些方法都有局限性。到目前为止,您只使用了一个 JVM。因此,您受到启动作业的机器上可用的 CPU 和内存的限制。但是,对于计算难度更大的更复杂的场景呢?您如何利用服务器集群来提高吞吐量?接下来的两节将讨论如何在单个 JVM 上扩展 Spring 批处理作业。
远程分块
Java 的多线程能力允许开发非常高性能的软件。但是任何单个 JVM 所能做的事情都是有限的。让我们开始研究如何将一个给定任务的处理分散到多台计算机上。这种类型的分布式计算的最大例子是 SETI@home 项目。SETI(搜寻外星智能)从射电望远镜中获取记录的信号,并将它们分成小块工作。为了分析这项工作,SETI 提供了一个屏幕保护程序,任何人都可以下载到他们的电脑上。屏保分析 SETI 下载的数据并返回结果。在撰写本书时,SETI@home 项目已经有超过 520 万的参与者,提供了超过 200 万年的累计计算时间。像这样扩大规模的唯一方法是让更多的计算机参与进来。
虽然您可能不需要扩展到 SETI@home 的级别,但事实仍然是,您需要处理的数据量可能至少会超过单个 JVM 的极限,并且可能会大到在您的时间窗口内无法处理。本节将介绍如何使用 Spring Batch 的远程分块功能来扩展处理能力,使之超越单个 JVM 所能做到的。
Spring Batch 提供了两种超越单个 JVM 的方法。远程分块在本地读取数据,将其发送到远程 JVM 进行处理,然后将结果接收回原始 JVM 进行写入。只有当项目处理成为流程中的瓶颈时,这种在单个 JVM 之外的扩展才有用。如果输入或输出是瓶颈,这种扩展只会让事情变得更糟。在使用远程分块作为扩展批处理的方法之前,有几件事情需要考虑:
- *处理需要成为瓶颈:*因为读取和写入是在主 JVM 中完成的,为了让远程分块有任何好处,将数据发送到从 JVM 进行处理的成本必须小于并行处理所带来的好处。
- *需要有保证的交付:*因为 Spring Batch 不维护任何类型的关于谁在处理什么的信息,如果其中一个从机在处理期间出现故障,Spring Batch 就没有办法知道什么数据在起作用。因此,需要一种持久的通信形式(通常是 JMS)。
远程分块利用了另外两个 Spring 项目。Spring Integration 项目是 Spring 项目的扩展,旨在提供 Spring 应用中的轻量级消息传递,以及通过消息传递与远程应用交互的适配器。在远程分块的情况下,您使用它的适配器通过 JMS 与从属节点进行交互。远程分块所依赖的另一个项目是 Spring Batch 集成项目。Spring Batch Admin 项目的这个子项目包含实现一些特性的类,这些特性包括远程分块和分区以及其他仍在开发中的集成模式。虽然 Spring Batch Integration 目前是 Spring Batch Admin 项目的一个子项目,但是长期的目标是在社区足够大的时候将其打破。
要在您的作业中实现远程分块,您可以使用 Spring Batch Admin 中包含的名为 Spring Batch Integration 的助手项目。这个项目还很年轻,正在社区中成长。一旦它足够成熟,就会分支成自己独立的项目。在此之前,它提供了许多有助于满足您的可伸缩性需求的资源。
要使用远程分块来配置作业,您需要从一个正常配置的作业开始,该作业包含一个您想要远程执行的步骤。Spring Batch 允许您在不改变作业本身配置的情况下添加该功能。相反,您劫持要远程处理的步骤的 ItemProcessor,并插入 ChunkHandler 实现的实例(由 Spring Batch Integration 提供)。org.springframework.batch.integration.chunk.ChunkHandler接口只有一个方法handleChunk,它的工作方式就像 ItemProcessor 接口一样。然而,ChunkHandler 实现并没有真正为给定的项做工作,而是发送要远程处理的项并监听响应。当条目返回时,它通常由本地条目编写器编写。图 11-16 显示了使用远程分块的步骤的结构。
***图 11-16。*使用远程分块的一个步骤的结构
如图 11-16 所示,作业中的任何一个步骤都可以通过远程分块进行配置。当您配置一个给定的步骤时,该步骤的 ItemProcessor 被 ChunkHandler 替换,如前所述。ChunkHandler 的实现使用一个特殊的 writer ( org.springframework.batch.integration.chunk.ChunkMessageChannelItemWriter)将项目写入队列。奴隶只不过是执行业务逻辑的消息驱动的 POJOs。当处理完成时,ItemProcessor 的输出被发送回 ChunkHandler 并传递给真正的 ItemWriter。
对于这个例子,您用每个客户在文件上的地址的经度和纬度来更新客户信息表。这些坐标对于使用 Web 上的大多数地图 API 来显示给定点的标记非常有用。为了获得客户的地理编码,您调用一个 web 服务,向它发送地址信息并接收要保存的客户的经度和纬度。
当您调用不受您控制的 web 服务时,存在潜在的瓶颈,因此您使用远程分块来处理这个步骤。首先,让我们列出你在这项工作中需要解决的事项:
- *为这项工作编写 Java:*这项工作只需要少量代码。具体来说,您需要开发一个域对象(
Customer)、一个将从数据库中检索的客户数据作为输入映射到客户对象的 RowMapper 实现、一个处理 web 服务调用的 ItemProcessor 和一个带有main方法的类(稍后讨论)。 - *配置基本作业:*使用远程分块不需要对作业的配置方式进行任何更改,因此您应该在添加远程分块之前创建一个完全可操作的作业。
- *用集成依赖项更新 POM 文件:*因为远程分块需要几个额外的依赖项,所以您需要更新您的 POM 文件来包含它们。
- *配置远程分块:*最后,您配置作业,让远程工作人员帮助处理您的客户。
在开始编写代码之前,让我们回顾一下这个例子的数据模型。Customers 表与本书中其他各种示例中使用的表相同。唯一增加的是经度和纬度这两个新列。图 11-17 显示了更新后的表格格式。
***图 11-17。*客户表
定义了数据模型之后,让我们看看所需的 Java 代码。没有任何代码包含任何特定于远程分块的内容,这是由设计决定的。添加远程分块是你可以做的事情,不会影响你的工作发展。这个项目的域对象Customer,包含了您所期望的所有字段;见清单 11-20 。
清单 11-20。??Customer.java
`package com.apress.springbatch.chapter11.domain;
import java.io.Serializable;
public class Customer implements Serializable{ private static final long serialVersionUID = 1L;
private long id;
private String firstName;
private String lastName;
private String address;
private String city;
private String state;
private String zip;
private Double longitude;
private Double latitude;
// Accessors go here ...
@Override public String toString() { return firstName + " " + lastName + " lives at " + address + "," + city + " " + state + "," + zip; } }`
关于Customer类需要注意的两件事是,它实现了java.io.Serializable接口,这样它就可以被序列化并通过您正在使用的 JMS 队列发送,并且您用一些有用的东西覆盖了toString方法,这样您就可以看到谁被哪个从属节点处理了。
下一个要编码的对象是 RowMapper 实现,它将来自图 11-17 中的 Customers 表的数据映射到Customer对象。清单 11-21 显示了CustomerRowMapper的代码。
清单 11-21。??CustomerRowMapper
`package com.apress.springbatch.chapter11.jdbc;
import java.sql.ResultSet; import java.sql.SQLException;
import org.springframework.jdbc.core.RowMapper;
import com.apress.springbatch.chapter11.domain.Customer;
public class CustomerRowMapper implements RowMapper {
public Customer mapRow(ResultSet rs, int arg1) throws SQLException { Customer cust = new Customer();
cust.setAddress(rs.getString("address"));
cust.setCity(rs.getString("city"));
cust.setFirstName(rs.getString("firstName"));
cust.setId(rs.getLong("id"));
cust.setLastName(rs.getString("lastName"));
cust.setState(rs.getString("state"));
cust.setZip(rs.getString("zip"));
cust.setLongitude(rs.getDouble(“longitude”));
cust.setLatitude(rs.getDouble(“latitude”)); return cust;
}
}`
因为对象和表都很简单,所以 RowMapper 只不过是将结果集中的每一列移动到其相关的 customer 属性中。
这项工作的最后一部分是 ItemProcessor,用于调用 web 服务和对客户的地址进行地理编码。该代码的大部分与您之前在语句作业中用来获取股票价格的代码相匹配。使用HttpClient,构建一个GET请求,并将逗号分隔的结果解析为客户的纬度和经度。清单 11-22 显示了GeocodingItemProcessor的代码。
清单 11-22。??GeocodingItemProcessor
`package com.apress.springbatch.chapter11.processor;
import java.net.URLEncoder;
import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.DefaultHttpClient; import org.springframework.batch.item.ItemProcessor;
import com.apress.springbatch.chapter11.domain.Customer;
public class GeocodingItemProcessor implements ItemProcessor<Customer, Customer> {
private static final String COMMA = ","; private static final String UTF_8 = "UTF-8"; private String url;
public Customer process(Customer customer) throws Exception { System.out.println("******** I'm going to process " + customer); HttpClient client = new DefaultHttpClient();
String address = buildAddress(customer);
if(address == null) { return null; }
HttpGet get = new HttpGet(url + "?q=" + address);
HttpResponse response = client.execute(get);
HttpEntity entity = response.getEntity();
String coordinantes = IOUtils.toString(entity.getContent()); coordinantes = StringUtils.strip(coordinantes);
if(coordinantes.length() > 0) { String [] values = coordinantes.split(COMMA); customer.setLongitude(Double.valueOf(values[0])); customer.setLatitude(Double.valueOf(values[1])); }
return customer; }
private String buildAddress(Customer customer) throws Exception { if(customer.getCity() == null && customer.getZip() == null) { return null; } else { StringBuilder address = new StringBuilder();
address.append( StringUtils.defaultIfEmpty( URLEncoder.encode(customer.getCity(), UTF_8) + COMMA, "")); address.append( StringUtils.defaultIfEmpty( URLEncoder.encode(customer.getState(), UTF_8) + COMMA, "")); address.append( StringUtils.defaultIfEmpty( URLEncoder.encode(customer.getZip(), UTF_8) + COMMA, ""));
return address.substring(0, address.length() - 1); } }
public void setUrl(String url) { this.url = url; } }`
虽然GeocodingItemProcessor没有包含任何您还没有见过的真正不寻常的东西,但是请看一下process方法的第一行。您对每个客户调用System.out.println,这样当您运行作业时,您可以看到每个客户在哪里被处理。这样,您可以在每个控制台的输出中看到谁处理了哪些项目。
代码的其余部分由构造 HTTP GET请求组成,您发送该请求以获取每个客户的经度和纬度。这就是批处理作业需要完成的所有编码。你需要另一个类来实现远程分块,但是你很快就会看到。现在,在您尝试处理远程分块增加的复杂性之前,让我们配置作业并确保它能够工作。
要配置这个作业,首先要用一个JdbcCursorItemReader来选择经度或纬度为null的所有客户。该读取器需要一个 RowMapper,这是接下来配置的。然后,您配置 ItemProcessor 来完成确定客户坐标的繁重工作。用于对地址进行地理编码的服务称为 TinyGeocoder。您提供服务的 URL 作为 ItemProcessor 的唯一依赖项。接下来是 ItemWriter,这份工作中的一个JdbcBatchItemWriter。在这种情况下,您更新客户记录,根据需要设置每个商品的经度和纬度。组装这些元素的作业配置包装了配置。清单 11-23 显示了这项工作的配置。
清单 11-23*。*配置为geocodingJob.xml 中的geocodingJob
` <beans:beans xmlns:beans="www.springframework.org/schema/bean…" xmlns:xsi="www.w3.org/2001/XMLSch…" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… www.springframework.org/schema/batc… www.springframework.org/schema/batc…
<beans:import resource="../launch-context.xml"/>
<beans:bean id="customerReader" class="org.springframework.batch.item.database.JdbcCursorItemReader"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="sql" value="select * from customers where longitude is null or latitude is null"/> <beans:property name="rowMapper" ref="customerRowMapper"/> </beans:bean>
<beans:bean id="customerRowMapper" class="com.apress.springbatch.chapter11.jdbc.CustomerRowMapper"/>
<beans:bean id="geocoder"
class="com.apress.springbatch.chapter11.processor.GeocodingItemProcessor">
<beans:property name="url" value="tinygeocoder.com/create-api.…
</beans:bean> <beans:bean id="customerImportWriter"
class="org.springframework.batch.item.database.JdbcBatchItemWriter">
<beans:property name="dataSource" ref="dataSource"/>
<beans:property name="sql" value="update customers set longitude = :longitude,
latitude = :latitude where id = :id"/>
<beans:property name="itemSqlParameterSourceProvider">
<beans:bean class="org.springframework.batch.item.database.
BeanPropertyItemSqlParameterSourceProvider"/>
</beans:property>
</beans:bean>
<chunk reader="customerReader" processor="geocoder" writer="customerImportWriter" commit-interval="1"/> </beans:beans>`
此时,您可以像构建和执行任何其他作业一样构建和执行该作业,并且它运行良好。但是,因为您希望将远程分块添加到这项工作中,所以需要对项目进行一些补充。如前所述,您需要向 POM 文件添加依赖项,再编写一个 Java 类,并配置远程分块所需的部分。
首先,让我们将新的依赖项添加到 POM 文件中。这些依赖项是针对 Spring Integration 项目([www.springsource.org/spring-integration](http://www.springsource.org/spring-integration));Spring Integration 的 JMS 模块;Spring 批量集成子项目([static.springsource.org/spring-batch/trunk/spring-batch-integration/](http://static.springsource.org/spring-batch/trunk/spring-batch-integration/));Apache HttpClient 项目([hc.apache.org/httpcomponents-client-ga/](http://hc.apache.org/httpcomponents-client-ga/))来处理您的 web 服务调用;和 ActiveMQ,它充当该作业的 JMS 实现。清单 11-24 显示了添加到 POM 文件中的附加依赖关系 3 。
***清单 11-24。*远程分块的附加依赖
... … <dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-core</artifactId> <version>${spring.integration.version}</version> </dependency> <dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-jms</artifactId> <version>${spring.integration.version}</version> </dependency> <dependency> <groupId>org.springframework.batch</groupId> <artifactId>spring-batch-integration</artifactId> <version>${spring.batch-integration.version}</version> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.1</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-core</artifactId> <version>5.4.2</version> <exclusions> <exclusion> <groupId>org.apache.activemq</groupId> <artifactId>activeio-core</artifactId> </exclusion> </exclusions> </dependency> ...
对于这个例子,你使用的是 Spring 3.0.5.RELEASE、Spring Batch 2.1.7.RELEASE、Spring Batch Integration 1 . 2 . 0 . release 和 Spring Integration 2.0.3.RELEASE。
你还需要做一件事。这个项目有两个工件:运行作业的普通 jar 文件和为每个从属 JVM 启动的 jar 文件。两者之间唯一的区别是你使用的主类。默认情况下,创建 jar 文件时将CommandLineJobRunner定义为主类,这对于执行作业的 jar 文件来说很好。然而,在其他 JVM 中,您不想执行作业;相反,您希望引导 Spring 并注册您的侦听器,以便能够处理它们遇到的任何项目。对于另一个 jar 文件,您创建一个主类,它为您引导 Spring,然后阻塞它,使它不会关闭。
但是这个新的 jar 文件与您的 POM 文件有什么关系呢?因为 POM 文件指定了 jar 文件被配置为执行的主类,所以您希望使它更加通用,以便生成两个 jar 文件。为此,您需要定义两个概要文件:您生成的两个工件各一个。您使用这些概要文件来定义为您生成的每个 jar 文件配置的主类。创建这些概要文件包括两个步骤:在 POM 文件的 build 部分删除对maven-jar-plugin的引用,然后添加您的概要文件。一个名为监听器,用于为您的从属 JVM 构建 jar 文件。另一个名为批处理,是默认的概要文件,配置 jar 文件使用CommandLineJobRunner作为 jar 文件的主类。清单 11-25 显示了新的剖面配置。
清单 11-25。 Maven 概要文件用于生成两个必需的工件
… <profiles> <profile> <id>listener</id> <build> <finalName>remote-chunking-1.0-listener-SNAPSHOT</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <index>false</index> <manifest> <mainClass>com.apress.springbatch.chapter11.main.Geocoder</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> <manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile> </archive> </configuration> </plugin> </plugins> </build> </profile> <profile> <id>batch</id> <activation> <activeByDefault>true</activeByDefault> </activation> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <index>false</index> <manifest> <mainClass> org.springframework.batch.core.launch.support.CommandLineJobRunner </mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> <manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile> </archive> </configuration> </plugin> </plugins> </build> </profile> </profiles> …
要构建您的工件,您可以对主 jar 文件使用标准的mvn clean install命令,因为批处理概要文件已经被配置为默认激活。要构建从 jar 文件,使用mvn clean install -P listener命令调用监听器概要文件。然而,为了让监听器概要工作,您需要编写Geocoder类;参见清单 11-26 。
清单 11-26。??Geocoder
`package com.apress.springbatch.chapter11.main;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class Geocoder {
/** * @param args */ public static void main(String[] args) throws Exception { new ClassPathXmlApplicationContext("/jobs/geocodeJob.xml"); System.in.read(); } }`
正如你在清单 11-26 中看到的,Geocoder类所做的只是加载你的上下文,并通过调用System.in.read()来阻塞。这将保持应用正常运行,直到您决定终止它。因为此时您可以构建两个 jar 文件,所以让我们看看如何将远程分块添加到应用中。
附加配置包括将以下九个新 beans 添加到geocodeJob.xml文件中:
chunkHandler:这是一个工厂 bean,用于创建 ChunkHandler,它取代了正在讨论的步骤中的 ItemProcessor。它也替你做了替换。chunkWriter:这个 bean 是一个特殊的编写器,用于将项目发送到监听的从节点进行处理。它还侦听响应,并将它们从 ItemWriter 的入站队列中取出,以完成处理。messageGateway:这是 Spring Integration 中的 MessagingTemplate,chunkWriter 使用它来进行 JMS 调用。requests和incoming:这是 chunkWriter 的传入和传出消息通道。- JMS 出站通道适配器:这个 bean 使您的 Spring Integration 通道适应物理出站请求的 JMS 队列。
headerExtractor:因为 Spring Integration 通道是内存中的概念,如果其中一个端点出现故障,您就有丢失消息的风险。Spring Integration 通过实现一个重新交付系统来解决这个问题。这个头提取器提取相关的头,并将其设置在org.springframework.batch.integration.chunk.ChunkResponse上,以便您的作业知道这是否是原始响应。replies:这是 Spring Integration 通道,用于将已处理的项目从从节点发送回主作业。listenerContainer:这是消息监听器的定义,它作为从属元素,处理主任务发出的每条消息。
正如您所看到的,这个例子包括了许多移动部件。虽然看起来清单很长,但配置并没有那么差。清单 11-27 显示了geocodingJob的配置。
清单 11-27。 gecodingJob配置有远程分块
<?xml version="1.0" encoding="UTF-8"?> <beans:beans xmlns:beans="http://www.springframework.org/schema/beans" xmlns:int-jms="http://www.springframework.org/schema/integration/jms" xmlns:int="http://www.springframework.org/schema/integration" xmlns:jms="http://www.springframework.org/schema/jms" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/integration/jms http://www.springframework.org/schema/integration/jms/spring-integration-jms.xsd http://www.springframework.org/schema/integration http://www.springframework.org/schema/integration/spring-integration-2.0.xsd http://www.springframework.org/schema/jms http://www.springframework.org/schema/jms/spring-jms-3.0.xsd http://www.springframework.org/schema/batch ` www.springframework.org/schema/batc…
...
<beans:bean id="chunkHandler" class="org.springframework.batch.integration.chunk.RemoteChunkHandlerFactoryBean"> <beans:property name="chunkWriter" ref="chunkWriter" /> <beans:property name="step" ref="step1" /> </beans:bean>
<beans:bean id="chunkWriter" class="org.springframework.batch.integration.chunk.ChunkMessageChannelItemWriter" scope="step"> <beans:property name="messagingOperations" ref="messagingGateway" /> <beans:property name="replyChannel" ref="replies" /> <beans:property name="maxWaitTimeouts" value="10"/> </beans:bean>
<beans:bean id="messagingGateway" class="org.springframework.integration.core.MessagingTemplate"> <beans:property name="defaultChannel" ref="requests"/> <beans:property name="receiveTimeout" value="1000"/> </beans:bean>
<int:channel id="requests" /> <int:channel id="incoming" /> <int-jms:outbound-channel-adapter connection-factory="connectionFactory" channel="requests" destination-name="requests" />
<int:transformer input-channel="incoming" output-channel="replies" ref="headerExtractor" method="extract" />
<beans:bean id="headerExtractor" class="org.springframework.batch.integration.chunk.JmsRedeliveredExtractor" />
<int:channel id="replies"> <int:queue /> int:interceptors <beans:bean id="pollerInterceptor" class="org.springframework.batch.integration.chunk.MessageSourcePollerInterceptor"> <beans:property name="messageSource"> <beans:bean class="org.springframework.integration.jms.JmsDestinationPollingSource"> beans:constructor-arg <beans:bean class="org.springframework.jms.core.JmsTemplate"> <beans:property name="connectionFactory" ref="connectionFactory" /> <beans:property name="defaultDestinationName" value="replies" /> <beans:property name="receiveTimeout" value="1000" /> </beans:bean> </beans:constructor-arg> </beans:bean> </beans:property> <beans:property name="channel" ref="incoming" /> </beans:bean> </int:interceptors> </int:channel>
<jms:listener-container connection-factory="connectionFactory" transaction-manager="transactionManager" acknowledge="transacted"> <jms:listener destination="requests" ref="chunkHandler" response-destination="replies" method="handleChunk"/> </jms:listener-container> </beans:beans>`
示例中远程分块的配置从 ChunkHandler 开始。它被配置为org.springframework.batch.integration.chunk.RemoteChunkHandlerFactoryBean的一个实例,该实例创建一个 ChunkHandler 的实例,并将其注入到您配置为其 ItemProcessor 的步骤中。RemoteChunkHandler的另一个依赖项是 ChunkWriter,这是下一个配置的 bean。
正如您在前面看到的,ChunkWriter 是一个专门创建的编写器,用于将项目发送到从属侦听器进行处理,并在处理完成时侦听返回的项目。该类需要三个依赖项:对执行所需 JMS 功能的 MessageTemplate 的引用、回复通道的名称(因为请求通道是 MessageTemplate 的默认通道),以及在认为作业失败之前它可以接受的最大超时错误(在本例中为 10)。如果在作业执行过程中达到您配置的超时次数,该步骤将被标记为失败。
messageGateway bean 是 Spring Integration 的org.springframework.integration.core.MessageTemplate的一个实例,用于在远程分块中完成与 JMS 功能相关的繁重工作。您将传出通道(请求)定义为defaultChannel,并指定一个超时值,指定在侦听回复时要等待多长时间。
请求通道是队列的内存表示。Spring Integration 使用通道从应用中抽象出消息传递的概念,让您可以使用真正的 JMS 队列或轻量级内存消息传递。在这种情况下,您可以稍后在配置中用一个真实的 JMS 队列来备份它。正如请求通道从作业向从属节点发送项目一样,传入通道接收结果。
要使用真正的 JMS 队列来备份请求通道,可以使用 Spring Integration 的出站通道适配器。该适配器接受与通道完成的所有交互,并将它们保存到您配置的队列中。为了让它工作,您需要为它指定一个连接工厂来连接您的 JMS 队列,告诉它从哪个通道获取项目,并指定要放入它的队列的名称。
当您处理来自远程服务器的消息时,可能会发生许多事情,比如超时或各种节点关闭。因此,如果某个项目因任何原因被投递了多次,它将被标记为重新投递。您在那里用它做什么取决于许多业务条件(是否重启,等等)。然而,要获得重新交付的标志,您需要使用一个 Spring Integration转换器。这个转换器从一个通道(传入)获取消息,应用某种形式的逻辑(在本例中是headerExtractor bean 的extract方法),然后将它们放到另一个通道(在本例中是回复)。
配置好所有的通信和工作中使用的组件后,剩下唯一需要配置的就是从属工作器了。在这种情况下,每个从节点只不过是一个消息驱动的 POJO,使用 Spring Integration 的侦听器容器和侦听器进行配置。侦听器容器用于将消息从队列中取出,并将回复放回队列中。对于收到的每条消息,侦听器本身都会被调用。
就是这样!这个解释看起来有点让人不知所措,但是要运行这个例子,您需要构建前面讨论过的两个工件:一个用于从属 JVM,一个用于作业。为了测试所有的部分,你至少需要做三件事:
- ActiveMQ: 为了让您的 JVM 相互通信,您需要运行 ActiveMQ 服务器。可以从
[apache.activemq.org](http://apache.activemq.org)下载。从 ActiveMQ 的bin目录中,执行activeMq脚本来启动服务器。 - *Slave JVM:*你可以随意启动这些 JVM。这些 JVM 对从队列中读取的每个项目执行 ItemProcessor。要启动从 JVM,对您希望运行的每个从 JVM 执行命令
java –jar remote-chunking-0.0.1-listener-SNAPSHOT.jar。 - *作业:*启动该作业的最后一步是执行配置为执行该作业的 jar 文件。您可以像执行任何其他作业一样,使用命令
java –jar remote-chunking-0.0.1-SNAPSHOT.jar jobs/geocodeJob.xml geocodingJob来执行它。
剩下的就交给 SpringBatch 了!
但是你怎么知道你的奴隶做了一些工作呢?证据就在输出中。首先要查看的是数据库,其中应该填充了每个客户的经度和纬度。除此之外,每个从节点以及运行作业的 JVM 都有输出语句,显示每个节点上处理的是谁。清单 11-28 显示了一个从站输出的例子。
清单 11-28。geocodingJob结果
`2011-04-11 21:49:31,668 DEBUG org.springframework.jms.listener.DefaultMessageListenerContainer#0-1 [org.springframework.batch.integration.chunk.ChunkProcessorChunkHandler] -
("******** I'm going to process Merideth Gray lives at 303 W Comstock Street,Seattle WA,98119 2011-04-11 21:49:31,971 DEBUG org.springframework.jms.listener.DefaultMessageListenerContainer#0-1 [org.springframework.batch.item.database.JdbcBatchItemWriter] - <Executing batch with 1 items.>`
您可能会注意到,不仅从节点在处理您的项目,执行作业的本地 JVM 也在处理项目。原因在于你的配置。因为作业的配置包含监听器的信息,所以本地 JVM 有一个监听器处理项目,就像任何其他从机一样。这是一件好事,因为当执行批处理作业的 JVM 除了监听结果之外什么也不做时,很少有理由将所有处理完全卸载给其他节点。
远程分块是将处理项目的成本分散到多个 JVM 上的一个好方法。它的好处是不需要修改您的作业配置,并且使用哑工人(不了解 Spring Batch 或您的作业数据库的工人,等等)。但是请记住,像 JMS 这样持久的通信是必需的,这种方法不能为瓶颈存在于步骤的输入或输出阶段的作业提供任何好处。
在仅仅卸载 ItemProcessor 的工作还不够的情况下(例如,I/O 成为瓶颈的情况),Spring Batch 还有另外一个选择:分区。在下一节中,您将看到分区以及如何使用它来扩展您的作业。
分区
虽然远程分块在处理项目过程中有瓶颈的流程时很有用,但大多数时候瓶颈存在于输入和输出中。与数据库交互或读取文件通常是性能和可伸缩性问题发挥作用的地方。为了帮助解决这个问题,Spring Batch 为多个工作人员提供了执行完整步骤的能力。整个 ItemReader、ItemProcessor 和 ItemWriter 交互可以卸载给从属工作器。这一节讨论什么是分区,以及如何配置作业来利用这个强大的 Spring 批处理特性。
分区的概念是,主步骤将工作外包给任意数量的监听从步骤进行处理。这听起来可能非常类似于远程分块(确实如此),但是有一些关键的区别。首先,从节点不像远程分块那样是消息驱动的 POJOs。分区中的从属步骤是 Spring 批处理步骤,每个步骤都有自己的读取器、处理器和写入器。因为它们是完整的 Spring 批处理步骤,所以分区提供了一些优于远程分块实现的独特优势。
与远程分块相比,分区的第一个优势是您不需要有保证的交付系统(例如 JMS)。就像任何其他 Spring 批处理步骤一样,每个步骤都保持自己的状态。目前,Spring Batch Integration 项目使用 Spring Integration 的通道来抽象出通信机制,因此您可以使用 Spring Integration 支持的任何东西。
第二个好处是不需要开发定制组件。因为 slave 是一个常规的 Spring 批处理步骤,所以您几乎不需要编写任何特殊的代码(有一个额外的类,稍后您将看到一个 Partitioner 实现)。
但是即使有这些优势,你也需要记住一些事情。首先,远程步骤需要能够与您的作业存储库通信。因为每个从步骤都是一个真正的 Spring 批处理步骤,所以它有自己的 StepExecution,并像任何其他步骤一样在数据库中维护其状态。此外,输入和输出需要可以从所有从节点访问。通过远程分块,主机处理所有输入和输出,因此数据可以集中。但是有了分区,从设备负责自己的输入和输出。因此,一些形式的 I/O 比其他的更适合于分区(例如,数据库通常比文件更容易)。
为了了解远程分块和分区之间的结构差异,图 11-18 显示了使用分区的作业是如何构成的。
***图 11-18。*一个分区作业
如您所见,主作业步骤负责将工作划分为分区,由每个从作业处理。然后,它发送一条消息,其中包含一个将由从属服务器使用的 StepExecution 这描述了要处理的内容。与远程分块不同,在远程分块中,数据是远程发送的,只有分区描述了要由从机处理的数据。例如,主步骤可以为每个分区确定要处理的数据库 id 的范围,并将其发送出去。一旦每个从机完成了所请求的工作,它将返回 StepExecution,并更新该步骤的结果,以供主机解释。当所有分区都成功完成时,该步骤就被认为完成了,作业继续进行。如果任何分区失败,该步骤将被视为失败,作业将停止。
为了了解分区在作业中是如何工作的,让我们重用您在远程分块示例中使用的地理编码作业,但是将其重构为使用分区。它的单个步骤现在在许多 JVM 中远程执行。因为大部分代码都是相同的,所以让我们先来看看分区需要的一个新类:Partitioner 接口的实现。
org.springframework.batch.core.partition.support.Partitioner接口有一个方法partition(int gridSize),它返回一个分区名的Map作为键,返回一个 StepExecution 作为值。Map中的每个步骤执行包含从属步骤需要的信息,以便知道该做什么。在这种情况下,在 StepExecution 中为每个从属服务器存储两个属性:要处理的客户的起始 id 和结束 id。清单 11-29 显示了ColumnRangePartitioner的代码。
清单 11-29。??ColumnRangePartitioner
`package com.apress.springbatch.chapter11.partition;
import java.util.HashMap; import java.util.Map;
import org.springframework.batch.core.partition.support.Partitioner;
import org.springframework.batch.item.ExecutionContext; import org.springframework.jdbc.core.JdbcTemplate;
public class ColumnRangePartitioner extends JdbcTemplate implements Partitioner {
private String column; private String table; private int gridSize;
public Map<String, ExecutionContext> partition(int arg0) { int min = queryForInt("SELECT MIN(" + column + ") from " + table); int max = queryForInt("SELECT MAX(" + column + ") from " + table); int targetSize = (max - min) / gridSize;
Map<String, ExecutionContext> result = new HashMap<String, ExecutionContext>(); int number = 0; int start = min; int end = start + targetSize - 1;
while (start <= max) {
ExecutionContext value = new ExecutionContext(); result.put("partition" + number, value);
if (end >= max) { end = max; } value.putInt("minValue", start); value.putInt("maxValue", end);
start += targetSize; end += targetSize; number++; }
return result; }
public void setColumn(String column) { this.column = column; }
public void setTable(String table) { this.table = table; }
public void setGridSize(int gridSize) { this.gridSize = gridSize; } }`
partition方法获取 Customers 表中的最小和最大 id(在 XML 中配置表名和列名)。从这里开始,您根据您拥有的从属服务器的数量来划分这个范围,并使用要处理的最小和最大 id 创建一个 StepExecution。当所有的分步执行都被创建并保存在Map中时,您返回Map。
是您在工作中使用分区所需要编写的唯一一个新类。其他需要更改的是配置。在查看配置之前,让我们先讨论一下消息流是如何发生的。图 11-19 显示了每个消息是如何被处理的。
***图 11-19。*使用分区作业的消息处理
作业配置了新类型的步骤。到目前为止,您一直使用 tasklet 步骤来执行代码。对于要分割的步骤,您使用一个分割步骤。这种类型的步骤与配置处理块的小任务步骤不同,它配置如何划分步骤(通过划分器实现)和负责向从设备发送消息并接收响应的处理程序。
注意在分区处理中,与远程工作人员的通信不需要是事务性的,也不需要保证交付。
Spring Batch Integration 项目提供了一个名为MessageChannelPartitionHandler的 PartitionHandler 接口的实现。这个类使用 Spring Integration 通道作为通信的形式,以消除对特定通信类型的依赖。对于这个例子,您使用 JMS。该通信由三个队列组成:主步骤发送工作请求的请求队列、从步骤回复的暂存队列,以及将合并的回复发送回主步骤的回复队列。在返回的路上有两个队列,因为每个步骤都用来自该步骤的步骤 Execution 进行回复。您使用一个聚合器将所有响应合并到一个 StepExecutions 列表中,这样就可以一次处理它们。
让我们看看使用分区的geocodingJob的配置;见清单 11-30 。
清单 11-30。 geocodingJob使用在geocodeJob.xml 中配置的分区
` <beans:beans xmlns:beans="www.springframework.org/schema/bean…" xmlns:int-jms="www.springframework.org/schema/inte…" xmlns:int="www.springframework.org/schema/inte…" xmlns:jms="www.springframework.org/schema/jms" xmlns:xsi="www.w3.org/2001/XMLSch…" xmlns:task="www.springframework.org/schema/task" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… www.springframework.org/schema/inte… www.springframework.org/schema/inte… www.springframework.org/schema/inte… www.springframework.org/schema/inte… www.springframework.org/schema/jms www.springframework.org/schema/jms/… www.springframework.org/schema/task www.springframework.org/schema/task… www.springframework.org/schema/batc… www.springframework.org/schema/batc…
<beans:import resource="../launch-context.xml"/>
<beans:bean id="partitioner" class="com.apress.springbatch.chapter11.partition.ColumnRangePartitioner"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="column" value="id"/> <beans:property name="table" value="customers"/> <beans:property name="gridSize" value="3"/> </beans:bean>
<chunk reader="customerReader" processor="geocoder" writer="customerImportWriter" commit-interval="1"/>
<beans:bean id="customerReader" class="org.springframework.batch.item.database.JdbcCursorItemReader" scope="step"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="sql"> beans:value= ? and id <= ? ]]></beans:value> </beans:property> <beans:property name="preparedStatementSetter"> <beans:bean class="org.springframework.batch.core.resource.ListPreparedStatementSetter"> <beans:property name="parameters"> beans:list beans:value#{stepExecutionContext[minValue]}</beans:value> beans:value#{stepExecutionContext[maxValue]}</beans:value> </beans:list> </beans:property> </beans:bean> </beans:property> <beans:property name="rowMapper" ref="customerRowMapper"/> </beans:bean>
<beans:bean id="customerRowMapper" class="com.apress.springbatch.chapter11.jdbc.CustomerRowMapper"/>
<beans:bean id="geocoder" class="com.apress.springbatch.chapter11.processor.GeocodingItemProcessor"> <beans:property name="url" value="tinygeocoder.com/create-api.… </beans:bean>
<beans:bean id="customerImportWriter" class="org.springframework.batch.item.database.JdbcBatchItemWriter"> <beans:property name="dataSource" ref="dataSource"/> <beans:property name="sql" value="update customers set longitude = :longitude, latitude = :latitude where id = :id"/> <beans:property name="itemSqlParameterSourceProvider"> <beans:bean class="org.springframework.batch.item.database. BeanPropertyItemSqlParameterSourceProvider"/> </beans:property> </beans:bean>
<beans:bean id="partitionHandler" class="org.springframework.batch.integration.partition. MessageChannelPartitionHandler"> <beans:property name="stepName" value="step1"/> <beans:property name="gridSize" value="3"/> <beans:property name="replyChannel" ref="outbound-replies"/> <beans:property name="messagingOperations"> <beans:bean class="org.springframework.integration.core.MessagingTemplate"> <beans:property name="defaultChannel" ref="outbound-requests"/> <beans:property name="receiveTimeout" value="100000"/> </beans:bean> </beans:property> </beans:bean>
<int:channel id="outbound-requests"/>
<int-jms:outbound-channel-adapter connection-factory="connectionFactory" destination="requestsQueue" channel="outbound-requests"/>
<int:channel id="inbound-requests"/> <int-jms:message-driven-channel-adapter connection-factory="connectionFactory" destination="requestsQueue" channel="inbound-requests"/>
<beans:bean id="stepExecutionRequestHandler" class="org.springframework.batch.integration.partition.StepExecutionRequestHandler"> <beans:property name="jobExplorer" ref="jobExplorer"/> <beans:property name="stepLocator" ref="stepLocator"/> </beans:bean>
<int:service-activator ref="stepExecutionRequestHandler" input-channel="inbound-requests" output-channel="outbound-staging"/>
<int:channel id="outbound-staging"/> <int-jms:outbound-channel-adapter connection-factory="connectionFactory" destination="stagingQueue" channel="outbound-staging"/>
<int:channel id="inbound-staging"/> <int-jms:message-driven-channel-adapter connection-factory="connectionFactory" destination="stagingQueue" channel="inbound-staging"/>
<int:aggregator ref="partitionHandler" input-channel="inbound-staging" output-channel="outbound-replies"/>
<int:channel id="outbound-replies"> <int:queue /> </int:channel> </beans:beans>`
作业的配置从作业本身开始。虽然这里的名称和步骤的名称与远程分块示例中的名称相同,但是这个步骤做的事情完全不同。Step1.master作为主步骤,将工作分配给从步骤,并汇总结果状态以确定该步骤是否成功。step1.master的分区步骤需要两个依赖项:一个是分区器接口的实现,用于将工作分成多个分区;另一个是 PartitionHandler,用于将消息发送给从设备并接收结果。
ColumnRangePartitioner是您在本例中使用的分区器接口的实现。正如您在清单 11-29 ,ColumnRangePartitioner扩展了 Spring 的JdbcTemplate,因此它依赖于一个数据源实现以及用于分区所需的表和列以及从服务器的数量(gridSize),因此您可以适当地划分工作。
在划分器之后配置的步骤与远程分块示例中使用的步骤完全相同。但是这一步是作为您的从属工写入器运行的,而不是在作业的上下文中运行。随着步骤的配置,您配置了它的 ItemReader 和所需的 RowMapper 实现(customerReader和customerRowMapper)、ItemProcessor ( geocoder)和 ItemWriter ( customerImportWriter)。
接下来,您将继续进行与分区相关的 beans 的配置。因为 PartitionHandler 是分区步骤的处理中心,所以让我们从它开始。org.springframework.batch.integration.partition.MessageChannelPartitionHandler需要四个依赖项:
stepName:从进程远程执行的步骤名称。在这种情况下,是第step1步。gridSize:参与分区步骤处理的从机数量。您运行三个 JVM,所以您有三个从机。replyChannel:其中MessageChannelPartitionHandler监听来自远程执行步骤的输入回复。messagingOperations:message template,将请求队列配置为默认通道。MessageChannelPartitionHandler使用它向从属 JVM 发送传出消息,并监听传入消息。
在MessageChannelPartitionHandler之后,您配置用于通信的通道。本例中涉及五个通道:两个队列中的一个入站和一个出站通道,以及一个用于最终聚合消息的通道。每个出站通道将消息放入队列中;入站通道从每个队列接收消息。为此作业配置了以下通道和适配器:
outbound-requests:这个通道将每个分区的请求放在请求队列中。您使用 JMS 出站通道适配器从 Spring Integration 通道获取消息,并将它们放在 JMS 队列中。inbound-requests:该通道接收请求队列中的消息。消息驱动的通道适配器将消息从 JMS 队列中取出,并放入该通道中。outbound-staging:当一个步骤被处理后,来自单个步骤的响应被暂存在该通道中,然后被保存到暂存队列中。同样,JMS 出站通道适配器将消息保存到 JMS 暂存队列中。inbound-staging:从暂存队列接收到的消息通过消息驱动的通道适配器放在该通道中,聚合成一条消息,由MessageChannelPartitionHandler处理。outbound-replies:这是用于将汇总的步骤结果传输回MessageChannelPartitionHandler的单一通道。
您已经将消息放入出站请求通道,并通过带有MessageChannelPartitionHandler的出站回复通道接收它们。要在收到消息时执行作业,可以使用配置的下一个 bean:StepExecutionRequestHandler。
StepExecutionRequestHandler获取您在ColumnRangePartitioner中创建的 StepExecution,并执行您用该 StepExecution 请求的步骤。因为它是一个消息驱动的 POJO,所以您使用 Spring Integration 的服务激活器在收到消息时执行它。配置StepExecutionRequestHandler的两个依赖项是对 JobExplorer 和 StepLocator 的引用。两者都用于定位和执行步骤。
最后要配置的是一个 Spring Integration 聚合器。您使用它是因为MessageChannelPartitionHandler期望一个包含步骤执行列表的消息,作为将所有单独的步骤执行发送给工作 JVM 的回报。因为MessageChannelPartitionHandler需要合并的输入,所以它提供了进行聚合的方法,正如您在配置中看到的。
在您认为配置已经完成之前,让我们检查一下launch-context.xml文件中的几个元素,如清单 11-31 所示。
清单 11-31。launch-context.xml
` <beans xmlns:p="www.springframework.org/schema/p" xmlns:xsi="www.w3.org/2001/XMLSch…" xmlns:amq="activemq.apache.org/schema/core" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… activemq.apache.org/schema/core activemq.apache.org/schema/core… ">
<bean id="jobOperator" class="org.springframework.batch.core.launch.support.SimpleJobOperator" p:jobLauncher-ref="jobLauncher" p:jobExplorer-ref="jobExplorer" p:jobRepository-ref="jobRepository" p:jobRegistry-ref="jobRegistry" />
<bean id="jobExplorer" class="org.springframework.batch.core.explore.support.JobExplorerFactoryBean" p:dataSource-ref="dataSource" />
<bean id="jobRegistry" class="org.springframework.batch.core.configuration.support.MapJobRegistry" />
<bean class="org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor">
<bean id="jobLauncher" class="org.springframework.batch.core.launch.support.SimpleJobLauncher">
<bean id="stepLocator" class="org.springframework.batch.integration.partition.BeanFactoryStepLocator" />
<bean id="jobRepository" class="org.springframework.batch.core.repository.support.JobRepositoryFactoryBean" p:dataSource-ref="dataSource" p:transactionManager-ref="transactionManager" />
<bean id="sessionFactory" class="org.springframework.orm.hibernate3.LocalSessionFactoryBean"> classpath:hibernate.cfg.xml <property name="configurationClass"> org.hibernate.cfg.AnnotationConfiguration false false update org.hibernate.dialect.MySQLDialect
<bean id="transactionManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager" lazy-init="true">
<amq:queue id="requestsQueue" physicalName="com.apress.springbatch.chapter11.partition.requests"/> <amq:queue id="stagingQueue" physicalName="com.apress.springbatch.chapter11.partition.staging"/> <amq:queue id="repliesQueue" physicalName="com.apress.springbatch.chapter11.partition.replies"/>
<bean id="placeholderProperties" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"> <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" /> `
从其他例子来看,launch-context.xml文件的大部分应该看起来很熟悉。不过,还是具体叫出几个豆子吧。首先是stepLocator。这个BeanFactoryStepLocator实例用于在当前 bean 工厂中查询任何类型为Step的 bean。对于分区,Spring Batch 使用它来查找位于远程 Spring 容器中的远程步骤配置。
您需要查看的launch-context.xml文件的另一部分是队列本身的配置。这个例子使用了三个 ActiveMQ JMS 队列。幸运的是,ActiveMQ XSD 使得配置它们变得很容易。您需要做的就是为每个队列配置 bean id 和物理名称。
现在配置完成了。正如在远程分块示例中一样,您使用两个 Maven 概要文件来构建这个示例所需的两个 jar 文件。首先,使用mvn clean install –P listener命令创建每个工作 JVM 使用的 jar 文件。要构建用于执行作业的 jar 文件,请通过mvn clean install使用默认概要文件。
要执行该作业,您需要执行三个 Java 进程。前两个充当作业的从节点;通过使用您用–P监听器选项创建的 jar 运行java –jar partitioning-0.0.1-listener-SNAPSHOT.jar来执行它们。在这两个节点都运行的情况下,您可以使用命令java –jar partitioning-0.0.1-SNAPSHOT.jar jobs/geocodeJob.xml geocodingJob运行作业。当作业完成时,您可以通过查看数据库中的 Customers 表来验证结果,并验证表中的每个人都保存了经度和纬度值。
使用分区作为扩展 Spring 批处理作业的一种方式是利用多服务器计算能力的一种很好的方式。这种方法不同于远程分块,但它为远程处理提供了自己的一套好处。
总结
使用 Spring Batch 的一个主要原因是它能够在不影响现有代码库的情况下进行扩展。尽管您可以自己编写这些特性,但是它们都不容易很好地实现,并且没有必要重新发明轮子。Spring Batch 提供了一组很好的方法来扩展带有许多选项的作业。
本章介绍了如何分析作业以获得瓶颈所在的信息。然后,您研究了 Spring Batch 为可伸缩性提供的四个选项中的每个选项的示例:并行步骤、多线程步骤、远程分块和分区。
十二、测试批处理过程
测试:每个人都喜欢的编程部分。有趣的是,就像生活中的大多数事情一样,一旦你擅长于此,测试实际上是有趣的。它让你更有效率。它为你尝试新事物提供了一个安全网。编程测试也给了你一个测试新技术的平台(如果你想在测试中尝试新的东西,大多数公司不会介意,但是如果你在即将投入生产的代码中尝试,他们会非常介意)。您已经花了前 10 章编写代码,却没有能力证明这些代码是有效的。这一章着眼于如何以各种方式运行你的代码,这样你不仅可以证明它如设计的那样工作,还可以在你修改它时提供一个安全网。
本章涵盖以下主题:
- 使用 JUnit 和 Mockito 进行单元测试:首先从 JUnit 和 Mockito 框架的高级概述开始。虽然在本章后面的部分中,您已经了解了 JUnit 的基本功能,但是 Spring Integration 到其测试设备中的概念是基于 JUnit 约定的,所以了解这些概念有助于您理解更高级的测试中正在发生的事情。本章还介绍了模拟对象框架 Mockito 如何帮助您对您为批处理开发的组件进行单元测试。
- 与 Spring 测试框架的集成测试:Spring 已经对大多数其他较难的 Java 任务做了测试:使它变得容易。它提供了一组类,允许您以最小的开销轻松测试与各种资源(数据库、文件等)的交互。您将学习如何使用 Spring 测试组件来测试 Spring 批处理作业的各个方面。
测试最基本的方面从单元测试开始,所以讨论从那里开始。
用 JUnit 和 Mockito 进行单元测试
单元测试可能是最容易编写的,也可能是最有价值的,是最容易被忽视的测试类型。尽管由于一些原因,本书中的开发没有采用测试驱动的方法,但是鼓励您在自己的开发中这样做。作为一种已被证明的方法,它不仅可以提高你开发的软件的质量,还可以提高任何一个开发人员和整个团队的整体生产力,这些测试中包含的代码是你所能开发的最有价值的代码。本节将介绍如何使用 JUnit 和 Mockito 对您为批处理开发的组件进行单元测试。
什么是单元测试?这是以可重复的方式对单个隔离组件的测试。让我们来分解一下这个定义,以了解它是如何应用于您要做的事情的:
- *单个的测试:*一个。单元测试旨在测试应用的最小构建块。单一方法通常是单元测试的范围。
- *孤立:*依赖会对系统的测试造成严重破坏。然而,所有系统都有依赖关系。单元测试的目标不是测试你与这些依赖项的集成,而是测试你的组件如何独立工作。
- *以可重复的方式:*当你打开浏览器点击你的应用时,这不是一个可重复的练习。您每次可以输入不同的数据。您可以按稍微不同的顺序单击按钮。单元测试应该能够一次又一次地重复完全相同的场景。这允许您在对系统进行更改时使用它们进行回归测试。
用于以可重复的方式执行组件隔离测试的框架有 JUnit、Mockito 和 Spring 框架。前两个是常见的多用途框架,对于为代码创建单元测试非常有用。Spring test 实用程序有助于测试更广泛的问题,包括不同层的集成,甚至端到端的测试作业执行(从服务或 Spring 批处理组件到数据库,然后返回)。
六月
被认为是测试 Java 框架的黄金标准, 1 JUnit 是一个简单的框架,它提供了以标准方式对 Java 类进行单元测试的能力。尽管您使用的大多数框架都需要像 IDE 和构建过程这样的附加组件,但是 Maven 和大多数 Java IDEs 都内置了 JUnit 支持,不需要额外的配置。关于测试和使用像 JUnit 这样的框架的主题已经写了整本书,但是快速回顾这些概念是很重要的。本节着眼于 JUnit 及其最常用的特性。
在撰写本书时,JUnit 的当前版本是 JUnit 4.8.2。尽管每个修订版都包含少量的改进和 bug 修复,但是框架的最后一个主要修订版是从 JUnit 3 迁移到 JUnit 4,引入了注释来配置测试用例。测试用例?让我们回顾一下 JUnit 测试是如何构建的。
JUnit 生命周期
JUnit 测试被分解成所谓的测试用例。每个测试用例都旨在测试一个特定的功能,公约数在类的层次上。通常的做法是每个类至少有一个测试用例。测试用例只不过是一个配置了 JUnit 注释的 Java 类,由 JUnit 执行。在一个测试用例中,既有测试方法,也有在每个测试或每组测试后设置先决条件和清理后置条件的方法。清单 12-1 展示了一个非常基本的 JUnit 测试用例。
***清单 12-1。*一个基本的 JUnit 测试用例
`package com.apress.springbatch.chapter12;
import org.junit.Test; import static org.junit.Assert.*;
public class StringTest {
@Test public void testStringEquals() { String michael = "Michael"; String michael2 = michael; String michael3 = new String("Michael"); String michael4 = "Michael";
assertTrue(michael == michael2); assertFalse(michael == michael3); assertTrue(michael.equals(michael2)); assertTrue(michael.equals(michael3)); assertTrue(michael == michael4); assertTrue(michael.equals(michael4)); } }`
或者至少它在 Betamax 与 VHS 的战争中赢得了对 TestNG 等框架的胜利。
清单 12-1 中的单元测试并没有什么奇特之处。它所做的只是证明在比较String s 时使用==和使用.equals方法是不一样的。然而,让我们浏览一下测试的不同部分。首先,JUnit 测试用例是一个常规的 POJO。您不需要扩展任何特定的类,JUnit 对您的类的唯一要求是它有一个无参数构造函数。
在每个测试中,您有一个或多个测试方法(在本例中是一个)。每个测试方法都必须是公共的、无效的,并且没有参数。为了表明一个方法是要由 JUnit 执行的测试方法,您使用了@Test注释。在给定测试的执行过程中,JUnit 执行一次用@Test注释注释的每个方法。
最后一部分StringTest是测试方法中使用的 assert 方法。该测试方法流程简单。它首先设置测试所需的条件,然后执行测试,同时使用 JUnit 的 assert 方法验证结果。org.junit.Assert类的方法用于验证给定测试场景的结果。在清单 12-1 中的StringTest的例子中,您验证了在String对象上调用.equals方法比较字符串的内容,而使用==来比较两个字符串验证了它们只是同一个实例。
虽然这个测试很有帮助,但是在使用 JUnit 时,您还应该了解一些其他有用的注释。前两个与 JUnit 测试生命周期相关。JUnit 允许您配置在每个测试方法之前和之后运行的方法,这样您就可以设置通用的前提条件,并在每次执行之后进行基本的清理。要在每个测试方法之前执行一个方法,您可以使用@Before注释;@After表示该方法应在每个测试方法之后执行。 2 就像任何测试方法一样,@Before ( setUp)和@After ( tearDown)标记的方法都要求是公开的、无效的、不带参数的。通常,您会在标有@Before的方法中创建一个要测试的对象的新实例,以防止一个测试的任何残余影响到另一个测试。图 12-1 显示了使用@Before、@Test和@After注释的 JUnit 测试用例的生命周期。
在以前版本的 JUnit 中,这些方法被称为 setUp 和 tearDown。
图 12-1。 JUnit 生命周期
如图 12-1 所示,JUnit 为每个用@Test标注标识的方法依次执行这三个方法,直到测试用例中的所有测试方法都被执行。清单 12-2 展示了一个使用所有三个讨论过的注释的测试用例。
清单 12-2。Foo的考验
`package com.apress.springbatch.chapter12;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull;
import org.junit.After; import org.junit.Before; import org.junit.Test;
public class FooTest {
private Foo fooInstance;
@Before public void setUp() { fooInstance = new Foo(); }
@Test public void testBar() { String results = fooInstance.bar();
assertNotNull("Results were null", results); assertEquals("The test was not a success", "success", results); }
@After public void tearDown() { fooInstance.close(); } }`
JUnit 在这些特性上提供了许多其他变体,包括@BeforeClass为给定测试类中的所有测试方法执行一次性设置,@Ignore指示要跳过的测试方法和类,@RunWith指示运行测试用例的类,而不是 JUnit 使用的默认类。然而,这些超出了本书的范围。本节的目标是为您提供测试批处理过程所需的工具。仅使用@Before、@Test和@After注释以及 JUnit 的Assert类中可用的 assert 方法,就可以测试所需的绝大多数场景。
但是有一个小问题。早期的单元测试定义认为单元测试是孤立的组件测试。当数据访问对象(DAO)依赖于 JDBC 和数据库时,如何使用 JUnit 测试它呢?测试一个 ItemStream 怎么样,它要求您使用 Spring Batch 组件作为它的一些参数?模拟对象填补了这个空白,接下来您将看到这些对象。
模拟物体
编写像前面测试的 String 对象这样的软件是非常容易的,它没有依赖性。然而,大多数系统都很复杂。批处理作业可能需要几十个或更多的类,并且依赖于外部系统,包括应用服务器、JMS 中间件和数据库等等。所有这些移动的部分可能很难管理,并且提供超出单元测试范围的交互。例如,当您想要测试您的一个 ItemProcessors 的业务逻辑时,您真的需要测试 Spring Batch 是否将上下文正确地保存到您的数据库中吗?这超出了单元测试的范围。不要误解——这确实需要测试,您将在本章的后面看到它。然而,为了测试您的业务逻辑,您不需要练习您的生产系统与之交互的各种依赖关系。您可以使用模拟对象来替换测试环境中的这些依赖项,并在不受外部依赖项影响的情况下练习您的业务逻辑。
注意存根不是模拟对象。存根是在测试中使用的硬编码实现,其中模拟对象是可重用的结构,允许在运行时定义所需的行为。
让我们花一点时间来说明模拟对象不是存根。 Stubs 是您编写的用来替换应用各个部分的实现。存根包含硬编码的逻辑,旨在模拟执行期间的特定行为。它们不是模拟对象(不管它们在你的项目中被命名为什么)!
模拟对象是如何工作的?大多数模拟对象框架主要采用两种不同的方法:基于代理的方法和类重映射方法。因为基于代理的模拟对象不仅最受欢迎,而且最容易使用,所以让我们先来看看它们。
一个代理对象是一个用来代替真实对象的对象。在模拟对象的情况下,代理对象用于模拟您的代码所依赖的真实对象。使用 mocking 框架创建一个代理对象,然后使用 setter 或构造函数在对象上设置它。这指出了使用代理对象模仿的一个固有问题:您必须能够通过外部方式设置依赖关系。换句话说,您不能通过在您的方法中调用new MyObject()来创建依赖,因为没有办法模仿通过调用new MyObject()创建的对象。这是像 Spring 这样的依赖注入框架起飞的原因之一——它们允许你在不修改任何代码的情况下注入代理对象。
这并不是 100%真实的。PowerMock 允许您模拟新的操作符。你可以在 code.google.com/p/powermock… PowerMock 的信息。](code.google.com/p/powermock…)
模仿的第二种形式是在类加载器中重新映射类文件。模仿框架 JMockit 是我所知道的唯一一个为模仿对象开发这种能力的框架。这个概念相对较新(因为 JDK 1.5,尽管 JMockit 也通过其他方式支持 JDK 1.4),由新的java.lang.instrument.Insturmentation接口提供。您告诉类加载器将引用重新映射到它加载的类文件。所以,假设你有一个类MyDependency和相应的.class文件MyDependency.class,你想模仿它使用MyMock来代替。通过使用这种类型的模拟对象,您实际上在类加载器中重新映射了从MyDependency到MyMock.class的引用。这允许您模仿使用new操作符创建的对象。虽然这种方法比代理-对象方法提供了更多的功能,因为它能够将几乎任何实现注入到类加载器中,但是考虑到您需要了解类加载器才能使用它的所有特性,这种方法也更加困难和混乱。
Mockito 是一个流行的基于代理的模拟对象框架,它提供了很大的灵活性和丰富的语法。它允许您相对容易地创建易于理解的单元测试。让我们来看看。
莫基托
大约在 2008 年,EasyMock 作为主要的嘲讽框架,几个人看了一下这个框架并问了一些问题。EasyMock 是一个基于代理的模拟对象框架,需要模型、记录、播放和验证。首先你记录下你需要的行为。然后您执行被测试的功能,最后您验证您先前记录的所有执行。但是,为什么需要定义一个对象要经历的所有可能的交互?为什么您需要确认所有的交互都发生了?Mockito 允许你嘲笑你关心的行为,只验证那些重要的行为。在这一节中,您将看到 Mockito 提供的一些功能,并使用它来测试 Spring Batch 组件。
尽管在任何时候使用 Maven 作为构建系统时,默认情况下都会包含 JUnit,但是要使用 Mockito,您需要添加它的依赖项。清单 12-3 显示了 Mockito 所需的依赖关系。
清单 12-3。 Maven 对 Mocktio 的依赖性
… <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <version>1.8.5</version> <scope>test</scope> </dependency> …
为了了解 Mockito 是如何工作的,让我们看看你为第十章中的语句工作开发的一个类。您为构建语句对象而创建的CustomerStatementReader是使用模拟对象的主要候选对象,它依赖于外部 ItemReader 和 DAO。为了提醒您,清单 12-4 展示了来自 ItemReader 的代码。
清单 12-4。??CustomerStatementReader
`package com.apress.springbatch.statement.reader;
import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.ParseException;
import org.springframework.batch.item.UnexpectedInputException;
import com.apress.springbatch.statement.dao.TickerDao; import com.apress.springbatch.statement.domain.Customer; import com.apress.springbatch.statement.domain.Statement;
public class CustomerStatementReader implements ItemReader {
private ItemReader customerReader; private TickerDao tickerDao;
public Statement read() throws Exception, UnexpectedInputException, ParseException {
Customer customer = customerReader.read();
if(customer == null) { return null; } else { Statement statement = new Statement();
statement.setCustomer(customer); statement.setSecurityTotal( tickerDao.getTotalValueForCustomer(customer.getId())); statement.setStocks(tickerDao.getStocksForCustomer(customer.getId()));
return statement; } }
public void setCustomerReader(ItemReader customerReader) { this.customerReader = customerReader; }
public void setTickerDao(TickerDao tickerDao) { this.tickerDao = tickerDao; } }`
你为这个类测试的方法显然是read()。这个方法需要两个外部依赖项:一个 ItemReader 的实例(记住,您在实际工作中使用了 JdbcCursorItemReader)和一个对您的TickerDao的引用。为了测试这个方法,您有两个测试方法,分别用于方法的两个执行分支(一个用于客户是null时,另一个用于客户不是【】时)。
为了开始这个测试,让我们创建测试用例类和@Before方法,以便构建您的对象供以后使用。清单 12-5 显示了用@Before注释和三个类属性标识的setup方法的测试用例。
清单 12-5。??CustomerStatementReaderTest
package com.apress.springbatch.statement.reader; `import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.when;
import java.math.BigDecimal; import java.util.ArrayList;
import org.junit.Before; import org.junit.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.batch.item.ItemReader;
import com.apress.springbatch.statement.dao.TickerDao; import com.apress.springbatch.statement.domain.Customer; import com.apress.springbatch.statement.domain.Statement; import com.apress.springbatch.statement.domain.Transaction;
public class CustomerStatementReaderTest {
private CustomerStatementReader reader; @Mock private TickerDao tickerDao; @Mock private ItemReader customerReader;
@Before public void setUp() { MockitoAnnotations.initMocks(this);
reader = new CustomerStatementReader(); reader.setCustomerReader(customerReader); reader.setTickerDao(tickerDao); } ... }`
测试类的三个属性是被测试的类(CustomerStatementReader)和两个依赖项(TickerDao和 ItemReader)。通过使用@Mock注释,您告诉 Mockito 这些属性应该在您的测试中被模拟。当测试被执行时,Mockito 为其中的每一个创建一个代理供您的测试使用。
在setup方法中,你做两件事。首先用 Mockito 的MockitoAnnotations.initMocks方法初始化模拟。这个方法用一个供您使用的模拟对象来初始化您之前指定的所有对象。这是一种快速简单的方法来创建您将来需要的模拟对象。
在setup方法中您要做的下一件事是创建一个要测试的类的新实例。通过在这里创建这个类,您可以确保每个测试方法都包含一个被测试类的干净实例。这可以防止一个方法的测试对象中的任何残留状态对您的其他测试方法产生影响。在创建了CustomerStatementReader之后,注入模拟对象,就像 Spring 在引导应用时为您做的一样。
因为您现在有了一个被测试对象的新实例和一组新的模拟对象来满足您对 Spring Batch 框架以及数据库的依赖,所以您可以编写您的测试方法。第一个测试没有客户从委托返回,非常简单;见清单 12-6 。
清单 12-6。??testReadNoCustomers()
… @Test public void testReadNoCustomers() throws Exception { assertNull(reader.read()); } …
等等,就这样?发生了什么事?在这种极其简单的测试方法背后,发生的事情比看上去要多得多。当你执行这个方法时,CustomerStatementReader的read方法被调用。在那里,当在第 40 行调用 mock ItemReader 的read方法时,Mockito 返回null。默认情况下,如果在模拟对象上调用方法时不告诉 Mockito 返回什么,它将返回一个适合类型的值(null表示对象,false表示boolean s,-1表示int s,依此类推)。因为您希望您的模拟对象为这个测试返回null,所以您不需要告诉 Mockito 为您做任何事情。在 Mockito 从您注入的 ItemReader 返回null之后,逻辑根据需要返回null。您的测试验证了阅读器使用 JUnit 的Assert.assertNull方法返回null。
您需要为CustomerStatementReader的read方法编写的第二个测试方法测试当客户返回时Statement对象是否被正确构建。在这个场景中,因为您没有使用数据库,所以您需要告诉 Mockito 当用客户 id 调用tickerDao.getTotalValueForCustomer和tickerDao.getStocksForCustomer时返回什么。清单 12-7 显示了testReadWithCustomer方法的代码。
清单 12-7。 testReadWtihCustomer
`… @SuppressWarnings("serial") @Test public void testReadWithCustomer() throws Exception { Customer customer = new Customer(); customer.setId(5l);
when(customerReader.read()).thenReturn(customer); when(tickerDao.getTotalValueForCustomer(5l)).thenReturn( new BigDecimal("500.00")); when(tickerDao.getStocksForCustomer(5l)).thenReturn( new ArrayList() { { add(new Transaction()); } });
Statement result = reader.read();
assertEquals(customer, result.getCustomer()); assertEquals(500.00, result.getSecurityTotal().doubleValue(), 0); assertEquals(1, result.getStocks().size()); } …`
testReadWithCustomer 方法是如何使用 Mockito 的一个很好的例子。首先,创建您需要的任何数据。在这种情况下,创建模拟对象返回的Customer对象。然后你告诉莫奇托你关心的每个调用要返回什么:对customerReader.read()的调用和对tickerDao的两个调用。在清单中,您将客户 id 设置为 5,并告诉 Mockito 期望 5 是传递给两个tickerDao调用的客户 id。要把这个告诉莫奇托,你用Mockito.when方法记录下你关心的方法叫什么。只有当这些场景发生时,Mockito 才会返回您在thenReturn调用中指定的内容。
使用 mocks 设置,然后执行正在测试的方法(在本例中为reader.read())。根据从该方法调用中得到的结果,您可以验证您的Statement对象是根据它收到的数据按照您的期望构建的。
这如何为你提供一个安全网?很简单。假设您更改CustomerStatementReader来将帐户的 id 而不是客户的 id 传递给其中一个tickerDao调用。如果出现这种情况,测试就失败了,这表明与您的期望不一致的变化已经发生,需要解决。
单元测试是一个可靠系统的基础。它们不仅提供了进行您所需要的更改的能力,而且还迫使您保持代码简洁,并作为您的系统的可执行文档。但是,你建立一个基金会不仅仅是为了看看。你在它上面建造。在下一部分中,您将扩展您的测试能力。
与 Spring 类的集成测试
上一节讨论了单元测试及其好处。单元测试,无论多么有用,都有其局限性。集成测试通过引导您的应用并使用您之前努力提取的相同依赖项来运行它,将您的自动化测试带到了一个新的层次。这一节将介绍如何使用 Spring 的集成测试工具来测试与各种 Spring beans、数据库以及批处理资源的交互。
使用 Spring 的一般集成测试
集成测试是关于测试不同部分之间的对话。DAO 连接正确吗,Hibernate 映射正确吗,这样您就可以保存您需要的数据了吗?您的服务从给定的工厂中检索正确的 beans 吗?当您编写集成测试时,会测试这些和其他情况。但是,如果不设置所有的基础设施并确保基础设施在您想要运行这些测试的任何地方都可用,您如何做到这一点呢?幸运的是,你不必这样做。
使用核心 Spring Integration 测试工具进行集成测试的两个主要用例是测试数据库交互和测试 Spring bean 交互(服务连接是否正确,等等)。为了测试这一点,让我们看看您在之前的单元测试中嘲笑的TickerDao(CustomerStatementReader)。然而,这一次,您让 Spring 连接TickerDao本身,并且您使用 HSQLDB 4 的内存实例作为您的数据库,以便您可以随时随地执行测试。HSQLDB 是一个 100%由 Java 实现的数据库,非常适合集成测试,因为它可以轻量级地运行一个实例。首先,让我们看看如何配置您的测试环境。
4 需要注意的是,由于数据库之间在实现 SQL 方面的差异,使用 JDBC 和切换数据库类型可能会很困难。在这种情况下,唯一的区别应该是 create 语句,对于这些语句,您可以使用单独的脚本。
配置测试环境
为了将您的测试执行与外部资源需求(特定的数据库服务器,等等)隔离开来,您应该配置一些东西。具体来说,您应该为数据库使用一个测试配置,该配置会在内存中为您创建一个 HSQLDB 实例。为此,您需要执行以下操作:
- 更新 POM 文件以包含 HSQLDB 数据库驱动程序。
- 重构 Spring 上下文文件的包含,以便更容易地覆盖测试配置。
- 配置特定于测试的属性。
让我们从将 HSQLDB 驱动程序添加到 POM 文件开始。 5 您需要添加的具体依赖关系如清单 12-8 所示。
清单 12-8。 HSQLDB 的数据库驱动依赖
… <dependency> <groupId>hsqldb</groupId> <artifactId>hsqldb</artifactId> <version>1.8.0.7</version> </dependency> …
下一步是做一些重构。到目前为止,您已经以一种非常适合批处理作业的方式构建了 Spring 配置文件。您已经将通用组件放在了launch-context.xml文件中,之后每个作业都有一个特定于作业的 XML 文件。然而,您在这里遇到了一个问题:您当前在launch-context.xml中将属性硬编码为batch.properties,这是为 MySQL 配置的。
为了使这更灵活,您重新构造了 XML 文件,这样就有三个而不是两个。第一个是没有placeholderProperties bean 的普通launch-context.xml文件。第二个是普通的statementJob.xml文件,没有launch-context.xml文件的import语句。您创建的新文件连接这三个文件并配置属性文件的位置。清单 12-9 显示了新配置文件job-context.xml的内容。
清单 12-9。??job-context.xml
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/context ` www.springframework.org/schema/cont…
<bean id="placeholderProperties" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"> <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" />
<bean id="dataSourceInitializer" class="org.springframework.jdbc.datasource.init.DataSourceInitializer"> classpath:schema.sql `
根据记录,如果您使用的是 Spring Batch CLI 原型附带的 POM 文件,您不需要这样做——Spring Batch 自己的测试已经包含了这些驱动程序。然而,因为到目前为止所有的例子都使用了 MySQL,所以您可能需要将它们添加回来。
这种配置结构的优点是您可以在测试中覆盖它。job-context.xml文件位于您的<PROJECT>/src/main/resources directory中。在<PROJECT>/src/test/resources中,您创建了一个名为test-context.xml的相同文件。然而,你没有提到batch.properties的位置,而是提到了test-batch.properties。对test-context.xml文件的另一个补充是 Spring 3 附带的实用程序的配置,这对集成测试有很大的帮助:DataSourceIntializer。
前面提到的test-batch.properties文件包含 HSQLDB 实例所需的信息,与test-context.xml位于同一个目录中。清单 12-10 显示了test-batch.properties的内容。
清单 12-10。??test-batch.properties
batch.jdbc.driver=org.hsqldb.jdbcDriver batch.jdbc.url=jdbc:hsqldb:mem:testdb;sql.enforce_strict_size=true batch.jdbc.user=sa batch.jdbc.password= batch.schema= batch.schema.script=org/springframework/batch/core/schema-hsqldb.sql
test-batch.properties文件定义了数据源用来连接到数据库的信息,以及启动时要执行的脚本列表。在这种情况下,HSQLDB 连接信息非常简单,您有两个脚本在启动时运行:schema-hsqldb.sql为您创建 Spring 批处理表,schema.sql创建语句表。
配置好测试环境后,您就可以开始编写您的第一个集成测试了。下一节看如何为TickerDao编写集成测试。
编写集成测试
用 Spring 编写集成测试非常简单。你需要做三件事:
- 告诉 Spring 加载上下文的位置。
- 扩展
AbstractTransactionalJUnit4SpringContextTests(是的,这确实是类的名字)来获得 Spring 提供的事务帮助。 - 告诉 Spring 连接什么值。
完成这三件事之后,您就可以像在应用中一样使用代码了。让我们从告诉 Spring 加载上下文的位置开始。为此,您在类级别使用 Spring 的@ContextConfiguration注释。出于您的目的,这个注释接受一个属性location,它告诉 Spring 在哪里可以找到test-context.xml文件。
使用 Spring 测试基础设施的主要优势之一是它提供的事务好处。能够在一个事务中运行一个测试是非常有帮助的,这个事务在每个测试方法被执行之后回滚。这样,您的数据库以完全相同的状态开始和结束每个测试用例。通过扩展 Spring 的AbstractTransactionalJUnit4SpringContextTests类,您无需做进一步的工作就可以获得该功能。清单 12-11 显示了配置了上下文位置的集成测试外壳,以及扩展了正确类的外壳。
清单 12-11。 TickerDaoIntegrationTest炮弹
`package com.apress.springbatch.statement.dao;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests;
@ContextConfiguration(locations = {"/test-context.xml"}) public class TickerDaoIntegrationTest extends AbstractTransactionalJUnit4SpringContextTests {
... }`
现在,因为您要测试TickerDao(确切地说是TickerDaoJdbc类),您需要 Spring 连接它并将其注入到您的测试中,这样它就可用了。为此,您使用 Spring 的@Autowired注释来标识您希望 Spring 为您连接的任何类属性。因为这个测试需要的只是TickerDao本身的连线,这就是你需要向 Spring 指示的。
Spring Integration 测试的其余部分与单元测试是一样的。您准备测试所需的任何数据,执行被测试的代码,最后使用 JUnit 的断言来验证发生了什么。清单 12-12 中的代码使用TickerDao测试了 ticker 的保存和检索。
***清单 12-12。*使用TickerDao 测试 Ticker 的保存和检索
`package com.apress.springbatch.statement.dao;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue;
import java.math.BigDecimal;
import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests;
import com.apress.springbatch.statement.domain.Ticker;
@ContextConfiguration(locations = {"/test-context.xml"}) public class TickerDaoIntegrationTest extends AbstractTransactionalJUnit4SpringContextTests {
@Autowired private TickerDao tickerDao;
@Test public void testTickerSaveRoundTrip() { Ticker ticker = new Ticker(); ticker.setPrice(new BigDecimal("222.22")); ticker.setTicker("MTM");
tickerDao.saveTicker(ticker);
Ticker result = tickerDao.findTickerBySymbol("MTM");
assertNotNull(result); assertEquals(222.22, result.getPrice().doubleValue(), 0); assertEquals("MTM", result.getTicker()); assertTrue(result.getId() >= 0); } }`
清单 12-12 所示的测试从创建一个新的要保存的Ticker对象开始。然后使用 Spring 提供的tickerDao来保存它,并随后检索它。最后,验证您保存的数据是否与检索到的数据相匹配,并且设置了 id,表明它确实保存到了数据库中。
当您执行这个测试时,HSQLDB 的一个新实例被启动,数据库模式被创建,您的对象被引导和注入,所有这些都在测试方法执行之前。测试方法在其自己的事务中执行,该事务在测试结束时回滚,为下一个要执行的测试方法保留原始数据库。
当你开发一个系统时,像testTickerSaveRoundTrip这样的集成测试会非常有价值。当您处理复杂系统时,确定事物是否正确连接、SQL 是否正确,甚至系统组件之间的操作顺序是否正确的能力可以提供相当大的安全性。
Spring Batch 测试的最后一部分是测试 Spring Batch 组件本身。可以用 Spring 提供的工具测试 ItemReaders、步骤甚至整个作业。本章的最后一节将介绍如何使用这些组件和批处理过程的测试部分。
测试 SpringBatch
尽管在处理健壮的批处理作业时,测试组件(如 DAO 或服务)的能力是绝对需要的,但是使用 Spring Batch 框架会在代码中引入一些额外的复杂性,为了构建健壮的测试套件,需要解决这些复杂性。这一节将介绍如何处理特定于 Spring Batch 的组件的测试,包括依赖于定制范围的元素、Spring Batch 步骤,甚至是完整的作业。
测试步骤范围的 Beans
正如您在本书的许多例子中看到的,Spring Batch 为 Spring Beans 定义的步骤范围是一个非常有用的工具。然而,当您为使用 step 范围的组件编写集成测试时,您会遇到一个问题:如果您在一个步骤的范围之外执行这些组件,这些依赖关系是如何解决的?在这一节中,您将看到 Spring Batch 提供的两种方式来模拟 bean 在步骤范围内的执行。
在过去,您已经看到了使用 step 作用域如何允许 Spring Batch 将运行时值从作业和/或步骤上下文注入到 beans 中。前面的例子包括输入或输出文件名的注入,或者特定数据库查询的标准。在每个例子中,Spring Batch 从 JobExecution 或 StepExecution 中获取值。如果您不是在一个步骤中运行作业,那么您就没有这两个执行。Spring Batch 提供了两种不同的方法来模拟一个步骤中的执行,以便可以注入这些值。第一种方法使用 TestExecutionListener。
TestExecutionListener 是一个 Spring API,它允许您定义在测试方法之前或之后发生的事情。与使用 JUnit 的@Before和@After注释不同,使用 Spring 的 TestExecutionListener 允许您以更可重用的方式在测试用例中的所有方法之间注入行为。虽然 Spring 提供了 TestExecutionListener 接口的三个有用的实现(dependencyinijectiontestexecutionlistener、dirtiescotexttestexecutionlistener 和 TransactionalTestExecutionListener),但是 Spring Batch 提供了一个处理您正在寻找的内容的实现:StepScopeTestExecutionListener。
StepScopeTestExecutionListener 提供了您需要的两个特性。首先,它使用测试用例中的工厂方法来获得 StepExecution,并使用返回的方法作为当前测试方法的上下文。第二,它为每个测试方法的生命周期提供了一个 StepContext。图 12-2 显示了使用 StepScopeTestExecutionListener 执行测试的流程。
**图 12-2。**使用 StepScopeTestExecutionListener测试执行
正如您所看到的,您在测试用例(getStepExecution)中创建的工厂方法在每个测试方法之前被调用,以获得新的 StepExecution。如果没有工厂方法,Spring Batch 使用默认的 StepExecution。
为了测试这一点,您需要配置一个 FlatFileItemReader 来获取要从jobParameters中读取的文件的位置。首先让我们看看要测试的 ItemReader 的配置和你使用的资源(见清单 12-13 )。
***清单 12-13。*汽车文件的 ItemReader 配置
… <beans:bean id="carFileReader" class="org.springframework.batch.item.file.FlatFileItemReader" scope="step"> <beans:property name="resource" value="#{jobParameters[carFile]}"/> <beans:property name="lineMapper"> <beans:bean class="org.springframework.batch.item.file.mapping.PassThroughLineMapper"/> </beans:property> </beans:bean> …
清单 12-13 是一个简单的 FlatFileItemReader,它被配置为使用 step 作用域,因此您可以在运行时通过作业参数设置输入文件的名称。为了测试这个 ItemReader,您可以用与使用TickerDaoIntegrationTest相同的方式开始,通过使用@ContextConfiguration注释告诉 Spring 上下文配置的位置。但是,在这个测试中,您将注释的使用扩展到包括以下内容:
- 使用 dependencyinijectontestexecutionlistener 消除了从 Spring 扩展任何特定类来获得通过 Spring 连接和注入 beans 的能力的需要。StepScopeTestExecutionListener 调用
getStepExecution来获得一个 StepExecution complete,其中包含 Spring Batch 将为您注入的任何参数。 @RunWith: 上一项中的监听器是 JUnit 未知的 Spring 概念。因此,您需要使用 Spring 的测试运行程序,而不是标准的 JUnit 运行程序。
为了让这个测试工作,您需要一个测试文件供 ItemReader 读取。出于测试的目的,在<PROJECT>/src/test/resources目录中包含任何测试文件是一个好主意,这样无论测试在哪里运行,它们都是可用的。在这种情况下,您在目录<project_home>/src/test/resources/data中包含一个名为carfile.csv的 CSV 文件。文件的内容如清单 12-14 所示。
清单 12-14。??carFile.csv
1987,Nissan,Sentra 1991,Plymouth,Laser 1995,Mercury,Cougar 2000,Infiniti,QX4 2001,Infiniti,QX4
使用您在上一个示例中使用的相同上下文配置环境,配置新的 ItemReader,以及为测试提供一个样本输入文件,您可以将集成测试放在一起。这个集成测试遍历文件,读取每一行,直到文件完成(在本例中是五次)。您验证每条记录都被返回,直到对读取器的最后一次(第六次)调用是null,这表明输入已经用尽。清单 12-15 展示了集成测试。
清单 12-15。CarFileReaderIntegrationTest
`package com.apress.springbatch.chapter12;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull;
import java.util.ArrayList; import java.util.List;
import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.StepExecution; import org.springframework.batch.item.ExecutionContext; import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.ItemStream; import org.springframework.batch.test.MetaDataInstanceFactory; import org.springframework.batch.test.StepScopeTestExecutionListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestExecutionListeners; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
@ContextConfiguration(locations = { "/test-context.xml" }) @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, StepScopeTestExecutionListener.class }) @RunWith(SpringJUnit4ClassRunner.class) public class CarFileReaderIntegrationTest {
@Autowired private ItemReader carFileReader;
@SuppressWarnings("serial") private List records = new ArrayList() {{ add("1987,Nissan,Sentra"); add("1991,Plymouth,Laser"); add("1995,Mercury,Cougar"); add("2000,Infiniti,QX4"); add("2001,Infiniti,QX4"); }};
public StepExecution getStepExecution() { JobParameters jobParams = new JobParametersBuilder().addString( "carFile", "classpath:/data/carfile.txt").toJobParameters();
return MetaDataInstanceFactory.createStepExecution(jobParams);
} @Test
public void testCarFileReader() throws Exception {
((ItemStream) carFileReader).open(new ExecutionContext());
for(int i = 0; i < 5; i++) { assertEquals(carFileReader.read(), records.get(i)); }
assertNull(carFileReader.read()); } }`
使用一种你至今还没见过的设施。MetaDataInstanceFactory是 Spring Batch 提供的一个类,用于创建 Spring Batch 域对象的模型。在大多数情况下,我强烈建议只使用 Mockito 来限制单元测试和 Spring 之间的耦合;但在这种情况下,事情有点不同。
Spring Batch 需要 StepExecution 对象。然而,它用它来做什么是相当复杂的,用 Mockito 来模拟它需要你了解 Spring Batch 的内部工作原理。您不希望使用 Mockito 来模拟这种情况,所以您可以使用 Spring 的 MetaDataInstanceFactory 来创建模拟。
如前所述,有两种方法可以测试 step 范围中定义的 Spring 批处理组件。第一种是您刚刚看到的监听器方法,它是非侵入性的,允许您将 step 范围应用到测试用例中的所有测试方法。但是如果您只需要在您的测试用例中的一两个测试方法中使用它,Spring Batch 提供了一个实用程序来将您的执行打包在一个步骤中。测试同一个组件,carFileReader,可以使用StepScopeTestUtils在一个步骤的范围内执行。清单 12-16 展示了更新后的单元测试,使用StepScopeTestUtils代替监听器来模拟一个步骤。
***清单 12-16。*使用 StepScopeTestUtils
`package com.apress.springbatch.chapter12;
import static org.junit.Assert.assertEquals;
import java.util.concurrent.Callable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemStream;
import org.springframework.batch.test.MetaDataInstanceFactory;
import org.springframework.batch.test.StepScopeTestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import
org.springframework.test.context.support.DependencyInjectionTestExecutionListener; @ContextConfiguration(locations = { "/test-context.xml" })
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class })
@RunWith(SpringJUnit4ClassRunner.class)
public class CarFileReaderIntegrationTest {
@Autowired private ItemReader carFileReader;
public StepExecution getStepExecution() { JobParameters jobParams = new JobParametersBuilder().addString( "carFile", "classpath:/data/carfile.txt").toJobParameters();
return MetaDataInstanceFactory.createStepExecution(jobParams); }
@Test public void testCarFileReader() throws Exception { StepExecution execution = getStepExecution();
Integer readCount = StepScopeTestUtils.doInStepScope(execution, new Callable() {
@Override public Integer call() throws Exception { ((ItemStream) carFileReader).open(new ExecutionContext());
int i = 0;
while(carFileReader.read() != null) { i++; }
return i; } });
assertEquals(readCount.intValue(), 5); } }`
StepScopeTestUtils对象包含一个名为doInStepScope的实用方法,如清单 12-16 中的所示。此方法接受 StepExecution 和可调用的实现。当测试被执行时,StepScopeTestUtils处理运行时注入,就像 Spring Batch 通常会做的那样,然后执行可调用的实现,返回结果。在这种情况下,可调用的实现计算测试文件中的记录数,并返回该数字供您验证其正确性。
这种性质的集成测试对于测试定制开发的组件非常有用,比如定制的 ItemReaders 和 ItemWriters。然而,正如您所看到的,测试 Spring Batch 自身组件的价值充其量是微乎其微的。请放心,它对这些事情都有测试覆盖。相反,通过执行整个步骤来测试批处理作业可能更有用。下一节将介绍 Spring Batch 为实现这一点而提供的工具。
测试一个步骤
工作被分成几个步骤。这本书已经证实了这一点。每个步骤都是一个独立的功能,可以在对其他步骤影响最小的情况下执行。由于步骤与批处理作业的内在分离,步骤成为测试的主要候选。在本节中,您将看到如何完整地测试 Spring 批处理步骤。
在上一节中基于 step 范围的示例中,您测试了一个作业的 ItemReader,该作业读入一个文件并写出完全相同的文件。这个单步作业就是您现在用来演示如何在 Spring Batch 中测试单步执行的作业。首先,让我们看看作业的配置;清单 12-17 有整个carJob的 XML。
清单 12-17。??carJob.xml
` <beans:beans xmlns:beans="www.springframework.org/schema/bean…" xmlns:util="www.springframework.org/schema/bean…" xmlns:xsi="www.w3.org/2001/XMLSch…" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… www.springframework.org/schema/util www.springframework.org/schema/util… www.springframework.org/schema/batc… www.springframework.org/schema/batc…
<beans:bean id="carFileReader" class="org.springframework.batch.item.file.FlatFileItemReader" scope="step"> <beans:property name="resource" value="#{jobParameters[carFile]}"/> <beans:property name="lineMapper"> <beans:bean class="org.springframework.batch.item.file.mapping.PassThroughLineMapper"/> </beans:property> </beans:bean>
<beans:bean id="carFileWriter" class="org.springframework.batch.item.file.FlatFileItemWriter" scope="step"> <beans:property name="resource" value="#{jobParameters[outputFile]}"/> <beans:property name="lineAggregator"> <beans:bean class="org.springframework.batch.item.file.transform.PassThroughLineAggregator"/> </beans:property> </beans:bean>
</beans:beans>`
carJob使用 FlatFileItemReader 读入一个文件,该文件的位置在运行时通过作业参数传递。它将输入传递给 FlatFileItemWriter,后者将输出写入一个文件,该文件的位置也是在运行时通过作业参数提供的。这两个组件在一个步骤中就可以完成您的工作。
为了通过集成测试执行这个步骤,测试的结构非常类似于清单 12-17 中的CarFileReaderIntegrationTest。您使用注释来告诉 Spring 在哪里可以找到您的上下文配置文件以及要注入什么,并且您配置测试通过 SpringJUnit4ClassRunner 来执行。您甚至可以构建自己的JobParameters对象来传递给作业。但相似之处也仅限于此。
要执行一个步骤,您需要使用 Spring Batch 提供的另一个实用程序:JobLauncherTestUtils。这个实用程序类提供了许多方法,用于以各种方式(带参数、不带参数、带执行上下文、不带等等)启动步骤和作业。当您执行一个步骤时,您会收到 JobExecution 返回,在其中您可以检查作业中发生了什么。清单 12-18 有测试carProcessingStep的代码。
***清单 12-18。*测试carProcessingStep
`package com.apress.springbatch.chapter12;
import static org.junit.Assert.assertEquals; import static org.springframework.batch.test.AssertFile.assertFileEquals;
import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.StepExecution; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestExecutionListeners; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
@ContextConfiguration(locations = { "/test-context.xml" }) @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class }) @RunWith(SpringJUnit4ClassRunner.class) public class CarProcessingStepIntegrationTest {
private static final String OUTPUT_FILE = "/"
+ System.getProperty("java.io.tmpdir") + "carOutput.txt";
private static final String INPUT_FILE = "/data/carfile.txt";
@Autowired
private JobLauncherTestUtils jobLauncherUtils; @Test
public void testCarProcessingStep() throws Exception {
assertEquals(BatchStatus.COMPLETED,
jobLauncherUtils.launchStep("carProcessingStep", getParams())
.getStatus());
assertFileEquals(new ClassPathResource(INPUT_FILE), new FileSystemResource(OUTPUT_FILE)); }
private JobParameters getParams() { return new JobParametersBuilder().addString("carFile", INPUT_FILE) .addString("outputFile", "file:/" + OUTPUT_FILE) .toJobParameters(); } }`
如前所述,这个测试的结构应该是熟悉的。和你之前几次测试用的一样。然而,这个测试有几个方面很有趣。首先是比恩jobLauncherUtils。这就是前面提到的实用程序类。Spring 将它自动连接到您的测试中,并将它自己的依赖项自动连接到诸如数据源和作业启动器之类的东西。因为JobLauncherTestUtils需要自动连线,你需要确保将它添加到你的test-context.xml文件中。清单 12-19 显示了这个测试的test-context.xml文件的内容。
清单 12-19。??test-context.xml
` <beans xmlns:xsi="www.w3.org/2001/XMLSch…" xmlns:context="www.springframework.org/schema/cont…" xmlns:util="www.springframework.org/schema/bean…" xsi:schemaLocation="www.springframework.org/schema/bean… www.springframework.org/schema/bean… www.springframework.org/schema/util www.springframework.org/schema/util… www.springframework.org/schema/cont… www.springframework.org/schema/cont…
<bean id="placeholderProperties" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"> <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" />
<bean id="dataSourceInitializer"
class="org.springframework.jdbc.datasource.init.DataSourceInitializer">
${batch.schema.script}
`
连接好JobLauncherTestUtils后,您可以用它来执行测试的testCarProcessingStep方法中的步骤。在执行完该步骤后,您要验证两件事:使用常规的 JUnit 断言,您要验证该步骤是否成功完成;并验证创建的文件与读入的文件是否相同。使用 JUnit 做这样的事情将是一个非常痛苦的练习;但是因为文件操作是 Spring Batch 框架的核心,Spring Batch 包含了断言两个文件相同的能力。AssertFile实用程序类可以让你比较两个文件的整体或者仅仅是两个文件的行数。这是测试工具中非常有用的工具。
剩下的唯一可以测试的就是整个工作。在下一节中,您将转向真正的功能测试,并从头到尾测试一个批处理作业。
测试一项工作
测试整个工作可能是一项艰巨的任务。如您所见,有些工作可能相当复杂,需要的设置也不容易。然而,能够自动化执行和结果验证的好处是不容忽视的。因此,强烈建议您尽可能在这个级别尝试自动化测试。这一节着眼于如何使用JobLauncherTestUtils来执行整个测试任务。
在本例中,您使用与上一节相同的carJob,但是这次您测试的是整个作业,而不是包含的步骤。为此,JobLauncherTestUtils类再次成为你的朋友,完成所有的艰苦工作。因为在您的上下文中只配置了一个作业,所以您需要做的就是调用JobLauncherTestUtils ' launchJob()方法来执行该作业。在这种情况下,您调用接受一个JobParameters对象的变量,这样您就可以传入您希望处理的输入和输出文件的位置。
launchJob()方法返回一个 JobExecution 对象。正如你在第四章中所知道的,这可以让你了解你工作期间发生的所有事情。可以检查作业和每一步的ExitStatus,可以验证每一步读取、处理、写入、跳过的项数等等。名单还在继续。能够用 Spring Batch 提供的便利以编程方式在这个级别测试作业的重要性怎么强调都不为过。清单 12-20 显示了整体测试carJob的测试方法。
***清单 12-20。*测试carJob
`… @Test public void testCarJob() throws Exception { JobExecution execution = jobLauncherUtils.launchJob(getParams());
assertEquals(ExitStatus.COMPLETED, execution.getExitStatus());
StepExecution stepExecution = execution.getStepExecutions().iterator().next(); assertEquals(ExitStatus.COMPLETED, stepExecution.getExitStatus()); assertEquals(5, stepExecution.getReadCount()); assertEquals(5, stepExecution.getWriteCount());
assertFileEquals(new ClassPathResource(INPUT_FILE), new FileSystemResource(OUTPUT_FILE)); }
private JobParameters getParams() { return new JobParametersBuilder().addString("carFile", INPUT_FILE) .addString("outputFile", "file:/" + OUTPUT_FILE) .toJobParameters(); } …`
如清单 12-20 所示,执行你的工作只需要一行代码。从那里,您能够验证作业的ExitStatus、作业中的任何步骤以及这些步骤的读和写计数,并且断言作业的结果与您预期的相匹配。
总结
从对系统中任何组件的单个方法进行单元测试,一直到以编程方式执行批处理作业,您已经涵盖了作为批处理程序员可能遇到的绝大多数测试场景。本章首先概述了 JUnit 测试框架和用于单元测试的 Mockito 模拟对象框架。然后,您探索了使用 Spring 提供的类和注释进行集成测试,包括在事务中执行测试。最后,您通过执行步骤范围中定义的组件、作业中的单个步骤,以及最后整个作业,查看了特定于 Spring 批处理的测试。