FlatFileItemReader

平面文件是指任何最多包含二维(表格)数据的文件类型。 在 Spring Batch 框架中,读取平面文件由名为 FlatFileItemReader 的类提供支持,该类提供了读取和解析平面文件的基本功能。FlatFileItemReader 的两个最重要的必需依赖项是 ResourceLineMapperLineMapper 接口将在接下来的章节中进一步探讨。resource 属性代表一个 Spring Core 的 Resource。关于如何创建此类 Bean 的文档可在 Spring Framework,第 5 章:资源 中找到。因此,本指南不会深入探讨创建 Resource 对象的细节,仅展示以下简单示例:spring-doc.cadn.net.cn

Resource resource = new FileSystemResource("resources/trades.csv");

在复杂的批处理环境中,目录结构通常由企业应用集成(EAI)基础设施进行管理,其中为外部接口设立了投放区,用于将文件从 FTP 位置移动到批处理位置,反之亦然。文件移动工具超出了 Spring Batch 架构的范围,但批处理作业流中将文件移动工具作为作业流中的步骤并不罕见。批处理架构只需知道如何定位待处理的文件即可。Spring Batch 从此起点开始将数据馈送到管道中。然而,Spring Integration 提供了许多此类服务。spring-doc.cadn.net.cn

FlatFileItemReader 中的其他属性可让您进一步指定数据的解释方式,如下表所述:spring-doc.cadn.net.cn

表 1. FlatFileItemReader 个属性
属性 类型 描述

评论spring-doc.cadn.net.cn

字符串数组spring-doc.cadn.net.cn

指定用于标识注释行的行前缀。spring-doc.cadn.net.cn

编码spring-doc.cadn.net.cn

字符串spring-doc.cadn.net.cn

指定要使用的文本编码。默认值为 UTF-8spring-doc.cadn.net.cn

行映射器spring-doc.cadn.net.cn

LineMapperspring-doc.cadn.net.cn

String 转换为表示该项目的 Objectspring-doc.cadn.net.cn

跳过的行数spring-doc.cadn.net.cn

整型spring-doc.cadn.net.cn

忽略文件顶部的行数。spring-doc.cadn.net.cn

记录分隔符策略spring-doc.cadn.net.cn

记录分隔符策略spring-doc.cadn.net.cn

用于确定行尾的位置, 并在引号字符串内部时执行跨行等操作。spring-doc.cadn.net.cn

资源spring-doc.cadn.net.cn

Resourcespring-doc.cadn.net.cn

要读取的资源。spring-doc.cadn.net.cn

skippedLinesCallbackspring-doc.cadn.net.cn

行回调处理器spring-doc.cadn.net.cn

该接口将文件中要跳过的行的原始行内容传递进去。如果设置为 2,则此接口会被调用两次。spring-doc.cadn.net.cn

严格spring-doc.cadn.net.cn

布尔值spring-doc.cadn.net.cn

在严格模式下,如果输入资源不存在,读取器会在 ExecutionContext 处抛出异常。否则,它会记录问题并继续执行。spring-doc.cadn.net.cn

LineMapper

RowMapper(它接受如 ResultSet 这样的低级构造并返回一个 Object)类似,平面文件处理也需要相同的构造,以便将一行 String 转换为一个 Object,如下面的接口定义所示:spring-doc.cadn.net.cn

public interface LineMapper<T> {

    T mapLine(String line, int lineNumber) throws Exception;

}

基本约定是:给定当前行及其关联的行号,映射器应返回一个结果域对象。这与RowMapper类似,其中每一行都与其行号关联,就像ResultSet中的每一行都与其行号绑定一样。这使得行号可以与结果域对象绑定,用于身份比较或提供更详细的日志记录。然而,与RowMapper不同,LineMapper接收的是原始行,如上所述,这仅能完成一半的工作。该行必须被分词为FieldSet,然后才能映射到对象,具体过程将在本文档后续部分描述。spring-doc.cadn.net.cn

LineTokenizer

将一行输入转换为 FieldSet 的抽象是必要的,因为可能存在多种需要转换为 FieldSet 的平面文件格式。在 Spring Batch 中,该接口是 LineTokenizerspring-doc.cadn.net.cn

public interface LineTokenizer {

    FieldSet tokenize(String line);

}

LineTokenizer 的契约是:给定一行输入(理论上String可以包含多行),返回一个代表该行的FieldSet。然后可以将此FieldSet传递给FieldSetMapper。Spring Batch 包含以下LineTokenizer实现:spring-doc.cadn.net.cn

  • DelimitedLineTokenizer: 用于记录中的字段由分隔符分隔的文件。最常见的分隔符是逗号,但也经常使用竖线或分号。spring-doc.cadn.net.cn

  • FixedLengthTokenizer:用于记录中每个字段均为“固定宽度”的文件。必须为每种记录类型定义每个字段的宽度。spring-doc.cadn.net.cn

  • PatternMatchingCompositeLineTokenizer: 通过匹配模式,决定在特定行上应使用列表中的哪一个 LineTokenizer(分词器)。spring-doc.cadn.net.cn

FieldSetMapper

FieldSetMapper 接口定义了一个单一方法 mapFieldSet,该方法接收一个FieldSet 对象,并将其内容映射为一个对象。此对象可以是自定义的 DTO、领域对象或数组,具体取决于作业需求。FieldSetMapperLineTokenizer 结合使用,将资源中的一行数据转换为所需类型的对象,如下面的接口定义所示:spring-doc.cadn.net.cn

public interface FieldSetMapper<T> {

    T mapFieldSet(FieldSet fieldSet) throws BindException;

}

使用的模式与JdbcTemplate 使用的RowMapper 相同。spring-doc.cadn.net.cn

DefaultLineMapper

既然读取平面文件的基本接口已经定义,那么很明显需要三个基本步骤:spring-doc.cadn.net.cn

  1. 从文件中读取一行。spring-doc.cadn.net.cn

  2. String 行传入 LineTokenizer#tokenize() 方法以检索一个FieldSetspring-doc.cadn.net.cn

  3. 将分词返回的 FieldSet 传递给 FieldSetMapper,并返回 ItemReader#read() 方法的结果。spring-doc.cadn.net.cn

上述两个接口代表了两个独立的任务:将一行转换为一个FieldSet,以及将一个FieldSet映射为领域对象。由于LineTokenizer的输入与LineMapper的输入(即一行)相匹配,且FieldSetMapper的输出与LineMapper的输出相匹配,因此提供了一个默认实现,该实现同时使用了LineTokenizerFieldSetMapper。如下类定义所示的DefaultLineMapper代表了大多数用户所需的行为:spring-doc.cadn.net.cn

public class DefaultLineMapper<T> implements LineMapper<>, InitializingBean {

    private LineTokenizer tokenizer;

    private FieldSetMapper<T> fieldSetMapper;

    public T mapLine(String line, int lineNumber) throws Exception {
        return fieldSetMapper.mapFieldSet(tokenizer.tokenize(line));
    }

    public void setLineTokenizer(LineTokenizer tokenizer) {
        this.tokenizer = tokenizer;
    }

    public void setFieldSetMapper(FieldSetMapper<T> fieldSetMapper) {
        this.fieldSetMapper = fieldSetMapper;
    }
}

上述功能由默认实现提供,而非直接构建到读取器本身中(如框架早期版本所做的那样),以便用户在控制解析过程时拥有更大的灵活性,特别是在需要访问原始行的情况下。spring-doc.cadn.net.cn

简单分隔文件读取示例

以下示例说明了如何在实际业务场景中读取平面文件。 此特定批处理作业从以下文件中读取足球运动员数据:spring-doc.cadn.net.cn

ID,lastName,firstName,position,birthYear,debutYear
"AbduKa00,Abdul-Jabbar,Karim,rb,1974,1996",
"AbduRa00,Abdullah,Rabih,rb,1975,1999",
"AberWa00,Abercrombie,Walter,rb,1959,1982",
"AbraDa00,Abramowicz,Danny,wr,1945,1967",
"AdamBo00,Adams,Bob,te,1946,1969",
"AdamCh00,Adams,Charlie,wr,1979,2003"

本文件的内容映射到以下 Player 领域对象:spring-doc.cadn.net.cn

public class Player implements Serializable {

    private String ID;
    private String lastName;
    private String firstName;
    private String position;
    private int birthYear;
    private int debutYear;

    public String toString() {
        return "PLAYER:ID=" + ID + ",Last Name=" + lastName +
            ",First Name=" + firstName + ",Position=" + position +
            ",Birth Year=" + birthYear + ",DebutYear=" +
            debutYear;
    }

    // setters and getters...
}

要将 FieldSet 映射为 Player 对象,需要定义一个返回玩家的 FieldSetMapper,如下例所示:spring-doc.cadn.net.cn

protected static class PlayerFieldSetMapper implements FieldSetMapper<Player> {
    public Player mapFieldSet(FieldSet fieldSet) {
        Player player = new Player();

        player.setID(fieldSet.readString(0));
        player.setLastName(fieldSet.readString(1));
        player.setFirstName(fieldSet.readString(2));
        player.setPosition(fieldSet.readString(3));
        player.setBirthYear(fieldSet.readInt(4));
        player.setDebutYear(fieldSet.readInt(5));

        return player;
    }
}

然后可以通过正确构造一个 FlatFileItemReader 并调用 read 来读取该文件,如下例所示:spring-doc.cadn.net.cn

FlatFileItemReader<Player> itemReader = new FlatFileItemReader<>();
itemReader.setResource(new FileSystemResource("resources/players.csv"));
DefaultLineMapper<Player> lineMapper = new DefaultLineMapper<>();
//DelimitedLineTokenizer defaults to comma as its delimiter
lineMapper.setLineTokenizer(new DelimitedLineTokenizer());
lineMapper.setFieldSetMapper(new PlayerFieldSetMapper());
itemReader.setLineMapper(lineMapper);
itemReader.open(new ExecutionContext());
Player player = itemReader.read();

每次调用 read 都会从文件的每一行返回一个新的 Player 对象。当到达文件末尾时, 将返回 nullspring-doc.cadn.net.cn

按名称映射字段

DelimitedLineTokenizerFixedLengthTokenizer 都允许一项额外的功能,其作用类似于 JDBC 的 ResultSet。字段名称可以注入到这些 LineTokenizer 实现中的任意一个,以提高映射函数的可读性。首先,将平面文件中所有字段的列名注入到分词器中,如下例所示:spring-doc.cadn.net.cn

tokenizer.setNames(new String[] {"ID", "lastName", "firstName", "position", "birthYear", "debutYear"});

FieldSetMapper 可以按如下方式使用此信息:spring-doc.cadn.net.cn

public class PlayerMapper implements FieldSetMapper<Player> {
    public Player mapFieldSet(FieldSet fs) {

       if (fs == null) {
           return null;
       }

       Player player = new Player();
       player.setID(fs.readString("ID"));
       player.setLastName(fs.readString("lastName"));
       player.setFirstName(fs.readString("firstName"));
       player.setPosition(fs.readString("position"));
       player.setDebutYear(fs.readInt("debutYear"));
       player.setBirthYear(fs.readInt("birthYear"));

       return player;
   }
}

将 FieldSet 自动映射到领域对象

对许多人来说,编写特定的 FieldSetMapper 与为 JdbcTemplate 编写特定的 RowMapper 一样繁琐。Spring Batch 通过提供 FieldSetMapper 简化了这一过程,它能够根据 JavaBean 规范,将字段名与对象上的 setter 方法进行匹配,从而自动映射字段。spring-doc.cadn.net.cn

再次以足球为例,BeanWrapperFieldSetMapper 配置在 Java 中看起来如下代码片段:spring-doc.cadn.net.cn

Java 配置
@Bean
public FieldSetMapper fieldSetMapper() {
	BeanWrapperFieldSetMapper fieldSetMapper = new BeanWrapperFieldSetMapper();

	fieldSetMapper.setPrototypeBeanName("player");

	return fieldSetMapper;
}

@Bean
@Scope("prototype")
public Player player() {
	return new Player();
}

再次以足球为例,BeanWrapperFieldSetMapper 配置在 XML 中看起来如下代码片段:spring-doc.cadn.net.cn

XML 配置
<bean id="fieldSetMapper"
      class="org.springframework.batch.infrastructure.item.file.mapping.BeanWrapperFieldSetMapper">
    <property name="prototypeBeanName" value="player" />
</bean>

<bean id="player"
      class="org.springframework.batch.samples.domain.Player"
      scope="prototype" />

对于 FieldSet 中的每个条目,映射器会在 Player 对象的新实例上查找对应的 setter 方法(因此需要原型作用域),其方式与 Spring 容器根据属性名查找匹配的 setter 方法相同。FieldSet 中所有可用的字段都会被映射,最终返回生成的 Player 对象,无需编写任何代码。spring-doc.cadn.net.cn

定长文件格式

到目前为止,我们只详细讨论了分隔文件。然而,它们仅代表文件读取场景的一半。许多使用平面文件的组织采用固定长度格式。以下是一个固定长度文件的示例:spring-doc.cadn.net.cn

UK21341EAH4121131.11customer1
UK21341EAH4221232.11customer2
UK21341EAH4321333.11customer3
UK21341EAH4421434.11customer4
UK21341EAH4521535.11customer5

虽然这看起来像一个大字段,但它实际上代表 4 个不同的字段:spring-doc.cadn.net.cn

  1. ISIN:所订购项目的唯一标识符 - 长度为 12 个字符。spring-doc.cadn.net.cn

  2. 数量:订购商品的数量 - 长度为 3 个字符。spring-doc.cadn.net.cn

  3. 价格:商品的价格 - 长度为 5 个字符。spring-doc.cadn.net.cn

  4. 客户:订购商品的客户 ID - 长度为 9 个字符。spring-doc.cadn.net.cn

在配置 FixedLengthLineTokenizer 时,每个长度都必须以范围的形式提供。spring-doc.cadn.net.cn

以下示例展示了如何在 Java 中为 FixedLengthLineTokenizer 定义范围:spring-doc.cadn.net.cn

Java 配置
@Bean
public FixedLengthTokenizer fixedLengthTokenizer() {
	FixedLengthTokenizer tokenizer = new FixedLengthTokenizer();

	tokenizer.setNames("ISIN", "Quantity", "Price", "Customer");
	tokenizer.setColumns(new Range(1, 12),
						new Range(13, 15),
						new Range(16, 20),
						new Range(21, 29));

	return tokenizer;
}

以下示例展示了如何在 XML 中为 FixedLengthLineTokenizer 定义范围:spring-doc.cadn.net.cn

XML 配置
<bean id="fixedLengthLineTokenizer"
      class="org.springframework.batch.infrastructure.item.file.transform.FixedLengthTokenizer">
    <property name="names" value="ISIN,Quantity,Price,Customer" />
    <property name="columns" value="1-12, 13-15, 16-20, 21-29" />
</bean>

因为 FixedLengthLineTokenizer 使用了与前面讨论相同的 LineTokenizer 接口,所以它返回的 FieldSet 与使用分隔符时相同。这使得可以采用相同的方法来处理其输出,例如使用 BeanWrapperFieldSetMapperspring-doc.cadn.net.cn

要支持前述的范围语法,需要在 ApplicationContext 中配置一个专用的属性编辑器 RangeArrayPropertyEditor。然而,在使用批处理命名空间的 ApplicationContext 中,该 Bean 会自动声明。spring-doc.cadn.net.cn

因为 FixedLengthLineTokenizer 使用了与上述讨论相同的 LineTokenizer 接口,所以它返回的 FieldSet 与使用分隔符时相同。这使得可以采用相同的方法来处理其输出,例如使用 BeanWrapperFieldSetMapperspring-doc.cadn.net.cn

单个文件中的多种记录类型

到目前为止,所有文件读取示例为了简化起见都做了一个关键假设:文件中的所有记录都具有相同的格式。然而,情况并非总是如此。非常常见的情况是,一个文件可能包含具有不同格式的记录,这些记录需要以不同的方式进行分词,并映射到不同的对象。以下文件摘录说明了这一点:spring-doc.cadn.net.cn

USER;Smith;Peter;;T;20014539;F
LINEA;1044391041ABC037.49G201XX1383.12H
LINEB;2134776319DEF422.99M005LI

在此文件中,我们包含三种类型的记录:spring-doc.cadn.net.cn

ItemReader 会逐行读取,但我们必须指定不同的 LineTokenizerFieldSetMapper 对象,以便 ItemWriter 接收到正确的项目。PatternMatchingCompositeLineMapper 通过允许配置模式到 LineTokenizers 的映射以及模式到 FieldSetMappers 的映射,使这一过程变得简单。spring-doc.cadn.net.cn

Java 配置
@Bean
public PatternMatchingCompositeLineMapper orderFileLineMapper() {
	PatternMatchingCompositeLineMapper lineMapper =
		new PatternMatchingCompositeLineMapper();

	Map<String, LineTokenizer> tokenizers = new HashMap<>(3);
	tokenizers.put("USER*", userTokenizer());
	tokenizers.put("LINEA*", lineATokenizer());
	tokenizers.put("LINEB*", lineBTokenizer());

	lineMapper.setTokenizers(tokenizers);

	Map<String, FieldSetMapper> mappers = new HashMap<>(2);
	mappers.put("USER*", userFieldSetMapper());
	mappers.put("LINE*", lineFieldSetMapper());

	lineMapper.setFieldSetMappers(mappers);

	return lineMapper;
}

以下示例展示了如何在 XML 中为 FixedLengthLineTokenizer 定义范围:spring-doc.cadn.net.cn

XML 配置
<bean id="orderFileLineMapper"
      class="org.spr...PatternMatchingCompositeLineMapper">
    <property name="tokenizers">
        <map>
            <entry key="USER*" value-ref="userTokenizer" />
            <entry key="LINEA*" value-ref="lineATokenizer" />
            <entry key="LINEB*" value-ref="lineBTokenizer" />
        </map>
    </property>
    <property name="fieldSetMappers">
        <map>
            <entry key="USER*" value-ref="userFieldSetMapper" />
            <entry key="LINE*" value-ref="lineFieldSetMapper" />
        </map>
    </property>
</bean>

在此示例中,"LINEA"和"LINEB"拥有各自的LineTokenizer实例,但它们都使用相同的FieldSetMapperspring-doc.cadn.net.cn

PatternMatchingCompositeLineMapper 使用 PatternMatcher#match 方法,以便为每一行选择正确的委托。PatternMatcher 允许使用两个具有特殊含义的通配符:问号("?")精确匹配一个字符,而星号("*")匹配零个或多个字符。请注意,在上述配置中,所有模式均以星号结尾,使它们实际上成为行的前缀。PatternMatcher 始终匹配尽可能最具体的模式,而与配置中的顺序无关。因此,如果同时列出了 "LINE*" 和 "LINEA*" 两种模式,则 "LINEA" 将匹配模式 "LINEA*",而 "LINEB" 将匹配模式 "LINE*"。此外,单个星号("*")可作为默认值,匹配任何其他模式未匹配的行。spring-doc.cadn.net.cn

以下示例展示了如何在 Java 中匹配未被任何其他模式匹配的行:spring-doc.cadn.net.cn

Java 配置
...
tokenizers.put("*", defaultLineTokenizer());
...

以下示例展示了如何在 XML 中匹配未被任何其他模式匹配的行:spring-doc.cadn.net.cn

XML 配置
<entry key="*" value-ref="defaultLineTokenizer" />

还有一个 PatternMatchingCompositeLineTokenizer 可以单独用于分词。spring-doc.cadn.net.cn

平文件包含每条记录跨越多行的情况也很常见。要处理这种情况,需要更复杂的策略。这种常见模式的演示可以在 multiLineRecords 示例中找到。spring-doc.cadn.net.cn

平面文件中的异常处理

在许多场景下,对一行进行Tokens化处理可能会抛出异常。许多平面文件并不完美,包含格式错误的记录。许多用户选择跳过这些错误行,同时记录问题、原始行内容以及行号。这些日志随后可以手动检查或由另一个批处理作业进行检查。因此,Spring Batch 提供了一套用于处理解析异常的异常层次结构:FlatFileParseExceptionFlatFileFormatException。当尝试读取文件时遇到任何错误,FlatFileItemReader 会抛出 FlatFileParseExceptionLineTokenizer 接口的实现类在Tokens化过程中遇到更具体的错误时会抛出 FlatFileFormatExceptionspring-doc.cadn.net.cn

IncorrectTokenCountException

DelimitedLineTokenizerFixedLengthLineTokenizer 都能够指定可用于创建 FieldSet 的列名。但是,如果指定的列名数量与对某行进行分词时发现的列数不匹配,则无法创建 FieldSet,并会抛出 IncorrectTokenCountException,该异常包含实际遇到的Tokens数量和预期数量,如下例所示:spring-doc.cadn.net.cn

tokenizer.setNames(new String[] {"A", "B", "C", "D"});

try {
    tokenizer.tokenize("a,b,c");
}
catch (IncorrectTokenCountException e) {
    assertEquals(4, e.getExpectedCount());
    assertEquals(3, e.getActualCount());
}

由于分词器配置了 4 个列名,但在文件中只找到了 3 个Tokens,因此抛出了 IncorrectTokenCountExceptionspring-doc.cadn.net.cn

IncorrectLineLengthException

采用固定长度格式的文件在解析时有额外的要求, 因为与分隔符格式不同,每一列都必须严格遵循其预定义的 宽度。如果整行的总长度不等于该列的最大宽度值,则会抛出异常,如下例所示:spring-doc.cadn.net.cn

tokenizer.setColumns(new Range[] { new Range(1, 5),
                                   new Range(6, 10),
                                   new Range(11, 15) });
try {
    tokenizer.tokenize("12345");
    fail("Expected IncorrectLineLengthException");
}
catch (IncorrectLineLengthException ex) {
    assertEquals(15, ex.getExpectedLength());
    assertEquals(5, ex.getActualLength());
}

上述分词器配置的范围是:1-5、6-10 和 11-15。因此,该行的总长度为 15。然而,在前面的示例中,传入的行长度仅为 5,导致抛出了 IncorrectLineLengthException。在此处抛出异常,而不仅仅是映射第一列,可以使该行处理更早失败,并提供比在 FieldSetMapper 中尝试读取第 2 列时失败所包含的更多信息。不过,也存在行长度并非始终固定的场景。因此,可以通过 'strict' 属性关闭行长度验证,如下例所示:spring-doc.cadn.net.cn

tokenizer.setColumns(new Range[] { new Range(1, 5), new Range(6, 10) });
tokenizer.setStrict(false);
FieldSet tokens = tokenizer.tokenize("12345");
assertEquals("12345", tokens.readString(0));
assertEquals("", tokens.readString(1));

前面的示例与之前的示例几乎完全相同,只不过调用了tokenizer.setStrict(false)。此设置指示标记化器在对行进行标记化时不强制限制行长度。现在可以正确创建并返回一个FieldSet。但是,对于剩余的值,它仅包含空标记。spring-doc.cadn.net.cn