Über unsMediaKontaktImpressum
Hans Jörg Heßmann 05. November 2019

Strukturierte Textdateien mit Spring Batch verarbeiten

Wie der Name schon sagt, ist Spring Batch ein umfangreiches Framework für Batch-Verarbeitung. Natürlich muss ein Batch-Job mitunter sehr große strukturierte Textdaten (CSV, XML, JSON) einlesen oder erzeugen. Diese Aufgabe ist zwar einfach, aber dennoch kein Grund, das Rad immer wieder neu zu erfinden. Die Reader und Writer, die Spring Batch dafür bietet, sind sehr flexibel und elegant, die Details dazu sind aber selbst unter Batch-Entwicklern wenig bekannt. Dabei beschränkt sich deren Einsatz nicht auf Batch-Verarbeitung und auch nicht auf das Spring-Umfeld. Dieser Artikel zeigt anhand von Codebeispielen, wie man diese Reader und Writer geschickt einsetzen kann.

Strukturierte Textdateien sind sowohl maschinenlesbar als auch für Menschen einfach zu editieren, deshalb werden sie gerne als Austauschformat zwischen verschiedenen Systemen verwendet. Solche Transferdateien enthalten normalerweise eine lange Liste gleichartiger Datensätze. Die prominentesten Dateiformate dafür sind CSV, XML oder JSON. Die immer wiederkehrende Aufgabe besteht darin, den Inhalt so einer Datei in eine Sequenz von Objekten zu überführen oder aus Objekten so eine Datei zu erzeugen. Es versteht sich geradezu von selbst, dass ein Batch-Framework diese Aufgabe unterstützen muss.

Bevor wir uns dem ersten konkreten Beispiel zuwenden, sind ein paar Grundlagen zu Spring Batch notwendig. Es geht hier um keine allgemeine Einführung, sondern wir wenden uns nur einem kleinen Teil des Frameworks zu, den wir isoliert betrachten und verwenden können.

Mit dem Kern stöpselt man die Batch-Jobs zusammen, führt sie aus, steuert und überwacht sie. Die Infrastruktur enthält u. a. die Reader und Writer, die die eigentliche Arbeit leisten. Für unsere Betrachtungen relevant ist, dass die Infrastruktur unabhängig vom Kern ist und somit auch ohne diesen eingesetzt werden kann.

Alle Reader und Writer, die wir in diesem Artikel betrachten. sind Subklassen von
AbstractItemStreamItemReader bzw. AbstractItemStreamItemWriter. Diese implementieren die Interfaces ItemReader bzw. ItemWriter und ItemStream.

Ein ItemReader liefert mit jedem Aufruf von read ein neues Objekt oder null, wenn keine weiteren Daten mehr vorliegen. Im Fehlerfall (z. B. ungültiges Datenformat in einer CSV-Zeile) wirft die Methode eine Exception. Die write-Methode des ItemWriter nimmt hingegen eine Liste von Objekten entgegen.

Letztendlich werden die Textdateien mit einem InputStream gelesen bzw. mit einem OutputStream geschrieben. Diese müssen vor der Verarbeitung geöffnet und danach wieder geschlossen werden, weshalb das ItemStream-Interface die Methoden open und close bietet. Aber wozu dient die Methode update und was hat es mit dem ExecutionContext-Parameter auf sich?

Der ExecutionContext ist eine Map, welche die Verknüpfung zum Spring-Batch-Framework herstellt. Das Framework aktualisiert den ExecutionContext am Ende jeder Transaktion mit update und persistiert ihn danach. Damit kann ein Reader nach einem Absturz seine Arbeit wieder an der richtigen Stelle fortsetzen. Für unsere Zwecke genügt es, wenn wir bei open ein new ExecutionContext() übergeben und somit NullPointerExceptions unterbinden.

CSV-Datei einlesen

Mit diesen Vorkenntnissen können wir uns nun endlich dem ersten Anwendungsbeispiel zuwenden. Es geht darum, eine CSV-Datei mit den Spalten Nachname, Vorname und Geburtsdatum einzulesen und als Liste von PersonData-Objekten zurückzugeben. Letzteres verwendet Lombok-Annotationen, um Boilerplate-Code zu sparen.

Listing 1: Persons.csv

Nachname; Vorname; Geburtsdatum
"Branntwein"; "Franz"; 17.12.1958
"Bolika"; "Anna"; 03.07.1972
"Panse"; "Jim"; 08.02.2002

Wir verwenden dazu den FlatFileItemReader. Dieser ist hochgradig konfigurierbar. Um die Konfiguration zu erleichtern, bietet Spring Batch zu fast jedem ItemReader oder ItemWriter einen korrespondierenden Builder. Das folgende Codebeispiel zeigt, wie man einen Reader konfiguriert, der die Persons.csv-Datei verarbeitet und für jede Personenzeile ein PersonData-Objekt zurückliefert:

Listing 2: PersonData.java

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonData {
    private String firstName;
    private String lastName;
    @DateTimeFormat(pattern = "dd.MM.yyyy")
    private LocalDate birthday;
}

Statt mit Dateien arbeiten die Reader und Writer mit der Resource-Abstraktion von Spring. Damit kann einheitlich auf Dateien, URL-Inhalte, Ressourcen im Classpath, etc. zugegriffen werden. Eine Resource für eine Datei erhält man mit new FileSystemResource(file).

Ein FlatFileItemReader kann grundsätzlich in zwei unterschiedlichen Modi arbeiten: Delimited oder FixedLength. Bei Delimited sind die einzelnen Spalten eines Datensatzes durch ein Trennzeichen voneinander getrennt und bei FixedLength hat jede Spalte eine feste Breite. Bei Person.csv wird statt eines Kommas ein Semikolon als Trennzeichen verwendet, deswegen muss man das Trennzeichen mit der delimiter-Methode explizit angegeben. Außerdem ist jeder einzelne Zeichenkettenwert in Anführungszeichen gesetzt, was über die quoteCharacter-Methode angegeben werden muss. Die Methode linesToSkip(1) bewirkt, dass die erste Zeile übersprungen wird. Das ist notwendig, da Person.csv in der 1. Zeile die Spaltenüberschriften enthält.

Jeder FlatFileItemReader benötigt einen LineTokenizer, der eine Zeile in ihre Bestandteile zerlegt und einen FieldSetMapper, der die zerlegte Zeile auf das Zielobjekt abbildet. Die zerlegte Zeile wird durch ein FieldSet abgebildet. Damit kann man auf jede Spalte der Zeile per Name oder Index zugreifen. Das FieldSet übernimmt zudem die Umwandlung in entsprechende primitive Datentypen. Im Modus Delimited wird immer der DelimitedLineTokenizer eingesetzt. Mit der Methode names vergibt man jeder Spalte einen Namen.

Listing 3: FlatFileItemReader konfigurieren

public static FlatFileItemReader<PersonData> createPersonCsvReader(Resource source) {
    return new FlatFileItemReaderBuilder<PersonData>()
            .name("personCsvReader") // arbitrary name
            .saveState(false) // don't save progress in ExecutionContext
            .resource(source) // read from this Resource
            .delimited() // expect a delimited (CSV) file
            .delimiter(";") // use ';' as delimiter instead of ','
            .quoteCharacter('\"') // remove quotation from content
            .names(new String[] { "lastName", "firstName", "birthday" }) // map column to names
            .fieldSetMapper(new BeanWrapperFieldSetMapper<PersonData>() {
                {
                    setTargetType(PersonData.class); // initialize mapper
                    var cv = new DefaultFormattingConversionService();
                    setConversionService(cv); // convert date/time values
                }
            })
            .linesToSkip(1) // skip first (header) row
            .build(); // create the reader
}

Als FieldSetMapper bietet Spring Batch neben dem primitiven PassThroughFieldSetMapper, der einfach das FieldSet durch reicht, den ArrayFieldSetMapper, der die Zeile als String-Array zurück gibt und den BeanWrapperFieldSetMapper, der ein FieldSet anhand der Spaltennamen auf die Properties einer Bean abbildet. Bei letzterem genügt es im einfachsten Fall, über setTargetType die Klasse des Zielobjekts anzugeben. Man kann aber auch einen ConversionService für typabhängige Konvertierungen angeben oder einzelnen Spalten einen speziellen PropertyEditor zuweisen.

Das Beispiel verwendet einen DefaultFormattingConversionService, damit der BeanWrapperFieldSetMapper Datums- und Zeitwerte verarbeiten kann. Dieser wertet die @DateTimeFormat-Annotation aus. Anstatt diese Annotation zu verwenden kann man auch den ConversionService so konfigurieren, dass er für ein LocalDate ein Datum im Format dd.MM.yyyy erwartet:

Listing 4: ConversionService für Datumswerte

private static ConversionService createDateFormattingConversionService() {
    var conversionService = new DefaultFormattingConversionService(false);
    var registrar = new DateTimeFormatterRegistrar();
    registrar.setDateFormatter(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
    registrar.registerFormatters(conversionService);
    return conversionService;
}

Der FlatFileItemReader implementiert ItemStream und muss somit vor Verwendung geöffnet und danach wieder geschlossen werden. Leider ist er kein AutoClosable, somit ist kein try-with-resources möglich. Unschön ist auch, dass die read-Methode eine java.lang.Exception werfen kann, deswegen ist immer ein entsprechender Try-catch-Block angebracht. Folgendes Codefragment zeigt, wie man einen ItemStreamReader verwendet:

Listing 5: ItemStreamReader verwenden

public static <T> List<T> read(ItemStreamReader<T> reader)
        throws IOException {
    List<T> items = new ArrayList<>();
    try {
        T item;
        reader.open(new ExecutionContext()); // provide dummy ExecutionContext
        while ((item = reader.read()) != null) { // null signals end of stream
            items.add(item);
        }
    } catch (Exception ex) { // interface may throw any exception
        throw new IOException("Unable to read form item stream.", ex);
    } finally {
        reader.close(); // can't use try-with-resource here
    }
    return items;
}

Textdatei erzeugen

Zum Schreiben bietet Spring Batch den FlatFileItemWriter. Dieser benötigt einen LineAggregator, der ein Objekt in eine Zeile schreibt. Die einfachste Variante ist der PassThroughLineAggregator, der den Zeileninhalt über die toString-Methode erzeugt.

Auch zum Schreiben wird zwischen Spaltentrennung durch Trennzeichen mittels DelimitedLineAggregator und fester Spaltenbreite mittels FormatterLineAggregator unterschieden. Die Spalten liefert ein FieldExtractor. Hierfür gibt es u. a. PassThroughFieldExtractor, LineItemFieldExtractor und BeanWrapperFieldExtractor.

Das folgende Code-Fragment zeigt, wie man einen FlatFileItemWriter mit LineAggregator für feste Spaltenbreiten konfiguriert. Statt den LineAggregator explizit zu erzeugen, könnte man auch die fluent API formatted().format("%1$-20s%2$-20s %3$td.%3$tm.%3$tY") verwenden.

Listing 6: FlatFileItemWriter konfigurieren

public static FlatFileItemWriter<PersonData> createPersonWriter(Resource sink) {
    var fieldExtractor = new BeanWrapperFieldExtractor<PersonData>();
    fieldExtractor.setNames(new String[] { "lastName", "firstName", "birthday" });
    fieldExtractor.afterPropertiesSet();
 
    var lineAggregator = new FormatterLineAggregator<PersonData>();
    lineAggregator.setFormat("%1$-20s%2$-20s %3$td.%3$tm.%3$tY");
    lineAggregator.setFieldExtractor(fieldExtractor);
 
    return new FlatFileItemWriterBuilder<PersonData>()
            .name("personFixedColumnsWriter")
            .resource(sink)
            .lineAggregator(lineAggregator)
            .build();
}

Der FlatFileItemWriter implementiert ItemStreamWriter, der genauso wie der ItemStreamReader verwendet wird. Natürlich muss statt der read-Methode write aufgerufen werden. Diese Methode nimmt eine Liste von Datensätzen entgegen, die jeweils als Zeile raus geschrieben werden. Einen ItemStreamWriter verwendet man folgendermaßen:

Listing 7: ItemStreamWriter verwenden

public static <T> void write(ItemStreamWriter<T> writer,
        List<? extends T> items) throws IOException {
    try {
        writer.open(new ExecutionContext()); // provide dummy ExecutionContext
        writer.write(items);
    } catch (Exception ex) {
        throw new IOException("Unable to write to item stream.", ex);
    } finally {
        writer.close(); // can't use try-with-resource here
    }
}

Verarbeitung großer XML-Dateien

Mit dem Object/XML Mapping (OXM) von Spring können verschiedene XML-Binding-Technologien über eine einheitliche Schnittstelle eingebunden werden. Diese verwandeln ein komplettes XML-File in einen Objektbaum, der vollständig im Speicher liegt. Für sehr große Dateien ist das nicht praktikabel, deshalb geht Spring Batch einen anderen Weg: Der StaxEventItemReader liefert jeweils ein XML-Fragment, welches mittels OXM in ein Objekt umgewandelt wird.

Als Beispiel wollen wir XML-Dateien mit Personendaten verarbeiten, die dem folgenden Schema entsprechen:

Listing 8: persons.xsd

<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <xsd:element name="persons">
        <xsd:complexType>
            <xsd:sequence>
                <xsd:element name="person" type="PersonType"
                             minOccurs="0" maxOccurs="unbounded" />
            </xsd:sequence>
        </xsd:complexType>
    </xsd:element>
 
    <xsd:complexType name="PersonType">
        <xsd:sequence>
            <xsd:element name="firstName" type="xsd:string" />
            <xsd:element name="lastName" type="xsd:string" />
            <xsd:element name="birthday" type="xsd:date" />
        </xsd:sequence>
    </xsd:complexType>
</xsd:schema>

Zum Binding verwenden wir JAXB. Wir konfigurieren den StaxEventItemReader wieder über einen entsprechenden Builder:

Listing 9: StaxEventItemReader konfigurieren

return new StaxEventItemReaderBuilder<PersonType>()
        .name("personXmlReader")
        .saveState(false)
        .resource(source)
        .addFragmentRootElements("person")
        .unmarshaller(getUnmarshaller())
        .build();

Neben dem Unmarshaller muss man vor allem das Root-Element angeben, das ein Fragment einleitet. In unserem Fall ist es das person-Element.

Der Jaxb2Marshaller implementiert sowohl Marshaller als auch Unmarshaller. Die MappedClass muss mit dem Root-Element des Fragments korrespondieren, das ergibt folgende Konfiguration für den (Un-)marshaller:

Listing 10: Unmarshaller für JAXB

Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
marshaller.setContextPath(Persons.class.getPackage().getName());
marshaller.setMappedClass(PersonType.class);
try {
    marshaller.afterPropertiesSet();
} catch (Exception ex) {
    throw new IllegalStateException("Unable to initialize JAXB2 marshaller");
}

StaxEventItemReader implementiert wieder das ItemStreamReader-Interface, kann also mit der bereits vorgestellten read-Methode verwendet werden.

XML-Dateien erzeugen

Der StaxEventItemWriter wandelt Objekte mittels OXM in XML-Fragmente um und gibt diese mittels StAX in eine XML-Datei aus. Bei Verwendung von JAXB für das Marshalling gibt es ein Problem: Der Marshaller kann ein Objekt nur dann in ein XML-Fragment umwandeln, wenn dieses mit der @XmlRootElement-Annotation versehen ist. Da die entsprechenden Klassen von JAXB generiert werden, ist ein eigenes Schema für ein XML-Fragment notwendig:

Listing 11: person.xsd

<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <xsd:element name="person">
        <xsd:complexType>
            <xsd:sequence>
                <xsd:element name="firstName" type="xsd:string" />
                <xsd:element name="lastName" type="xsd:string" />
                <xsd:element name="birthday" type="xsd:date" />
            </xsd:sequence>
        </xsd:complexType>
    </xsd:element>
</xsd:schema>

Die Konfiguration des Jaxb2Marshaller unterscheidet sich kaum von der für den Reader:

Listing 12: Marshaller für JAXB

Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
marshaller.setContextPath(Person.class.getPackage().getName());
marshaller.setMarshallerProperties(Collections.singletonMap( // will be ignored
        javax.xml.bind.Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE));
try {
    marshaller.afterPropertiesSet();
} catch (Exception ex) {
    throw new IllegalStateException("Unable to initialize JAXB2 marshaller");
}

Tatsächlich funktioniert das Beispiel auch mit demselben Jaxb2Marshaller, der beim Reader verwendet wurde. Die Angabe zur formatierten Ausgabe spielt nur bei einem StreamResult eine Rolle. Der StaxEventItemWriter verwendet ein StAXResult, welches die Formatierungsinformationen ignoriert. Leider bietet der StaxEventItemWriter keine einfache Möglichkeit, eine formatierte XML-Ausgabe zu erzeugen.

Der Marshaller kennt nur die XML-Struktur eines Fragments. Der StaxEventItemWriter benötigt deshalb die Information, unter welchem Root-Element die Fragmente erstellt werden sollen. Man kann auch noch Attribute für das Root-Element angeben oder über Callbacks Header oder Footer erzeugen. Folgender Code-Block zeigt die Konfiguration des StaxEventItemWriter:

Listing 13: StaxEventItemWriter konfigurieren

return new StaxEventItemWriterBuilder<Person>()
        .name("personXmlWriter")
        .saveState(false)
        .marshaller(getMarshaller())
        .resource(sink)
        .rootTagName("persons") // root-tag of the file
        .overwriteOutput(true) // overwrite existing file
        .build();

Auch dieser Writer implementiert ItemStreamWriter und kann somit über die bereits vorgestellte write Methode verwendet werden.

JSON

Zum Lesen und Schreiben von JSON-Dateien kennt Spring Batch den JsonItemReader und den JsonItemWriter. Zur (De-)serialisierung können wahlweise Jackson oder Gson eingesetzt werden. Mit Jackson sieht die Konfiguration des Readers folgendermaßen aus:

Listing 14: JsonItemReader konfigurieren

return new JsonItemReaderBuilder<PersonData>()
        .name("personJsonReader")
        .saveState(false)
        .jsonObjectReader(new JacksonJsonObjectReader<>(PersonData.class) {{
            var om = new ObjectMapper();
            om.registerModule(new JavaTimeModule());
            setMapper(om);
        }})
        .resource(source)
        .build();

Einzige Besonderheit ist, dass man dem ObjectMapper beibringen muss, mit dem LocalDate für das Geburtsdatum umzugehen. Dazu ist das JavaTimeModule notwendig. Den Writer konfigurieren wir anlog:

Listing 15: JsonItemWriter konfigurieren

return new JsonFileItemWriterBuilder<PersonData>()
        .name("personJsonWriter")
        .saveState(false)
        .jsonObjectMarshaller(new JacksonJsonObjectMarshaller<>() {{
            var om = new ObjectMapper();
            om.registerModule(new JavaTimeModule());
            om.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
            setObjectMapper(om);
        }})
        .resource(sink)
        .shouldDeleteIfExists(true)
        .build();

Mit Gson müssen GsonJsonObjectReader statt JacksonJsonObjectReader bzw. GsonJsonObjectMarshaller statt JacksonJsonObjectMarshaller eingesetzt werden. Lesen und Schreiben funktioniert wie gehabt, da wir es auch hier wieder mit einem ItemStreamReader bzw. ItemStreamWriter zu tun haben.

Kombination mit Spring-Batch-Framework

Die bisher vorgestellten Reader und Writer kann man so direkt in eigenen Applikationen verwenden. Dabei sollte man darauf achten, dass die Schnittstelle zwischen eigenem Code und diesen Readern und Writeren sich auf ItemStreamReader und ItemStreamWriter beschränkt.

In einem Batch-Job wird meistens ein Reader mit einem Writer kombiniert. Wie das mit Spring Batch geht, sehen wir uns an einem einfachen Beispiel an, das eine CSV-Datei einliest und wieder als JSON-Datei ausgibt. Um besser verstehen zu können, was tatsächlich passiert, lassen wir mal Spring's Autokonfiguration bei Seite.

Zunächst ist ein bisschen Infrastrukur-Setup notwendig, um eine JobBuilderFactory und eine StepBuilderFactory zu erzeugen:

Listing 16: Spring Batch einrichten

jobRepository = new MapJobRepositoryFactoryBean().getObject();
transactionManager = new ResourcelessTransactionManager();
jobBuilderFactory = new JobBuilderFactory(jobRepository);
stepBuilderFactory = new StepBuilderFactory(jobRepository, transactionManager);

Das MapJobRepository und der ResourcelessTransactionManager sind einfache Stub-Implemenierungen für das Framework. Sie eignen sich für einfache Batch-Jobs, die keine Datenbank und keine Transaktionen benötigen, oder für Unittests.

Bei Spring Batch umfasst jeder Job einen oder mehrere Schritte (Steps). In unserem Beispiel haben wir genau einen Step, der Daten mit einem CSV-Reader liest und sie sofort wieder mit einem JSON-Writer ausgibt:

Listing 17: Job konfigurieren

private Job createCopyJob() {
    ItemStreamReader<PersonData> reader = PersonReaderFactory
            .createPersonCsvReader(new ClassPathResource("Persons.csv"));
    ItemStreamWriter<PersonData> writer = PersonJsonWriterFactory
            .createPersonJsonWriter(new FileSystemResource("target/Persons.json"));
    TaskletStep step = stepBuilderFactory
            .get("copyStep")
            .<PersonData, PersonData>chunk(1)
            .reader(reader)
            .writer(writer)
            .build();
    return jobBuilderFactory.get("copyJob").start(step).build();
}

Um einen Job ausführen zu können, ist ein JobLauncher notwendig. Wir verwenden dafür den SimpleJobLauncher:

Listing 18: Launcher einrichten

var launcher = new SimpleJobLauncher();
launcher.setJobRepository(jobRepository);
launcher.afterPropertiesSet();

Nachdem nun alles eingerichtet ist, kann der Job direkt ausgeführt werden:

Listing 19: Job ausführen

// create the job
Job job = createCopyJob();
 
// launch the job
JobExecution execution = jobLauncher.run(job, new JobParameters());
 
// make sure job finished successfully
assertThat(execution.getAllFailureExceptions()).isEmpty();
assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED);

Der JobLauncher gibt eine JobExecution zurück, die über Erfolg oder eventuelle Ausführungsfehler informiert.

Wenn man das Spring-Framework einsetzt kann man sich natürlich den ganzen Setup-Code sparen. Dieser verbirgt sich hinter der @EnableBatchProcessing-Annotation. Unsere Beispielanwendung als Spring-Boot-Applikation sieht damit so aus:

Listing 20: Batch-Job als Spring-Boot-Applikation

@SpringBootApplication
@EnableBatchProcessing
public class CopyApp {
    @Autowired
    private JobBuilderFactory jobBuilderFactory;
    @Autowired
    private StepBuilderFactory stepBuilderFactory;
 
    public static void main(String[] args) {
        SpringApplication.run(CopyApp.class, args);
    }
 
    @Bean
    Job createCopyJob() {
        // ...
    }
}

Fazit

Gerade zum Verarbeiten oder Erstellen von strukturierten Textdateien bietet die Spring-Batch-Infrastruktur nützliche Hilfsmittel, die auch ohne das ganze Framework eingesetzt werden können. Natürlich bietet Spring Batch noch viele weitere Reader und Writer für diverse Persistenzframeworks oder Messagingsysteme. Deren Einsatz ist aber ohne Transaktionen, ausgeklügelte Fehlerbehandlung und evtl. Parallelverarbeitung wenig sinnvoll. In solchen Szenarien kann Spring Batch seine Stärken erst wirklich ausspielen, da lohnt es sich auf jeden Fall sich tiefer mit dem Framework zu beschäftigen. Dafür empfehle ich die hervorragende Referenzdokumentation zu Spring Batch [1] und das neu erschienene Buch "The Definitive Guide to Spring Batch" [2]. Auch der schon etwas ältere Artikel "Transactions in Spring Batch" [3] ist für das Verständnis der Transaktionsverwaltung von Spring Batch sehr zu empfehlen.

Quellen
  1. Referenzdokumentation zu Spring Batch
  2. Michael T. Minella: The Definitive Guide to Spring Batch
  3. Tobias Flohre: Transactions in Spring Batch – Part 1: The Basics

Alle Beispielsourcen zu diesem Artikel finden sie auf Github.

Autor
Kommentare (0)

Neuen Kommentar schreiben