FlatFileItemReader

平面文件是任何类型包含最多二维(表格)数据的文件。在 Spring Batch 框架中读取平面文件由名为 FlatFileItemReader 的类提供便利,该类提供读取和解析平面文件的基本功能。FlatFileItemReader 的两个最重要的必需依赖项是 ResourceLineMapperLineMapper 接口将在接下来的部分中详细介绍。资源属性表示 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[]

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

encoding

String

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

lineMapper

LineMapper

String 转换为表示项目的 Object

linesToSkip

int

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

recordSeparatorPolicy

RecordSeparatorPolicy

用于确定行结束符的位置,并在引号字符串内执行诸如跨越行结束符的操作。

resource

Resource

要从中读取的资源。

skippedLinesCallback

LineCallbackHandler

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

strict

boolean

在严格模式下,如果输入资源不存在,读取器会在 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中的每个条目,映射器都会在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。但是,此 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。但是,它只包含剩余值的空标记。