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 架构的范围,但在批处理作业流中包含文件移动实用程序作为作业流中的步骤并不罕见。批处理架构只需要知道如何定位要处理的文件。Spring Batch 从这个起点开始将数据馈送到管道中。但是,Spring Integration 提供了许多此类服务。

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

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

注释

String[]

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

编码

字符串

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

lineMapper

LineMapper

String 转换为表示项目的 Object

linesToSkip

整数

要忽略的文件顶部的行数。

recordSeparatorPolicy

RecordSeparatorPolicy

用于确定行结束符在哪里,并执行诸如在引号字符串内继续换行符之类的操作。

资源

资源

从中读取的资源。

skippedLinesCallback

LineCallbackHandler

传递文件中要跳过的行的原始行内容的接口。如果 linesToSkip 设置为 2,则此接口会被调用两次。

严格

布尔值

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

LineMapper

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

public interface LineMapper<T> {

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

}

基本约定是,给定当前行及其关联的行号,映射器应返回一个结果域对象。这类似于 RowMapper,因为每行都与其行号相关联,就像 ResultSet 中的每一行都与其行号相关联一样。这允许将行号绑定到结果域对象以进行身份比较或更详细的日志记录。但是,与 RowMapper 不同,LineMapper 获取的是原始行,如上所述,这只能让你走一半路程。该行必须被标记化为 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 对象并将它的内容映射到一个对象。根据作业的需要,此对象可以是自定义 DTO、域对象或数组。FieldSetMapperLineTokenizer 结合使用,将资源中的一行数据转换为所需类型的对象,如下面的接口定义所示

public interface FieldSetMapper<T> {

    T mapFieldSet(FieldSet fieldSet) throws BindException;

}

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

DefaultLineMapper

现在,已经定义了读取平面文件的基本接口,很明显需要三个基本步骤

  1. 从文件中读取一行。

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

  3. 将从标记化返回的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;
    }
}

上述功能是在默认实现中提供的,而不是内置在读取器本身中(如框架的先前版本中所做的那样),以允许用户在控制解析过程中具有更大的灵活性,尤其是在需要访问原始行时。

简单分隔符文件读取示例

以下示例说明如何使用实际的域场景读取平面文件。这个特定的批处理作业从以下文件中读取足球运动员

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对象,需要定义一个返回运动员的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.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来简化此操作,该FieldSetMapper通过使用 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中的每个条目,映射器都以与 Spring 容器查找与属性名称匹配的 setter 的方式,在Player对象的新实例(因此需要原型作用域)上查找相应的 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。但是,此 bean 会在使用批处理命名空间的ApplicationContext中自动声明。

由于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接收正确的项目。PatternMatchingCompositeLineMapper通过允许配置模式到LineTokenizers的映射以及模式到FieldSetMappers的映射来简化此操作。

  • 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示例中找到此常见模式的演示。

平面文件中的异常处理

在许多情况下,标记化一行可能会导致抛出异常。许多平面文件不完善,包含格式不正确的记录。许多用户选择跳过这些错误行,同时记录问题、原始行和行号。这些日志稍后可以手动或由另一个批处理作业检查。为此,Spring Batch 提供了一个用于处理解析异常的异常层次结构:FlatFileParseExceptionFlatFileFormatException。当尝试读取文件时遇到任何错误时,FlatFileItemReader会抛出FlatFileParseExceptionFlatFileFormatExceptionLineTokenizer接口的实现抛出,并指示在标记化过程中遇到的更具体的错误。

IncorrectTokenCountException

DelimitedLineTokenizerFixedLengthLineTokenizer都能够指定列名,这些列名可用于创建FieldSet。但是,如果列名的数量与在标记化一行时找到的列数不匹配,则无法创建FieldSet,并且会抛出IncorrectTokenCountException,其中包含遇到的标记数和预期数,如下例所示

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 个标记,因此抛出了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());
}

上面标记器的配置范围为: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)。此设置告诉标记器在标记化行时不要强制执行行长度。现在正确创建并返回了FieldSet。但是,它仅包含其余值的空标记。