FlatFileItemReader
平面文件是包含最多二维(表格)数据的任何类型文件。Spring Batch 框架中读取平面文件由名为 FlatFileItemReader 的类提供便利,该类提供读取和解析平面文件的基本功能。FlatFileItemReader 的两个最重要的必需依赖项是 Resource 和 LineMapper。LineMapper 接口将在后续章节中进一步探讨。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 中的其他属性允许您进一步指定数据如何解释,如下表所示
| 属性 | 类型 | 描述 | 
|---|---|---|
| comments | String[] | 指定指示注释行的行前缀。 | 
| encoding | String | 指定使用的文本编码。默认值为  | 
| lineMapper | 
 | 将  | 
| linesToSkip | int | 要忽略文件顶部行数。 | 
| recordSeparatorPolicy | RecordSeparatorPolicy | 用于确定行尾位置,并在引号字符串内时执行跨行继续等操作。 | 
| resource | 
 | 要读取的资源。 | 
| skippedLinesCallback | LineCallbackHandler | 将待跳过行的原始行内容传递给该接口。如果  | 
| strict | boolean | 在严格模式下,如果输入资源不存在,reader 会在  | 
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、域对象或数组。FieldSetMapper 与 LineTokenizer 结合使用,将资源中的数据行转换为所需类型的对象,如下接口定义所示
public interface FieldSetMapper<T> {
    T mapFieldSet(FieldSet fieldSet) throws BindException;
}使用的模式与 JdbcTemplate 使用的 RowMapper 相同。
DefaultLineMapper
既然已经定义了读取平面文件的基本接口,那么显然需要三个基本步骤
- 
从文件中读取一行。 
- 
将 String行传递给LineTokenizer#tokenize()方法以检索FieldSet。
- 
将 tokenizing 返回的 FieldSet传递给FieldSetMapper,并从ItemReader#read()方法返回结果。
上面描述的两个接口代表了两个独立的任务:将行转换为 FieldSet 和将 FieldSet 映射到域对象。由于 LineTokenizer 的输入与 LineMapper 的输入(一行)匹配,并且 FieldSetMapper 的输出与 LineMapper 的输出匹配,因此提供了一个使用 LineTokenizer 和 FieldSetMapper 的默认实现。以下类定义中所示的 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。
按名称映射字段
DelimitedLineTokenizer 和 FixedLengthTokenizer 都允许额外的一项功能,该功能与 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 中如下所示
@Bean
public FieldSetMapper fieldSetMapper() {
	BeanWrapperFieldSetMapper fieldSetMapper = new BeanWrapperFieldSetMapper();
	fieldSetMapper.setPrototypeBeanName("player");
	return fieldSetMapper;
}
@Bean
@Scope("prototype")
public Player player() {
	return new Player();
}再次使用橄榄球示例,BeanWrapperFieldSetMapper 配置在 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 个不同的字段
- 
ISIN:订购商品的唯一标识符 - 长度为 12 个字符。 
- 
数量:订购商品的数量 - 长度为 3 个字符。 
- 
价格:商品价格 - 长度为 5 个字符。 
- 
客户:订购商品的客户 ID - 长度为 9 个字符。 
配置 FixedLengthLineTokenizer 时,必须以范围的形式提供这些长度。
- 
Java 
- 
XML 
以下示例展示了如何在 Java 中为 FixedLengthLineTokenizer 定义范围
@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 定义范围
<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。
| 为了支持前述的范围语法,需要在  | 
由于 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 逐行读取,但我们必须指定不同的 LineTokenizer 和 FieldSetMapper 对象,以便 ItemWriter 接收正确的 item。PatternMatchingCompositeLineMapper 通过允许配置模式到 LineTokenizer 的映射和模式到 FieldSetMapper 的映射,简化了这一过程。
- 
Java 
- 
XML 
@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 定义范围
<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 中匹配任何其他模式不匹配的行
...
tokenizers.put("*", defaultLineTokenizer());
...以下示例展示了如何在 XML 中匹配任何其他模式不匹配的行
<entry key="*" value-ref="defaultLineTokenizer" />还有一个 PatternMatchingCompositeLineTokenizer 可用于仅进行分词。
平面文件还经常包含跨越多行的记录。要处理这种情况,需要更复杂的策略。在 multiLineRecords 示例中可以找到这种常见模式的演示。
平面文件中的异常处理
分词一行时,可能会抛出异常的场景很多。许多平面文件不完美,包含格式错误的记录。许多用户选择跳过这些错误行,同时记录问题、原始行和行号。这些日志稍后可以手动检查或由另一个批处理 Job 处理。为此,Spring Batch 提供了一个用于处理解析异常的异常层次结构:FlatFileParseException 和 FlatFileFormatException。当尝试读取文件时遇到任何错误时,FlatFileItemReader 会抛出 FlatFileParseException。LineTokenizer 接口的实现会抛出 FlatFileFormatException,表示在分词时遇到了更具体的错误。
IncorrectTokenCountException
DelimitedLineTokenizer 和 FixedLengthTokenizer 都具有指定列名以用于创建 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。