纯文本文件 - FlatFileItemReader
6.6.2 FlatFileItemReader
译注:
本文中 将 Flat File 翻译为“平面文件”, 这是一种没有特殊格式的非二进制的文件,里面的内容没有相对关系结构的记录。
平面文件(flat file)是最多包含二维(表格)数据的任意类型的文件。在 Spring Batch 框架中 FlatFileItemReader 类负责读取平面文件, 该类提供了用于读取和解析平面文件的基本功能。FlatFileItemReader 主要依赖两个东西: Resource 和 LineMapper。LineMapper接口将在下一节详细讨论。 resource
属性代表一个 Spring Core Resource(Spring核心资源)。关于如何创建这一类 bean 的文档可以参考 Spring框架, Chapter 5.Resources。所以本文档就不再深入讲解创建 Resource 对象的细节。 但可以找到一个文件系统资源的简单示例,如下所示:
Resource resource = new FileSystemResource("resources/trades.csv");
在复杂的批处理环境中,目录结构通常由EAI基础设施管理, 并且会建立放置区(drop zones),让外部接口将文件从ftp移动到批处理位置, 反之亦然。文件移动工具(File moving utilities)超出了spring batch架构的范畴, 但在批处理作业中包括文件移动步骤这种事情那也是很常见的。 批处理架构只需要知道如何定位需要处理的文件就足够了。Spring Batch 将会从这个起始点开始,将数据传输给数据管道。当然, Spring Integration也提供了很多这一类的服务。
FlatFileItemReader 中的其他属性让你可以进一步指定数据如何解析:
Table 6.1. FlatFileItemReader 的属性(Properties)
属性(Property) | 类型(Type) | 说明(Description) |
---|---|---|
comments | String[] | 指定行前缀,用来表明哪些是注释行 |
encoding | String | 指定使用哪种文本编码 - 默认值为 “ISO-8859-1” |
lineMapper | LineMapper | 将一个 转换为相应的 . |
linesToSkip | int | 在文件顶部有多少行需要跳过/忽略 |
recordSeparatorPolicy | RecordSeparatorPolicy | 记录分拆策略, 用于确定行尾, 以及如果在引号之中时,如何处理跨行的内容. |
resource | Resource | 从哪个资源读取数据. |
skippedLinesCallback | LineCallbackHandler | 忽略输入文件中某些行时, 会将忽略行的原始内容传递给这个回调接口。 如果 linesToSkip 设置为2, 那么这个接口就会被调用2次。 |
strict | boolean | 如果处于严格模式(strict mode), reader 在 ExecutionContext 中执行时,如果输入资源不存在, 则抛出异常. |
LineMapper
就如同 RowMapper 在底层根据 ResultSet 构造一个 Object 并返回, 平面文件处理过程中也需要将一行 String 转换并构造成 Object :
public interface LineMapper<T> {
T mapLine(String line, int lineNumber) throws Exception;
}
基本的约定是, 给定当前行以及和它关联的行号(line number), mapper 应该能够返回一个领域对象。这类似于在 RowMapper 中每一行也有一个 line number 相关联, 正如 ResultSet 中的每一行(Row)都有其绑定的 row number。这允许行号能被绑定到生成的领域对象以方便比较(identity comparison)或者更方便进行日志记录。
但与 RowMapper 不同的是, LineMapper 只能取得原始行的String值, 正如上面所说, 给你的是一个半成品。 这行文本值必须先被解析为 FieldSet, 然后才可以映射为一个对象,如下所述。
LineTokenizer
对将每一行输入转换为 FieldSet 这种操作的抽象是很有必要的, 因为可能会有各种平面文件格式需要转换为 FieldSet。在Spring Batch中, 对应的接口是 LineTokenizer:
public interface LineTokenizer {
FieldSet tokenize(String line);
}
使用 LineTokenizer 的约定是, 给定一行输入内容(理论上 String 可以包含多行内容), 返回一个表示该行的 FieldSet 对象。这个 FieldSet 接着会传递给 FieldSetMapper。Spring Batch 包括以下LineTokenizer实现:
DelmitedLineTokenizer
适用于处理使用分隔符(delimiter)来分隔一条数据中各个字段的文件。最常见的分隔符是逗号(comma),但管道或分号也经常使用。FixedLengthTokenizer
适用于记录中的字段都是“固定宽度(fixed width)”的文件。每种记录类型中,每个字段的宽度必须先定义。PatternMatchingCompositeLineTokenizer
通过使用正则模式匹配,来决定对特定的某一行应该使用 LineTokenizers 列表中的哪一个来执行字段拆分。
FieldSetMapper
FieldSetMapper 接口只定义了一个方法, mapFieldSet
, 这个方法接收一个 FieldSet 对象,并将其内容映射到一个 object 中。 根据作业需要, 这个对象可以是自定义的 DTO , 领域对象, 或者是简单数组。FieldSetMapper 与 LineTokenizer 结合使用以将资源文件中的一行数据转化为所需类型的对象:
public interface FieldSetMapper<T> {
T mapFieldSet(FieldSet fieldSet);
}
这和 JdbcTemplate 中的 RowMapper 是一样的道理。
DefaultLineMapper
既然读取平面文件的接口已经定义好了,那很明显我们需要执行以下三个步骤:
- 从文件中读取一行。
- 将读取的字符串传给
LineTokenizer#tokenize()
方法,以获取一个 FieldSet。 - 将解析后的 FieldSet 传给 FieldSetMapper ,然后将
ItemReader#read()
方法执行的结果返回给调用者。
上面的两个接口代表了两个不同的任务: 将一行文本转换为 FieldSet, 以及把 FieldSet 映射为一个领域对象。 因为 LineTokenizer 的输入对应着 LineMapper 的输入(一行), 并且 FieldSetMapper 的输出对应着 LineMapper 的输出, 所以SpringBatch 提供了一个使用LineTokenizer和FieldSetMapper的默认实现。DefaultLineMapper 就是大多数情况下用户所需要的:
public class DefaultLineMapper<T> implements LineMapper<T>, 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 本身内置的(以前版本的框架这样干), 让用户可以更灵活地控制解析过程, 特别是需要访问原始行的时候。
文件分隔符读取简单示例
下面的例子用来说明一个实际的领域情景。这个批处理作业将从如下文件中读取 football player(足球运动员) 信息:
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
, 返回 player 对象:
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<Player>();
itemReader.setResource(new FileSystemResource("resources/players.csv"));
//DelimitedLineTokenizer defaults to comma as its delimiter
LineMapper<Player> lineMapper = new DefaultLineMapper<Player>();
lineMapper.setLineTokenizer(new DelimitedLineTokenizer());
lineMapper.setFieldSetMapper(new PlayerFieldSetMapper());
itemReader.setLineMapper(lineMapper);
itemReader.open(new ExecutionContext());
Player player = itemReader.read();
每调用一次 read
方法,都会读取文件中的一行,并返回一个新的 Player
对象。如果到达文件结尾, 则会返回 null
。
根据 Name 映射 Fields
有一个额外的功能, 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 字段映射为 Domain Object
很多时候, 创建一个 FieldSetMapper 就跟 JdbcTemplate 里编写 RowMapper 一样繁琐。Spring Batch通过使用JavaBean规范,提供了一个 FieldSetMapper 来自动将字段映射到对应setter的属性域。还是使用足球的例子, BeanWrapperFieldSetMapper 的配置如下所示:
<bean id="fieldSetMapper"
class="org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper">
<property name="prototypeBeanName" value="player" />
</bean>
<bean id="player"
class="org.springframework.batch.sample.domain.Player"
scope="prototype" />
对于 FieldSet 中的每个条目(entry), mapper都会在Player对象的新实例中查找相应的setter (因此,需要指定 prototype scope), 和 Spring容器 查找 setter匹配属性名是一样的方式。FieldSet 中每个可用的字段都会被映射, 然后返回组装好的 Player 对象,不需要再手写代码。
Fixed Length File Formats
到这一步,我们讨论了带分隔符的文件, 但实际应用中可能只有一半左右是这种文件。还有很多机构使用固定长度形式的平面文件。固定长度文件的示例如下:
UK21341EAH4121131.11customer1
UK21341EAH4221232.11customer2
UK21341EAH4321333.11customer3
UK21341EAH4421434.11customer4
UK21341EAH4521535.11customer5
虽然看起来像是一个很长的字段,但实际上代表了4个分开的字段:
- ISIN : 唯一标识符,订购的商品编码 - 占12字符。
- Quantity : 订购的商品数量 - 占3字符。
- Price : 商品的价格 - 占5字符。
- Customer : 订购商品的顾客Id - 占9字符。
配置好 FixedLengthLineTokenizer
以后, 每个字段的长度必须用范围(range)的形式指定:
<bean id="fixedLengthLineTokenizer"
class="org.springframework.batch.io.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。
注意
要支持上面这种范围式的语法需要使用专门的属性编辑器:
RangeArrayPropertyEditor
, 可以在 ApplicationContext 中配置。当然,这个 bean 在批处理命名空间中的 ApplicationContext 里已经自动声明了。
单文件中含有多种类型数据的处理
前面所有的文件读取示例,为简单起见都做了一个关键性假设: 在同一个文件中的所有记录都具有相同的格式。但情况有时候并非如此。其实在一个文件包含不同的格式的记录是很常见的,需要使用不同的拆分方式,映射到不同的对象中。下面是一个文件中的片段,仅作演示:
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
:
<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
方法来为每一行选择正确的代理(delegate)。PatternMatcher 支持两个有特殊的意义通配符(wildcard): 问号(“?
”, question mark) 将匹配 1 个字符(注意不是0-1次), 而星号(“*
”,asterisk)将匹配 0 到多个 字符。
请注意,在上面的配置中,所有以星号结尾的 pattern , 使他们变成了行的有效前缀。 PatternMatcher 总是匹配最具体的可能模式, 而不是按配置的顺序从上往下来。所以如果 “LINE*
“ 和 “LINEA*
“ 都配置为 pattern, 那么 “LINEA
“ 将会匹配到 “LINEA*
“, 而 “LINEB
“ 将匹配到 “LINE*
“。此外,单个星号(“*
”)可以作为默认匹配所有行的模式,如果该行不匹配其他任何模式的话。
<entry key="*" value-ref="defaultLineTokenizer" />
还有一个 PatternMatchingCompositeLineTokenizer 可用来单独解析。
在平面文件中,也常常有单条记录跨越多行的情况。要处理这种情况,就需要一种更复杂的策略。这种模式的示例可以参考 第 11.5 节 “跨域多行的记录”。
Flat File 的异常处理
在解析一行时, 可能有很多情况会导致异常被抛出。很多平面文件不是很完整, 或者里面的某些记录格式不正确。许多用户会选择忽略这些错误的行, 只将这个问题记录到日志, 比如原始行,行号。稍后可以人工审查这些日志,也可以由另一个批处理作业来检查。出于这个原因,Spring Batch提供了一系列的异常类: FlatFileParseException
,和 FlatFileFormatException
。
FlatFileParseException 是由 FlatFileItemReader 在读取文件时解析错误而抛出的。 FlatFileFormatException
是由实现了 LineTokenizer 接口的类抛出的, 表明在拆分字段时发生了一个更具体的错误。
IncorrectTokenCountException
DelimitedLineTokenizer 和 FixedLengthLineTokenizer 都可以指定列名(column name), 用来创建一个 FieldSet。但如果 column name 的数量和 拆分时找到的列数目, 则不会创建 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());
}
因为 tokenizer 配置了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)
。这个设置告诉 tokenizer 在对一行进行解析(tokenizing)时不要去管(enforce)行的长度。然后就正确地创建了一个 FieldSet并返回。当然,剩下的值就只会包含空的token值。