W dzisiejszym poście chcielibyśmy porozmawiać o operacjach wejścia wyjścia w Javie. Do tej pory nasze programy były zamknięte na świat, nie wykonywały żadnej komunikacji poza wyświetlaniem w konsoli informacji dla użytkownika i wczytywanie wprowadzonych przez niego danych. Jednak to zdecydowanie za mało, nasz program musi umieć robić takie rzeczy jak obsługa plików, komunikacja z bazami danych, przesyłanie i odbieranie danych po sieci czy integracja z innymi systemami lub urządzeniami.
Standardowe wejście i wyjście
Zaczniemy od standardowego wejścia i wyjścia w systemie operacyjnym. W naszym pierwszym wyzwaniu napisaliśmy taki program:
package pl.kodolamacz;
import java.util.Scanner;
public class MyFirstJavaApplication {
public static void main(String[] args) {
System.out.println("Witaj świecie!");
Scanner scanner = new Scanner(System.in); // przekazujemy standardowe wejście (System.in)
String userString = scanner.nextLine();
scanner.close();
System.out.println("Twój napis to: " + userString); // korzystamy ze standardowego wyjścia (System.out)
}
}
Wykorzystaliśmy w nim klasę Scanner do komunikacji z użytkownikiem, dokładnie pobrania od niego imienia i wyświetlenia go w konsoli. Jednak klasa Scanner komunikuje się z systemem operacyjnym by pobrać wpisany przez użytkownika tekst w terminalu, dlatego korzysta ze specjalnego strumienia systemowego, tak zwanych standardowych strumieni.
Strumienie są właśnie mechanizmem komunikacji z innymi zasobami systemowymi i przesyłanie między naszym programem a nimi danych. Strumień to tak naprawdę sekwencja bajtów.
Na diagramie wyżej widzimy, że takie standardowe strumienie są w systemie trzy, standardowe wejście gdzie pobieramy informacje z klawiatury, standardowe wyjście gdzie wyświetlamy coś na ekranie oraz standardowe wyjście błędów gdzie także wyświetlamy coś użytkownikowi, ale są to informacje o błędach.
Zamykanie strumieni
Wiele osób pytało, dlaczego w naszym kodzie musimy użyć metody scanner.close. Wtedy było za wcześnie by to wytłumaczyć, więc zrobię to teraz.
W artykule o świecie Javy opisałem, że dzięki specjalnemu “odśmiecaczowi” (garbage collector) Java sama zarządza pamięcią operacyjną i tutaj, w przeciwieństwie do niektórych języków, nie musimy tej pamięci ani alokować ani dealokować, czyli zwalniać na inne potrzeby. Ze strumieniami jest jednak inaczej, bo często dotyczą zasobów typu Input/Output inaczej IO, czyli wejścia wyjścia, gdzie korzystamy z zasobów systemowych, które trzeba zamykać. Jeśli tego nie zrobimy możemy spowodować problem zwany “Resource leak” czyli wyciek zasobów. Dlatego wszystkie zasoby inne niż pamięć takie jak uchwyty do plików, otwarte sockety i tym podobne, trzeba samodzielnie zamykać. Zwłaszcza, że systemy operacyjne pozwalają zwykle na ograniczoną ilość otwartych zasobów systemowych. Oczywiście, sam system operacyjny, a dokładnie jego kernel czyli jądro systemu, może uznać, że jakiś zasób zostanie zamknięty, nawet jeśli program sam tego nie zrobił, ale zwykle dzieje się to dopiero gdy nasz program zostanie wyłączony. W naszym krótkim programie nic takiego wielkiego się nie stanie, jeśli tego nie zrobimy, ale trzeba pamiętać, że w Javie tworzy się często oprogramowanie, które działa miesiącami lub nawet latami bez restartu i wtedy prawidłowa obsługa strumieni jest niezwykle ważna.
W drugą stronę, jeśli spróbujemy zamknąć strumień dwukrotnie, lub wykonać jakąś akcję na nim po zamknięciu dostaniemy wyjątek “java.lang.IllegalStateException” lub podobny.
Obsługa wyjątków
Zanim jednak pójdziemy dalej, musimy powiedzieć sobie o jeszcze jednej ważnej rzeczy, mianowicie obsłudze wyjątków. Wyjątek to informacja, że w programie “coś poszło nie tak”. Problemem może być nasz kod, czyli typowy błąd programisty, ale niektóre błędy nie zależą od nas, na przykład chcemy odczytać plik który nie istnieje. W takiej sytuacji program wyrzuci wyjątek mówiący o tym, że nie odnaleziono pliku. Takie wyjątki, możemy także sami “wyrzucać” (ang. throw), jeśli napotkamy sytuację nieprawidłową, np. gdy użytkownik próbuje podzielić przez zero w naszym kalkulatorze.
Wyjątki w Javie to też jakieś obiekty, które dziedziczą po klasie java.lang.Exception. Klasa Exception dziedziczy zaś po klasie java.lang.Throwable. Nie zagłębiając się zbytnio w szczegóły, nasze wyjątki powinny dziedziczyć po klasie Exception a nie Throwable, dlatego, że ta druga klasa jest także klasą po której dziedziczy klasa java.lang.Error, która oznacza błędy krytyczne, np. związane z maszyną wirtualną, których to wyjątków także zwykła aplikacja nie obsługuje (łapie).
Skoro jednak Java “wyrzuci” nam wyjątek, to musimy umieć go “złapać” (ang. catch). Służą do tego składnia Try-Catch lub jej rozszerzenie Try-Catch-Finally (ewentualnie można też Try-Finally):
try {
// tutaj jakiś kod który może wyrzucić wyjątek
} catch (Exception e){
// ten kod zadziała tylko gdy będzie wyjątek
} finally {
// tutaj robimy coś na koniec
}
W Javie 7 wprowadzono jeszcze jedną wersję obsługi błędów, mianowicie tak zwany try-with-resources, który pokażę za chwilę na przykładzie.
Pamiętajmy, że łapiemy klasę Exception lub te które po niej dziedziczą, a nie klasę Throwable!
Wyjątki w Javie dzielą się także na te które musimy “łapać” (checked) oraz takie których nie trzeba łapać (unchecked). Te pierwsze dziedziczą po klasie Exception zaś te drugie po klasie RuntimeException (która sama dziedziczy po Exception). Więcej można o tej decyzji przeczytać tutaj.
Jeśli byśmy kiedyś chcieli stworzyć własną metodę która wyrzuca wyjątek, powinniśmy użyć wtedy słowa throws mówiącego jakie wyjątki ona “może” wyrzucić, oraz słowa throw gdy coś pójdzie nie tak i będziemy chcieli ten wyjątek wyrzucić:
public void thisMethodThrowsException() throws Exception {
throw new Exception("Message...");
}
Obsługa plików
W tym wyzwaniu skupimy się na obsłudze plików. Wykorzystamy tutaj zarówno API dostępne od pierwszych wersji Javy jak i nowe API zwane NIO (non-blocking I/O lub new I/O) wprowadzone w Javie 7.
Ścieżka do pliku
Pierwsze co nam będzie potrzebne, do ścieżka do pliku, czyli jego lokalizacja. W NIO służy do tego klasa Path:
Path pathToDirectory = Paths.get("/tmp/kodolamacz");
Path pathToFile = Paths.get("/tmp/kodolamacz/hello.txt")
Odczyt plików
Teraz możemy odczytać zawartość pliku z podanej ścieżki. Wystarczy użyć do tego klasy Files:
byte[] bytes = Files.readAllBytes(pathToFile);
Metoda zwraca nam zawartość w postaci tablicy bajtów. Gdy wiemy że ten plik jest tekstowy (u mnie taki jest), możemy przekonwertować to na obiekt klasy String za pomocą:
String fileContent = new String(bytes,Charset.defaultCharset());
Musimy jednak tam przekazać kodowanie naszego pliku. W powyższym przykładzie zakładamy, że plik jest zapisany w domyślnym kodowaniu.
Cały nasz program będzie wyglądał tak:
package pl.kodolamacz.io;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class InputOutputExample {
public static void main(String[] args) {
Path pathToDirectory = Paths.get("/tmp/kodolamacz");
Path pathToFile = Paths.get("/tmp/kodolamacz/hello.txt");
try {
byte[] bytes = Files.readAllBytes(pathToFile);
String fileContent = new String(bytes,Charset.defaultCharset());
System.out.println("Zwartość pliku: ");
System.out.println(fileContent);
} catch (Exception e){
// ten kod zadziała tylko gdy będzie wyjątek
System.out.println("Dostaliśmy błąd obsługi pliku: " + e.getMessage());
} finally {
// tutaj robimy coś na koniec
}
}
}
Zapis do pliku
Jeśli nasz program zmienia zawartość pliku, możemy chcieć go na koniec zapisać. Nic prostszego, tutaj także wystarczy użyć klasy Files:
fileContent += " więcej tekstu..."; // dopisujemy coś do pliku
Files.write(pathToFile, fileContent.getBytes(Charset.defaultCharset()), StandardOpenOption.APPEND);
Nasz program ostatecznie będzie wyglądał tak:
package pl.kodolamacz.io;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class InputOutputExample {
public static void main(String[] args) {
Path pathToFile = Paths.get("/tmp/kodolamacz/hello.txt");
try {
byte[] bytes = Files.readAllBytes(pathToFile);
String fileContent = new String(bytes, Charset.defaultCharset());
System.out.println("Aktualna zawartość pliku: ");
System.out.println(fileContent);
fileContent += " więcej tekstu..."; // dopisujemy coś do pliku
Files.write(pathToFile, fileContent.getBytes(Charset.defaultCharset()));
} catch (Exception e) {
// ten kod zadziała tylko gdy będzie wyjątek
System.out.println("Dostaliśmy błąd obsługi pliku: " + e.getMessage());
}
}
}
Istnieje także możliwość wczytania naszego pliku jako listy linii tekstu a następnie ich zapisu jak w poniższym przykładzie:
package pl.kodolamacz.io;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
public class InputOutputExample2 {
public static void main(String[] args) {
Path pathToFile = Paths.get("/tmp/kodolamacz/hello.txt");
try {
List<String> lines = Files.readAllLines(pathToFile);
System.out.println("Aktualna zawartość pliku: ");
System.out.println(lines);
lines.add(" więcej tekstu..."); // dopisujemy coś do pliku
Files.write(pathToFile, lines);
} catch (Exception e) {
// ten kod zadziała tylko gdy będzie wyjątek
System.out.println("Dostaliśmy błąd obsługi pliku: " + e.getMessage());
}
}
}
Istnieje także możliwość użycia do tego Stream API z Java 8 (nie mylić ze strumieniami danych opisanymi powyżej), ale temu poświęcimy kolejne wyzwanie.
Jednak z powyższymi metodami należy uważać gdy pliki są bardzo duże, bo w całości są wczytywane do pamięci. Jeśli nasz plik jest duży lepiej użyć strumienia do pliku i czytać go partiami, tak by w naszym programie nie zabrakło pamięci RAM, co mogłoby zawiesić lub zamknąć program.
To samo tyczy się także plików binarnych, wtedy także nie możemy użyć powyższych metod.
Odczyt strumieniowy plików
Musimy wtedy użyć bardziej niskopoziomowego (“surowego”) API i skorzystać z takich klas jak FileInputStream, FileReader, BufferedReader lub Scanner. Dwie pierwsze pracują wprost na bajtach, stąd wczytywania znak po znaku, kolejne już potrafią wczytać całe linie lub słowa. Poniżej przykład użycia tych czterech klas:
package pl.kodolamacz.io;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.util.Scanner;
public class InputOutputExample3 {
public static void main(String args[]) {
String path = "/tmp/kodolamacz/hello.txt";
System.out.println("Przykład nr 1 - FileInputStream");
try (FileInputStream inputStream = new FileInputStream(path)) {
int character = inputStream.read();
while (character != -1) {
System.out.print((char) character);
character = inputStream.read();
}
System.out.println(); // kończymy linię i zaczynamy nową
} catch (IOException e) {
System.out.println("Problem z odczytem pliku");
e.printStackTrace();
}
System.out.println("Przykład nr 2 - FileReader");
try (FileReader fileReader = new FileReader(path)) {
int character = fileReader.read();
while (character != -1) {
System.out.print((char) character);
character = fileReader.read();
}
System.out.println(); // kończymy linię i zaczynamy nową
} catch (IOException io) {
System.out.println("Problem z odczytem pliku");
io.printStackTrace();
}
System.out.println("Przykład nr 3 - BufferedReader");
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(path))) {
String line = bufferedReader.readLine();
while (line != null) {
System.out.println(line);
line = bufferedReader.readLine();
}
} catch (IOException io) {
System.out.println("Problem z odczytem pliku");
io.printStackTrace();
}
System.out.println("Przykład nr 4 - Scanner");
try (Scanner in = new Scanner(new FileReader(path))) {
while (in.hasNext()) {
String next = in.next();
System.out.print(next + " ");
}
} catch (IOException io) {
System.out.println("Problem z odczytem pliku");
io.printStackTrace();
}
}
}
W powyższym kodzie została użyta nowa składnia try-with-resources wprowadzona w Javie 7. Pozwala ona na otwarcie strumienia we wnętrzu wyrażenia try:
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(path))) {
co powoduje, że Java sama na koniec zamknie ten strumień. Oznacza to, że nie musimy troszczyć się o problemy opisane powyżej, związane z wyciekami zasobów. Wcześniej, gdy nie było tej instrukcji, nasz kod musiałby wyglądać tak jak niżej:
package pl.kodolamacz.io;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class InputOutputExample4 {
public static void main(String args[]) {
String path = "/tmp/kodolamacz/hello.txt";
BufferedReader fileReader = null;
try {
fileReader = new BufferedReader(new FileReader(path));
fileReader.readLine();
} catch (IOException io) {
System.out.println("Problem z odczytem pliku");
io.printStackTrace();
} finally {
if (fileReader != null) {
try {
fileReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
Dochodzi nam blok finally w którym zamykamy nasze strumienie. Kod w bloku finally wykonuje się zawsze, bez względu czy błąd wystąpił czy nie.
Klasy takie jak InputStream czy Reader możemy także tworzyć wykorzystując klasę Files:
InputStream inputStream = Files.newInputStream(pathToFile)
BufferedReader bufferedReader = Files.newBufferedReader(pathToFile, Charset.defaultCharset());
Inne operacje na plikach i katalogach
Klasa Files umożliwia nam wykonywanie wielu innych operacji na plikach i katalogach. Poniżej przykład kilku z nich:
package pl.kodolamacz.io;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class InputOutputExample5 {
public static void main(String args[]) throws IOException {
Path pathToDirectory = Paths.get("/tmp/kodolamacz-test");
Path pathToFile = Paths.get("/tmp/kodolamacz/hello1.txt");
Path pathToAnotherFile = Paths.get("/tmp/kodolamacz/hello2.txt");
Files.createDirectory(pathToDirectory); // tworzy katalog
Files.createDirectories(pathToDirectory); // tworzy katalog z katalogami pośrednimi
Files.createFile(pathToFile); // tworzy pusty plik
Files.copy(pathToFile, pathToAnotherFile); // kopiuje plik
Files.delete(pathToAnotherFile); // usuwa plik
Files.move(pathToFile, pathToAnotherFile); // przenosi plik
}
}
Wyzwanie
Czas na nasze wyzwanie. Tym razem prosimy Was o napisanie programu który będzie operować na plikach.
Niech program poprosi użytkownika o podanie ścieżki do pliku wejściowego oraz wyjściowego gdzie będzie wynik. Następnie niech program wczyta plik wejściowy, zliczy w nim ilość linii, wyświetli tą liczbę użytkownikowi i zapisze nazwę pliku oraz liczbę linii w pliku wyjściowym.
Dla chętnych, prośba o dodanie obsługi błędów. Jeśli plik nie będzie istniał, niech program wyświetli o tym informację i poprosi o inny plik.