跳到主要内容

FlatFileItemReader

QWen Plus 中英对照 FlatFileItemReader FlatFileItemReader

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

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

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

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

表 1. FlatFileItemReader 属性

属性类型描述
commentsString[]指定表示注释行的行前缀。
encodingString指定要使用的文本编码。默认值为 UTF-8
lineMapperLineMapper将一个 String 转换为表示该项的 Object
linesToSkipint忽略文件顶部的行数。
recordSeparatorPolicyRecordSeparatorPolicy用于确定行结束的位置,并在处于引号字符串内部时执行跨行操作等。
resourceResource从中读取的资源。
skippedLinesCallbackLineCallbackHandler接口,用于传递文件中将被跳过的行的原始内容。如果 linesToSkip 设置为 2,则此接口将被调用两次。
strictboolean在严格模式下,如果输入资源不存在,读取器会在 ExecutionContext 上抛出异常。否则,它会记录问题并继续执行。

LineMapper

RowMapper 类似,RowMapper 将低级结构如 ResultSet 转换为一个 Object,扁平文件处理也需要类似的构造将 String 行转换为一个 Object,如下所示的接口定义:

public interface LineMapper<T> {

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

}
java

基本的契约是,给定当前行及其关联的行号,映射器应返回一个结果域对象。这与 RowMapper 类似,因为每一行都与其行号相关联,就像 ResultSet 中的每一行都与其行号绑定一样。这使得行号可以与结果域对象绑定,用于身份比较或更具信息量的日志记录。然而,与 RowMapper 不同的是,LineMapper 获得的是一个原始行,正如上文所述,这只能让你走到一半。行必须被分解为一个 FieldSet,然后才能映射到对象,具体方法将在本文档后面部分描述。

LineTokenizer

将输入行转换为 FieldSet 的抽象是必要的,因为可能存在许多需要转换为 FieldSet 的平面文件数据格式。在 Spring Batch 中,这个接口是 LineTokenizer

public interface LineTokenizer {

FieldSet tokenize(String line);

}
java

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;

}
java

使用的模式与 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;
}
}
java

上述功能是在默认实现中提供的,而不是内置在读取器本身中(如框架的早期版本中所做的那样),以便用户能够更灵活地控制解析过程,特别是当需要访问原始行时。

简单分隔文件读取示例

以下示例说明了如何在实际域场景中读取平面文件。此特定批处理作业从以下文件中读取足球运动员:

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...
}
java

要将 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;
}
}
java

然后可以通过正确构建 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();
java

每次调用 read 都会从文件的每一行返回一个新的 Player 对象。当到达文件末尾时,将返回 null

按名称映射字段

还有一项额外的功能,DelimitedLineTokenizerFixedLengthTokenizer 都支持,其功能类似于 JDBC 的 ResultSet。可以通过将字段名称注入到这两个 LineTokenizer 实现之一中,来提高映射函数的可读性。首先,将平面文件中所有字段的列名注入到分词器中,如下例所示:

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

一个 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;
}
}
java

自动映射 FieldSets 到领域对象

对许多人来说,编写特定的 FieldSetMapper 和为 JdbcTemplate 编写特定的 RowMapper 一样繁琐。Spring Batch 通过提供一个 FieldSetMapper 来简化这一过程,该 FieldSetMapper 可以根据 JavaBean 规范,通过匹配字段名称与对象上的 setter 方法来自动映射字段。

再次使用足球示例,BeanWrapperFieldSetMapper 的配置在 Java 中如下所示:

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

fieldSetMapper.setPrototypeBeanName("player");

return fieldSetMapper;
}

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

对于 FieldSet 中的每一项条目,映射器会在 Player 对象的新实例上查找对应的 setter 方法(因此需要原型作用域),其方式与 Spring 容器根据属性名称查找 setter 方法的方式相同。FieldSet 中每个可用的字段都会被映射,并返回生成的 Player 对象,且无需编写任何代码。

固定长度文件格式

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

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

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

  1. ISIN: 订单项的唯一标识符 - 长度为 12 个字符。

  2. Quantity: 订单项的数量 - 长度为 3 个字符。

  3. Price: 项的价格 - 长度为 5 个字符。

  4. Customer: 下订单客户的 ID - 长度为 9 个字符。

在配置 FixedLengthLineTokenizer 时,这些长度中的每一个都必须以范围的形式提供。

下面的示例展示了如何在 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;
}
java

因为 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 的映射,使得这一过程变得简单。

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

在这个例子中,"LINEA" 和 "LINEB" 拥有各自的 LineTokenizer 实例,但它们都使用相同的 FieldSetMapper

PatternMatchingCompositeLineMapper 使用 PatternMatcher#match 方法为每一行选择正确的委托对象。PatternMatcher 允许使用两个具有特殊含义的通配符:问号 (?) 匹配 恰好一个字符,而星号 (*) 匹配 零个或多个字符。请注意,在前面的配置中,所有模式都以星号结尾,这使得它们实际上成为行的前缀。无论配置中的顺序如何,PatternMatcher 始终匹配最具体的模式。因此,如果 "LINE*" 和 "LINEA*" 都被列为模式,则 "LINEA" 会匹配模式 "LINEA*",而 "LINEB" 会匹配模式 "LINE*"。此外,单个星号 (*) 可以作为默认值,匹配未被其他任何模式匹配的任意行。

下面的示例展示了在 Java 中如何匹配未被其他任何模式匹配的行:

...
tokenizers.put("*", defaultLineTokenizer());
...
java

还提供了一个 PatternMatchingCompositeLineTokenizer,它可以单独用于分词。

平面文件中包含每条记录跨越多行的情况也很常见。为了处理这种情况,需要一个更复杂的策略。这种常见模式的示例可以在 multiLineRecords 示例中找到。

异常处理在平面文件中

在对一行进行分词时,有许多场景可能会导致异常被抛出。许多平面文件(flat files)并不完美,包含格式不正确的记录。许多用户选择跳过这些错误行,同时记录问题、原始行和行号。这些日志之后可以手动检查或由另一个批处理作业处理。基于此原因,Spring Batch 提供了一套用于处理解析异常的异常层次结构:FlatFileParseExceptionFlatFileFormatException。当尝试读取文件时遇到任何错误,FlatFileItemReader 会抛出 FlatFileParseExceptionFlatFileFormatException 则由 LineTokenizer 接口的实现类抛出,表示在分词过程中遇到的更具体的错误。

IncorrectTokenCountException

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

因为分词器配置了 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());
}
java

上面配置的分词器范围是: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));
java

前面的例子与之前的例子几乎相同,唯一的区别是调用了 tokenizer.setStrict(false)。此设置告诉分词器在对行进行分词时不要强制执行行长度限制。现在正确创建并返回了一个 FieldSet。但是,它仅包含剩余值的空令牌。