Lesen und Schreiben von Dateien

1. Überblick

In Java existieren 2 unterschiedliche Ansätze, die u.a. zum Zugriff auf Dateien verwendet werden:

  • Der ältere, üblicherweise als Java I/O bezeichnete (Packages unter java.io)

  • Der jüngere, als New I/O bezeichnete (Packages unter java.nio)

Es ist auch das Mischen beider Ansätze möglich - es gibt Konvertierungsmethoden zwischen den beiden zentralen Klassen java.io.File und java.nio.file.Path, die jeweils eine relativ allgemeine Repräsentation einer Datei sowie eines Verzeichnisses (wie der allgemeine Begriff Dateisystem-Pfad) darstellen.

Wichtig zu wissen ist auch, dass beide Ansätze neben Dateizugriff auch Netzwerkzugriff und weiteres ermöglichen.

Hier eine Übersicht der funktionalen Zuordnungen zwischen den beiden Ansätzen: Legacy File I/O Code (The Java™ Tutorials > Essential Classes > Basic I/O) aus den Oracle Java Tutorials.

Für Lese- und Schreiboperationen verwendet java.nio in vielen Bereichen die Funktionalitäten des älteren Ansatzes - diese haben also nach wie vor Relevanz, werden aber bei komplexen Anforderungen ersetzt bzw. ergänzt.

Wir werden uns vorerst auf einfache Anforderungen beschränken und in dieser Beschreibung ausschließlich das ältere java.io nutzen.

Details siehe auch Java I/O (The Java™ Tutorials > Essential Classes > Basic I/O) aus den Oracle Java Tutorials.

2. Wichtige Grundkonzepte

Folgende Konzepte bilden die Eckpunkte:

  • Pfad: "Orts"-Angabe im Dateisystem

  • Datenstrom: diesen kann man als mehr oder weniger großes Reservoir von Daten verstehen, zu dem man eine Verbindung herstellen kann. Dann kann man sukzessive "abzapfen", bis nichts mehr da ist.

  • Basis-Elemente: Unterscheidung, ob man als Elemente "Atome" (Bytes) oder "Moleküle" (Characters) verwendet.

  • Buffer: Zugriff auf mehrere Basis-Elemente gemeinsam (meist starke Geschwindigkeits-Steigerung).

2.1. Pfade

Hier sind folgende Begriffe von Bedeutung:

Laufwerk (Drive)

Nur unter Windows verwendet. Linux, Mac OS, Android binden Laufwerke in einen einzigen "Dateisystem-Baum" ein

Ordner/Verzeichnis (Folder/Directory)

Bilden eine Verschachtelungs-Hierarchie. Enthalten Daten nur in Form enthaltener Dateien.

Datei (File)

Die eigentlichen Daten-Container

Endung/Erweiterung (Extension)

Dateinamen definieren (oft sehr grob) den Inhaltstyp.

Absoluter Pfad

Exakte Ortsbeschreibung im Dateisystem (Beginnend beim "Wurzelverzeichnis"/Root-Directory). in Windows werden die Elemente mit '\' getrennt, in Mac OS, Linux, Android mit '/'.

Aktuelles Arbeitsverzeichnis (current working directory)

Verzeichnis, "in dem man gerade ist" (wird für alle Operationen verwendet, die sich auf ein Verzeichnis)

Relativer Pfad

Pfad relativ zum einem anderen Dateisystem-"Ort" (oft aktuelles Arbeitsverzeichnis). Relative Pfade innerhalb von Projekten bleiben bei Verschieben des ganzen Projekts (als Verzeichnisstruktur) üblicherweise gültig, absolute Pfade müssen angepasst werden.

Navigationselemente

'.' steht für das aktuelle Verzeichnis, '..' für 'übergeordnetes Verzeichnis'., Environment-Variable (Betriebssystem-Ebene) und System-Properties (Java-Laufzeitsystem) enthalten diverse gespeicherte Pfade.

Pfad-Beispiele
  • ./ein-verzeichnis ist (fast immer) gleichbedeutend mit ein-verzeichnis

  • .. ins übergeordnete Verzeichnis

  • C:\Users\maier\eclipse Windows-Verzeichnis für Eclipse

  • /home/maier/eclipse Linux-Verzeichnis für Eclipse

  • jfx-app.css Einfachst-möglicher Pfad ist ein Dateiname im aktuellen Arbeitsverzeichnis

2.2. Datenströme

Dabei unterscheidet man zwei "Flussrichtungen":

  • in das Programm hinein (Benennungen: Input…​ bzw. Lesen=…​Reader)

  • aus dem Programm hinaus (Benennungen: Output…​ bzw. Schreiben=…​Writer)

2.3. Neu Schreiben oder am Ende Anhängen

Normalerweise wird eine Datei beim Öffnen zum Schreiben überschrieben, d.h. der Inhalt wird gelöscht und durch den neuen Inhalt ersetzt. Oft ist es aber sinnvoll, den Inhalt zu erhalten und am Ende anzuhängen. Dies ist mit dem Konstruktor-Parameter append für FileOutputStream und FileWriter möglich.

Für Lesen und Schreiben (auch beides) an bestimmten Stellen einer bestehenden Datei gibt es die Klasse java.io.RandomAccessFile. Diese wird aber hier nicht weiter besprochen.

2.4. Basis-Elemente

Die grundlegendste Zugriffsart erfolgt auf Basis von Bytes. Für Binärdateien ist dies angemessen (Bilder, Audio, Video, Softwarepakete, Archive, etc.).

Falls aber bekannt ist, dass der Inhalt textuell ist, hilft (außer bei Kopier-Operationen) ein einzelnes Byte nicht sehr viel, da die Bedeutung u.U. erst (je nach Encoding wie UTF-8, UTF-16, ASCII, ISO-LATIN-1/ISO-8859-1, EBCDIC, …​) durch Kombination mehrerer Bytes entsteht.

Darüber hinaus gibt es das Konzept von Worten/Tokens und von Zeilen, in höher strukturierten Texten (Menschliche Sprachen, HTML, XML, JSON, CSV, etc.) auch noch weitere logische Strukturen.

Neben dem Encoding ist auch die Codierung von Zeilenschaltungen in mehreren Variationen üblich:

  • In Windows ist eine Zeilenschaltung "traditionell" als 2 ASCII-Zeichen: dezimal 13, '\r' (carriage return) gefolgt von dezimal 10, '\n' (new line), oft gekennzeichnet als "CR/LF"

  • Unix, Linux, OS-X ist es nur ASCII dezimal 10, '\n' (new line).

  • Sehr alte Apple-Dateien verwenden noch nur das '\r' als Zeilenschaltung

2.5. Buffer

Je nach Natur des Datenträgers ist die Vorbereitung des Zugriffs aufwändig (extrem hoch bei Bändern, sehr hoch bei Festplatten, geringer bei SSDs, …​). Daher ist es immer sinnvoll, auf mehrere Bytes gemeinsam zuzugreifen. Darüber hinaus ist – zumindest bei Text-Dateien – die Möglichkeit gegeben, zeilenweise zu lesen.

3. Binär-Dateien

Das Arbeiten auf tiefster Ebene (Bytes) nutzt Klassen, deren Namen mit "Stream" enden und davor zur Dokumentation der "Richtung" entweder "Input" oder "Output" enthalten, ganz vorne eine endgültig klärende Bezeichnung der Aufgabe (z.B. FileInputStream, BufferedOutputStream, etc.).

Folgende Schritte sind nötig:

  • Erzeugen eines Objekts, das eine Datei repräsentiert (File)

  • Öffnen eines "…​InputStream" oder "…​OutputStream":
    Früher war das explizite Schließen nötig, mittlerweile gibt es "Try with Resources", wo in runden Klammern nach try alle automatisch zu schließenden Strukturen (implementieren Interface AutoCloseable) definiert werden

  • die gewünschten Lese- oder Schreib-Operationen

  • Das Exception-Handling (alternativ throws …​ im Methodenkopf)

3.1. Binär Lesen

import java.io.File;
import java.io.IOException;
import java.io.FileInputStream;
import java.io.BufferedInputStream;

public class BinaryFileReadDemo1 {

    private byte[] content;

    public void read1() throws IOException {
        //...
        String filePathTxt = "irgend/eine/DateiMit.endung";
        File file = new File(filePathTxt);
        try (FileInputStream fis = new FileInputStream(file);
                BufferedInputStream bis = new BufferedInputStream(fis);) {
            content = bis.readAllBytes();   // für nicht zu große Dateien sinnvoll
        }
        for (int i = 0; i < content.length; i++) {
            System.out.print((char)content[i]);
        }
    }
}

3.2. Binär Schreiben

import java.io.File;
import java.io.IOException;
import java.io.FileOutputStream;
import java.io.BufferedOutputStream;
import java.nio.charset.StandardCharsets;

public class BinaryFileWriteDemo1 {
    private byte[] contentBytes = "ProbeText für Binäres Schreiben\n... cool :-)\n".getBytes();

    public void write1() throws IOException {
        // Vorbereitung:
        String filePathTxt = "irgend/eine/Datei2-mit.endung";
        File file = new File(filePathTxt);
        System.out.format("Zu schreibende Datei: '%s'%n"
            + "===== Inhalt: =====%n%s%n===================%n",
            file.getAbsolutePath(), new String(contentBytes, StandardCharsets.UTF_8));

        // Datei öffnen und Schreiben
        try (FileOutputStream fos = new FileOutputStream(file);  // Roh-Zugriff
                BufferedOutputStream bos = new BufferedOutputStream(fos);) { // optimiert
            bos.write(contentBytes);   // für nicht zu große Dateien sinnvoll
        }
    }
}

4. Text-Dateien

4.1. Textuell Lesen mit BufferedReader

import java.io.File;
import java.io.IOException;
import java.io.FileReader;
import java.io.BufferedReader;
import java.util.List;
import java.util.ArrayList;

public class TextFileReadDemo1 {

    private List<String> contentLines = new ArrayList<>();

    public void read1() throws IOException {
        contentLines.clear();   // damit bei Mehrfach-Aufruf kein Mehrfach-Add erfolgt

        // Vorbereitung:
        String filePathTxt = "irgend/eine/DateiMit.endung";
        File file = new File(filePathTxt);
        System.out.format("Zu lesende Datei: '%s'%n", file.getAbsolutePath());

        // Zeilenweises Lesen, Ignorieren von Zeilen ohne Inhalt bzw. mit Kommentar:
        try (FileReader fr = new FileReader(file);
                BufferedReader br = new BufferedReader(fr);) {
            String line;
            int idx = 0;
            while ((line = br.readLine()) != null) {
                if (line.isBlank() || line.startsWith("#")) {
                    continue;   // Zeile ignorieren, gleich zur nächsten Runde
                }
                contentLines.add(line);
                idx++;
            }
        }

        // Kontroll-Ausgabe:
        for (int i = 0; i < contentLines.size(); i++) {
            System.out.format("Line %2d: %s%n", i+1, contentLines.get(i));
        }
    }
}

4.2. Textuell Schreiben mit 'FileWriter'

Hier wird als Basis der FileWriter(..) verwendet. Solange nur eine einzige Schreib-Operation erfolgt, ist ein "umhüllender" BufferedWriter nicht sinnvoll.

Gesamten Text mit einer einzigen Schreib-Operation:
import java.io.File;
import java.io.IOException;
import java.io.FileWriter;

public class TextFileWriteDemo1 {
    public void write2(File file, String txt) throws IOException {
        // Vorbereitung:
        System.out.format("===== Zu lesende Datei: '%s'%n: =====", file.getAbsolutePath());
        if (txt.length() > 80) {
            System.out.println(txt.substring(0,77) + " ...");  // Zeilenschaltungen ignoriert
        } else {
            System.out.println(txt);
        }
        // Zeilenweises Schreiben:
        try (FileWriter fw = new FileWriter(file)) {
            fw.write(txt);
        }
    }
}

Wenn Text in kleinen Stücken vorliegt (z.B. in Zeilenliste), reduziert der umhüllende BufferedWriter die Anzahl der "teuren" Schreiboperationen (Kooperation mit dem Betriebssystem, dort erfordern Dateizugriffe erheblichen Vor- und Nachbereitungsaufwand):

Text in mehreren Schreib-Operationen mit BufferedWriter:
import java.io.File;
import java.io.IOException;
import java.io.FileWriter;

public class TextFileWriteDemo1 {
    public void write2(File file, String txt) throws IOException {
        // Vorbereitung:
        System.out.format("===== Zu schreibende Datei: '%s': =====%n", file.getAbsolutePath());
        if (txt.length() > 80) {
            System.out.println(txt.substring(0,77) + " ...");  // Zeilenschaltungen ignoriert
        } else {
            System.out.println(txt);
        }
        // Zeilenweises Schreiben:
        try (FileWriter fw = new FileWriter(file)) {  // append=true als par2 bei Bedarf
            fw.write(txt);
        }
    }
}

Eine komfortable Möglichkeit in eine Datei zu schreiben ist der PrintWriter (aus java.io), der alle Möglichkeiten bietet, die von System.out.println() und "Verwandten" wie System.out.format("", …​) bekannt sind.
Wenn an bestehende Datei am Ende weiterer Text angefügt werden soll (wie hier an eine Log-Datei), wird ein anderer Konstruktor für den FileWriter verwendet, der einen 2. Parameter append vom Typ boolean erwartet.

Text in mehreren Schreib-Operationen mit PrintWriter:
    public static final File ERRLOG_FILE = new File("err.log");

    public void writeToLog(String msg) throws IOException {
        System.out.println("... ERRLOG-Eintrag!");
        try (FileWriter fr = new FileWriter(ERRLOG_FILE, true); //par2 true: append to endOfFile
                PrintWriter pr = new PrintWriter(fr)) {
            //pr.format("%s: %s%n", LocalDateTime.now(), msg);
            pr.println(msg);
        }
    }