Autor: 'Kriz'
Wichtiger Hinweis!!!
Die Grundlagen über die Java-Programmierung ist nicht Bestandteil der Tutorialreihe! Zum Lernen von Java empfehle ich das hervorragende Online-Tutorial von Guido Krüger, welches man sich kostenlos von www.javabuch.de runterladen kann.Außerdem verweise ich auch auf die ebenfalls umfangreiche und sehr gute Online-Tutorialreihe von Galileo Computing, das man sich ebenfalls kostenlos hier herunterladen kann.
In diesem Tutorial geht es um das Entwickeln einer Klasse, die man für permanentes Fehlerlogging einsetzen kann. Das Loggen von Laufzeitfehlern ist bei vielen Programmen heutzutage schon Standard, weshalb sollte man also seine Applikation nicht auch mit einem solche Feature ausstatten.
Die Vorteile sind klar: Der Benutzer (und auch der Entwickler der Applikation) wird über Laufzeitfehler, die durch das Design nicht zu einer erzwungenen Terminierung des Prozesses führen, in Form einer Fehlerprotokolldatei informiert. Je nach Detailgrad der Fehlerinformation kann man sofort erkennen, welcher Fehler wo und warum aufgetreten ist. Wenn man ganz benutzerorientiert arbeiten möchte, kann man auch eine Funktionalität implementieren, die das Fehlerprotokoll per E-Mail oder HTTP ect. an den Entwickler schickt.
Permanentes Fehlerlogging setzt gewisse Dinge voraus, die man im Vorfeld beim Design der Applikation beachten muß:
Schauen wir uns zu jedem Punkt einige Gedankengänge an, die uns später die Implementierung erleichtern werden.
Zu Punkt 1 erlaubt Java nur eine Möglichkeit, nämlich das Design einer statischen Klasse. Statische Klassen sind permanent existierende Instanzen, die man nicht extra mit dem new Operator alloziieren muß. Sie werden beim Erzeugen der Applikation von der Java Virtual Machine einmalig instanziert und erst beim Beenden der Applikation wieder zerstört.
Permanent bedeutet speziell bei Java, daß nicht die Klasse selbst statisch ist, sondern nur alle in ihr enthaltenen Membervariablen und Methoden. Das erreicht man, indem man jeder Membervariablen und jeder Methode den Qualifizierer static verpasst. Durch die Dereferenzierung über den Klassennamen kann man so jederzeit und überall in der Applikation alle öffentlichen Elemente aufrufen (vorausgesetzt das ggf. entsprechende Paket wurde vorher importiert).
Punkt 2 hört sich erstmal unlösbar an. Wie soll eine Fehlerlogging Klasse eigene Fehler loggen können, wenn diese Fehler das Loggen quasi abbrechen? Die Antwort lautet: Garnicht. Zumindest nicht als Ausgabe in eine Protokolldatei. Hier muß man auf die gegebenen Fehlerkanäle von Java zurückgreifen, sprich die Standardfehlerausgabe benutzen, die einen String über STDERR ausgibt (meistens der Bildschirm bzw. die Konsole).
Bei Punkt 3 sollte man redundante oder unnötige Kommunikation mit einer Fehlerlogging Klasse unterlassen. Der Grund ist der, daß alle statischen Membervariablen und Methoden permanent im Speicher vorhanden sind. Viele Membervariablen und Methoden belasten so nur den Speicher, was bei umfangreichen Applikationen mit komplexen Datenstrukturen und Operationen nicht sehr vorteilhaft ist. Man sollte sich auf die wesentlichen Dinge beschränken, also sowas wie eine Ausgabemethode in die Protokolldatei usw.
Außerdem sollte man die Fehlerlogging Klasse final deklarieren. So kann man zwar nicht mehr von ihr ableiten, aber dafür erhöht sich die Abarbeitungsgeschwindigkeit gegenüber nichtfinalen Klassen (in Java sind alle Klasse von Natur erstmal virtuell deklariert, d.h. die Java Virtual Machine muß eine Virtualitätstabelle führen, um polymorphe Zusammenhänge zwischen Vater- und Kindklasse organisieren zu können. Finale Klassen dagegen sind nichtvirtuell und ersparen dem Interpreter diesen Verwaltungskram.).
Punkt 4 ist dank Java wesentlich einfach. Die auszugebenden Texte in die Protokolldatei werden einfach über einen Characterstream geleitet. Das hat zwar den Nachteil, daß Java erst alle Texte von UNICODE nach ASCII konvertieren muß, gleichzeitig garantiert man aber auch, daß Java alle Texte der lokalen Spracheinstellung des Systems entsprechend in die Protokolldatei ausgibt. Umlaute usw. werden so ohne Probleme verwertet.
Das Basisdesign der Fehlerlogging Klasse sieht erstmal ganz primitiv so aus:
public final ErrorLog { }
Wie man sieht, sieht man (noch) nichts. Was erstmal fehlt sind Ressourcen für einen Dateizugriff. Diese werden in Java über die sogenannten Writerklassen ermöglicht. Für das Öffnen und Verwalten eines Dateiausgabestreams ist in Java die Klasse FileWriter zuständig. Diese erwartet beim Konstruieren einer Instanz entweder einen Dateinamen als String, ein entsprechendes File Objekt oder eine Instanz vom Typ FileDescriptor. Wir wollen uns auf einen festen Dateinamen einigen mit dem Namen errorlog.txt. Hier erweist es sich als vorteilhaft, wenn man anstatt des Dateinamens als String ein File Objekt übergibt, denn anhand des File Objekts kann man später investigative Operationen auf eine Datei durchführen (z.B. prüfen, ob die Datei bereits existiert usw.).
Wie bereits besprochen müssen FileWriter und File Objekt statisch deklariert werden. Das File Objekt wird zudem nur einmal erzeugt und danach nicht mehr verändert, weshalb wir es auch zusätzlich noch als final deklarieren können (entspricht der const Deklaration in C++). Der Zugriff auf diese Membervariablen ist von außerhalb nicht gestattet, daher werden beide Elemente private deklariert (protected ist in diesem Falle zwar auch möglich, aber sinnlos, da die Klasse sowieso final ist und nicht mehr abgeleitet werden kann).
import java.io.*; // I/O Unterstützung public final ErrorLog { private static final File datei = new File("errorlog.txt"); private static FileWriter fw = null; }
Warum habe ich das FileWriter Objekt fw auf null gesetzt? Ein Blick in die Java API zeigt, daß der Konstruktor von FileWriter im Falle eines Fehlers eine Ausnahme vom Typ IOException wirft. Da man Ausnahmen nicht auf Klassenebene abfangen kann, sondern nur auf Methodenebene, muß ich also mit der eigentlichen Instanzierung von fw noch warten.
So, nun kommen wir zum nächsten Schritt. Ein FileWriter alleine macht nichts anderes, als einen Ausgabekanal auf eine Datei zu öffnen und zu verwalten. Der Stream, der durch den FileWriter verwaltet wird, benötigt daher noch Input von außerhalb: Was soll in die Datei geschrieben werden? Wie erwähnt, soll ein Characterstream für die Ausgabe herhalten. Java bietet mehrere Sorten von Characterstreams an, wobei die meisten von ihnen nur spezialisierte Klassen für bestimmte Probleme sind.
Um Strings kümmert sich in diesem Fall eine raffinierte Klasse namens BufferedWriter, die Strings ähnlich wie ein BufferedString Objekt erstmal zwischenspeichert, bis der Puffer geleert werden soll. Das ist ein großer Vorteil gegenüber einen ungepufferten Characterstream, der sofort die Ausgaben weiterleitet. Je nach Anwendung können ungepufferte Characterstreams mit ihren permanenten Zugriffen auf das Ausgabemedium eine ganze Applikation ins Stocken bringen. Java ist übrigens für dieses Phänomen sehr anfällig, weshalb der Einsatz eines gepufferten Characterstreams absolute Pflicht ist!
Nun gut, der Konstruktor von BufferedWriter erwartet ein Objekt der Klasse FileWriter, damit die Klasse weiß, wohin die Daten weitergeleitet werden sollen. Genau wie FileWriter ist auch BufferedWriter mit einem Konstruktor gesegnet, der im Falle eines Fehlers eine IOException Ausnahme wirft. Daher muß auch die BufferedWriter Membervariable erstmal mit null gleichgesetzt werden, ehe eine Methode die Ausnahmen behandeln kann:
import java.io.*; // I/O Unterstützung public final ErrorLog { private static final File datei = new File("errorlog.txt"); private static FileWriter fw = null; private static BufferedWriter bw = null; }
Ok, die wichtigsten Membervariablen haben wir nun. Kommen wir daher zu den Methoden, mit denen wir Fehlermeldungen in die Protokolldatei schreiben können. Das diese Methoden ebenfalls static deklariert sein müssen, dürfte wohl mittlerweile klar sein (allerdings public, sonst können wir ja nicht darauf zugreifen). Ich vereinfache das Ganze mal dahingehend, daß wir nur einen einfachen String in die Protokolldatei schreiben wollen. Eine sinnvolle Methode wäre daher sowas wie:
public static void write(String s)
Soweit, sogut. Angenommen, diese Methode write() könnte bereits Strings in die Datei ausgeben. Aber woher weiß die Methode eigentlich, ob die nötigen Zugriffsobjekte FileWriter und BuffereWriter bereits instanziert worden sind? Bisher existieren die Membervariablen nur als null-Referenzen und nicht als konkrete Instanzen. Die Lösung lautet: Die Methode write() muß selber Sorge dafür tragen, daß die benötigten Ressourcen soweit aktiv sind. Das geht in Java ganz simpel dadurch, indem man prüft, ob die Membervariablen noch null sind oder nicht. Sind sie null, so müssen sie erst erzeugt werden, ansonsten existieren sie bereits und können sofort verwendet werden:
public static void write(String s) { if(fw == null && bw == null) { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } }
Es wird also geprüft, ob beide Writerobjekte noch null sind. Ist dies der Fall, so werden sie konkret im if-Block alloziiert. Der FileWriter Konstruktor bekommt als Argument das statisch-finale File Objekt übergeben und der BufferedWriter Konstruktor seinerseits das soeben erzeugte FileWriter Objekt. Wie erwähnt schmeißen beide Konstruktoren im Falle eines Fehlers eine Ausnahme vom Typ IOException. Diese kann man entweder an den Aufrufer der Methode weiterreichen, indem man die Methode so umschreibt:
public static void write(String s) throws IOException { if(fw == null && bw == null) { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } }
oder man arbeitet eine mögliche Ausnahmebehandlung direkt im Methodenblock ab:
public static void write(String s) { if(fw == null && bw == null) { try { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } catch(IOException exc) { } } }
Wir wählen die zweite Lösung und zwar aus folgendem Grund: Wenn die erste Lösung benutzt wird, so muß jeder (!) Aufruf von ErrorLog.write() in einem eigenen try/catch Block behandelt werden oder der Bereich, der ErrorLog.write() aufruft, muß seinerseits wieder eine IOException Ausnahme weiterreichen. Damit kann man sich designerisch so einiges versauen und sehr praktikabel wird das letztendlich nicht. An dieser Stelle wird übrigens Punkt 2 behandelt.
Um nun irgendwie einen möglichen Fehler abzufangen, kann die Methode nichts anderes machen, als den Fehler ihrerseits über die standardisierten Fehlerausgabekanäle von Java an den Benutzer zu melden. Das geschieht ganz einfach, indem wir die statische Systemmethode System.err.println() aufrufen und als Argument den Fehlerstring des Ausnahmeobjekts übergeben:
public static void write(String s) { if(fw == null && bw == null) { try { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } }
Die Methode getLocalizedMessage() liefert eine (falls möglich) übersetze Fehlermeldung als String zurück. Dieser wird dann auf der Standardfehlerausgabe von Java ausgegeben. An dieser Stelle kann man sich entscheiden, ob dieser Art von Fehler schwerwiegender Natur ist oder eher nebensächlich. Schwerwiegende Fehler haben oftmals den Abbruch der Applikation zur Folge, was an dieser Stelle aber arg übertrieben wäre. Hier reicht es, den Fehler auszugeben und weiterzumachen. Schließlich ist die Fehlerlogging Klasse ja kein essenzieller Bestandteil mit tiefgreifenden Funktionen, sondern eher eine Supporttechnik, ohne die die Applikation auch gut leben kann.
So, angenommen alles verläuft glatt und die Instanzierung der beiden Writer Objekte ist erfolgreich gewesen. Die Klasse kann nun also auf die geöffnete (und eventuell erzeugte Datei) errorlog.txt zugreifen und Texte reinschreiben. Das geht wie folgt:
BufferedWriter übernimmt einen unformatierten String und speichert diesen zwischen. Unformatiert bedeutet, daß im String keine Zeilenumbrüche wie LF (\n) oder CR (\r) auftauchen dürfen. Da der String als Rohmaterial in die Datei geschrieben wird, werden diese Zeichen auch nicht vorher in das betriebssystemspezifische Format umgewandelt! Was bedeutet das? Nun ja, Windows kennt seit Anbeginn als Zeilenumbruch die Zeichensequenz CR+LF, UNIX dagegen nur LF und Apple wiederum nur CR. Wenn man später die Protokolldatei lesen möchte, könnte das zu einer bösen Überraschung werden, wenn das Betriebssystem auf Zeilenumbrüche stößt, die es nicht korrekt interpretieren und wiedergeben kann. Daher ist der Einsatz solcher Zeichen verboten. Als Ersatz bietet BufferedWriter die Methode newLine() an, die einen exakt auf das Betriebssystem zugeschnittenen Zeilenumbruch in den Puffer schreibt.
Wir erweitern also unsere Methode write() wie folgt:
public static void write(String s) { if(fw == null && bw == null) { try { fw = new FileWriter(datei); bw = new BufferedWriter(fw); bw.write(s); // String schreiben bw.newLine(); // Danach einen Zeilenumbruch durchführen } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } }
Moment mal! Dieser Aufbau ist extrem sinnfrei! Es würden nur Ausgaben geschrieben, wenn die beiden Writer Objekte null wären. Bei bereits existierenden Objekten würde rein garnichts passieren. Also müssen wir die if-Abfrage um einen else Block erweitern und dort die Ausgaben schreiben lassen:
public static void write(String s) { if(fw == null && bw == null) { try { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } else { bw.write(s); // String schreiben bw.newLine(); // Danach einen Zeilenumbruch durchführen } }
Hm… Das stimmt aber immernoch etwas nicht! Dummerweise erzeugen die beiden Methoden write() und newLine() ihrerseits wieder IOException Ausnahmen, falls Fehler auftauchen sollten. Wir müssen diese also auch in einen try/catch Block einbetten:
public static void write(String s) { if(fw == null && bw == null) { try { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } else { try { bw.write(s); // String schreiben bw.newLine(); // Danach einen Zeilenumbruch durchführen } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } }
Nun ist alles richtig. Fast richtig. Der geübte Java Programmierer erkennt sofort: Halt, hier ist ein try/catch Block überflüssig! Indem man die if-Abfrage direkt in einen großen try/catch Block einbetten, erspart man sich zwei einzelne Blöcke:
public static void write(String s) { try { if(fw == null && bw == null) { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } else { bw.write(s); // String schreiben bw.newLine(); // Danach einen Zeilenumbruch durchführen } } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } }
Perfekt :)
Nun gibt es nur noch ein Problem zu lösen, nämlich wie man die geöffneten Streams wieder schließt, da die Ausgabe in die Protokolldatei erst dann erfolgt, wenn das BufferedWriter Objekt geschlossen wird. Hier kann man leider erstmal nichts automatisieren. Würde man das Schließen des Streams direkt in der write() Methode realisieren, gäbe es mitunter bei einem sehr häufigen Aufrufen von write() ein ewiges Erzeugen/Öffnen/Schreiben/Schließen der Protokolldatei. Das ist nicht sehr effizient. Da BufferedWriter darüberhinaus jedesmal den Dateiinhalt löscht, wenn ein neuer Stream geöffnet wird, darf solange die Applikation läuft der Ausgabestream nicht geschlossen werden. Erst am Ende der Applikation sollte als letztes explizit der BufferedWriter Stream geschlossen werden. Hierfür bauen wir noch eine Zusatzmethode namens close() in unsere ErrorLog Klasse ein, die das Schließen der Streams übernimmt. Auch hier wirft bw.close() eine Ausnahme vom Typ IOException, die wir gekonnt abfangen:
public static boolean close() { if(fw == null && bw == null) return false; else { try { bw.close(); bw = null; fw = null; return true; } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); return false; } } }
Wenn die Streams noch nicht existieren, wird false zurückgegeben, andernfalls wird der BuffereWriter geschlossen, die Datei geschrieben (das eingebettete FileWriter Objekt wird automatisch mitgelöscht) und true zurückgegeben. Wenn eine Exception auftritt, wird gewungenermaßen false zurückgegeben (ansonsten meckert der Compiler wegen fehlendem Rückgabewert).
Die folgende Testklasse demonstriert unserer ErrorLog Funktionalität:
import java.io.*; public class Test { public static void main(String[] args) { int a = 5, b = 7; if(a < b) { ErrorLog.write("Fehler: a ist kleiner als b!"); } ErrorLog.close(); } } class ErrorLog { private static final File datei = new File("errorlog.txt"); private static FileWriter fw = null; private static BufferedWriter bw = null; public static void write(String s) { try { if(fw == null && bw == null) { fw = new FileWriter(datei); bw = new BufferedWriter(fw); } bw.write(s); bw.newLine(); } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); } } public static boolean close() { if(fw == null && bw == null) return false; else { try { bw.close(); bw = null; fw = null; return true; } catch(IOException exc) { System.err.println(exc.getLocalizedMessage()); return false; } } } }
Allgegenwärtiges muß in Java stets statisch deklariert werden, damit es auch überall und jederzeit erreichbar ist. Bei der Handhabung von statischen Aktionen muß man speziell im Klassendesign alle Eventualitäten berücksichtigen. In diesem Falle einer Fehlerlogging Klasse sind die Ausnahmebehandlungen der entsprechenden Writer Objekte direkt in den Methoden zu behandeln, da eine Weiterreichung der Ausnahmen an die Aufrufer zu einem sehr unübersichtlichen Quellcode führen würde.
Aber Achtung! Nicht jede Trivialität wie diese simple Fehlerlogging Klasse kann so entworfen werden! Bei manchen Ideen müssen entsprechende Ausnahmen weitergegeben werden, sonst drohen je nach Art und Ausmaß Dateninkonsistenzen, Datenverlußt oder fatale Programmabstürze!
Außerdem darf man nie vergessen: Statisches Klassendesign beansprucht permanent Speicherplatz zur Laufzeit! Bei sehr umfangreichen und komplexen Klassen schwillt der Speicherbedarf von Java stark an! Besonders im Zusammenhang mit Swing kann dadurch sehr viel RAM beansprucht werden, welcher woanders wieder fehlt…
Dieses Tutorial stammt aus der ehemaligen Sammlung des resourcecode.de und konnte dank der freundlichen Zustimmung des Autors in das thewall-Wiki übertragen werden.