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 流程中的步驟並不罕見。批次架構只需要知道如何找到要處理的檔案即可。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

在嚴格模式下,如果輸入資源不存在,讀取器會在 ExecutionContext 上擲回例外。否則,它會記錄問題並繼續。

LineMapper

如同 RowMapper 接收諸如 ResultSet 之類的低階結構並傳回 Object 一樣,平面檔案處理也需要相同的結構來將 String 行轉換為 Object,如下列介面定義所示

public interface LineMapper<T> {

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

}

基本合約是,給定目前行及其關聯的行號,mapper 應傳回產生的領域物件。這與 RowMapper 類似,因為每一行都與其行號關聯,就像 ResultSet 中的每一列都與其列號綁定一樣。這允許將行號與產生的領域物件關聯,以進行身分比較或提供更豐富的日誌資訊。然而,與 RowMapper 不同,LineMapper 獲得的是原始行,如上所述,這只完成了一半的工作。該行必須被 Token 化為 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(從 Tokenizer 清單中)。

FieldSetMapper

FieldSetMapper 介面定義了一個單一方法 mapFieldSet,它接收一個 FieldSet 物件,並將其內容映射到一個物件。此物件可以是自訂 DTO、領域物件或陣列,具體取決於 Job 的需求。FieldSetMapperLineTokenizer 結合使用,以將資源中的資料行轉換為所需類型的物件,如下列介面定義所示

public interface FieldSetMapper<T> {

    T mapFieldSet(FieldSet fieldSet) throws BindException;

}

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

DefaultLineMapper

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

  1. 從檔案讀取一行。

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

  3. 將從 Token 化傳回的 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;
    }
}

上述功能是在預設實作中提供的,而不是內建到讀取器本身(如同框架的先前版本中所做的那樣),以便讓使用者在控制解析過程方面具有更大的彈性,尤其是在需要存取原始行時。

簡單分隔檔案讀取範例

以下範例說明如何使用實際的領域情境讀取平面檔案。此特定批次 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 物件,需要定義一個傳回球員的 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;
   }
}

自動將 FieldSets 映射到領域物件

對於許多人來說,撰寫特定的 FieldSetMapper 與為 JdbcTemplate 撰寫特定的 RowMapper 一樣繁瑣。Spring Batch 透過提供 FieldSetMapper 來簡化此操作,該 FieldSetMapper 會自動映射欄位,方法是使用 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 物件 (因此需要 Prototype 範圍) 上尋找對應的 Setter,方式與 Spring 容器尋找與屬性名稱匹配的 Setter 相同。FieldSet 中的每個可用欄位都會被映射,並傳回產生的 Player 物件,而無需任何程式碼。

固定長度檔案格式

到目前為止,僅詳細討論了分隔檔案。然而,它們僅代表檔案讀取圖景的一半。許多使用平面檔案的組織都使用固定長度格式。以下是一個固定長度檔案範例

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

雖然這看起來像一個大型欄位,但它實際上代表 4 個不同的欄位

  1. ISIN:訂購 Item 的唯一識別碼 - 長度為 12 個字元。

  2. Quantity:訂購 Item 的數量 - 長度為 3 個字元。

  3. Price:Item 的價格 - 長度為 5 個字元。

  4. Customer:訂購 Item 的客戶 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

支援上述範圍語法需要 Specialized Property Editor RangeArrayPropertyEditorApplicationContext 中設定。但是,此 Bean 會在使用批次命名空間的 ApplicationContext 中自動宣告。

由於 FixedLengthLineTokenizer 使用與上述討論的相同的 LineTokenizer 介面,因此它傳回的 FieldSet 與使用分隔符號時相同。這讓可以使用相同的方法來處理其輸出,例如使用 BeanWrapperFieldSetMapper

單一檔案中的多種記錄類型

到目前為止,所有檔案讀取範例都為了簡單起見,做出了一個關鍵假設:檔案中的所有記錄都具有相同的格式。然而,情況可能並非總是如此。檔案可能具有不同格式的記錄,需要以不同的方式進行 Token 化並映射到不同的物件,這非常常見。以下檔案摘錄說明了這一點

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 透過允許將模式映射到 LineTokenizers 和模式映射到 FieldSetMappers 來簡化此操作。

  • 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 可以單獨用於 Token 化。

平面檔案也常見於包含跨越多行的記錄。為了處理這種情況,需要更複雜的策略。可以在 multiLineRecords 範例中找到此常見模式的示範。

平面檔案中的例外處理

在 Token 化行時,可能會擲回例外的情況有很多。許多平面檔案並不完美,並且包含格式不正確的記錄。許多使用者選擇跳過這些錯誤行,同時記錄問題、原始行和行號。這些日誌稍後可以手動或由另一個批次 Job 檢查。因此,Spring Batch 提供了一個例外層級結構,用於處理解析例外:FlatFileParseExceptionFlatFileFormatException。當嘗試讀取檔案時遇到任何錯誤時,FlatFileItemReader 會擲回 FlatFileParseExceptionFlatFileFormatExceptionLineTokenizer 介面的實作擲回,並指示在 Token 化時遇到的更具體的錯誤。

IncorrectTokenCountException

DelimitedLineTokenizerFixedLengthLineTokenizer 都有能力指定可用於建立 FieldSet 的欄名。但是,如果欄名數量與 Token 化行時找到的欄數不符,則無法建立 FieldSet,並且會擲回 IncorrectTokenCountException,其中包含遇到的 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 在 Token 化行時不要強制執行行長度。現在已正確建立並傳回 FieldSet。但是,它僅包含剩餘值的空 Token。