Google Code Prettify

2018年12月7日 星期五

Spring Batch: FixedLengthTokenizer

在 2014 年我寫的一篇「剖析固定長度欄位的訊息字串」說明怎麼方便的剖析一個固定欄位長度的檔案,這一篇打算改用 Spring Batch,在往下看之前,建議回頭看一下這兩篇:
  1. 剖析固定長度欄位的訊息字串
  2. Spring Batch: getting started
在說明 Spring Batch 怎麼處理固定長度欄位檔案前,先看一下 Spring Batch 怎麼處理 csv 檔。如下是 csv 檔的內容:
Buterin,24,Anglo-Saxon,Canada
中本聰,47,大和民族,波士頓
只有兩筆資料 … 程式如下: (Person 等相關類別請參考 Spring Batch: getting started)
FlatFileItemReader<Person> itemReader = new FlatFileItemReader<Person>();
itemReader.setResource(new ClassPathResource("Person.csv"));
 
DefaultLineMapper<Person> lineMapper = new DefaultLineMapper<Person>();
lineMapper.setLineTokenizer(new DelimitedLineTokenizer(){{
    setNames(new String[] { "name", "age", "nation", "address" });
}});
lineMapper.setFieldSetMapper(new PersonFieldSetMapper());
itemReader.setLineMapper(lineMapper);
  
itemReader.open(new ExecutionContext());
  
Person person = null;
while ((person = itemReader.read()) != null) {
    System.out.println(person.toString());
}
說明如下:
  • FlatFileItemReader: 這個類別可用來讀取文字檔,當然,csv 檔是文字檔的一種,也用來讀取 csv 檔。它主要依賴兩類別 -- Resource 及 LineMapper,前者為 spring 提供的基礎類別,可以存取檔案或網路資源,這裡使用的 ClassPathResource 會到 classpath 目錄下讀取指定的檔案。
  • DefaultLineMapper: spring batch 定義了 LineMapper 介面,並實作多個類別,這些類別是用來將 String 轉換成相對應的 Object,DefaultLineMapper 可用來處理有分隔符號或固定長度欄位的字串。
  • DelimitedLineTokenizer: 當要處理的字串為有分隔符號的,就用這個類別,這裡有使用 setNames 傳入欄位名稱,這是方便在 PersonFieldSetMapper  (前一篇) 中使用欄位名稱存取各欄位的值,沒有設定欄位名稱,可以用 index,從 0 開始。
現在改成處理固定長度欄位檔案,檔案不再用逗點分隔欄位,改成如下:
Buterin   24Anglo-Saxon       Canada    
中本聰    47大和民族          波士頓    
程式碼幾乎不用改,差別只有一個,就是將 tokenizer 改成 FixedLengthTokenizer !! 程式如下。
FlatFileItemReader itemReader = new FlatFileItemReader();
itemReader.setResource(new ClassPathResource("Person.txt"));
  
DefaultLineMapper lineMapper = new DefaultLineMapper();
FixedLengthTokenizer tokenizer = new FixedLengthTokenizer();
tokenizer.setNames(new String[] { "name", "age", "nation", "address" });
  
Range range1 = new Range(1, 10);
Range range2 = new Range(11, 12);
Range range3 = new Range(13, 30);
Range range4 = new Range(31, 40);
tokenizer.setColumns(new Range[] { range1, range2, range3, range4 });
  
lineMapper.setLineTokenizer(tokenizer);
lineMapper.setFieldSetMapper(new PersonFieldSetMapper());
itemReader.setLineMapper(lineMapper);
  
itemReader.open(new ExecutionContext());
  
Person person = null;
while ((person = itemReader.read()) != null) {
    System.out.println(person.toString());
}
如上,除了 tokenizer 改成 FixedLengthTokenizer,要設定每個欄位的開始位置、結束位置,開始位置從 1 開始。執行的結果是,讀第一行時沒問題,第二行就出現如下 exception 了!
org.springframework.batch.item.file.FlatFileParseException: Parsing error at line: 2 in resource=[class path resource [Person.txt]], input=[中本聰    47大和民族          波士頓    ]
…
Caused by: org.springframework.batch.item.file.transform.IncorrectLineLengthException: Line is shorter than max range 40
因為 Java 預設的編碼為 UTF-8,我的檔案編碼是 MS950,在切 token 時,spring batch 會檢查字串長度,發現長度不足最長的 40,就拋出 exception 了。這時候可以改寫 FixedLengthTokenizer,這裡寫了一個命名為 ZhFixedLengthTokenizer 的類別。
@Slf4j
public class ZhFixedLengthTokenizer extends FixedLengthTokenizer {
    private Range[] ranges;
    private int maxRange = 0;
    boolean open = false;
 
    public void setColumns(Range[] columns) {
        this.ranges = columns;
    }
 
    @Override
    public List<String> doTokenize(String line) {
        List<String> tokens = new ArrayList<String>(ranges.length);
        String token;

        try {
            byte[] b = line.getBytes("MS950");
            int lineLength = b.length;

            for (int i = 0; i < ranges.length; i++) {
                int startPos = ranges[i].getMin() - 1;
                int endPos = ranges[i].getMax();

                if (lineLength >= endPos) {
                    token = getZhString(b, startPos, endPos);
                }
                else if (lineLength >= startPos) {
                    token = getZhString(b, startPos, lineLength);
                }
                else {
                    token = "";
                }

                tokens.add(token);
            }
        } catch (UnsupportedEncodingException e) {
            log.error(e.getMessage(), e);
        }

        return tokens;
    }

    private String getZhString(byte[] b, int startPos, int endPos) throws UnsupportedEncodingException {
        String token;
        byte[] subB = Arrays.copyOfRange(b, startPos, endPos);
        token = new String(subB, "MS950");
        return token;
    }
}

這個類別繼承了 FixedLengthTokenizer,然後覆寫其中的 doTokenize,把切 token 時的字串編碼改為 MS950,這樣就可以得到正確結果了! 這些程式碼基本上是從原本的 FixedLengthTokenizer 裡 copy 過來改寫的,然後把 tokenizer 改用這個類別就可以了,如下:
FixedLengthTokenizer tokenizer = new ZhFixedLengthTokenizer();