Parsowanie plików tekstowych o stałej długości linii (Java)

Kilka lat temu pracowałem przy projekcie, który pobierał informacje z pewnego portalu za pomocą płaskich plików tekstowych, w których dane były zapisywane w sposób uporządkowany. Dane zawsze znajdowały się na określonych pozycjach i miały z góry określoną pozycję w linii. Napisałem sobie wtedy prostą funkcję w PHPie, która odczytywała taki plik i zwracała wynik jako tablicę. Całość w kilkudziesięciu linijkach kodu.

Ostatnio przy okazji pracy nad modyfikacją projektu dla klienta musiałem przygotować komunikację z maszyną, która zwracała dane właśnie w postaci takiego pliku tekstowego. W związku z tym napisałem uniwersalną klasę Parsera z wykorzystaniem typów generycznych, annotacji Javowych oraz mechanizmu refleksji. Parser w bardzo łatwy sposób daje się rozszerzyć o czym za chwilę 🙂

Na początek klasa (mapa), która zawiera pola z informacjami o ich rozmieszczeniu w pliku i długościach:

package utils.parsers;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Date;

@TextParser(lineLength=84)
public class MachineData {
    @TextParserField(startPosition=0, length=12)
    public String batchNumber;

    @TextParserField(startPosition=12, length=6)
    public String machineNumber;
	
    @TextParserField(startPosition=18, length=15)
    public String productCode;
	
    @TextParserField(startPosition=33, length=9)
    public double quantity;

    @TextParserField(startPosition=42, length=8, format="ddMMyyyy")
    public LocalDate date;

    @TextParserField(startPosition=50, length=4, format="HHmm")
    public LocalTime time;

    @TextParserField(startPosition=54, length=1)
    public String tank;

    @TextParserField(startPosition=55, length=3)
    public String phase;

    @TextParserField(startPosition=58, length=1)
    public int measurementUnit;

    @TextParserField(startPosition=59, length=1)
    public int additionFlag;

    @TextParserField(startPosition=60, length=24)
    public String recipeCode;
}

Teraz kod klasy parsującej:

package utils.parsers;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.BiFunction;

public class TextDataFileParser<T> {

    // Typ klasy parametru generycznego
    private final Class<T> _classType;

    // Cache dla pozycji pól w pliku tekstowym
    private final Map<Field, Entry<Integer, Integer>> _positions = new HashMap<Field, Entry<Integer,Integer>>();
    // Cache formatów danych dla pól
    private final Map<Field, String> _fieldFormats = new HashMap<Field, String>();
    // Cache dla metod parsujących
    private final Map<Field, Method> _parsingMethods = new HashMap<Field, Method>();
    // Parsery dla typów danych
    private final Map<Class<?>, BiFunction<String, String, ?>> _parsingFunctions = new HashMap<Class<?>, BiFunction<String, String, ?>>();

    private int _lineLength = 0;

    // --- Constructors
    public TextDataFileParser(Class<T> classType) throws TextParserException {
        _classType = classType;

        initialize();
        registerDefaultParsingFunctions();
    }

    /**
     * Inicjalizacja parsera. Tworzy mapę pól i metod parsujących dla
     * klasy przekazanej jako parametr generyczny. 
     * @throws TextParserException 
     */
    private void initialize() throws TextParserException {
        // Annotacja dla parsera
        TextParser parserAnnotation = _classType.getAnnotation(TextParser.class);
        if (parserAnnotation == null) {
            throw new TextParserException(String.format("Klasa '%s' nie posiada wymaganej annotacji 'TextParser'.", _classType.getName()));
        }

        _lineLength = parserAnnotation.lineLength();
        if (_lineLength <= 0) {
            throw new TextParserException(String.format("Nieprawidłowy rozmiar linii podany w annotacji 'TextParser'.", _lineLength));
        }
        Field[] fields = _classType.getFields();
        for(Field field : fields) {
            if (Modifier.isPublic(field.getModifiers())) {
                // Annotacja dla pola dotycząca pozycji i długości
                TextParserField annotation = field.getAnnotation(TextParserField.class);
                if (annotation != null) {
                    int startIndex = annotation.startPosition();
                    int length = annotation.length();
                    if (startIndex + length > _lineLength) {
                        throw new TextParserException(String.format("Parametry pola '%s' wykraczają poza dopuszczalną długość linii w pliku (%d).", field.getName(), _lineLength));
                    }
                    _positions.put(field, new AbstractMap.SimpleEntry<Integer, Integer>(startIndex, length));
                    if (!annotation.format().isEmpty()) {
                        _fieldFormats.put(field, annotation.format());
                    }

                    // Próba wyszukania metody parsującej dla pola
                    Method parseMethod;
                    try {
                        String parseMethodNameForField = String.format("%sParse", field.getName());
                        parseMethod = _classType.getMethod(parseMethodNameForField, String.class);
                        _parsingMethods.put(field, parseMethod);
                    }
                    catch (NoSuchMethodException | SecurityException e) { }
                }
            }
        }
    }

    /**
     * Rejestracja domyślnych funkcji do parsowania typów.
     */
    private void registerDefaultParsingFunctions() {
        BiFunction<String, String, Byte> byteParser = (s, f) -> { return Byte.parseByte(s); };
        BiFunction<String, String, Short> shortParser = (s, f) -> { return Short.parseShort(s); }; 
        BiFunction<String, String, Integer> intParser = (s, f) -> { return Integer.parseInt(s); };
        BiFunction<String, String, Long> longParser = (s, f) -> { return Long.parseLong(s); }; 
        BiFunction<String, String, Float> floatParser = (s, f) -> { return Float.parseFloat(s); }; 
        BiFunction<String, String, Double> doubleParser = (s, f) -> { return Double.parseDouble(s); }; 
        BiFunction<String, String, Boolean> booleanParser = (s, f) -> {
            s = s.toLowerCase();
            return (s.equals("1") || s.equals("y") || s.equals("yes") || s.equals("t") || s.equals("true"));
        };
        BiFunction<String, String, LocalDate> localDateParser = (s, f)-> { return LocalDate.parse(s, DateTimeFormatter.ofPattern(f)); };
        BiFunction<String, String, LocalTime> localTimeParser = (s, f)-> { return LocalTime.parse(s, DateTimeFormatter.ofPattern(f)); };
        BiFunction<String, String, Date> dateParser = (s, f)-> {
            try {
                return new SimpleDateFormat(f).parse(s);
            } catch (ParseException e) {
                return null;
            }};
		
        registerTypeParser(Byte.class, byteParser);
        registerTypeParser(byte.class, byteParser);
        registerTypeParser(Short.class, shortParser);
        registerTypeParser(short.class, shortParser);
        registerTypeParser(Integer.class, intParser);
        registerTypeParser(int.class, intParser);
        registerTypeParser(Long.class, longParser);
        registerTypeParser(long.class, longParser);
        registerTypeParser(Float.class, floatParser);
        registerTypeParser(float.class, floatParser);
        registerTypeParser(Double.class, doubleParser);
        registerTypeParser(double.class, doubleParser);
        registerTypeParser(Boolean.class, booleanParser);
        registerTypeParser(boolean.class, booleanParser);
        registerTypeParser(Date.class, dateParser);
        registerTypeParser(LocalDate.class, localDateParser);
        registerTypeParser(LocalTime.class, localTimeParser);
    }

    /**
     * Parsuje podany plik tekstowy zapisany w kodowaniu UTF-8.
     * @param filePath	Ścieżka do pliku z danymi
     * @return			Lista sparsowanych elementów 
     * @throws TextParserException
     */
    public List<T> parseFile(String filePath) throws TextParserException{
        return parseFile(filePath, Charset.forName("UTF-8"));
    }

    /**
     * Parsuje podany plik tekstowy.
     * @param filePath	Ścieżka do pliku z danymi
     * @param charset	Kodowanie pliku
     * @return			Lista sparsowanych elementów 
     * @throws TextParserException
     */
    public List<T> parseFile(String filePath, Charset charset) throws TextParserException{
        List<T> lines;
        try {
            lines = Files.readAllLines(Paths.get(filePath), charset);
        }
        catch (IOException e) {
            throw new TextParserException("Błąd odczytu pliku z danymi", e); 
        }

        List<T> data = new ArrayList();
        int currentLine = 1;
        for(String line : lines) {
            int currentLineLength = line.length();
            if (_lineLength != 0 && currentLineLength != _lineLength) {
                throw new TextParserException(String.format("Nieprawidłowa długość linii nr %d (%d zamiast %d znaki)", currentLine, currentLineLength, _lineLength));
            }

            T instance = null;
            try {
                instance = _classType.newInstance();
            }
            catch (InstantiationException | IllegalAccessException e) {
                throw new TextParserException("Błąd podczas tworzenia instancji klasy.", e);
            }

            for(Field field : _positions.keySet()) {
                int startPosition = _positions.get(field).getKey();
                int length = _positions.get(field).getValue();
                String stringValue = line.substring(startPosition, startPosition + length);
                if (_parsingMethods.containsKey(field)) {
                    Method parseMethod = _parsingMethods.get(field);
                    try {
                        parseMethod.invoke(instance, stringValue);
                    }
                    catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
                        throw new TextParserException(String.format("Błąd podczas parsowania. Linia numer: %d, pole: '%s', wartość: '%s'.", currentLine, field.getName(), stringValue), e); 
                    }
                } else {
                    Object value = stringValue;
                    try {
                        if (_parsingFunctions.containsKey(field.getType())) {
                            String fieldFormat = _fieldFormats.containsKey(field) ? _fieldFormats.get(field) : "";
                            value = _parsingFunctions.get(field.getType()).apply(stringValue, fieldFormat);
                        }
			field.set(instance, value);
                    }
                    catch (IllegalArgumentException | IllegalAccessException e) {
                        throw new TextParserException(String.format("Błąd podczas parsowania. Linia numer: %d, pole: '%s', wartość: '%s'.", currentLine, field.getName(), stringValue), e); 
                    }
                }
            }
            data.add(instance);
            currentLine++;
        }
        return data;
    }

    /**
     * Rejestruje funkcję konwertującą dla podanego typu danych.
     * @param classType 	Typ danych
     * @param parseFunction Funkcja parsująca
     */
    public <Type> void registerTypeParser(Class<Type> classType, BiFunction<String, String, Type> parseFunction) {
        _parsingFunctions.put(classType, parseFunction);
    }
}

Jeszcze tylko klasy annotacji dla parsera:

package utils.parsers;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TextParser {
	int lineLength();
}

i

package utils.parsers;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface TextParserField {
	int startPosition();
	int length();
	String format() default "";
}

oraz klasa wyjątku:

package utils.parsers;

public class TextParserException extends Exception {

	private static final long serialVersionUID = 1L;

	public TextParserException(String message) {
		super(message);
	}
	
	public TextParserException(String message, Throwable cause) {
		super(message, cause);
	}
}

Format pliku tekstowego jest dowolny, ważne jest to, aby linie miały stałą długość.

Klasa mapy jest niezależna od parsera i może być używana w różnych miejscach projektu bez ograniczeń.

Mapowanie pól

Klasa może zawierać dowolną ilość pól oraz metod. Kolejność pól w klasie (mapie) nie ma znaczenia. Klasa nie musi posiadać pełnej mapy na linię tekstu. Oznacza to, że możemy w klasie dodać tylko te pola, których faktycznie potrzebujemy. Pola w klasie, które mają być brane pod uwagę przez parser muszą:

  • być publiczne
  • mieć zdefiniowaną annotację TextParserField

Parser bierze pod uwagę również metody parsujące o określonej sygnaturze zawarte w klasie mapującej (patrz niżej).

Formatowanie danych

Do każdego pola w annotacji TextParserField można dodać atrybut format (nie jest on wymagany), który zostanie przekazany do funkcji parsującej. Przydatne np. dla Date, LocalDate, LocalTime oraz dla własnych funkcji parsujących.

Funkcje parsujące

Parser ma wbudowane funkcje do konwersji wartości typu string na niektóre (podstawowe) typy danych. Funkcje te obsługują formatowanie danych określone w annotacji TextParserField. Domyślnie są zarejestrowane funkcje parsujące dla następujących typów (metoda registerDefaultParsingFunctions()):

  • byte oraz Byte
  • short oraz Short
  • int oraz Integer
  • long oraz Long
  • float oraz Float
  • double oraz Double
  • Date, LocalDate oraz LocalTime
  • boolean oraz Boolean. Domyślnie, jeśli parsowany string ma wartość: „1”, „y”, „yes”, „t”, „true” wówczas parser typu zwróci True. W pozostałych przypadkach zwrócone zostanie false.

Własne funkcje parsujące

Istnieje możliwość zarejestrowania własnej funkcji parsującej dla konkretnego typu danych (klasy, enumeracji itd) lub też zastąpienia wbudowanego parsera np. dla typu Double.

Funkcja parsująca jest typu: BiFunction<String, String, T> co oznacza, że przyjmuje dwa parametry typu String (1szy to ciąg znaków do sparsowania, 2gi to format danych jeśli został podany dla pola) oraz zwraca wartość typu T.

Rejestracja własnej funkcji odbywa się za pomocą metody parsera:

public <Type> void registerTypeParser(Class<Type> classType, BiFunction<String, String, Type> converter)

przykład użycia:

BiFunction<String, String, String> stringReverser = new BiFunction<String, String, String>() {
    @Override
    public String apply(String t, String u) {
        return new StringBuilder(t).reverse().toString(); 
    }
};

To samo można uzyskać w wersji skróconej (lamba):

parser.registerTypeParser(String.class, 
   (s, f) -> { return new StringBuilder(s).reverse().toString(); }
);

Zarejestrowanie tej funkcji parsującej spowoduje, że wartości wszystkich pól tekstowych, które znajdują się w mapie, podczas parsowania automatycznie będą odwracane 🙂

Metody parsujące

Dodatkową funkcjonalnością jest możliwość tworzenia własnych metod parsujących dane jeśli domyślne funkcje parsujące są niewystarczające. Działają one na podobnej zasadzie jak funkcje parsujące ale są umieszczane bezpośrednio w kodzie klasy generycznej (mapy). Metody takie są ‚automagicznie’ wykrywane przez parser na etapie uruchomienia. Wymagania dla metod parsujących:

  • musi być publiczna
  • musi przyjmować jeden parametr typu String. Jako parametr nie jest przekazywana cała linia z pliku, tylko konkretna jej część zgodnie z ustawionymi parametrami początek i długości pola
  • nazwa metody musi być odpowiednia (nazwa_pola + końcówka „Parse”), np. quantityParse(String input)

przykład metody dla pola quantity:

@TextParserField(startPosition=33, length=9)
public double quantity;
public void quantityParse(String input){
    this.quantity = Double.parseDouble(input);
}

Metody parsujące mają priorytet wyższy od funkcji parsujących, znaczy to, że Parser najpierw sprawdza czy w kodzie klasy mapy istnieje metoda parsująca dla pola i ewentualnie ją wywoła. Jeśli nie, to wywołana zostanie funkcja parsująca dla typu danych pola lub nastąpi próba przypisania Stringa. Jeśli nie zostanie znaleziona metoda lub funkcja parsująca a typ pola jest inny niż String, wówczas zostanie rzucony wyjątek TextParserException.

Zakładając, że mamy przykładowy plik file.dat zawierający dwie linijki z danymi:

123456789012X48Z99ProductCode 000001568120420161439788810999999999999999999999999
111111111111222222KodProduktu 000001568231120150956125401888888888888888888888888

Przykład wywołania parsera:

try {
    TextDataFileParser parser = new TextDataFileParser(MachineData.class);
    List data = parser.parseFile("file.dat");
    for(MachineData record : data){
        System.out.println("batchNumber: " + record.batchNumber);
        System.out.println("machineNumber: " + record.machineNumber);
        System.out.println("productCode: " + record.productCode);
        System.out.println("quantity: " + record.quantity);
        System.out.println("date: " + record.date);
        System.out.println("time: " + record.time);
        System.out.println("tank: " + record.tank);
        System.out.println("phase: " + record.phase);
        System.out.println("measurementUnit: " + record.measurementUnit);
        System.out.println("additionFlag: "  + record.additionFlag);
        System.out.println("recipeCode: " + record.recipeCode);
    }
}
catch (TextParserException e) { e.printStackTrace(); }

oraz wynik:

batchNumber: 123456789012
machineNumber: X48Z99
productCode: ProductCode 
quantity: 1568.0
date: 2016-04-12
time: 14:39
tank: 7
phase: 888
measurementUnit: 1
additionFlag: 0
recipeCode: 999999999999999999999999
----------------------
batchNumber: 111111111111
machineNumber: 222222
productCode: KodProduktu 
quantity: 1568.0
date: 2015-11-23
time: 09:56
tank: 1
phase: 254
measurementUnit: 0
additionFlag: 1
recipeCode: 888888888888888888888888

 

I to by było na tyle. Mam nadzieję, że wszystko jest jasne i że ten kawałek kodu się kiedyś komuś przyda 🙂


Tagi:, ,

Nie ma jeszcze komentarzy.

Dodaj komentarz

Uzupełnij * Time limit is exhausted. Please reload the CAPTCHA.