FlatFileItemReader

平面檔案是包含最多二維(表格)資料的任何型別檔案。Spring Batch 框架中讀取平面檔案由名為 FlatFileItemReader 的類提供便利,該類提供讀取和解析平面檔案的基本功能。FlatFileItemReader 的兩個最重要的必需依賴項是 ResourceLineMapperLineMapper 介面將在後續章節中進一步探討。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 中的其他屬性允許您進一步指定資料如何解釋,如下表所示

表 1. FlatFileItemReader 屬性
屬性 型別 描述

comments

String[]

指定指示註釋行的行字首。

encoding

String

指定使用的文字編碼。預設值為 UTF-8

lineMapper

LineMapper

String 轉換為表示 item 的 Object

linesToSkip

int

要忽略檔案頂部行數。

recordSeparatorPolicy

RecordSeparatorPolicy

用於確定行尾位置,並在引號字串內時執行跨行繼續等操作。

resource

Resource

要讀取的資源。

skippedLinesCallback

LineCallbackHandler

將待跳過行的原始行內容傳遞給該介面。如果 linesToSkip 設定為 2,則該介面會被呼叫兩次。

strict

boolean

在嚴格模式下,如果輸入資源不存在,reader 會在 ExecutionContext 上丟擲異常。否則,它會記錄問題並繼續。

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、域物件或陣列。FieldSetMapperLineTokenizer 結合使用,將資源中的資料行轉換為所需型別的物件,如下介面定義所示

public interface FieldSetMapper<T> {

    T mapFieldSet(FieldSet fieldSet) throws BindException;

}

使用的模式與 JdbcTemplate 使用的 RowMapper 相同。

DefaultLineMapper

既然已經定義了讀取平面檔案的基本介面,那麼顯然需要三個基本步驟

  1. 從檔案中讀取一行。

  2. String 行傳遞給 LineTokenizer#tokenize() 方法以檢索 FieldSet

  3. 將 tokenizing 返回的 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;
    }
}

上述功能在預設實現中提供,而不是內建在 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

按名稱對映欄位

DelimitedLineTokenizerFixedLengthTokenizer 都允許額外的一項功能,該功能與 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 中如下所示

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 中的每個條目,mapper 會在一個新的 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。然而,在使用 batch 名稱空間的 ApplicationContext 中,此 bean 會自動宣告。

由於 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 接收正確的 item。PatternMatchingCompositeLineMapper 透過允許配置模式到 LineTokenizer 的對映和模式到 FieldSetMapper 的對映,簡化了這一過程。

  • 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 示例中可以找到這種常見模式的演示。

平面檔案中的異常處理

分詞一行時,可能會丟擲異常的場景很多。許多平面檔案不完美,包含格式錯誤的記錄。許多使用者選擇跳過這些錯誤行,同時記錄問題、原始行和行號。這些日誌稍後可以手動檢查或由另一個批處理 Job 處理。為此,Spring Batch 提供了一個用於處理解析異常的異常層次結構:FlatFileParseExceptionFlatFileFormatException。當嘗試讀取檔案時遇到任何錯誤時,FlatFileItemReader 會丟擲 FlatFileParseExceptionLineTokenizer 介面的實現會丟擲 FlatFileFormatException,表示在分詞時遇到了更具體的錯誤。

IncorrectTokenCountException

DelimitedLineTokenizerFixedLengthTokenizer 都具有指定列名以用於建立 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。