FlatFileItemReader
-
Resource:表示 Spring Core Resource,用于识别待处理文件的路径。
-
LineMapper:将 String 行转换为对象,允许将平面文件数据标记化为字段集和映射为域对象。
LineMapper 接口涉及两个基本任务:将行标记化为字段集和将字段集映射到域对象。Spring Batch 提供了抽象,例如 LineTokenizer(标记化行)和 FieldSetMapper(映射字段集)来执行这些任务。
默认情况下,DefaultLineMapper 使用 LineTokenizer 和 FieldSetMapper 来转换行。对于简单的分隔文件,可以使用 DelimitedLineTokenizer 和 PlayerFieldSetMapper 等预构建的实现。
对于定长文件格式,FixedLengthTokenizer 提供了标记化功能,而 BeanWrapperFieldSetMapper 可以根据 JavaBean 规范自动映射字段。
为了处理文件中具有不同格式的记录,可以配置 PatternMatchingCompositeLineMapper,它通过模式匹配将行映射到 LineTokenizers 和 FieldSetMappers。
Spring Batch 还提供异常处理功能,当在标记化行时遇到问题时会抛出异常。这些异常包括 FlatFileParseException 和 FlatFileFormatException,它们提供有关错误原因的详细信息。
平面文件是最多包含二维(表格)数据的任何类型的文件。通过称为 FlatFileItemReader
的类可以轻松地在 Spring Batch 框架中读取平面文件,该类提供了读取和解析平面文件の基本的な機能。FlatFileItemReader
的两个最重要的必需依赖项是 Resource
和 LineMapper
。LineMapper
接口将在下一部分中进行详细介绍。资源属性表示 Spring Core Resource
。有关如何创建此类型的 Bean 的文档可以在 Spring
Framework, Chapter 5. Resources 中找到。因此,本指南不会详细介绍创建 Resource
对象,而仅展示以下简单的示例:
Resource resource = new FileSystemResource("resources/trades.csv");
在复杂批处理环境中,目录结构通常由企业应用程序集成 (EAI) 基础设施管理,其中外部接口的放置区域用于将文件从 FTP 位置移动到批处理位置,反之亦然。文件移动工具超出了 Spring Batch 架构的范围,但批处理作业流中包含文件移动工具作为作业流中的步骤并不罕见。批处理架构只需知道如何找到要处理的文件。Spring Batch 从此开始将数据引入管道。但是, Spring Integration 提供了许多此类服务。
FlatFileItemReader
中的其他属性允许您进一步指定如何解释数据,如下表所述:
Property | Type | Description |
---|---|---|
comments |
String[] |
指定表示注释行的行前缀。 |
encoding |
String |
指定要使用的文本编码。默认值为 |
lineMapper |
|
将 |
linesToSkip |
int |
要跳过的文件顶部的行数。 |
recordSeparatorPolicy |
RecordSeparatorPolicy |
用于确定行结束位置并执行某些操作,如在引号字符串中继续超出行尾。 |
resource |
|
要从中读取的资源。 |
skippedLinesCallback |
LineCallbackHandler |
一个接口,它将文件中的行的原始行内容传递给要跳过的文件。如果 |
strict |
boolean |
在严格模式下,如果输入资源不存在,则读取器将在 |
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
: 用于字段按分隔符分隔的文件。最常见的分隔符是逗号,但竖线或分号也可用于 as。 -
FixedLengthTokenizer
: 用于字段各为 "固定宽度" 的文件。必须为每个记录类型定义每个字段的宽度。 -
PatternMatchingCompositeLineTokenizer
: 通过针对模式进行检查,确定应在特定行上使用标记列表中的哪个LineTokenizer
。
FieldSetMapper
FieldSetMapper
接口定义了一种方法 mapFieldSet
,该方法采用一个 FieldSet
对象并将它的内容映射到一个对象。根据作业的需要,此对象可以是自定义 DTO、域对象或数组。FieldSetMapper
与 LineTokenizer
结合使用,将资源中的数据行转换为所需类型的一个对象,如下面的接口定义所示:
public interface FieldSetMapper<T> {
T mapFieldSet(FieldSet fieldSet) throws BindException;
}
所使用的模式与 JdbcTemplate
使用的 RowMapper
相同。
DefaultLineMapper
现在已经定义了读取平面文件的基本接口,很明显需要三个基本步骤:
-
从文件读一行。
-
将
String
行传递到LineTokenizer#tokenize()
方法中以检索FieldSet
。 -
将标记返回的
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;
}
}
上述功能是在默认实现中提供的,而不是内置在读取器自身中(如框架的先前版本中所做的那样),以允许用户在控制解析过程中享有更大的灵活性,尤其是在需要访问原始行的情况下。
Simple Delimited File Reading Example
以下示例演示如何使用实际的域场景来读取平面文件。这个特定的批处理作业从以下文件中读取足球运动员:
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
。
Mapping Fields by Name
DelimitedLineTokenizer
和 FixedLengthTokenizer
提供的其他一个功能类似于 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;
}
}
Automapping FieldSets to Domain Objects
对于大多数人来说,必须编写一个特定的 FieldSetMapper
与编写一个特定的 RowMapper
以获得 JdbcTemplate
一样麻烦。Spring Batch 通过提供一个 FieldSetMapper
来简化此过程,该 FieldSetMapper
使用 JavaBean 规范,通过将字段名与对象上的 setter 进行匹配来自动映射字段。
- Java
-
仍然使用足球示例,
BeanWrapperFieldSetMapper
配置看上去如 Java 中的以下代码片段所示:
@Bean
public FieldSetMapper fieldSetMapper() {
BeanWrapperFieldSetMapper fieldSetMapper = new BeanWrapperFieldSetMapper();
fieldSetMapper.setPrototypeBeanName("player");
return fieldSetMapper;
}
@Bean
@Scope("prototype")
public Player player() {
return new Player();
}
- XML
-
仍然使用足球示例,
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
中的每个条目,映射器都在新实例的 Player
对象上查找一个对应的 setter(因此需要原型作用域),与 Spring 容器查找与属性名称匹配的 setter 的方式相同。FieldSet
中的每个可用字段都已映射,并且返回生成的 Player
对象,而不需要任何代码。
Fixed Length File Formats
到目前为止,主要详细讨论了分隔文件。然而,它们只占文件读取图的一半。许多使用平面文件的组织都使用定长格式。接下来是一个定长文件示例:
UK21341EAH4121131.11customer1 UK21341EAH4221232.11customer2 UK21341EAH4321333.11customer3 UK21341EAH4421434.11customer4 UK21341EAH4521535.11customer5
虽然这看起来像一个大字段,但实际上它表示 4 个不同的字段:
-
ISIN:要订购的商品的唯一标识符 - 长 12 个字符。
-
数量:要订购的商品数量 - 长 3 个字符。
-
价格:商品价格 - 长 5 个字符。
-
客户:订购该商品的客户 ID - 长 9 个字符。
在配置 FixedLengthLineTokenizer
时,每个长度都必须以范围的形式提供。
- Java
-
以下示例显示如何在
FixedLengthLineTokenizer
中定义范围: - Java
-
Java Configuration
@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
-
以下示例显示如何在 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
与使用分隔符返回的 FieldSet
相同。这允许使用相同的方法来处理它的输出,例如使用 BeanWrapperFieldSetMapper
。
为了支持范围的上述语法,需要在 ApplicationContext
中配置一个专门的属性编辑器 RangeArrayPropertyEditor
。然而,此 bean 在使用了批命名空间的 ApplicationContext
中会自动声明。
由于 FixedLengthLineTokenizer
使用前面讨论过的相同 LineTokenizer
接口,因此它返回的 FieldSet
与使用分隔符返回的 FieldSet
相同。这允许使用相同的方法来处理它的输出,例如使用 BeanWrapperFieldSetMapper
。
Multiple Record Types within a Single File
到目前为止,所有的文件读取示例都对简单性做了一个关键的假设:文件中所有的记录都有相同的格式。然而,情况可能并非总是如此。一个文件非常可能包含有不同格式的记录,需要对它们进行不同的标记化和映射到不同的对象。以下文件摘录对此进行了说明:
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
接收正确的条目。PatternMatchingCompositeLineMapper
通过允许将模式映射到 LineTokenizers
和模式映射到 FieldSetMappers
进行配置,从而简化了此过程。
- Java
-
Java Configuration
@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
-
以下示例显示如何在 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
-
以下示例展示了如何在 Java 中匹配其他任何模式都未匹配的行:
...
tokenizers.put("*", defaultLineTokenizer());
...
- XML
-
以下示例展示了如何在 XML 中匹配其他任何模式都未匹配的行:
<entry key="*" value-ref="defaultLineTokenizer" />
还存在可用于仅进行标记化的 PatternMatchingCompositeLineTokenizer
。
平面文件通常包含跨越多行的记录。若要处理这种情况,需要更复杂的策略。可在 multiLineRecords
采样中找到此常见模式的演示。
Exception Handling in Flat Files
在标记化行时,可能会引发异常的情况有很多。许多平面文件不完善,包含格式不正确的记录。许多用户选择在记录该问题、原始行和行号的同时跳过这些错误行。这些日志随后可以通过人工或其他批处理作业来检查。因此,Spring Batch 提供了一个处理解析异常的异常层次结构:FlatFileParseException
和 FlatFileFormatException
。当尝试读取文件时遇到任何错误时,FlatFileItemReader
抛出 FlatFileParseException
。FlatFileFormatException
由 LineTokenizer
接口的实现抛出,表明在标记化时遇到了更具体的错误。
IncorrectTokenCountException
DelimitedLineTokenizer
和 FixedLengthLineTokenizer
都能够指定用于创建 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 列时失败所包含的更多信息。但是,存在行的长度并非始终恒定的情况。因此,可以通过“严格”属性关闭行长度验证,如下例所示:
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
已正确创建并返回。但是,它仅为剩余值包含空标记。