FlatFileItemReader

平面文件是包含最多二维(表格)数据的任何类型文件。Spring Batch 框架中读取平面文件由名为 FlatFileItemReader 的类提供便利,该类提供读取和解析平面文件的基本功能。FlatFileItemReader 的两个最重要的必需依赖项是 ResourceLineMapperLineMapper 接口将在后续章节中进一步探讨。resource 属性表示一个 Spring Core Resource。有关如何创建这种类型 bean 的文档可以在 Spring Framework,第 5 章 资源 中找到。因此,本指南除了展示以下简单示例外,不会详细介绍创建 Resource 对象

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

在复杂的批处理环境中,目录结构通常由企业应用集成 (EAI) 基础设施管理,其中为外部接口建立投放区域,用于将文件从 FTP 位置移动到批处理位置,反之亦然。文件移动工具超出了 Spring Batch 架构的范围,但批处理 Job 流包含文件移动工具作为 Job 流中的 Step 并不罕见。批处理架构只需知道如何找到待处理的文件。Spring Batch 从这个起点开始将数据输入管道。然而,Spring Integration 提供了许多此类服务。

FlatFileItemReader 中的其他属性允许您进一步指定数据如何解释,如下表所示

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

comments

String[]

指定指示注释行的行前缀。

encoding

String

指定使用的文本编码。默认值为 UTF-8

lineMapper

LineMapper

String 转换为表示 item 的 Object

linesToSkip

int

要忽略文件顶部行数。

recordSeparatorPolicy

RecordSeparatorPolicy

用于确定行尾位置,并在引号字符串内时执行跨行继续等操作。

resource

Resource

要读取的资源。

skippedLinesCallback

LineCallbackHandler

将待跳过行的原始行内容传递给该接口。如果 linesToSkip 设置为 2,则该接口会被调用两次。

strict

boolean

在严格模式下,如果输入资源不存在,reader 会在 ExecutionContext 上抛出异常。否则,它会记录问题并继续。

LineMapper

RowMapper 类似,RowMapper 接收底层构造(如 ResultSet)并返回一个 Object,平面文件处理也需要相同的构造来将 String 行转换为 Object,如下接口定义所示

public interface LineMapper<T> {

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

}

基本契约是,给定当前行及其关联的行号,mapper 应返回一个结果域对象。这类似于 RowMapper,每行都与其行号相关联,就像 ResultSet 中的每行都与其行号相关联一样。这允许将行号与结果域对象相关联,用于身份比较或更详细的日志记录。然而,与 RowMapper 不同,LineMapper 接收原始行,正如上面讨论的,这只完成了一半的工作。该行必须被分词(tokenize)成一个 FieldSet,然后才能映射到对象,如本文档后面所述。

LineTokenizer

由于需要将多种格式的平面文件数据转换为 FieldSet,因此需要一个将输入行转换为 FieldSet 的抽象。在 Spring Batch 中,这个接口就是 LineTokenizer

public interface LineTokenizer {

    FieldSet tokenize(String line);

}

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

  • DelimitedLineTokenizer:用于记录中的字段由分隔符分隔的文件。最常见的分隔符是逗号,但管道或分号也经常使用。

  • FixedLengthTokenizer:用于记录中的字段具有“固定宽度”的文件。必须为每种记录类型定义每个字段的宽度。

  • PatternMatchingCompositeLineTokenizer:通过检查模式来确定列表中哪个 LineTokenizer 应该用于特定行。

FieldSetMapper

FieldSetMapper 接口定义了一个方法 mapFieldSet,该方法接收一个 FieldSet 对象,并将其内容映射到对象。根据 Job 的需求,该对象可以是自定义 DTO、域对象或数组。FieldSetMapperLineTokenizer 结合使用,将资源中的数据行转换为所需类型的对象,如下接口定义所示

public interface FieldSetMapper<T> {

    T mapFieldSet(FieldSet fieldSet) throws BindException;

}

使用的模式与 JdbcTemplate 使用的 RowMapper 相同。

DefaultLineMapper

既然已经定义了读取平面文件的基本接口,那么显然需要三个基本步骤

  1. 从文件中读取一行。

  2. String 行传递给 LineTokenizer#tokenize() 方法以检索 FieldSet

  3. 将 tokenizing 返回的 FieldSet 传递给 FieldSetMapper,并从 ItemReader#read() 方法返回结果。

上面描述的两个接口代表了两个独立的任务:将行转换为 FieldSet 和将 FieldSet 映射到域对象。由于 LineTokenizer 的输入与 LineMapper 的输入(一行)匹配,并且 FieldSetMapper 的输出与 LineMapper 的输出匹配,因此提供了一个使用 LineTokenizerFieldSetMapper 的默认实现。以下类定义中所示的 DefaultLineMapper 表示大多数用户需要的行为

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;
    }
}

上述功能在默认实现中提供,而不是内置在 reader 本身中(如框架早期版本中所做),以允许用户在控制解析过程时具有更大的灵活性,特别是如果需要访问原始行时。

简单的分隔文件读取示例

以下示例说明了如何在实际域场景中读取平面文件。这个特定的批处理 Job 从以下文件中读取橄榄球运动员

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 域对象

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 对象,需要定义一个返回 player 的 FieldSetMapper,如下例所示

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 方法,可以读取文件,如下例所示

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 对象。当到达文件末尾时,返回 null

按名称映射字段

DelimitedLineTokenizerFixedLengthTokenizer 都允许额外的一项功能,该功能与 JDBC ResultSet 的功能类似。字段的名称可以注入到其中任何一个 LineTokenizer 实现中,以提高映射函数的易读性。首先,将平面文件中所有字段的列名注入到 tokenizer 中,如下例所示

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

FieldSetMapper 可以按如下方式使用此信息

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 使这变得更容易,该 mapper 使用 JavaBean 规范,通过将字段名与对象上的 setter 匹配来自动映射字段。

  • Java

  • XML

再次使用橄榄球示例,BeanWrapperFieldSetMapper 配置在 Java 中如下所示

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 中如下所示

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

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

对于 FieldSet 中的每个条目,mapper 会在一个新的 Player 对象实例上查找相应的 setter(因此需要原型范围),就像 Spring 容器查找匹配属性名的 setter 一样。FieldSet 中每个可用的字段都被映射,然后返回结果 Player 对象,无需编写代码。

固定长度文件格式

到目前为止,只详细讨论了分隔文件。然而,它们只代表文件读取图景的一半。许多使用平面文件的组织使用固定长度格式。以下是一个固定长度文件示例

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

虽然这看起来像一个大字段,但它实际上代表 4 个不同的字段

  1. ISIN:订购商品的唯一标识符 - 长度为 12 个字符。

  2. 数量:订购商品的数量 - 长度为 3 个字符。

  3. 价格:商品价格 - 长度为 5 个字符。

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

配置 FixedLengthLineTokenizer 时,必须以范围的形式提供这些长度。

  • Java

  • XML

以下示例展示了如何在 Java 中为 FixedLengthLineTokenizer 定义范围

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 定义范围

XML 配置
<bean id="fixedLengthLineTokenizer"
      class="org.springframework.batch.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。这允许使用相同的方法来处理其输出,例如使用 BeanWrapperFieldSetMapper

为了支持前述的范围语法,需要在 ApplicationContext 中配置一个专用的属性编辑器 RangeArrayPropertyEditor。然而,在使用 batch 命名空间的 ApplicationContext 中,此 bean 会自动声明。

由于 FixedLengthLineTokenizer 使用与上面讨论相同的 LineTokenizer 接口,它返回与使用分隔符时相同的 FieldSet。这允许使用相同的方法来处理其输出,例如使用 BeanWrapperFieldSetMapper

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

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

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

在这个文件中,我们有三种类型的记录:“USER”、“LINEA”和“LINEB”。“USER”行对应于一个 User 对象。“LINEA”和“LINEB”都对应于 Line 对象,尽管“LINEA”比“LINEB”包含更多信息。

ItemReader 逐行读取,但我们必须指定不同的 LineTokenizerFieldSetMapper 对象,以便 ItemWriter 接收正确的 item。PatternMatchingCompositeLineMapper 通过允许配置模式到 LineTokenizer 的映射和模式到 FieldSetMapper 的映射,简化了这一过程。

  • Java

  • XML

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 定义范围

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 实例,但它们都使用相同的 FieldSetMapper

PatternMatchingCompositeLineMapper 使用 PatternMatcher#match 方法为每行选择正确的委托。PatternMatcher 允许使用两个具有特殊含义的通配符:问号(“?”)匹配正好一个字符,而星号(“*”)匹配零个或多个字符。请注意,在前面的配置中,所有模式都以星号结尾,使它们有效地成为行的前缀。PatternMatcher 总是匹配最具体的模式,无论配置中的顺序如何。因此,如果“LINE*”和“LINEA*”都被列为模式,“LINEA”将匹配模式“LINEA*”,而“LINEB”将匹配模式“LINE*”。此外,单个星号(“*”)可以通过匹配任何其他模式不匹配的行来充当默认值。

  • Java

  • XML

以下示例展示了如何在 Java 中匹配任何其他模式不匹配的行

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

以下示例展示了如何在 XML 中匹配任何其他模式不匹配的行

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

还有一个 PatternMatchingCompositeLineTokenizer 可用于仅进行分词。

平面文件还经常包含跨越多行的记录。要处理这种情况,需要更复杂的策略。在 multiLineRecords 示例中可以找到这种常见模式的演示。

平面文件中的异常处理

分词一行时,可能会抛出异常的场景很多。许多平面文件不完美,包含格式错误的记录。许多用户选择跳过这些错误行,同时记录问题、原始行和行号。这些日志稍后可以手动检查或由另一个批处理 Job 处理。为此,Spring Batch 提供了一个用于处理解析异常的异常层次结构:FlatFileParseExceptionFlatFileFormatException。当尝试读取文件时遇到任何错误时,FlatFileItemReader 会抛出 FlatFileParseExceptionLineTokenizer 接口的实现会抛出 FlatFileFormatException,表示在分词时遇到了更具体的错误。

IncorrectTokenCountException

DelimitedLineTokenizerFixedLengthTokenizer 都具有指定列名以用于创建 FieldSet 的能力。但是,如果在分词行时找到的列数与列名数不匹配,则无法创建 FieldSet,并会抛出 IncorrectTokenCountException,其中包含遇到的 token 数和期望的 token 数,如下例所示

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());
}

因为 tokenizer 配置了 4 个列名,但文件中只找到了 3 个 token,所以抛出了 IncorrectTokenCountException

IncorrectLineLengthException

固定长度格式的文件在解析时有额外的要求,因为与分隔格式不同,每列必须严格遵守其预定义的宽度。如果总行长度不等于此列的最宽值,则会抛出异常,如下例所示

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());
}

上面 tokenizer 配置的范围是:1-5、6-10 和 11-15。因此,行的总长度为 15。然而,在前面的示例中,传入了一行长度为 5 的行,导致抛出 IncorrectLineLengthException。在这里抛出异常而不是只映射第一列,可以让行的处理更早失败,并且包含更多信息,这比在 FieldSetMapper 中尝试读取第 2 列时失败要好。然而,在某些情况下,行的长度并不总是固定的。出于此原因,可以通过 'strict' 属性关闭行长度验证,如下例所示

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)。此设置告诉 tokenizer 在分词行时不要强制执行行长度。现在已正确创建并返回 FieldSet。但是,它包含剩余值的空 token。