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 mitein-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 nachtry
alle automatisch zu schließenden Strukturen (implementieren InterfaceAutoCloseable
) 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.
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):
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.
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);
}
}