Lendo Arquivos Flat no Spring Batch
Objective
Leitura é a primeira fase do processamento chunk-oriented, e o Spring Batch a modela com um único contrato: ItemReader<T>, cujo read() retorna o próximo item ou null no fim da entrada. Readers de arquivo também implementam ItemStream, para que a posição atual de leitura seja salva no execution context — é isso que torna um job que falhou reiniciável em vez de forçar recomeçar do zero. Este concept cobre como o FlatFileItemReader embutido transforma linhas cruas em objetos de domínio.
Para arquivos flat, o FlatFileItemReader nunca faz o parsing das linhas sozinho. Ele delega para um LineMapper; o DefaultLineMapper padrão é um pipeline de dois estágios — um LineTokenizer divide uma linha num FieldSet, e então um FieldSetMapper constrói o objeto de domínio a partir desse FieldSet. Tudo o mais (campos delimitados vs. de largura fixa, registros multilinha, registros heterogêneos, JSON) é uma questão de trocar tokenizers, mappers, e policies dentro desse pipeline. O reader é a ponta de origem de um step chunk (veja spring-batch-chunk-processing), e seu resource de entrada é tipicamente late-bound a um job parameter (veja spring-batch-step-scope-and-spel-late-binding).
Use Cases
- Importar um catálogo de produtos delimitado (estilo CSV) cujas colunas mapeiam por nome para um bean de domínio.
- Ingerir um extrato de mainframe de largura fixa, onde cada campo é uma faixa de colunas em vez de separado por delimitador.
- Pular uma linha de cabeçalho, forçar um encoding de arquivo não padrão, e ler de um resource cujo caminho só é conhecido no momento do lançamento.
- Fazer parsing de registros que se estendem por várias linhas físicas num único item lógico.
- Carregar um arquivo que mistura tipos de registro (por exemplo telefones e livros) com layouts diferentes no mesmo arquivo.
- Ligar um stream de documentos JSON diretamente a objetos tipados.
Deep Dive
O contrato ItemReader: um read() por item
A fase de leitura fica pendurada numa interface pequena:
javapublic interface ItemReader<T> {
T read() throws Exception, UnexpectedInputException,
ParseException, NonTransientResourceException;
}read() retorna o próximo item, ou null para sinalizar que a entrada se esgotou — momento em que o chunk ao redor para de acumular e comita o que tem. Readers de arquivo embutidos também implementam ItemStream (open/update/close), então eles persistem sua posição no execution context e conseguem retomar depois de uma falha. Conceitualmente, o reader é só a fonte que alimenta itens num step chunk (spring-batch-chunk-processing); o resto deste concept é sobre transformar uma linha de um arquivo flat num único T.
FlatFileItemReader: linha → FieldSet → objeto de domínio
FlatFileItemReader lê linhas cruas de um resource e entrega cada uma a um LineMapper. DefaultLineMapper divide o trabalho em dois: um LineTokenizer produz um FieldSet (tokens nomeados e tipados — o análogo, em arquivo flat, de uma linha de ResultSet JDBC), e um FieldSetMapper mapeia esse FieldSet para seu objeto.
plaintext<bean id="productItemReader" class="org.springframework.batch.item.file.FlatFileItemReader"> <property name="resource" value="classpath:products.txt"/> <property name="linesToSkip" value="1"/> <property name="encoding" value="UTF-8"/> <property name="lineMapper" ref="productLineMapper"/> </bean> <bean id="productLineMapper" class="org.springframework.batch.item.file.mapping.DefaultLineMapper"> <property name="lineTokenizer" ref="productLineTokenizer"/> <property name="fieldSetMapper" ref="productFieldSetMapper"/> </bean> <bean id="productLineTokenizer" class="org.springframework.batch.item.file.transform.DelimitedLineTokenizer"> <property name="delimiter" value=","/> <property name="names" value="id,name,description,price"/> </bean>
linesToSkip="1" descarta a linha de cabeçalho (um skippedLinesCallback pode capturar essas linhas puladas se você precisar delas); encoding sobrescreve o charset default do reader; e o resource fixo acima costuma ser late-bound a um job parameter em vez disso (spring-batch-step-scope-and-spel-late-binding). Um FieldSetMapper customizado lê campos tipados do FieldSet por nome:
javapublic class ProductFieldSetMapper implements FieldSetMapper<Product> {
public Product mapFieldSet(FieldSet fs) throws BindException {
Product p = new Product();
p.setId(fs.readString("id"));
p.setName(fs.readString("name"));
p.setDescription(fs.readString("description"));
p.setPrice(fs.readBigDecimal("price"));
return p;
}
}FieldSet expõe leitores tipados (readString, readBigDecimal, readDate, readInt, …), então o mapper só faz conversão de domínio, nunca split de string. Quando os nomes de propriedade do bean batem exatamente com os nomes de campo do tokenizer, você pode dispensar a classe customizada e usar o BeanWrapperFieldSetMapper já pronto (apontado para um bean prototype-scoped), que define cada propriedade reflexivamente a partir do campo correspondente.
Tokenizers delimitados vs. de tamanho fixo
Dois tokenizers cobrem a maioria dos arquivos flat, e ambos produzem o mesmo FieldSet, então o FieldSetMapper a jusante é idêntico de qualquer forma — só o tokenizer muda. DelimitedLineTokenizer (acima) divide num delimitador (vírgula por default). Quando os campos não carregam nenhum separador, mas têm larguras de coluna fixas, FixedLengthTokenizer mapeia faixas de coluna para nomes:
plaintext<bean id="productLineTokenizer" class="org.springframework.batch.item.file.transform.FixedLengthTokenizer"> <property name="columns" value="1-9,10-35,36-50,51-56"/> <property name="names" value="id,name,description,price"/> </bean>
Faixas de coluna são 1-based (o RangeArrayPropertyEditor do Spring faz o parse da string), uma armadilha comum de off-by-one para quem está acostumado com indexação zero-based.
Registros multilinha: um RecordSeparatorPolicy customizado
Por padrão, uma linha física é um registro. Quando um registro lógico se estende por várias linhas, um RecordSeparatorPolicy diz ao reader onde um registro termina; isEndOfRecord() inspeciona o texto acumulado e retorna false até que o registro esteja completo:
javapublic class TwoLineProductRecordSeparatorPolicy implements RecordSeparatorPolicy {
// a complete product has 4 comma-separated fields => 3 commas
public boolean isEndOfRecord(String line) {
return countCommas(line) == 3;
}
public String preProcess(String line) { return line; }
public String postProcess(String record) { return record; }
}Ligue-o através da propriedade recordSeparatorPolicy do reader; o tokenizer então vê o registro multilinha concatenado como uma única linha e faz o parsing normalmente.
Registros heterogêneos: PatternMatchingCompositeLineMapper
Quando um arquivo mistura tipos de registro com layouts diferentes — digamos celulares (linhas prefixadas com PRM) e livros (PRB) — um único tokenizer não resolve. PatternMatchingCompositeLineMapper roteia cada linha por um padrão de prefixo para seu próprio tokenizer e field-set mapper:
plaintext<bean id="productLineMapper" class="org.springframework.batch.item.file.mapping.PatternMatchingCompositeLineMapper"> <property name="tokenizers"> <map> <entry key="PRM*" value-ref="mobileTokenizer"/> <entry key="PRB*" value-ref="bookTokenizer"/> </map> </property> <property name="fieldSetMappers"> <map> <entry key="PRM*" value-ref="mobileFieldSetMapper"/> <entry key="PRB*" value-ref="bookFieldSetMapper"/> </map> </property> </bean>
As chaves wildcard * escolhem o par tokenizer/mapper por linha — a forma usual de construir um modelo polimórfico (um Product base com subclasses MobilePhoneProduct/BookProduct) a partir de um único arquivo heterogêneo.
Lendo JSON (a abordagem do livro)
O livro de 2012 lê JSON com um JsonLineMapper, que faz o parsing de cada objeto JSON num java.util.Map<String,Object> — não um objeto tipado:
plaintext<bean id="productsLineMapper" class="org.springframework.batch.item.file.mapping.JsonLineMapper"/>
Como normalmente você quer instâncias de Product, e não maps, o livro então envolve esse mapper num LineMapper delegante que converte cada Map manualmente:
javapublic class JsonLineMapperWrapper implements LineMapper<Product> {
private JsonLineMapper delegate;
public Product mapLine(String line, int lineNumber) throws Exception {
Map<String, Object> m = delegate.mapLine(line, lineNumber);
Product p = new Product();
p.setId((String) m.get("id"));
p.setName((String) m.get("name"));
p.setPrice(new BigDecimal(m.get("price").toString()));
return p;
}
}Essa dança manual de Map para objeto é exatamente o que o Spring Batch moderno elimina — veja a próxima seção.
Livro vs. hoje: XML + JsonLineMapper → builders + JsonItemReader
Duas coisas mudaram desde o livro (Spring Batch 2.1, 2012).
Primeiro, desde o Spring Batch 4 o FlatFileItemReaderBuilder fluente substitui a ligação XML: ele constrói o DefaultLineMapper, o tokenizer, e o FieldSetMapper para você. .delimited()/.fixedLength() escolhem o tokenizer, .names(...) define os nomes de campo, e .targetType(Product.class) instala um BeanWrapperFieldSetMapper:
java@Bean
@StepScope
public FlatFileItemReader<Product> productItemReader(
@Value("#{jobParameters['input.file']}") Resource resource) {
return new FlatFileItemReaderBuilder<Product>()
.name("productItemReader")
.resource(resource)
.linesToSkip(1)
.encoding("UTF-8")
.delimited().delimiter(",")
.names("id", "name", "description", "price")
.targetType(Product.class)
.build();
}(Para largura fixa, troque por .fixedLength().columns(new Range(1, 9), new Range(10, 35), …).names(…).) O namespace XML batch: ainda funciona, mas está deprecated desde o Spring Batch 6.0 (remoção planejada para a 7.0), então configuração Java mais builders é o estilo recomendado.
Segundo, desde o Spring Batch 4.1 um JsonItemReader de primeira classe liga JSON diretamente a objetos tipados, removendo o código de Map-e-wrapper acima. Ele delega o parsing a um JsonObjectReader — JacksonJsonObjectReader (ou GsonJsonObjectReader):
java@Bean
public JsonItemReader<Product> productJsonReader() {
return new JsonItemReaderBuilder<Product>()
.name("productJsonReader")
.resource(new ClassPathResource("products.json"))
.jsonObjectReader(new JacksonJsonObjectReader<>(Product.class))
.build();
}Um detalhe de pacote que vale citar com precisão: no Spring Batch 6.0 essas classes de infraestrutura foram realocadas. FlatFileItemReader agora vive em org.springframework.batch.infrastructure.item.file e os tipos JSON em org.springframework.batch.infrastructure.item.json, enquanto a série 5.x e o livro usavam org.springframework.batch.item.file/...item.json. Confirmado pela referência atual do Spring Batch (Flat Files, e JSON Item Reading and Writing) e pelo Javadoc da API 6.0.x para FlatFileItemReaderBuilder e JsonItemReader.
Trade-offs
- Formato delimitado vs. largura fixa — Arquivos delimitados são compactos e tolerantes a comprimentos de campo variáveis, mas quebram com um delimitador não escapado dentro dos dados; arquivos de largura fixa são posicionalmente autodescritivos e seguros contra delimitador, mas maiores, e frágeis se qualquer largura de coluna mudar. Combine o tokenizer com a fonte, não com o gosto.
BeanWrapperFieldSetMappervs. umFieldSetMappercustomizado — O mapper bean-wrapper é zero-código quando os nomes de campo batem com as propriedades do bean, mas faz binding por nome via reflexão, com pouco espaço para validação; um mapper customizado é mais código, mas dá leituras tipadas explícitas (readBigDecimal,readDate), valores default, e validação. Convenção vs. controle.- Streaming, um item por vez —
FlatFileItemReaderlê e mapeia de forma preguiçosa a cadaread(), então a memória fica constante para arquivos enormes e a posição é reiniciável. O custo é que o reader é inerentemente stateful e não thread-safe por padrão, então paralelizar um único arquivo exige particionamento deliberado em vez de simplesmente compartilhar um reader. - Policies orientadas a configuração vs. um
LineMappercustomizado — UmRecordSeparatorPolicyouPatternMatchingCompositeLineMappermantém o reader declarativo, mas gramáticas de registro genuinamente complexas forçam o casamento por prefixo além do razoável; a partir de um certo ponto umLineMapperescrito à mão (ou um formato de origem melhor estruturado) é mais claro do que esticar wildcards. JsonItemReadervs. oJsonLineMapperdo livro — O reader tipado moderno remove o boilerplate deMap-para-objeto e dá um tipo alvo concreto, mas espera um stream de objetos JSON e uma biblioteca de binding (Jackson/Gson) no classpath; a abordagemMapdo livro é não tipada, mas leve em dependências.