Programowanie operacji wejścia-wyjścia w Javie nie jest banalne, choćby
ze względu na bardzo dużą liczbę klas, które temu służą. Tu poznamy prawie
wszystkie te klasy, ale przede wszystkim koncepcje, leżące u podstaw konstrukcji
niezwykle bogatego w możliwości, ale jednocześnie dość zawikłanego środowiska
programowania wejścia-wyjścia w Javie.
Materiał jest obszerny i należy go przerabiać jako dwa kolejne wykłady, traktując przy tym fragmenty o "nowym wejściu-wyjściu" w Javie jako - w szczegółach - fakultatywne.
5.1. O wejściu-wyjściu w Javie
Java dostarcza dwóch podstawowych pakietów (z podpakietami), służących do przeprowadzania operacji wejścia-wyjścia:
Pakiet java.io zawiera przede wszystkim klasy, które pozwalają operować na strumieniach danych.
Strumień danych jest pojęciem abstrakcyjnym, logicznym.
Oznacza ciąg danych, właśnie „strumień”, do którego dane mogą być dodawane i z którego dane mogą być pobierane.
Przy czym:
- strumień związany jest ze źródłem lub odbiornikiem danych
-
źródło lub odbiornik mogą być dowolne: plik, pamięć, URL, gniazdo, potok ...
-
strumień służy do zapisywania-odczytywania informacji - dowolnych danych
-
program:
-
kojarzy strumień z zewnętrznym źródłem/odbiornikiem,
-
otwiera strumień,
-
dodaje lub pobiera dane ze strumienia,
-
zamyka strumień.
|
Klasy reprezentujące strumienie (inaczej: klasy strumieniowe) omówimy najpierw,
są one bowiem podstawowym środkiem programowania operacji wejścia-wyjścia
w Javie.
W pakiecie java.nio ("Java new input-output", w skrócie NIO) wprowadzono dodatkowe środki wejścia-wyjścia, takie jak kanały, bufory i selektory
. Mimo nazwy ("new input-output") środki te nie zastępują klas strumieniowych.
Służą przede wszystkim do zapewnienia wysokiej efektywności i elastyczności
programów, które w bardzo dużym stopniu obciążone są operacjami wejścia-wyjścia.
W szczególności dotyczy to serwerów, które muszą równolegle obsługiwać ogromną
liczbę połączeń sieciowych. Elementom nowych klas wejściowo-wyjściowych przyjrzymy
się na końcu wykładu, bardziej zaawansowaną dyskusję tego tematu odkładając
jednak do następnego semestru, gdy poznamy zasady programowania klient-serwer.
Oprócz tego Java dostarcza klas reprezentujących inne od strumieni obiekty operacji wejścia-wyjścia.
Do klas tych należy np. klasa File z pakietu java.io - opisująca pliki i
katalogi, a także - w pakiecie java.net - klasy reprezentujące obiekty "sieciowe",
takie jak URL czy gniazdko (socket), mogące stanowić źródło lub odbiornik
danych w sieci (w szczególności w Internecie).
Obiekty tych klas nie stanowią strumieni. Do operowania na nich strumienie
(lub kanały) są jednak potrzebne i możemy je uzyskać albo przez użycie odpowiednich
konstruktorów lub metod.
5.2. Klasy strumieniowe
Na strumieniach możemy wykonywać dwie podstawowe operacje: odczytywanie
danych i zapisywanie danych. Z tego punktu widzenia możemy mówić o strumieniach
wejściowych i wyjściowych. I odpowiednio do tego – Java wprowadza dwie rozłączne
hierarchie klas strumieniowych: klasy strumieni wejściowych i klasy strumieni wyjściowych.
Dalej, pobranie/zapis danych może dotyczyć określonych atomistycznych (minimalnie
rozróżnialnych w trakcie operacji) „porcji danych”. Okazuje się, że nie są
to tylko bajty. W Javie – ze względu na przyjęcie standardu kodowania znaków
(Unikod) – wyróżnia się również inny „atom danych” – znak Unikodu, złożony
z dwóch bajtów. Wobec tego powstają kolejne dwie rozłączne hierarchie klas
strumieniowych: klasy strumieni bajtowych („atomem” operacji we-wy jest bajt) oraz klasy strumieni znakowych („atomem” są znaki Unikodu – 2 bajty).
Przy przetwarzaniu tekstów należy korzystać ze strumieni znakowych ze względu
na to, iż w trakcie czytania/pisania wykonywane są odpowiednie operacje kodowania/dekodowania
ze względu na stronę kodową właściwą dla źródła/odbiornika
Zatem mamy aż cztery hierarchie klas strumieniowych.
Początkowe nadklasy tych hierarchii pokazuje poniższa tabela.
| Wejście | Wyjście |
Strumienie bajtowe | InputStream | OutputStream |
Strumienie znakowe | Reader | Writer |
Są to klasy abstrakcyjne, zatem bezpośrednio nie można tworzyć obiektów tych klas.
Dostarczają one natomiast podstaw dla wszystkich innych klas strumieniowych
oraz paru ogólnych użytecznych (choć bardzo podstawowych) metod. Metody
te umożliwiają m.in.
- czytanie - read(..) (bajtów, znaków) - różne wersje tej (przeciążonej)
metody pozwalają na przeczytanie jednego bajtu ze strumienia bajtowego lub
znaku ze strumienia znakowego albo całej porcji bajtów/znaków,
- zapisywanie (write(...)) (bajtów/znaków) - różne wersje tej
(przeciążonej) metody pozwalają zapisywac pojedyńcze bajty/znaki lub tablice
bajtów/znaków, a w przypadku strumieni znakowych również napisy (obiekty
klasy String),
- pozycjonowanie strumieni (metody skip(..), mark(..), reset()
) - każdy strumień może być traktowany jako sekwencja bajtów/znaków, czytanie
i zapisywanie zawsze dotyczy bieżącej pozycji tej sekwencji; po wykonaniu
operacji czytania lub zapisu bieżąca pozycja zwiększa się o jeden; metody
pozycjonowania pozwalają zmieniać bieżącą pozycję.
- zamykanie strumieni (metoda close()) - strumień zawsze należy zamknąć po zakończeniu operacji na nim.
Metody te są zazwyczaj odpowiednio przedefiniowane w klasach dziedziczących;
polimorfizm zapewnia ich oszczędne i właściwe użycie.
Dzięki temu możemy np. opracować ogólną klasę udostępniającą rudymentarne kopiowanie strumieni.
import java.io.*;
class Stream {
static void copy(InputStream in, OutputStream out) throws IOException {
int c;
while ((c = in.read()) != -1) out.write(c);
}
static void copy(Reader in, Writer out) throws IOException {
int c;
while ((c = in.read()) != -1) out.write(c);
}
}
Uwaga: metoda read() zwraca liczbę całkowitą, reprezentującą kolejny znak
ze strumienia znakowego (lub bajt ze strumienia bajtowego) albo wartość -1
gdy czytanie sięga poza koniec pliku.
Możemy teraz użyć metody copy wobec dowolnych strumieni z odpowiednich konkretnych klas hierarchii klas strumieniowych, np.
Stream.copy(input, output);
Właśnie! Po to by kopiowanie miało sens input musi oznaczać konkretne źródło danych , a output – konkretny odbiornik danych.
Strumień abstrakcyjny (w którymś momencie) musi być związany z konkretnym źródlem bądź odbiornikiem.
W Javie jest to możliwe głównie (ale nie tylko) dzięki wprowadzeniu na kolejnych
szczeblach dziedziczenia omawianych czterech hierarchii (we-wy, bajty-znaki)
konkretnych klas oznaczających różne rodzaje źródła/odbiornika danych. Można
by je nazwać klasami przedmiotowymi, bowiem mają one ustalone „przedmioty” operacji - konkretne rodzaje źródła bądź odbiornika.
5.3. Strumieniowe klasy przedmiotowe. Wiązanie strumieni ze źródłem/odbiornikiem
Źródła bądź odbiorniki danych mogą być różnorodne. Strumień może być związany
np. z plikiem, z pamięcią operacyjną, z potokiem, z URLem, z gniazdkiem
(socket)....
Klasy przedmiotowe wprowadzono dla wygody operowania na konkretnych rodzajach żródeł i odbiorników.
Klasy przedmiotowe
Źródło/odbiornik | Strumienie znakowe | Strumienie bajtowe |
---|
Pamięć | CharArrayReader, CharArrayWriter | ByteArrayInputStream, ByteArrayOutputStream |
StringReader, StringWriter | StringBufferInputStream |
Potok | PipedReader, PipedWriter | PipedInputStream, PipedOutputStream |
Plik | FileReader, FileWriter | FileInputStream, FileOutputStream |
Teraz już możemy użyć przykładowej (pokazanej poprzednio) klasy Stream np.do
kopiowania plików tekstowych i do zapisu zawartości łańcucha znakowego do
pliku
class StreamCopy1 {
static public void main(String[] args) {
try {
FileReader in1 = new FileReader("plik0.txt");
FileWriter out1 = new FileWriter("plik1.txt");
Stream.copy(in1, out1);
in1.close();
out1.close();
String msg = "Ala ma kota";
StringReader in2 = new StringReader(msg);
FileWriter out2 = new FileWriter("plik2.txt");
Stream.copy(in2, out2);
in2.close();
out2.close();
} catch(IOException exc) {
exc.printStackTrace();
}
}
}
Komentarze:
- jedną z wersji konstruktorów klas strumieniowych związanych z plikami
są konstruktory, w których podajemy jako argument nazwę pliku (można także
utworzyć strumień plikowy podając jako argument konstruktora referenecję
do obiektu klasy File),
- przy tworzeniu obiektów klas strumieniowych, związanych z plikami,
odpowiednie pliki są otwierane; strumienie wejściowe są otwierane "tylko
do odczytu", strumienie wyjściowe "tylko do zapisu".
- strumienie
wyjściowe mogą być ottwarte w trybie dopisywania (należy użyć konstruktora
z drugim argumentem "append mode" = true); w takim przypadku dane będo dopisywane
do końca strumienia,
- przy operacjach na strumieniach może powstać wyjątek klasy IOException
oznaczający błąd operacji (np. odczytu lub zapisu), a także wyjątki klas
pochodnych FileNotFoundException (brak pliku) oraz EOFException (w trakcie
operacji czytania lub pozycjonowania osiągnięto koniec pliku),
- przy obsłudze wyjątków wejścia-wyjścia stosujemy metodę printStackTrace(),
która wyprowadza dokładne informacje o przyczynie i miejscu wystąpienia wyjątku.
Użycie klas przedmiotowych nie jest jedynym sposobem związania logicznego strumienia z fizycznym źródłem lub odbiornikiem.
Inne klasy (spoza pakietu java.io, np. klasy sieciowe) mogą dostarczać metod,
które zwracają jako wynik referencję do abstrakcyjnego strumienia związanego
z konkretnym źródłem odbiornikiem (np. plikiem w Sieci).
5.4. Klasy przetwarzające (przekształacanie danych w trakcie operacji na strumieniach)
Przy wykonywaniu operacji we-wy mogą być dokonywane przekształcenia danych.
Java oferuje nam wiele klas wyspecjalizowanych w konkretnych rodzajach automatycznego
przetwarzania strumieni. Klasy te implementują określone rodzaje przetwarzania
strumieni, niezależnie od rodzaju źródła/odbiornika
Rodzaj przetwarzania | Strumienie znakowe | Strumienie bajtowe |
---|
Buforowanie | BufferedReader,
BufferedWriter | BufferedInputStream,
BufferedOutputStream |
Filtrowanie | FilterReader,
FilterWriter | FilterInputStream,
FilterOutputStream |
Konwersja: bajty-znaki | InputStreamReader,
OutputStreamWriter | |
Konkatenacja | | SequenceInputStream |
Serializacja obiektów | | ObjectInputStream,
ObjectOutputStream |
Konwersje danych | | DataInputStream,
DataOutputStream |
Zliczanie wierszy | LineNumberReader | LineNumberInputStream |
Podglądanie | PushbackReader | PushbackInputStream |
Drukowanie | PrintWriter | PrintStream |
Komentarze:
-
Buforowanie ogranicza liczbę fizycznych odwołań do urządzeń zewnętrznych.
-
Klasy Filter... są klasami abstrakcyjnymi, definiującymi interfejs dla
rzeczywistych filtrów. Filtrami są:
-
DataInputStream i DataOutputStream,
-
BufferedInputStream i BufferedOutputStream,
-
LineNumberInputStream,
-
PushbackInputStream,
-
PrintStream,
Można tworzyć własne filtry.
-
Konwersje bajty-znaki
-
InputStreamReader czyta bajty ze strumienia definiowanego przez InputStream
(strumień bajtowy) i zamienia je na znaki (16 bitowe), używając domyślnej
lub podanej strony kodowej,
-
OutputStreamWriter wykonuje przy zapisie konwersję odwrotną.
-
Konkatenacja strumieni wejściowych pozwala połączyć strumienie i traktować
je jak jeden strumień.
-
Serializacja służy do "utrwalania" obiektów po to, by odtworzyć je w innym
kontekście (przy ponownym uruchomieniu programu lub w innym miejscu (np.
programie działającym w innym miejscu sieci po przekazaniu "utrwalonego"
obiektu przez socket),
- DataInputStream i DataOutputStream pozwalają
czytać/pisać dane typów pierwotnych (np. liczby rzeczywiste) w postaci binarnej.
Strumienie są tutaj strumieniami binarnymi, w związku z tym koniec strumienia
rozpoznaje się jako wyjątek EOFException.
-
LineNumber... zlicza wiersze strumienia przy czytaniu (i pozwala w każdym
momencie uzyskać informację o numerze wiersza).
-
PushBack.. pozwala podglądnąć następny znak/bajt w strumieniu bez "wyciągania" tego znaku/bajtu.
-
Klasy Print... zawierają wygodne metody wyjścia (np. println). Niekoniecznie
oznacza to drukowanie fizyczne, często wykorzystywane jest w powiązaniu z
innymi strumieniami po to by łatwo wyprowadzać informacje.
Konstruktory klas przetwarzających mają jako argument referencję do obiektów
podstawowych klas abstrakcyjnych hierarchii dziedziczenia (InputSteram, OutputSteram,
Reader, Writer).
Dlatego przetwarzanie (automatyczna transformacja) danych jest logicznie
oderwana od fizycznego strumienia, stanowi swoistą na niego nakładkę.
Zatem zastosowanie klas przetwarzających wymaga:
- stworzenia obiektu związanego z fizycznym źródłem/odbiornikiem
- stworzenie obiektu odpowiedniej klasy przetwarzającej, "nałożonego" na fizyczny strumień.
Przykłady tego zobaczymy za chwilę. Przedtem jednak - dla lepszej orientacji
w gąszczu klas strumieniowych - warto przedstawić ich hierarchie dziedziczenia.
5.5. Hierarchie dziedziczenia klas strumieniowych
Na poniższych rysunkach pokazano hierarchię klas znakowych i strumieniowych.
Zaciemnione elementy oznaczają klasy przedmiotowe (związane z konkretnym
źródłem/odbiornikiem), jasne - klasy przetwarzające (realizujące określone
rodzaje orzetwarzania).
Klasy dla strumieni bajtowych


Żródło: Java Tutorial, Sun Microsystems 2002
Klasy dla strumieni znakowych


Żródło: Java Tutorial, Sun Microsystems 2002
5.6. Buforowanie
Buforowanie ogranicza liczbę fizycznych odwołań do urządzeń zewnętrznych,
dzięki temu, że fizyczny odczyt lub zapis dotyczy całych porcji danych, gromadzonych
w buforze (wydzielonym obszarze pamięci). Jedno fizyczne odwołanie wczytuje
dane ze strumienia do bufora lub zapisuje zawartość bufora do strumienia.
W naszym programie operacje czytania lub pisania dotyczą w większości bufora
(dopóki są w nim dane lub dopóki jest miejsce na dane) i tylko niekiedy
powodują fizyczny odczyt (gdy bufor jest pusty) lub zapis (gdy bufor jest
pełny).
Np. przy czytaniu dużych plików tekstowych należy unikać bezpośredniego czytania
za pomocą klasy FileReader. To samo dotyczy zapisu.
Zastosowanie klasy BufferedReader (czy BufferedWriter) powinno przynieść poprawę efektywności działania programu.
Ale klasa BufferedReader (BufferedWriter) jest klasą przetwarzającą, a wobec tego w jej konstruktorze
nie możemy bezpośrednio podać fizycznego źródła danych.
Np. przy czytaniu plików źródło to podajemy przy konstrukcji obiektu typu FileReader, a po to, żeby
uzyskać buforowanie, "opakowujemy" FileReadera BufferedReaderem.
Wygląda to mniej więcej tak:
FileReader fr = new FileReader("plik.txt"); // tu powstaje związek
// z fizycznym źródłem
BufferedReader br = new BufferedReader(fr); // tu dodajemy "opakowanie"
// umożliwiające buforowanie
// czytamy wiersz po wierszu
String line;
while ((line = br.readLine()) != null) { // kolejny wiersz pliku:
// metoda readLine zwraca wiersz
// lub null jeśli koniec pliku
// ... tu coś robimy z odczytanym wierszem
}
Uwaga.
W konstruktorach klas Buffered... możemy podać rozmiar bufora. Domyślny rozmiar jest wystarczający dla codziennych zastosowań.
Przykład buforowania: program, czytający plik tekstowy i zapisujący jego zwartośc do innego pliku wraz z numerami wierszy.
import java.io.*;
class Lines {
public static void main(String args[]) {
try {
FileReader fr = new FileReader(args[0]);
LineNumberReader lr = new LineNumberReader(fr);
BufferedWriter bw = new BufferedWriter(
new FileWriter(args[1]));
String line;
while ((line = lr.readLine()) != null) {
bw.write( lr.getLineNumber() + " " + line);
bw.newLine();
}
lr.close();
bw.close();
} catch(IOException exc) {
System.out.println(exc.toString());
System.exit(1);
}
}
}
Komentarze:
- klasa LineNumberReader dziedziczy klasę BufferedReader, dając możliwość
prostego uzyskiwania informacji o numerze bieżącego wiersza (metoda getLineNumner(),
- zastosowanie metody newLine() z klasy BufferedWriter pozwala w niezależny
od platformy systemowej sposób zapisywać separatory wierszy,
- przy zamknięciu wyjściowego strumienia buforowanego zawartość bufora
jest zapisywana do strumienia; istnieje też metoda void flush( ), zapisujące
dane które pozostaływ buforze a nie zostały jeszcze zapisane w miejscu przeznaczenia;
takie "ręczne" opróżnianie bufora jest czasem przydatne.
Analogicznie postępujemy przy buforowaniu plików wyjściowych.
5.7. Strumienie binarne
Klasy przetwarzające DataInputStream i DataOutputStream służą do odczytu/zapisu
danych typów pierwotnych w postaci binarnej (oraz łańcuchów znakowych).
Metody tych klas mają postać:
typ readTyp( )
void writeTyp(typ arg)
gdzie typ odpowiada nazwie któregoś z typów pierwotnych
Mamy więc np. metody readInt(), readDouble() itp.
Dane typu String mogą być zapisywane/czytane do/z strumieni binarnych za pomocą metod writeUTF i readUTF.
Dla przykładu, stwórzmy klasę Obs, której obiekty reprezentują obserwacje.
Każda obserwacaja ma nazwę oraz odpowiadający jej ciąg (tablicę) liczb rzeczywistych.
Może to być np. MAX_TEMPERATURA z 12 liczbami, pokazującymi maksymalną temperaturę
w 12 miesiącach roku.
W klasie tej zdefiniujemy także dwie metody, slużące do zapisu obserwacji
w postaci binarnej do strumienia i odczytywania binarnych strumieni obserwacji.
import java.io.*;
class Obs {
String name;
double[] data;
public Obs() {}
public Obs(String nam, double[] dat) {
name = nam;
data = dat;
}
public void writeTo(DataOutputStream dout)
throws IOException {
dout.writeUTF(name);
dout.writeInt(data.length);
for (int i=0; i<data.length; i++) dout.writeDouble(data[i]);
}
public Obs readFrom(DataInputStream din)
throws IOException {
name = din.readUTF();
int n = din.readInt();
data = new double[n];
for (int i=0; i<n; i++) data[i] = din.readDouble();
return this;
}
public void show() {
System.out.println(name);
for (int i=0; i<data.length; i++) System.out.print(data[i] + " ");
System.out.println("");
}
}
Zwróćmy uwagę, że przyjęliśmy następujący format zapisu obserwacji w pliku binarnym:
nazwa
liczba_elementów_tablicy
dane_tablicy.
Dzięki temu, metoda readFrom bez kłopotu może odczytywać dowolne obserwacje
z dowolnych plików binarnych, pod warunkiem, że pliki te mają podany format.
Przykład wykorzystania klasy: tworzymy dwie obserwacje, pokazujemy jak wyglądają
(show) zapisujemy je do pliku (writeTo(out)), po czym z tego samego pliku
odczytujemy dane do innych (ad hoc tworzonych) obiektów-obserwacji i jednocześnie
pokazujemy odczytane dane na konsoli (new Obs().readFrom(in).show()).
class BinDat {
public static void main(String args[]) {
double[] a = { 1, 2, 3, 4 };
double[] b = { 7, 8, 9, 10 };
Obs obsA = new Obs("Dane A", a);
Obs obsB = new Obs("Dane B", b);
obsA.show();
obsB.show();
try {
DataOutputStream out = new DataOutputStream(
new FileOutputStream("dane")
);
obsA.writeTo(out);
obsB.writeTo(out);
out.close();
DataInputStream in = new DataInputStream(
new FileInputStream("dane")
);
new Obs().readFrom(in).show();
new Obs().readFrom(in).show();
in.close();
} catch (IOException exc) {
exc.printStackTrace();
System.exit(1);
}
}
}
5.8. Kodowanie
Java posługuje się znakami w formacie Unicode. Są to - ogólnie - wielkości 16-bitowe.
Środowiska natywne (np. Windows) najczęściej zapisują teksty jako sekwencje bajtów (z przyjętą stroną kodową).
Jak pogodzić najczęściej bajtowy charakter plików natywnych ze znakowymi strumieniami?
Otóż strumienie znakowe potrafią - niewidocznie dla nas - przekształcać
bajtowe źródła w znaki Unikodu i odwrotnie. "Pod pokrywką" tego procesu znajdują
się dwie klasy: InputStreamReader i OutputStreamWriter, które dokonują właściwych konwersji w trakcie czytania/pisania.
Klasy te możemy wykorzystać również samodzielnie.
Jeśli w konstruktorach tych klas nie podamy strony kodowej - przy konwersjach zostanie przyjęta domyślna strona kodowa.
Aby się dowiedzieć, jakie jest domyślne kodowanie można użyć następującego programiku:
public class DefaultEncoding {
public static void main(String args[])
{
String p = System.getProperty("file.encoding");
System.out.println(p);
}
}
W zależności od ustawień na danej platformie otrzymamy różne wyniki.
Np. ibm-852 lub Cp852 (Latin 2) albo Cp1252 (Windows Western Europe / Latin-1).
Inna wersja konstruktorów pozwala na podanie stron kodowych, które będą używane do kodownia i dekodowania bajty-znaki .
Napiszmy program wykonujący konwersje plików z-do dowolnych (dopuszczalnych
przez Javę) formatów kodowania.
Dopuszczalne symbole kodowania można znaleźć na stronach java.sun.com.
import java.io.*;
class Convert {
public static void main(String[] args) {
if (args.length != 4) {
System.out.println("Syntax: in in_enc out out_enc");
System.exit(1);
}
String infile = args[0], // plik wejściowy
in_enc = args[1], // wejściowa strona kodowa
outfile = args[2], // plik wyjściowy
out_enc = args[3]; // wyjściowa strona kodowa
try {
FileInputStream fis = new FileInputStream(infile);
BufferedReader in = new BufferedReader(new InputStreamReader(fis, in_enc));
FileOutputStream fos = new FileOutputStream(outfile);
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(fos, out_enc));
String line;
while ((line = in.readLine()) != null) {
out.write(line);
out.newLine();
}
in.close();
out.close();
} catch (IOException e) {
System.err.println(e);
System.exit(1);
}
}
}
Przykładowe wykorzystanie do konwersji pliku zle.htm (zapisanego w Windows
1250) na plik dobrze.htm ( ISO-8859-2):
java Convert zle.htm Cp1250 dobrze.htm ISO8859_2
O innych sposobach kodowania i dekodowania danych wejściowo-wyjściowych
- zob. punkt dotyczący "nowego wejścia-wyjścia" (java.nio).
5.9. Serializacja obiektów
Obiekty tworzone przez program rezydują w pamięci operacyjnej, w przestrzeni adresowej procesu.
Są zatem nietrwałe, bo kiedy program kończy działanie wszystko co znajduje
się w jego przestrzeni adresowej ulega wyczyszczeniu i nie może być odtworzone.
Serializacja (szeregowanie) pozwala na utrwalaniu obiektów.
W Javie polega ona na zapisywaniu obiektów do strumienia.
Podstawowe zastosowania serializacji:
- komunikacja pomiędzy obiektami/aplikacjami poprzez gniazdka (sockets),
-
zachowanie obiektu (jego stanu i właściwości) do późniejszego odtworzenia i wykorzystania
przez tę samą lub inną aplikację.
|
Do zapisywania/odczytywania obiektów służą klasy ObjectOutputStream
oraz ObjectInputStream, które należą do strumieniowych klas przetwarzających.
Metoda klasy ObjectInputStream:
void writeObject(Object o) zapisuje obiekt o do strumienia
Metoda klasy ObjectInputStream:
Object readObjectO odczytuje obiekt ze strumienia i zwraca referencję do niego
Do strumieni mogą być zapisywane tylko serializowalne obiekty.
Obiekt jest serializowalny jeśli jego klasa implementuje interfejs Serializable
Prawie wszystkie klasy standardowych pakietów Javy implementują ten interfejs.
Również tablice (które są obiektami specjalnych klas definiowanych w trakcie kompilacji) są serializowalne.
Zatem bez problemu możemy utrwalać obiekty większości klas standardowych Javy oraz tablice.
Przykład: program zapisuje do strumienia obiekty - datę, tablicę opisów
i odpowiadającą każdemu opisowi temperaturę. Następnie odczytuje te obiekty
ze strumienia i odtwarza je.
import java.io.*;
import java.util.*;
class Serial {
public static void main(String args[]) {
Date data = new Date();
int[] temperatura = { 25, 19 , 22};
String[] opis = { "dzień", "noc", "woda" };
// Zapis
try {
ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("test.ser")
);
out.writeObject(data);
out.writeObject(opis);
out.writeObject(temperatura);
out.close();
} catch(IOException exc) {
exc.printStackTrace();
System.exit(1);
}
// Odtworzenie (zazwyczaj w innym programie)
try {
ObjectInputStream in = new ObjectInputStream(
new FileInputStream("test.ser")
);
Date odczytData = (Date) in.readObject();
String[] odczytOpis = (String[]) in.readObject();
int[] odczytTemp = (int[]) in.readObject();
in.close();
System.out.println(String.valueOf(odczytData));
for (int i=0; i<odczytOpis.length; i++)
System.out.println(odczytOpis[i] + " " + odczytTemp[i]);
} catch(IOException exc) {
exc.printStackTrace();
System.exit(1);
} catch(ClassNotFoundException exc) {
System.out.println("Nie można odnaleźć klasy obiektu");
System.exit(1);
}
}
}
Przykładowy wydruk programu.
Wed Jan 15 18:30:17 CET 2003
dzień 25
noc 19
woda 22
Metoda readObject() pobiera ze strumienia zapisane charakterystyki obiektu
(w tym również oznaczenie klasy do której należy zapisany obiekt) - na ich
podstawie tworzy nowy obiekt tej klasy i inicjuje go odczytanymi wartościami.
Wynikiem jest referencja formalnego typu Object wskazująca na nowoutworzony
obiekt, który jest identyczny z zapisanym.
Ponieważ wynikiem jest Object, nalezy wykonać odpowiednią konwersję
zawężającą do właściwego typu (referencji do konkretnej podklasy klasy Object,
tej mianowicie, której egzemplarzem faktycznie jest odczytany obiekt).
Może się też okazać, że w strumieniu zapisano obiekt klasy, która nie jest
dostępna przy odczytywaniu (np. została usunięta). Wtedy przy tworzeniu obiektu
z odczytanych danych powstanie wyjątek ClassNotFoundException, który musimy
obslugiwać.
A jak serializować obiekty własnych klas?
Odpowiedź już znamy: klasa winna implementować interfejs Serializable.
Takie interfejsy (bez metod) nazywane są interfejsami znacznikowymi. Ich
jedyną funkcją jest umożliwienie sprawdzenia typu np. za pomocą operatora
instanceof. Metoda writeObject to własnie robi, gdy podejmuje decyzje o zapisie:
jeśli jej argument x jest typu Serializable (x instanceof Serializable ma
wartośc true), to obiekt jest zapisywany do strumienia, w przeciwnym razie
- nie
Rzecz nie jest trudna, bowiem interfejs ten jest pusty - nie musimy
więc implementować żadnych jego metod, wystarczy tylko wskazać, że nasza
klasa implementuje interfesj Serializable.
Zobaczmy to na przykladzie nieco bardziej praktycznego zastosowania serializacji.
Przypomnijmy sobie klasę TravelSearcher z poprzedniego semestru. Slużyła
ona do zgromadzenia w tablicy obiektów klasy Travel, opisujących destynacje
(napis, np. Cypr) i ceny podróży (liczba całkowita, np. 1500) oraz dostarczała
metody wyszukiwania informacji o cenie na podstawie podanej destynacji.
Klasa Travel jest bardzo prosta:
public class Travel ... {
private String dest; // destynacja podrózy
private int price; // cena
public Travel(String s, int p) {
dest = s;
price = p;
}
public String getDest() { return dest; }
public int getPrice() { return price; }
public String toString() { return dest + ", cena: " + price; }
}
Schemat klasy TravelSearcher przedstawiono poniżej:
public class TravelSearcher ... {
private Travel[] travel; // tablica podróży
private int lastIndex = -1; // indeks ostatnio zapisanej
private final int MAX_COUNT = 5; // max rozmiar tablicy
private boolean sorted = false; // czy jest posortowana
// Konstruktor: tworzy tablicę
public TravelSearcher() {
travel = new Travel[MAX_COUNT];
}
// Metoda add dodaje nowy element do tablicy
// jeżeli przekrozcono zakres
// - zgłaszany jest wyjątek własnej klasy NoSpaceForTravelException
public void add(Travel t) throws NoSpaceForTravelException {
try {
lastIndex++;
travel[lastIndex] = t;
} catch (ArrayIndexOutOfBoundsException exc) {
lastIndex--;
throw new NoSpaceForTravelException("Brakuje miejsca dla dodania podróży");
}
sorted = false;
}
// Jaki jest ostatni zapisany indeks
public int getLastIndex() { return lastIndex; }
// Wyszukiwanie podróży na podstawie podanego celu (destynacji)
public Travel search(String dest) {
if (!sorted) sortByDest();
// ... wyszukiwanie binarne
}
// Sortowanie - aby można było stosować wyszukiwanie binarne
private void sortByDest() {
// ... sortowanie
sorted = true;
}
public String toString() {
// zwraca spis podróży z tablicy travel (destynacji i cen)
}
}
Wyobraźmy sobie, że w innej klasie dostarczamy jakiś interfejs użytkownika,
umożliwiający wprowadzanie informacji o podróżach i na tej podstawie tworzenie
obiektów klasy Travel oraz wpisywanie ich do tablicy w klasie TravelSearcher.
Informacje podawane są na bieżąco (w jakichś oknach dialogowych) i zapisywane
j dodawane do tablicy travel w klasie TravelSearcher.
W trakcie dzialania tego programu możemy zapewnić wyszuskiwanie informacji
o wpisanych podróżach. Ale gdy program zakończy działanie - obiekt klasy
TravelSearcher, a co za tym idzie cala tablica informacji o podróżach - znikną.
Możemy temu zaradzić zapewniając zapis informacji do pliku. Jeśli zastosujemy
zwykłe strumienie (np. znakowe), to - oczywiście - w dość prosty sposób możemy
zapisać do pliku listę podróży i wprowadzić ją znowu przy ponownym uruchomieniu
aplikacji.
Dużo prościej jednak będzie zapisać do strumienia obiekt klasy TravelSearcher.
Dodatkowo zyskamy całkowite odtworzenie stanu aplikacji (obiektu TravelSearcher)
zapamiętanego przy poprzednim jej uruchomieniu. A ten stan, to nie tylko
tablica wycieczek, ale również istotne dla dzialania aplikacji wartości indeksu
ostatniego zapisanego elementu tablicy oraz zmiennej sorted pokazującej czy tablica jest posortowana.
Zatem należy zagwarantować serializowalność obiektów klasy TravelSearcher
oraz - w głównej aplikacji (określającej interfejs użytkownika) zapewnić
ich serializację poprzez użycie metody writeObject oraz odtwarzanie - za
pomoca metody readObject.
Napiszemy więc na pewno:
public class TravelSearcher implements Serializable {
...
}
Czy to wystarczy? Zwróćmy uwagę, że polem klasy TravelSearcher jest tablica
referencji do obiektów klasy Travel. Tablice - jak widzieliśmy poprzednio
- zapisują się do strumieni obiektowych bez kłopotu. A co z obiektami klasy
Travel, na które wskazują elementy tablicy?
Szczęśliwie:
Przy serializacji zapisywany i odtwarzany jest pełny stan obiektu (w tym - rekursywnie - obiektów
składowych).
Ale nie są zapisywane stany obiektów składowych, które należą do
klas nieserializowalnych. Bo choć pola, odpowiadające tym obiektom są zapisywane, to przy odtwarzaniu, takie obiekty są tworzone
za pomocą konstruktorów bezparametrowych z ich klas i nie ma żadnej innej inicjacji ich elementów.
Musimy zatem zapewnić również serializację obiektów klasy Travel:
public class Travel implements Serializable {
...
}
Po tych poprawkach, klas TravelSearcher i Travel możemy użyć w przykladowej
aplikacji, stanowiącej interfejs użytkownika do wprowadzania, wyszukiwania,
zapisywania i lstowania podróży.
Argumentem aplikacji jest plik "kartoteka", który zawiera lub będzie
zawierał utrwalony obiekt klasy TravelSearcher. Jeżeli taki plik już istnieje,
to informacje o wycieczkach są z niego odtwarzana za pomoca deserializacji
utrwalonego obiektu. Użytkownik ma do wyboru różne tryby działania (np. wprowadzanie nowych
danych, ich wyszukiwanie i - oczywiście - utrwalenie.
Pokazuje to poniższy program.
Przy okazji warto zwrócić uwagę na sposoby oprogramowania dialogow wyboru
(zastosowane tu postaci metod z klasy JOptionPane będą wyjaśnione w następnych
wykładach; już teraz można jednak samodzielnie zapoznać się z nimi na podstawie
dokumentacji).
import java.io.*;
import java.util.*;
import javax.swing.*;
public class TravelApp {
private String travFileName;
private TravelSearcher travels;
private boolean dataSaved = false;
public TravelApp(String[] tfn) {
try {
travFileName = tfn[0];
ObjectInputStream in = new ObjectInputStream(
new FileInputStream(travFileName)
);
travels = (TravelSearcher) in.readObject();
in.close();
} catch(ArrayIndexOutOfBoundsException exc) {
showMsg("Syntax: java TravelApp plik_kartoteki");
System.exit(1);
} catch(FileNotFoundException exc) {
showMsg("Nowa kartoteka!!!");
travels = new TravelSearcher();
} catch(IOException exc) {
exc.printStackTrace();
System.exit(2);
} catch(ClassNotFoundException exc) {
showMsg("Brak klasy dostępu do klasy TravelSearcher");
System.exit(3);
}
String[] modes = { "Wprowadzanie", "Szukanie", "Zapis", "Pokaz", "Koniec" };
while (true) {
switch ( select("Wybierz tryb działania", modes)) {
case 'W' : inputData(); break;
case 'S' : searchData(); break;
case 'Z' : saveData(); break;
case 'P' : showData(); break;
case 'K' : finish(); break;
default : break;
}
}
}
private char select(String msg, String[] modes) {
int sel = JOptionPane.showOptionDialog(null, msg,
"Travel App", 0, JOptionPane.QUESTION_MESSAGE,
null, modes, modes[1]);
if (sel == JOptionPane.CLOSED_OPTION) return 0;
return modes[sel].charAt(0);
}
public void inputData() {
String data = "";
String msg = "Wprowadź dane";
while((data = ask(msg, data)) != null) {
StringTokenizer st = new StringTokenizer(data);
try {
String dest = st.nextToken();
int price = Integer.parseInt(st.nextToken());
travels.add(new Travel(dest, price));
dataSaved = false;
} catch(NoSpaceForTravelException exc) {
showMsg(exc.getMessage());
return;
} catch(Exception exc) {
msg = "Dane wadliwe - popraw";
continue;
}
msg = "Wprowadź dane";
data = "";
}
}
public void searchData() {
if (travels.getLastIndex() >= 0) {
String dest = "";
String msg = "Podaj miejsce podróży";
while((dest = ask(msg, "")) != null) {
Travel t = travels.search(dest);
String info = (t == null ? "Nie ma takiej podróży!" : t.toString() );
showMsg(info);
}
}
else showMsg("Nie ma żadnych danych do przeszukiwania!");
}
public void saveData() {
ObjectOutputStream out = null;
try {
out = new ObjectOutputStream(
new FileOutputStream(travFileName)
);
out.writeObject(travels);
} catch(IOException exc) {
showMsg(exc.getMessage());
} finally {
try { out.close(); } catch (Exception exc) {}
}
dataSaved = true;
}
public void showData() {
System.out.println("Dane\n" + travels);
}
public void finish() {
while (!dataSaved) {
char ans = select("Czy zapisać dane?", new String[] { "Tak", "Nie" } );
if (ans == 'T') saveData();
else if (ans == 'N') break;
}
System.exit(0);
}
private void showMsg(String msg) {
JOptionPane.showMessageDialog(null, msg);
}
private String ask(String msg, String initVal) {
return JOptionPane.showInputDialog(null, msg, initVal);
}
public static void main(String[] args) {
new TravelApp(args);
}
}
Na koniec warto powiedzieć, że:
- przy serializacji nie są zapisywane pola statyczne oraz pola deklarowane
ze specyfikatorem
transient; specyfikatora transient używamy więc wobec elementów informacji o obiekcie, których nie chcemy poddawać utrwaleniu.
- pełniejszą kontrolę nad sposobem serializacji możemy zyskać definiując
odpowiednie metody w klasie obiektu serializowanegoo, metody te winny mieć
następujące sygnatury:
private void readObject(java.io.ObjectInputStream stream)
throws IOException, ClassNotFoundException;
private void writeObject(java.io.ObjectOutputStream stream)
throws IOException
- calkowitą kontrolę nad formatem i sposobem serializacji zyskujemy
poprzez implementację w klasie interfejsy Externalizable i dostarzcenie metod
writeExternal i readExternal
5.10. Potoki
Potoki służą do przesyłania danych pomiędzy równolegle działającymi wątkami.
Wątek produkujący dane zapisuje je do potoku wyjściowego (PipedWriter lub PipedOutputStream).
Potok ten za pomocą konstruktorów potokowych klas wejściowych (PipedReader i PipedInputStream) można przyłączyć do strumienia wejściowego, z którego inny watek będzie czytał dane.
Niech na przyklad obiekt-wątek klasy DataPutter produkuje jakieś dane i umieszcza
je w strumieniu wyjściowym, do którego referencję otrzymuje konstruktor.
class DataPutter extends Thread {
OutputStream out;
public DataPutter(OutputStream o) {
out = o;
}
public void run() {
try {
for (char c = 'a'; c <= 'z'; c++) out.write(c);
out.close();
} catch(IOException exc) { return; }
}
}
a obiekt-wątek klasy DataGetter, odczytuje jakieś dane ze strumienia i wypisuje je na konsoli.
class DataGetter extends Thread {
InputStream in;
public DataGetter(InputStream i) {
in = i;
}
public void run() {
try {
int c;
while ((c = in.read()) != -1) System.out.println((char) c);
} catch(IOException exc) { return; }
}
}
Za pomocą potoku możemy połączyć wyjściowy strumień, do którego pisze DataPutter
z wejściowym strumieniem czytanym przez DataGetter.
W tym celu zwiążemy strumień wyjściowy, do którego ma pisać DataPutter z potokiem:
PipedOutputStream pout = new PipedOutputStream();
i potok ten połączymy ze strumieniem wejściowym, z którego będzie czytał DataGetter.
PipedInputStream pin = new PipedInputStream(pout);
Po uruchomieniu obu watków:
class Main {
public static void main(String[] args) throws IOException {
PipedOutputStream pout = new PipedOutputStream();
PipedInputStream pin = new PipedInputStream(pout);
new DataPutter(pout).start();
new DataGetter(pin).start();
}
}
uzyskamy oczekiwany wynik: produkowanie przez jeden z nich danych i przesylanie ich potokiem do drugiego wątku.
Uwaga: przy pisaniu/czytaniu znaków Unikodu należy stosować klasy PipeWriter i PipeReader.
Podstawową zaletą potoków jest to, iż umożliwiają one uproszczenie komunikacji
pomiędzy wątkami. Wątek zapisuje dane do potoku i o nic więej nie musi dbać.
Inny wątek czyta dane z potoku za pomocą zwyklego read(), na którym - ew.
jest blokowany, jeśli danych jeszcze nie ma. Nie musimy martwić się o synchronizację
i koordynację dzialania wątków. Samo pisanie i czytanie danych za pomocą
potoków taką synchronizację i koordynacje już zapewnia.
Przykład-zadanie:
Wątek-Autor pisze teksty, skladając losowo wybrane słowa w wiersze o losowo
wybranej liczbie słów. Wiersze te pobiera wątek-Duplikator i rozdysponowuje
je do wielu wątków-przepisywaczy (TxtWriter), Każdy z przepisywaczy równolegle
wypisuje tekst Autora w przydzielonynym mu miejscu (nazwiemy je SpaceToWrite).
Przed lekturą dalszego tekstu proszę spróbowac rozwiąza to zdanie samodzielnie.
Poniżej pokazano możliwe rozwiązanie. Klasy są bardzo szczegółowo komentowane,
zatem dodatkowy opis programu ograniczymy do minimum.
Klasa Author zzapewnie generowanie tekstów autora. Są one tworzone w wątku
głównym programu, zatem nie dostarczyliśmy tu metody run().
import java.io.*;
import java.util.*;
public class Author {
private int linesToWrite; // ile wierszy ma napisac autor
String[] words; // z jakich slów się będą składać
private Writer out; // strumień do którego zapisuje teksty
static final int N = 5; // maksymalna liczba słów w wierszu
public Author(int l, String[] words, Writer w) {
linesToWrite = l;
this.words = words;
out = w;
try {
write(); // wywołanie pisania
} catch(IOException exc) {
System.out.println(exc.toString());
} catch(InterruptedException exc) {}
}
// Metoda pisania przez autora
public void write() throws IOException,
InterruptedException {
Random rand = new Random();
for (int i=0; i < linesToWrite; i++) {
// Każdy wiersz składa się z losowo wybranej nw liczby słów
int nw = rand.nextInt(N) + 1;
String line = "";
for (int k=0; k<nw; k++) { // słowa są losowane z tablicy words
int wordNum = rand.nextInt(words.length);
line += words[wordNum] + " ";
}
out.write(line);
out.write('\n');
Thread.sleep((rand.nextInt(3) + 1) * 1000); // autor myśi nad
} // następnym wierszem :-)
out.write("Koniec pracy\n");
out.close();
System.out.println("Autor skończył pisać");
}
}
Każdy z przepisywaczy (klasa TxtWriter) stanowi odrębny wątek, czyta teksty
ze strumienia wypisuje je w miejscu określonym przez przekazany konstruktorowi
obiekt SpaceToWrite. Generalnie może to być cokolwiek, na czym będzie widać
tekst. Jak zobaczymy dalej - w tym programie zastosujemy wielowierszowe pola
edycyjne umieszczone w jednym oknie (po jednym polu dla każdego z przepisywaczy).
import java.io.*;
public class TxtWriter extends Thread { // Klasa przepisywacza
private LineNumberReader in; // strumień skąd czyta
private SpaceToWrite spw; // miejsce gdzie pisze
public TxtWriter(String name, // nazwa przepisywacza
Reader in_, // z jakiego strumeinia czyta
SpaceToWrite spw_ // gdzie pisze
)
{
super(name);
in = new LineNumberReader(in_); // filtrowanie strumienia
// by mieć numery wierszy
spw = spw_;
}
// Kod wątku przepisywacza
// czyta wiersze ze strumienia wejściowego
// i zapisuje je w miejscu oznaczanym spw (SpaceToWrite)
// dopóki nie nadszedl sygnał o końcu pracy (tekst "Koniec pracy")
public void run() {
spw.writeLine(" *** " + getName() + " rozpoczął pracę" + " ***");
spw.writeLine("---> czekam na teksty !");
String txt;
try {
txt = in.readLine();
while(!txt.equals("Koniec pracy")) {
spw.writeLine(in.getLineNumber() + " " + txt);
txt = in.readLine();
}
in.close();
spw.writeLine("**** " + getName() + " skończył pracę");
} catch(IOException exc) {
spw.writeLine("****" + getName() + " - zakonczenie na skutek bledu");
exc.printStackTrace();
return;
}
}
}
Wątki przepisywaczy tworzy i uruchamia Duplikator. Pośredniczy on rownież
w przekazywaniu tekstów od autora do przepisywaczy i czyni to właśnie za
pomocą potoków.
import java.io.*;
public class Duplicator extends Thread {
PipedReader fromAuthor; // potok od autora
PipedWriter[] toWriters; // potoki do przepisywaczy
public Duplicator(PipedReader pr, // potok od autora
SpaceToWrite[] space // na czym piszą pzrepisywacze?
) throws IOException {
fromAuthor = pr;
int numOfWriters = space.length; // tylu jest przepisywaczy
// ile miejsc na których piszą
// Tworzymy tablicę potoków do przepisywaczy
toWriters = new PipedWriter[numOfWriters];
for (int i = 0; i < numOfWriters; i++) { // dla każdego przepisywacza
// tworzymy potok do niego
toWriters[i] = new PipedWriter();
// tworzymy przepisywacza
// podając: nazwę, z jakiego potoku ma czytać, miejsce gdzie ma pisać
TxtWriter tw = new TxtWriter("TxtWriter " + (i+1),
new PipedReader( toWriters[i]), // połączenie!
space[i]);
// uruchamiamy wątek przepisywacza
tw.start();
}
}
// Kod wykonywany w wątku Duplikatora
public void run() {
try {
// Buforowanie potoku od autora
BufferedReader in = new BufferedReader(fromAuthor);
// czytanie wierszy z potoku od autora
// i zapisywanie ich do potoków, czytanych przez przepisywaczy
while (true) {
String line = in.readLine();
for (int i = 0; i < toWriters.length; i++) {
toWriters[i].write(line);
toWriters[i].write('\n');
}
if (line.equals("Koniec pracy")) break;
}
} catch (IOException exc) { return; }
System.out.println("Duplikator zakończył działanie");
}
}
Główna klasa aplikacji organizuje cały ten "proceder".
import java.io.*;
class PipesShow {
PipedWriter authorWrites = new PipedWriter(); // potok, do którego pisze autor
PipedReader duplicatorReads; // potok, z ktorego czyta duplikator
Duplicator dup;
PipesShow(int numLines, int numWriters) {
// każdy przepisywacz na swoją przestrzeń pisania
SpaceToWrite[] writeSpace = new SpaceToWrite[numWriters];
for (int i=0; i < writeSpace.length; i++)
writeSpace[i] = new SpaceToWrite(20, 30); // 20 wierszy, 30 kolumn
try {
// Połączenie potoku do ktorego pisze autor
// z nowoutworzonym potokiem, z którego będzie czytał duplikator
duplicatorReads = new PipedReader(authorWrites);
// utworzenie duplikatora (on z kolei stworzy i uruchomi przepisywaczy)
dup = new Duplicator(duplicatorReads, // skąd będzie czytał
writeSpace); // przetstrzeń pisania dla przepisywaczy
// start wątku duplikatora
dup.start();
} catch (IOException exc) {
System.out.println("Nie można stworzyć duplikatora");
exc.printStackTrace();
System.exit(1);
}
SpaceToWrite.show(numWriters); // pokazanie ogólnej przestrzeni pisania
// grupującej przestrzenie pisania
// każdego przepisywacza
// Teraz autor będzie pisał!
// Utworzenie obiektu klasy Autor powoduje rozpoczęcie przez niego pisania
String words[] = { "Ala", "ma", "kota", "i", "psa" };
Author autor = new Author(numLines, // ile wierszy ma napisać
words, // z jakich słów składać teksty
authorWrites); // Dokąd je zapisywać
}
public static void main(String args[]) {
int numLin = 0; // ile wierszy ma napisać autor
int numWri = 0; // ilu jest przepisywaczy
try {
numLin = Integer.parseInt(args[0]);
numWri = Integer.parseInt(args[1]);
} catch(Exception exc) {
System.out.println("Syntax: java PipesShow numLines numWri");
System.exit(1);
}
new PipesShow(numLin, numWri);
}
}
No i w końcu przestrzeń przepisywania. Uprzedzając nieco wykłady o graficznych
interfejscah użytkownika zastosujemy tu proste elementy AWT. Jeżeli w tej
chwili będzie to niezrozumiałe - proszę się nie martwić (dzięki klasie SpaceToWrite
odseparowaliśmy wygląd aplikacji od jej funkcjonalności, zatem z punktu widzenia
eksperymentowania z potokami nie ma większego znaczenia jaką postać ma ta
klasa).
// Klasa, określająca przestrzenie
// na których piszą przepisywacze
// oraz grupująca te przestrzenie w oknie.
// Każdy przepisywacz wypisuje tekst
// do wielowierszowego pola edycyjnego (TextArea z pakietu AWT)
// do czego służy mu metoda writeLine.
// Wszystkie przestrzenie grupowane są w oknie frame.
import java.awt.*;
import java.awt.event.*;
public class SpaceToWrite extends TextArea {
private static Frame frame = new Frame("Write space");
// Konstruktor: tworzy nową przetrzeń pisania dla jednego przepisywacza
public SpaceToWrite(int rows, int cols) {
super(rows, cols); // utworzenie TextArea - z podaną liczbą wierszy, kolumn
frame.add(this); // dodanie TextArea do okna
}
// Metoda dopisująca nowy wiersz do textarea
public void writeLine(String s) {
this.append(s + '\n');
}
// Metoda ustalająca ułożenie pól edycyjnych w oknie
// rozmiar okna (pack daje rozmiar taki jak akurat potrzreba)
// i pokazująca okno
public static void show(int numWriters) {
frame.setLayout(new GridLayout(0, numWriters));
frame.pack();
frame.show();
// Umożliwienie zakończenia aplikacji poprzez zamknięcie okna
frame.addWindowListener(new WindowAdapter() {
public void windowClosing(WindowEvent e) {
frame.dispose();
System.exit(1);
}
});
}
Wynik dzialania całego programu pokazuje poniższy rysunek:
Dla prześledzenia równoleglej pracy przepisywaczy
proszę skompilować i uruchomić ten program na własnym komputerze (program
znajduje się w katalogu PipesTest).
5.11. Strumienie z Internetu
Abstrakcyjny strumień (np. InputStream) może być związany z zasobem
sieci, oznaczanym przez URL.
To co dostaniemy w rezultacie czytania takiego strumienia zależy od
tego w jaki sposób serwer definiuje przesyłanie informacji dotyczących
tego zasobu. Zazwyczaj, jeśli zasobem jest plik - uzyskamy możliwość odczytania tego pliku.
Istnieją dwa elementarne sposoby działania na strumieniach z Internetu:
- stworzyć URL i uzyskać związany z nim strumień wejściowy (umożliwia tylko
czytanie),
-
stworzyć URL, uzyskać połączenie, a następnie odpowiednie strumienie - wejsćiowy i wyjściowy - dla tego połączenia (umożliwa
czytanie i pisanie np. automatyczne wypełnianie formularzy).
Przykład: czytanie z Internetu dokumentów html, których adresy
(np. w postaci: http://....) zapisane są w pliku podanym jako argument
programu.
import java.net.*; // konieczne do posługiwania się klasą URL
import java.io.*;
import java.util.*;
class URLReader {
public static void main(String[] args) throws Exception {
BufferedReader list = new BufferedReader( // lista URLi
new FileReader(args[0]));
String urlString;
while ((urlString = list.readLine()) != null) {
readAndSave(new URL(urlString)); // tworzony nowy obiekt klasy URL
} // oznaczający zasób z Sieci
list.close();
System.exit(0);
}
static void readAndSave(URL url) throws Exception {
BufferedReader in = new BufferedReader(
new InputStreamReader(
url.openStream() // zwraca InputStream związany z URLem
)
);
String fname = null;
StringTokenizer st = new StringTokenizer(
url.getFile(), // <- zwraca nazwę pliku dla URLa
"/"
);
while (st.hasMoreTokens()) fname = st.nextToken(); // pobieramy nazwę pliku
// pod którą ma być zachowany
BufferedWriter out = new BufferedWriter(new FileWriter(fname));
String s;
while ((s = in.readLine()) != null) {
out.write(s);
out.newLine();
}
in.close();
out.close();
}
}
Inne sposoby dzialania w Sieci, szczególnie w ujęciu klient-serwer (m.in.
komunikacja przez gniazda - sockets) zostaną omówione w przyszłym semestrze.
5.12. Obiekty plikowe
Klasa File oznacza obiekty plikowe (pliki i katalogi). Jej metody umożliwiają
m.in. uzyskiwanie informacji o plikach i katalogach, jak również wykonywanie
działań na systemie plikowym.
Wybrane metody klasy File
|
boolean | canRead()
Czy plik może być czytany? |
boolean | canWrite() Czy plik może być zapisywany? |
boolean | createNewFile()
Tworzy nowy plik |
static File | createTempFile(String prefix,
String suffix)
Tworzy nowy plik tymczasowy z nazwą wg wzorca |
static File | createTempFile(String prefix,
String suffix,
File directory)
Tworzy nowy plik tymczasowy z nazwą wg wzorca w podanym katalogu |
boolean | delete()
Usuwa plik lub katalog |
void | deleteOnExit()
Zaznacza plik do usunięcia po zakończeniu programu. |
boolean | exists()
Czy plik/katalog istnieje? |
String | getName()
Nazwa pliku lub katalogu |
String | getParent()
Katalog nadrzędny |
String | getPath()
Ścieżka |
boolean | isDirectory()
Czy to katalog? |
boolean | isFile()
Czy plik? |
boolean | isHidden()
Czy ukryty? |
long | lastModified()
Czas ostatniej modyfikacji |
long | length()
Rozmiar |
String[] | list()
Lista nazw plików i katalogów w katalogu |
String[] | list(FilenameFilter filter)
Lista nazw plików wg wzorca; zob wykład 3 |
File[] | listFiles()
Lista plików i katalogów |
File[] | listFiles(FileFilter filter) Lista plików i katalogów
|
File[] | listFiles(FilenameFilter filter) Lista plików i katalogów |
static File[] | listRoots()
Lista dostępnych "rootów" w systemie plikowym |
boolean | mkdir()
Tworzy katalog |
boolean | mkdirs()
Tworzy katalog i ew. niezbędne (niestniejące) katalogi nadrzędne |
boolean | renameTo(File dest)
Renames the file denoted by this abstract pathname. |
boolean | setLastModified(long time)
Ustala czas ostatniej modyfikacji |
boolean | setReadOnly()
Zaznacza jako tylko od odczytu |
URI | toURI()
Tworzy obiekt klasy URI (Uniform Resource Identifier), reprezentujący ten obiekt plikowy |
URL | toURL()
Tworzy obiekt klasy URL (Uniform Resource Locator), reprezentujący ten obiekt plikowy |
5.13. Pliki o dostępie swobodnym
Klasa RandomAccessFile definiuje pliki o dostępie swobodnym, które mogą
być otwarte z trybie "czytania" lub "czytania i pisania". Swobodny dostęp
oznacza dostęp do dowolnego bajtu danych bez potrzeby sekwencyjnego przetwarzania
pliku od początku.
Konstruktory klasy mają następującą postać:
RandomAccessFile(String filename, String mode)
RandomAccessFile(File file, String mode)
gdzie
mode oznacza jeden z następujących trybów otwarcia
-
"r" - plik tylko do odczytu,
-
"rw" - plik do odczytu i zapisu,
-
"rws", "rwd" - jak "rw", ale z wymuszeniem synchronicznego zapisu każdej zmiany na dysk.
Pliki o dostępie swobodnym mogą być traktowane jako ciągi bajtów. Bieżący
bajt do odczytu lub miejsce do zapisu określa specjalny wskaźnik pozycji w pliku
(filePointer). Pozycję tę możemy zmieniać za pomoca metod seek() i skip().
Jest także zmieniana przy każdej operacji czytania lub pisania.
Do czytania/pisania służy wiele metod read... i write... , które pozwalają
operować na różnych rodzaajch danych odczytywanych z i zapisywanych do
pliku (np. readDouble, readLine, writeInt itp.),
Pliki o dostępie swobodnym nie są strumieniami. Klasa RandomAccessFile nie należy więc do hierarchii klas strumieniowych
5.14. Nowe wejście-wyjście (NIO): przegląd
Niewątpliwie podstawowym motywem wprowadzenia nowych środków wejścia-wyjścia,
zawartych w pakicie java.nio i jego podpakietach było zapewnienie zwiększenia
efektywności działania programów w wysokim stopniu obciążonych operacjami
wejścia-wyjścia o dużej częstotliwości.
Tradycyjne wejście-wyjście w Javie jest blokujące, tzn. wątek, który
podejmuje próbę odczytania danych ze strumienia (metoda read()) jest blokowany,
jeśli danych w strumieniu (jeszcze) nie ma. Zatem obsługa strumieni połączeń
sieciowych (przez serwer) musiała być wykonywana poprzez wiele wątków, każdy
z których obsługiwał strumień związany z jednym gniazdem (socket) i ew. był
blokowany na tym strumieniu, czekający na dane. W takiej sytuacji przy dużej
liczbie połączeń działa równolegle dużo wątków, a ponieważ liczba połaczeń
zmienia się dynamicznie, to z dużą częstotliwością wiele wątków powstaje
i umiera (przechodzi do stanu Dead). W sumie prowadzi to do problemów efektywnościowych,
a także jest trudne w programowaniu, gdyż:
- tworzenie i przełączanie wątków (realizowane przez JVM) jest czasowo kosztowne,
- szybkie narastanie niepotrzebnych obiektów (zakończonych wątków)
powoduje zmniejszenie efektywności dzialania programu ze względu na zwiększającą
się zajętośc pamięci, bowiem standardowy odśmiecacz JVM nie jest odpowiednio
przystosowany do takiej sytuacji,
- synchronizowanie dużej liczby wątków na wspóldzielonych zasobach
może być trudne w programowaniu, zawodne i bardzo czasochłonna z punktu widzenia efektywności
dzialania programu.
Podstawowym elementem nowego wejścia-wyjścia, odpowiadającym na te problemy, jest koncepcja kanału.
Kanał reprezentuje otwarte połączenie do obiektu, który wykonuje
jedną lub wiele różnych operacji wejścia-wyjścia. Takim obiektem może być
urządzenie sprzętowe, plik, gniazdo sieciowe, a nawet komponent programu.
Gniazdo sieciowe (socket) jest punktem docelowym dwustronnej komunikacji
dwóch programów działających równolegle w sieci
Kanały podłączone do gniazd
sieciowych (np. klasa SocketChannel) umożliwiają nieblokujące (asynchroniczne) wejście-wyjście.
Przy czytaniu z takiego kanału za pomoca metody read(...), wątek nie jest
blokowany, gdy brak danych. Metoda read(...) natychmiast zwraca wynik - liczbę
przeczytanych bajtów (jeśli nie ma jeszcze danych - to 0).
Umożliwia to odpytywanie (polling) - sprawdzanie co jakiś czas czy dane nadeszły. Wątek nie jest blokowany i może wykonywać inne zadania.
Schemat nieblokującego wejścia
SocketChannel socketChannel = SocketChannel.open(); // otwarcie kanału
socketChanel.connect(...) // podłączenie do gniazda
socketChannel.configureBlocking (false); // tryb nieblokujący
...
while (true) {
...
if (socketChannel.read (buffer) != 0) { // czy są jakieś dane?
processInput (buffer); // tak - przetwórz je
}
else { // nie - wykonuj inne czynności
...
}
}
Uwaga: co to jest buffer? O tym za chwilę.
Jasne jest, że stanowi to alternatywę dla wielu wątków, z których każdy blokowany
jest na czytaniu danych z kanału. Teraz jeden wątek może obsługiwać wiele
kanałów bez blokowania przy braku danych.
Nazywa się to multipleksowaniem kanałów wejścia/wyjścia.
Ale samo nieblokowane wejście-wyjście nie wystarcza jeszcze, by prawidłowo
rozwiązać problem obsługi wielu połączeń. "Ręczne" odpytywanie wielu kanałów
ma wady:
- trzeba je oprogramować (i można przy tym popełnić błędy),
- polega na wysyłaniu instrukcji we/wy za pośrednictwem JVM (read(...)),
co jest czasowo kosztowne i w rezultacie - przy dużej liczbie połączeń -
może niedopuszczalnie zmniejszać czas reakcji na każdym z połaczeń.
Dlatego w java.nio wprowadzono mechanizm selektorów, który łączy ze sobą zalety nieblokującego odpytywania oraz natychmiastowej reakcji na dane po blokowaniu wątku na odczycie.
Dzięki nieblokującemu we/wy, a szczególnie selektorom:
- jeden wątek może łatwo monitorować dużą liczbę gniazd sieciowych,
- wątek ten może być zablokowany dopóki nie pojawią się dane z któregokolwiek gniazda, a po ich pojawieniu się natychmiast wznowiony,
- może przy tym dowiedzieć się, który ze strumieni danych jest gotowy do przetwarzania i natychmiast podjąc to przetwarzanie.
Niewątpliwie przy nieblokującym wejściu-wyjściu można by to oprogramować
w Javie i bez selektorów, ale użycie selektorów ma dwie podstawowe zalety:
- zwalnia nas od obowiązku pisania trudnego kodu,
- jest efektywne, bowiem multipleksowanie i selekcja kanałów są zrealizowane
w dużej mierze przez odwolania do rodzimych funkcji platformy systemowej
i - wobec tego - omija JVM, pozostawiając ciężką pracę do wykonania procedurom
systemowym, które robią to najsprawniej.
O użyciu kanałów i selektorów do komunikacji w środowiskach sieciowych będziemy
mogli powiedzieć więcej przy okazji omawiania programowania klient-serwer..
"Nowe wejście-wyjście" ma rownież szereg nie związanych z programowaniem
sieciowym, mających ogólniejsze znaczenie, własciwości.
Obecnie w Javie kanały mogą być połączone z gniazdami, plikami oraz dowolnymi
strumieniami klas InputStream, OutputStream (te ostatnie kanały nazwiemy
kanałami strumieniowymi).
Wszystkie takie kanały mają jedną wspólną właściwość: kanały wejściowe wprowadzają dane do buforow bajtowych, a kanały wyjściowe wyprowadzają dane z tych buforów.
Bufor bajtowy przypomina nieco tablicę bajtów: jest to skończona sekwencja
bajtów, na której można wykonywać pewne operacje (m.in. pobieranie i dodawanie);
od zwykłej tablicy różni się przede wszystkim tym, że operacje na buforze
są dostosowane do potrzeb wejścia-wyjścia
Zatem zapoznanie się z koncepcją
buforów - również jednego z elementów "nowego wejścia-wyjścia" - jest nieodzowne
do korzystania z kanałów.
Bufory mogą być też stosowane samodzielnie i dostarczają pewnych nowych,
ciekawych możliwości, które mogą być wykorzystywane niezależnie od kanałowego
wejścia-wyjścia.
Zanim przejdziemy do bardziej konkretnego omawiania niektórych nowych możliwości,
dostarczanych przez kanały i bufory, warto rzucić okiem na syntezę tych
możliwości (tablica).
KANAŁY
|
|
Kanały gniazdowe
|
Kanały plikowe
|
Kanały strumieniowe
|
uzyskanie kanału
|
metoda open() z klas SocketChannel,
ServerSocketChannel,
DatagramChannel
+ connect
|
Metoda
getChannel()
z klas:
FileInputStream
FileOutputStream
RandomAccessFile
|
Metody statyczne klasy
Channels:
newChannel(InputStream)
newChannel(OutputStream)
|
|
Możliwości dostarczane przez kanały
|
nieblokujące wejście-wyjście
i możliwość stosowania selektorów
|
tak
|
nie
|
nie
|
atomistyczne czytanie rozprowadzające po wielu buforach (scattering read), atomistyczne pisanie gromadzące z wielu buforów (gathering write)
|
tak
|
tak
|
nie
|
mapowanie plików na pamięć
|
nie
|
tak
|
nie
|
bezpośrednie transfery kanałowe (np. jedno wywołanie kopiuje cały plik do innego w sposób bardzo efektywny)
|
tak, jeśli jednym z kanałów jest kanał plikowy
|
tak
|
tak, jeśli jednym z kanałów jest kanał plikowy
|
blokowanie (lock) dostępu do całego pliku lub jego części
|
nie
|
tak
|
nie
|
czytanie, pisanie do/z buforów
(zob. możliwości buforów)
|
tak
|
tak
|
tak
|
Możliwości, dostarczane przez bufory
|
różne widoki buforów bajtowych (jako ciągu bajtów lub elementów wybranego typu pierwotnego) |
przestawianie bajtów (możliwość wyboru i/lub
łatwej zmiany konwencj uporządkowania bajtów danych binarnych - czy bardziej
znaczące częsci danej mają w pamięci mniejsze czy większe adresy)
|
kodowanie - dekodowanie danych w buforach
znakowych przy uwzględnieniu wybranej strony kodowej (klasy Charset, CharsetDecoder
i CharsetEncoder, operujące na buforach)
|
bufory bezpośednie (bufory takie "opakowują"
pamięć alokowaną poza JVM przez natywne środki platformy systemowej i umożliwiają
m.in. wysoką efektywność operacji kanałowych, a także dają programiście możliwość
dostępu z poziomu programu Javy do dowolnego obszaru pamięci, alokowanego
w systemie - np. bezpośredniej pamięci graficznej)
|
Zwróćmy na koniec jeszcze raz uwagę, że "nowe wejście-wyjście" (NIO)
nie zastępuje "starego" (klas strumieniowych). Generalnie klasy strumieniowe
operują na wyższym poziomie abstrakcji, kanały zaś przeznaczone są przede
wszystkim do zwiększenia efektywności operacji wejścia-wyjścia i ukierunkowane
są raczej na niskopoziomowe operowanie na sekwencjach bajtów.
5.15. NIO: bufory
Bufor jest ciągłą, skończoną sekwencją elementów jednego z typów pierwotnych: byte, short, char, long, float, double
Odpowiednio do tego mamy różne klasy opisujące bufory np.
ByteBuffer - bufor bajtów (elementy typu byte),
IntBuffer - bufor zawierający liczby calkowite typu int,
DoubleBuffer - bufor liczb typu double.
Oprócz tych klas, odpowiadających typom pierwotnym, istnieje rownież klasa
MappedBuffer, za pomocą której możemy mieć dostęp do pliku mapowanego na
pamięć.
Ogólne charakterystyki i funkcjonalność wszystkich klas buforowych określa klasa abstrakcyjna Buffer.
Rysunek przedstawia hierarchię klas buforowych.
Źródło: Ron Hitchens, Java NIO, O'Reilly 2002
UWAGA: Kanały operują wyłącznie na buforach bajtowych.
Opakowanie danych tablicy przez bufor oznacza, że dane są te same
- zarówno w buforze jak i tablicy, a operacje na buforze, zmieniające dane
bufora zmieniają równocześnie dane w tablicy i vice versa
Bufory mogą być
tworzone poprzez:
- alokowanie prywatnej pamięci bufora - metody allocate i allocateDirect (ta ostatnia dla buforów bezposrednich),
- opakowanie istniejącej tablicy elementów wybranego typu pierwotnego - metoda wrap,
- mapowanie plików lub ich części (metoda map(...) z klasy FileChanel)
Dane do buforów mogą być zapisywane za pomoca metod put(..) z klas
buforowych oraz na skutek czytania z kanału (read(...) z klas kanałowych).
Metody kanałowe zapisują do bufora całe sekwencje bajtów.
Dane z buforów mogą być pobierane za pomocą metod get(...) z klas buforowych oraz zapisywane do kanału (write()...) z klas kanałowych).
Metody put(...) i get(...) dzielą się na relatywne (zapisujące lub odczytujące bufor poczynając od jego bieżącej pozycji) i absolutne (zapisujące/odczytujące element na podanej pozycji w buforze).
Operacje relatywne mogą dotyczyć pojedynczego elementu lub ich sekwencji
(zapisywanych do bufora z tablicy lub innego bufora, odczytywanych z bufora
do tablicy).
Metody get(...) z klas buforowych
(typ oznacza byte, char, short, int, long, float, doble - w zależności od typu bufora).
|
typ
| get()
Relatywna metoda get - zwraca bieżący element |
typBuffer | get(typ[] dst)
Relatywna metoda get - zapisuje tablicę dst elementami bufora, poczynając od jego bieżacej pozycji. |
typBuffer | get(typ[] dst,
int offset,
int length)
j.w. ale diotyczy części tablicy |
typ | get(int index)
Absolutna metoda get - zwraca element na pozycji index. |
Metody put z klas buforowych
(typ oznacza byte, char, short, int, long, float, doble - w zależności od typu bufora).
|
typBuffer | put(typ b)
Relatywna metoda put - zapisuje element b na bieżącej pozycji |
typBuffer | put(typ[] src)
Relatywna metoda put - zapisuje do bufora tablicę elementów |
typBuffer | put(typ[] src,
int offset,
int length)
j.w. - część tablicy |
typBuffer | put(typBuffer src)
Relatywna metoda put - zapisuje zawartosc bufora src do bufora |
typBuffer | put(int index, typ b)
Absolutna metoda put, zapisuje b na pozycji index |
Uwaga: jeżeli bufor jest tylko do odczytu (read-only), to użycie metody put(...) spowodują powstanie wyjątku ReadOnlyBufferException. Bufor możemy uczynić "tylko do odczytu" za pomocą metod asReadOnly() z klas buforowych.
|
Każdy z buforów ma następujące (
ważne, a czasem nieintuicyjne) charakterystyki:
- pojemność (capacity) - liczbę elementów bufora (niezmienną!),
- limit (limit) - indeks pierwszego elementu, który nie może być czytany z bufora lub zapisany do bufora,
- pozycję (position) - indeks kolejnego elementu, który będzie odczytany lub zapisany (inaczej bieżącą pozycję bufora).
Informacje o tych charakterystykach można uzyskac za pomocą metod
- int capacity()
- int limit()
- int position()
Pojemność bufora nie może być zmieniona, ale pozycję i limit możemy sustawiać za pomocą metod
- Buffer position(int pos)
- Buffer limit(int lim)
Metoda
remaining() zwraca liczbę elementów znajdujących się pomiędzy pozycją a limitem.
Metoda
hasRemaining() zwraca true, jeśli liczba ta jest większa od 0.
Każdy relatywny zapis do bufora zapisuje element bądź elementy do
bufora poczynając od jego bieżącej pozycji i zmienia pozycję, tak by wskazywała
na następny element (miejsce), który może być zapisany.
Każde relatywne czytanie z bufora czyta element bądź elementy, poczynajac
od jego bieżącej pozycji i zmienia pozycję tak, by wskazywala na następny
jeszcze nie odczytany element.
Uwaga:
Operacje absolutne nie zmieniają pozycji bufora.
Czytanie z bufora bądź zapis do bufora nie zmieniają limitu bufora.
Próba odczytu danych poza limitem spowoduje powstanie wyjątku BufferUnderflowException
(brak danych w buforze), natomiast próba zapisu danych poza limit spoowoduje
wyjątek BufferOverflowException (przepełnienie bufora).
Po zapisaniu danych do bufora, po to by można było je odczytać należy bufor przestawić.
Przestawienie bufora polega na wywolaniu metody flip(), która ustawia limit na bieżącą pozycję, po czym ustala pozycję na zero.
Jeżeli po odczytaniu danych z bufora chcemy jeszcze raz przeczytać je od początku, powinniśmy bufor przewinąć. Służy temu metoda rewind(), która ustala pozcycję na zero, pozostawiając limit bez zmian
Zachowanie buforów i zmiany ich charakterystyk ilustruje poniższy program.
import java.nio.*;
class Buffers {
static void say(String s) { System.out.println(s); }
static void showParms(String msg, Buffer b) {
say("Charakterystyki bufora - " + msg);
say("capacity :" + b.capacity());
say("limit :" + b.limit());
say("position :" + b.position());
say("remaining :" + b.remaining());
}
public static void main(String args[]) {
// alokacja bufora 10 bajtowego (inicjalnie warości elementów = 0)
ByteBuffer b = ByteBuffer.allocate(10);
showParms("Po utworzeniu", b);
// Zapis dwóch bajtów do bufora
b.put((byte) 7).put((byte) 9);
showParms("Po dodaniu dwóch elementów", b);
// Przestawienie bufora
b.flip();
showParms("Po przestawieniu", b);
// Teraz możemy czytać wpisane dane
say("Czytamy pierwszy element: " + b.get());
showParms("Po pobraniu pierwszego elementu", b);
say("Czytamy drugi element: " + b.get());
showParms("Po pobraniu drugiego elementu", b);
say("Czy możemy jeszcze czytać?");
try {
byte x = b.get();
} catch (BufferUnderflowException exc) {
say("No, nie - proszę spojrzeć na ostatni limit!");
}
// Jeszcze raz odczytajmy dane z bufora
// w tym celu musimy go przewinąć
b.rewind();
showParms("Po przewinięciu", b);
say("Czytanie wszystkiego, co wpisaliśmy");
while (b.hasRemaining())
say("Jest: " + b.get());
}
}
który na wyjściu da:
Charakterystyki bufora - Po utworzeniu
capacity: 10
limit: 10
position: 0
remaining: 10
Charakterystyki bufora - Po dodaniu dwóch elementów
capacity: 10
limit: 10
position: 2
remaining: 8
Charakterystyki bufora - Po przestawieniu
capacity: 10
limit: 2
position: 0
remaining: 2
Czytamy pierwszy element: 7
Charakterystyki bufora - Po pobraniu pierwszego elementu
capacity: 10
limit: 2
position: 1
remaining: 1
Czytamy drugi element: 9
Charakterystyki bufora - Po pobraniu drugiego elementu
capacity: 10
limit: 2
position: 2
remaining: 0
Czy możemy jeszcze czytać?
No, nie - proszę spojrzeć na ostatni limit!
Charakterystyki bufora - Po przewinięciu
capacity: 10
limit: 2
position: 0
remaining: 2
Czytanie wszystkiego, co wpisaliśmy
Jest: 7
Jest: 9
Proszę uważnie przeanalizować działanie i wyniki tego programu.
Warto podkreślić, że nawigacja "po buforze" zawsze odbywa się w kontekście typu jego elementów.
Np. w buforze typu IntBuffer elementami są liczby typu int, get zwraca element
typu int, a pozycja będzie się przesuwać tak, by wskazywać na następny element
typu int (4 bajty).
Natomiast w buforze typu ByteBuffer pobieranie, zapisywanie i zmiany pozycji dotyczą pojedynczych bajtów.
Należy zwrócić szczególną uwagę na bufory bajtowe, bowiem
Bufory bajtpwe (typu ByteBuffer) odgrywają szczególną rolę, bowiem tylko one są używane w operacjach na kanalach.
5.16 NIO: kanały i bufory
Aby używać kanalów musimy importować nazwy klas z pakietu java.nio.channels.
Bufory wymagają importu java.nio.
Schematy użycia kanałów w operacjach wejścia-wyjścia przedstaiwono poniżej.
Dla ustalenia uwagi posługujemy się wyłącznie kanałami plikowymi.
Zapis do kanału
|
1. Alokacja bufora bajtowego
ByteBuffer buf = ByteBuffer.allocate(N); // N - rozmiar bufora
2. Zapis danych do bufora (np. z użyciem metod put...)
3. Uzyskanie kanału klasy FileChannel "do zapisu"
Możemy go uzyskać za pomocą metod getChannel() z klas FileOutputStream oraz
RandomAccessFile (kanał "do zapisu i do odczytu", jeśli taki był tryb otwarcia
pliku o dostępie swobodnym). Np.
FileOutputStream out = new FileOutputStream(...);
FileChannel fc = out.getChannel();
4. Zapis bufora - użycie metod write z klasy FileChannel. Np.
fc.write(buf);
5. Zamknięcie kanału:
fc.close();
Zamknięty kanał pozostaje zamknięty. Metoda isOpen() pozwala stwierdzić czy kanał jest otwarty.
|
Czytanie z kanału
|
1. Alokacja bufora bajtowego
ByteBuffer buf = ByteBuffer.allocate(N); // N - rozmiar bufora
2. Uzyskanie kanału klasy FileChannel "do odczytu"
Możemy go uzyskać za pomocą metod getChannel() z klas FileInputStream oraz
RandomAccessFile (kanał "do zapisu i do odczytu", jesli taki był tryb otwarcia
pliku o dostępie swobodnym). Np.
FileInputStream out = new FileInputStream(...);
FileChannel fc = out.getChannel();
3. Czytanie do bufora - użycie metod read z klasy FileChannel. Np.
fc.read(buf);
4. Zamknięcie kanału:
fc.close();
Zamknięty kanał pozostaje zamknięty.
5. Określenie właściwej pozycji i limitu bufora po wczytaniu do niego danych.
Np. poprzez przestawienie bufora:
buf.flip();
6. Odczytanie danych z bufora za pomocą metod get...
|
Poniższy program ilustruje powyższy schemat. Komentarze szczegółowo omawiają użyte w nim konstrukcje. Proszę zwrócić na nie baczną uwagę.
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
class SimpleChannel {
String fname = "test.tmp";
byte[] data = {1,2,3,4,5 };
SimpleChannel() {
try {
writeChannel(fname, data);
byte[] wynik = readChannel(fname);
for (int i=0; i < wynik.length; i++) System.out.println(wynik[i]);
} catch(Exception exc) {
exc.printStackTrace();
System.exit(1);
}
}
void writeChannel(String fname, byte[] data) throws Exception {
// Możemy utworzyć bufor przez opakowanie istniejącej tablicy
ByteBuffer buf = ByteBuffer.wrap(data);
FileOutputStream out = new FileOutputStream(fname);
// Uzyakanie kanału
FileChannel fc = out.getChannel();
//Zapis
fc.write(buf);
fc.close();
}
byte[] readChannel(String fname) throws Exception {
// Używamy obiektu klasy File
// by dowiedzieć się jaki jest rozmiar pliku
// i odpowiednio alokowac bufor
File file = new File(fname);
// Stworzenie strumienia na podstawie obiektu klasy File
FileInputStream in = new FileInputStream(file);
// Uzyskanie kanału
FileChannel fc = in.getChannel();
// Metoda size() z klasy FileChannel
// zwraca long -rozmiar plku, do którego podlączony jest kanał
int size = (int) fc.size();
// Utworzenie bufora
ByteBuffer buf = ByteBuffer.allocate(size);
// Czytanie do bufora
// nbytes - liczba przeczytanych bajtów
int nbytes = fc.read(buf);
fc.close();
// Po przeczytaniu danych musimy bufor przestawić
buf.flip();
// Stworzenie tablicy na wynik czytania
// jej rozmiar będzie określony przez liczbę przeczytanuych bajtów
// którą możemy podać na dwa sposoby: poprzednie nbytes
// lub uzyskując informację o liczbie jeszcze nieodczytanych bajtów z bufora
byte[] wynik = new byte[buf.remaining()];
buf.get(wynik);
return wynik;
}
public static void main(String args[]) {
new SimpleChannel();
}
}
W klasie FileChannel zdefiniowano wiele metod pozwalających na czytanie z
- zapis do kanałów oraz pozycjonowanie i przewijanie plików, do których te
kanały są podlączone.
5.17. Widoki buforów bajtowych
Jak już wiemy, w operacjach kanalowych używa się wyłącznie buforów bajtowych,.
Zapewniają one łatwy dostęp do elementów - bajtów.
Ale jak w takim razie przetwarzać dane innych typów (niż bajty) pobierane z kanałów lub zapisywać takie dane do kanału?
Są po temu przynajmniej dwie możliwości.
Pierwsza polega na użyciu metod klasy ByteBuffer:
ttt elt = getTtt()
putTtt(Ttt elt)
które umożliwają zapisywanie i pobieranie danych typu ttt (short, int, long, float, double, char).
Drugi sposób - znacznie ciekawszy i przyjemniejszy - polega na użyciu widoków buforów bajtowych
Metoda klasy ByteBuffer:
TttBuffer asTttBuffer()
gdzie: ttt - jeden z typów short, int, long, float, double, char
użyta wobec bufora bajtowego buf zwraca referencję do bufora klasy TttBuffer, co pozwala na działanie na danych pierwotnego bufora bajtowego buf za pomocą metod get i put klasy TttBuf czyli tak, jakby pierwotny bufor bajtowy zawierał elementy typu ttt.
Mówimy, że metoda ...asBuffer() zwraca widok bufora bajtowego jako bufora typu TttBuffer, gdyż nie następuej tu żadne kopiowanie danych i zmiany danych wykonywane
za pośrednictwem obu obiektów - bufora pierwotnego oraz jego widoku dotyczą
tego samego bufora.
Koncepcję widoku bufora bajtowego ilustruje poniższy program.
import java.nio.*;
class BufView1 {
public static void main(String args[]) {
final int SHORT_SIZE = 2;
// Alokacja buforu bajtowego,
// mogącego przechowywać do 10 liczb typu short
ByteBuffer bb = ByteBuffer.allocate(10*SHORT_SIZE);
// Widok na ten bofor jak na short-bufor
ShortBuffer sb = bb.asShortBuffer();
// Dodanie trzech liczb typu short
short a = 1, b = 2, c = 3;
sb.put(a).put(b).put(c);
// Co wpisano do bufora? Na wydruku: 1, 2, 3
sb.flip();
while (sb.hasRemaining()) System.out.println(sb.get());
// Operujemy teraz na nim za pomocą bufora bajtowego
// zmieniając bajty na pozycji 1, 3 i 5.
byte x = 4, y = 5, z = 6;
bb.put(1, x).put(3, y).put(5, z);
// Co pokaże short-bufor? Na wydruku 4, 5, 6
sb.rewind();
while (sb.hasRemaining()) System.out.println(sb.get());
}
}
Z widokami bufora bajtowego wiąże się jednak pewna pułapka.
Otóż, mimo, że bufor i jego widok dotyczą tych samych danych to jednak mamy
dwa bufory (bufor bajtowy i jego widok jakiegoś wybranego typu - np. ShortBuffer
), a w tych buforach zmiany pozycji oraz limitu są niezalezne od siebie.
Pozycja i limit bufora bajtowego są niezależne od pozycji i limitu jego widoku.
Gdybyśmy np. chcieli w poprzendim przykladzie po wpisaniu liczb short do
bufora bajtowego (za pomocą jego widoku) pokazać kolejne bajty wszystkich
wpisanych liczb, to moglibyśmy pojść błędnym tropem: po wpisaniu danych przestawić
bufor short (flip) i sądząc, że pierwotny bufor bajtowy także dostosuje swoją
pozycję i limit wypsiywać bajty w pętli dopóki hasRemaining() nie zwróci
false.
Ilustruje to poniższy program (w którym używamy metody showParms zpoprzednich przyjładów dla pokazania charakterystyk bufora):
public static void main(String args[]) {
final int SHORT_SIZE = 2;
ByteBuffer bb = ByteBuffer.allocate(10*SHORT_SIZE);
ShortBuffer sb = bb.asShortBuffer();
short a = 1, b = 2, c = 3;
sb.put(a).put(b).put(c);
showParms("bufor short - po dodaniu liczb", sb);
showParms("bufor bajtowy - po dodaniu liczb", bb);
sb.flip();
showParms("bufor short - po przestawieniu", sb);
showParms("bufor bajtowy - po flip bufora short", bb);
System.out.print("Dane");
while (bb.hasRemaining()) System.out.print(" " + bb.get());
}
}
Wydruk.
Charakterystyki bufora - bufor short - po dodaniu liczb
capacity: 10
limit: 10
position: 3
remaining: 7
Charakterystyki bufora - bufor bajtowy - po dodaniu liczb
capacity: 20
limit: 20
position: 0
remaining: 20
Charakterystyki bufora - bufor short - po przestawieniu
capacity: 10
limit: 3
position: 0
remaining: 3
Charakterystyki bufora - bufor bajtowy - po flip bufora short
capacity: 20
limit: 20
position: 0
remaining: 20
Dane 0 1 0 2 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0
Zwrócmy uwagę: dodanie elementów do widoku bufora bajtowego nie zmienia jego
pozycji i limitu, zmienia tylko pozycję i limit w widoku, Rownież flip()
widoku nie ma wpływu na te parametry bufora bajtowego. Program wypisuje cały
bufor, bajt po bajcie, a nie bajty tych elementów, ktore zostały dodane.
Aby w tym przykładzie wypisac tylko bajty wpisanych danych, powinniścmy po
przestawieniu widoku bufora zmienić odpowiednio limit bufora bajtowego:
sb.flip();
bb.limit(sb.limit()*SHORT_SIZE);
System.out.print("Bajty wpisanych danych");
while (bb.hasRemaining()) System.out.print(" " + bb.get());
Jednak przy czytaniu danych z kanałów nie ma takiej potrzeby. Dane są wczytywane
do bufora bajtowego, a nie do jego widoku. Zatem należy przestawić bufor
bajtowy, a dopiero potem stworzyć i posługiwać się jego widokiem. Jest to sensowne,
bowiem widok bierze pod uwagę pozycję i limit bufora, na podstawie którego
jest tworzony, odpowiednio ustawiając swoją pozycję i limit.
Co pokazuje poniższy program.
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
class BuffChan {
String fname = "testfile.tmp";
// inicjacja danych testowych
void init() throws Exception {
double[] data = { 0.1, 0.2, 0.3 };
DataOutputStream out = new DataOutputStream(
new FileOutputStream(fname)
);
for (int i=0; i < data.length; i++) out.writeDouble(data[i]);
out.close();
}
BuffChan() throws Exception {
// inicjalcja danych testowych
init();
// utworzenie bufora
ByteBuffer buf = ByteBuffer.allocate(1000); // nie wiemy ile, maks 100B
// uzyskanie kanału
FileChannel fcin = new FileInputStream(fname).getChannel();
// czytanie z kanału do bufora
fcin.read(buf);
fcin.close();
// przestawienie bufora bajtowego
buf.flip();
// Utworzenie widoku bufora
DoubleBuffer dbuf = buf.asDoubleBuffer();
// Wypisanie odczytanych danych
while (dbuf.hasRemaining()) System.out.println(dbuf.get());
}
public static void main(String args[]) throws Exception {
new BuffChan();
}
}
5.18. NIO: bufory - uporządkowanie bajtów (endianess)
Przy czytaniu i zapisywaniu plików, tworzonych na różnych platformach
sprzętowych, powstaje problem uporządkowania (kolejności występowania) bajtów
danych binarnych,
Wynika to z tego w jaki sposób dane binarne przechowywane są w pamięci (i jak zapisywane w rejestrach procesora).
Porządek bajtów w reprezentacji binarnej danych określa się terminem endianess.
Istnieją dwa różne porządki:
-
Porządek BIG ENDIAN oznacza, że mniej znaczące bajty danych są zapisywane
pod większymi adresami pamięci (niejako od prawej do lewej)
-
Porządek LITLLE ENDIAN jest odwrotny: najpierw są zapisywane mniej znaczące bajty, później bardziej (od lewej do prawej).
Ilustruje to rysunek, na którym pokazano porządek bajtów liczby typu int równej 1234567 (heksadecymalnie 0x12D687).
O tym jaki jest porządek bajtów w buforach możemy dowiedzieć się za pomocą
metody order(), która zwraca referencję do obiektu klasy ByteOrder.
W przypadku buforów bajtowych (i tylko dla nich) możemy zmienić porządek
za pomocą wywołania metody order(ByteOrder) z argumentem będącym stałą statyczną
z klasy ByteOrder:
-
order (ByteOrder.BIG_ENDIAN) - zmienia porządek na BIG ENDIAN
-
order (ByteOrder.LITTLE_ENDIAN) - zmienia porządek na LITTLE ENDIAN
Widoki bufora bajtowego mają ten porządek bajtów, który występował dla
bufora bajtowego w momencie tworzenia widoku (możemy powiedzieć, że widok
dziedziczy porządek swojego bufora, bajtowego obowiązujący w chwili tworzenia
widoku)
Ilustruje to poniższy program:
import java.nio.*;
class Endianness {
static void show(int n) {
String s = Integer.toHexString(n);
int l = s.length();
for (int i=l; i < 8; i++) s = '0' + s;
System.out.println("Liczba " + n + " hex -> " + s.toUpperCase());
}
public static void main(String args[]) {
int num = Integer.parseInt(args[0]);
ByteBuffer buf = ByteBuffer.allocate(4);
System.out.println(buf.order().toString());
IntBuffer b1 = buf.asIntBuffer();
System.out.println("Porządek b1 " + b1.order());
b1.put(num);
b1.flip();
show(b1.get());
buf.order(ByteOrder.LITTLE_ENDIAN);
System.out.println("Porządek buf " + buf.order());
System.out.println("Porządek b1 " + b1.order());
b1.rewind();
show(b1.get());
System.out.println("Porządek buf " + buf.order());
System.out.println("Porządek dziedzizcony " + buf.asIntBuffer().order());
show(buf.asIntBuffer().get());
}
}
który dla podanej jako argument liczby 987654321 wyprowadzi następujące wyniki:
BIG_ENDIAN
Porządek b1 BIG_ENDIAN
Liczba 987654321 hex -> 3ADE68B1
Porządek buf LITTLE_ENDIAN
Porządek b1 BIG_ENDIAN
Liczba 987654321 hex -> 3ADE68B1
Porządek buf LITTLE_ENDIAN
Porządek dziedzizcony LITTLE_ENDIAN
Liczba -1318527430 hex -> B168DE3A
5.19. NIO: bufory znakowe. Kodowanie i dekodowanie.
Bufory znakowe (klasa CharBuffer) reprezentują sekwencje elementów typu char (znaki unikodu).
Mają one wszystkie właściwości buforów (o których już była mowa), ale dodatkowo zapewniają pewną specyficzną funkcjonalność.
Zauważmy, że sekwencje znaków są reprezentowane również przez obiekty klas String i StringBuffer.
W Javie 1.4 uogólniono więc pewne elementarne operacje na sekwencjach znaków, wprowadzając interfejs CharSequence
. Interfej ten implementowany jest przez wszystkie klasy w/w klasy: String,
StringBuffer i CharBuffer, dzięki czemu możliwe jest (do pewnego stopnia)
uniwersalne operowanie na sekwencjach znaków, niezaleznie od tego, czy są
one obiektami klas String, StringBuffer czy CharBuffer.
Metody interfejsu CharSequence podano w tablicy,
char | charAt(int index)
Zwraca znak na pozycji index |
int | length()
Zwraca długość sekwencji znaków |
CharSequence | subSequence(int start,
int end)
Zwraca podsekwencję od znaku na pozycji start do znaku na pozycji end (wyłacznie) |
String | toString()
Zwraca referencję do nowego obiektu klasy String, zawierającego tę sekwencję znaków. |
Używając tych metod można napisac uniwersalną metodę operującą na sekwncjach
znaków, która jako argument może przyjmować referencję do obiektu dowolnej
z klas: String, StringBUffer i CharBuffer (należy się spodziewać, że zetsaw
metod interfejsu CharSequence zostanie w przyszłości wzbogacony, co rozszerzy
możliwości pisania uniwersalnych fragmentów kodu). Już teraz zresztą CharSequence
pełni istotną użyteczną rolę, bowiem metody klas pakietu java.util.regex,
umozliwiające anlalizę łańcuchów znakowych za pomocą wyrażeń regularnych,
operują na obiektach typu CharSequence.
Z punktu widzenia buforów znakowych, wprowadzenie interfejsu CharSequence ma dwie ważne konsekwencje:
- poza znanymi nam metodami buforowymi na buforach znakowych można operowac za pomocą metod tego interfejsu,
- klasa CharBuffer dostarcza metody wrap opakowującej dowolną sekwencję
znaków (obiekt klasy implementującej interfejs CharSequence) i dzięki temu
możemy np. w następujący sposób tworzyć bufory znakowe.:
String txt = editor.getText();
CharBuffer buf = CharBuffer.wrap(txt);
To miłe, że mamy takie bufory znakowe, ale przecież kanały mogą operowac wyłącznie na buforach bajtowych.
Przy kanałowym czytaniu i zapisie strumieni tekstowych powstaje więc problem kodowania i dekodowania informacji tekstowej.
Znany nam sposób kodowania-dekodowania za pomocą klas InputStreamReader i
OutputStreamReader dotyczy tylko plików, a przy tym nie nadaje się do zastosowania
przy polączeniach kanałowych, bowiem kanały plikowe można uzyskać tylko od
obiektów klas FileInputStream, FileOutputStream i RandomAccessFile.
Dla rozwiązania problemu dekodowania - kodowania można, co prawda, zastosować
klasę String, jak pokazuje poniższy program (konwertujący pliki z jednej
strony kodowej na inną).
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
class Ende1 {
public static void main(String[] args) {
if (args.length != 4) {
System.out.println("Syntax: in in_enc out out_enc");
System.exit(1);
}
String infile = args[0], // plik wejściowy
in_enc = args[1], // wejściowa strona kodowa
outfile = args[2], // plik wyjściowy
out_enc = args[3]; // wyjściowa strona kodowa
try {
FileChannel fcin = new FileInputStream(infile).getChannel();
FileChannel fcout = new FileOutputStream(outfile).getChannel();
ByteBuffer buf = ByteBuffer.allocate((int)fcin.size());
// czytanie z kanału
fcin.read(buf);
// przeniesienie zawartości bufora do tablicy bytes
buf.flip();
byte[] bytes = new byte[buf.capacity()];
buf.get(bytes);
// dekodowanie - za pomocą konstruktora klasy String
String txt = new String(bytes, in_enc);
// enkodowanie za pomocą metody getBytes z klasy String
// utworzenie nowego bufora dla kanału wyjściowego
// zapis do pliku poprzez kanał
bytes = txt.getBytes(out_enc);
buf = ByteBuffer.wrap(bytes);
fcout.write(buf);
fcin.close();
fcout.close();
} catch (Exception e) {
System.err.println(e);
System.exit(1);
}
}
}
ale jest to rozwiązanie dodatkowo obciążające pamięć operacyjną oraz - jak widać - mało wygodne w programowaniu.
W NIO rozwiązano problem kodowania-dekodowania za pomoca wprowadzenia nowych klas Charset, CharsetDecoder, CharsetEncoder w pakiecie java.nio.charset.
- Klasy CharsetDecoder i CharsetEncoder dostarczają bogatych środków i pełnej kontroli nad procesem kodowania i dekodowania.
- Klasa Charset dostarcza definicji stron kodowych, a także wygodnych
metod kodowania i dekodowania (które są łatwiejsze w użyciu niż zastosowanie
klas CharsetEncoder i CharsetDecoder).
Dekodowanie-kodowanie za pomocą Charset polega na:
- utworzeniu obiektu klasy Charset, za pomocą statycznej metody forName(String),
reprezentująccgo stronę kodową, której nazwę podano jako argument np:
Charset charset = Charset.forName("ISO-8895-2");
- dekodowaniu bajtów z bufora bajtowego z danej strony kodowej
na sekwencję znaków Unicode, zapisaną w buforze znakowym za pomocą metody
decode(...):
ByteBuffer buf = ...
CharBuffer cbuf = charset.decode(buf);
- lub kodowaniu znaków Unicodu z bufora znakowego do bajtów, reprezentujących tekst zakodowany wedle danej strony kodowej w buforze bajtowym:
CharBuffer cbuf = ...
ByteBuffer buf = charset.encode(cbuf);
Informację o nazwach dostępnych w danej implementacji JVM stron kodowych można
uzyskać za pomocą metody availCharsets() z klasy Charset.
Przykładowy program pokazuje zastosowanie tej procedury do konwersji plików
z jednej strony kodowej na inną (z wykorzystaniem kanałów).
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
class Ende2 {
public static void main(String[] args) {
if (args.length != 4) {
System.out.println("Syntax: in in_enc out out_enc");
System.exit(1);
}
String infile = args[0], // plik wejściowy
in_enc = args[1], // wejściowa strona kodowa
outfile = args[2], // plik wyjściowy
out_enc = args[3]; // wyjściowa strona kodowa
try {
FileChannel fcin = new FileInputStream(infile).getChannel();
FileChannel fcout = new FileOutputStream(outfile).getChannel();
ByteBuffer buf = ByteBuffer.allocate((int)fcin.size());
// czytanie z kanału
fcin.read(buf);
// Strony kodowe
Charset inCharset = Charset.forName(in_enc),
outCharset = Charset.forName(out_enc);
// dekodowanie bufora bajtowego
buf.flip();
CharBuffer cbuf = inCharset.decode(buf);
// enkodowanie bufora znakowego
// i zapis do pliku poprzez kanał
buf = outCharset.encode(cbuf);
fcout.write(buf);
fcin.close();
fcout.close();
} catch (Exception e) {
System.err.println(e);
System.exit(1);
}
}
}
5.20. NIO: operacje kanałowe na wielu buforach (scattering i gathering)
Kanały pozwalają za jednym odwołaniem czytać dane do wielu buforów (scatter read) lub
zapisywać dane z wielu buforów (gather write).
Służą do tego metody read i write (z klas
kanałowych), które jako argument mają tablice buforów.
Jest to bardzo użyteczna właściwość, pozwala bowiem w łatwiejszy sposób programować
uzyskiwanie przez kanały danych, mających określoną strukturę, w której można
wyróżnić jakieś części o określonym rozmiarze.
Przykładem mogą być pliki graficzne lub dane z nagłówkami komunikacyjnymi.
Program testujący czytanie do wielu buforów.
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
public class ScatteringTest {
static final String fname = "scatter.tst"; // nazwa pliku testowego
public static void main(String[] args) throws Exception {
// Zapisywanie danych testowych
DataOutputStream out = new DataOutputStream(
new FileOutputStream(fname) );
short[] dat1 = { 1, 2, 3, };
double[] dat2 = { 10.1, 10.2, 10.3 };
for (int i=0; i < dat1.length; i++) out.writeShort(dat1[i]);
for (int i=0; i < dat2.length; i++) out.writeDouble(dat2[i]);
out.close();
//-----------------------------------------------------------+
// Odczytywanie danych testowych |
//-----------------------------------------------------------+
FileInputStream in = new FileInputStream(fname);
// Uzyskanie kanału
FileChannel channel = in.getChannel();
// Tablica bajt-buforów
final int SHORT_SIZE = 2, // ile bajtów ma short
DOUBLE_SIZE = 8; // ........... i double
ByteBuffer[] buffers = { ByteBuffer.allocate(dat1.length*SHORT_SIZE),
ByteBuffer.allocate(dat2.length*DOUBLE_SIZE)
};
// jedno czytanie z kanału zapisuje kilka buforów !
long r = channel.read(buffers);
System.out.println("Liczba bajtów przeczytanych do obu buforów: " + r);
// Przed uzyskiwaniem danych z buforów - trzeba je przestawić!
buffers[0].flip();
buffers[1].flip();
// Pierwssy bufor
// Widok na bufor jako na zawierający liczby short
ShortBuffer buf1 = buffers[0].asShortBuffer();
System.out.println("Dane 1");
while ( buf1.hasRemaining()) {
short elt = buf1.get();
System.out.println(elt);
}
// Drugi bufor
// Widok na bufor jako na zawierający liczby double
DoubleBuffer buf2 = buffers[1].asDoubleBuffer();
System.out.println("Dane 2");
while ( buf2.hasRemaining()) System.out.println(buf2.get());
}
}
wyprowadzi następujące wyniki:
Liczba bajtów przeczytanych do obu buforów: 30
Dane 1
1
2
3
Dane 2
10.1
10.2
10.3
Również przy zapisywaniu danych przez kanały, możliwość jednorazowego zapisu
z wielu buforów może być wielce wygodna. Np. w pewnych buforach możemy przygotować
stałe fragmenty danych i już ich nie zmieniać, inne zaś bufory wypełniać
dynamicznie zmieniającą się treścią.
Potrzebne będzie tylko jedno wywołanie metody write, by zapisać cały zestaw
buforów (co oprócz wygody programistycznej ma również pozytywny wpływ na efektywność
działania naszego programu).
Przykładowy program zapisuje kilka plików, każdy z których ma taki sam nagłówek
i zakończenie. Częsć środkową wypełniają zmieniające się - od pliku do pliku
- dane.
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
class GatheringTest {
public static void main(String[] args) throws Exception {
// To będą stałe części każdego pliku
String sHeader = "To jest nagłówek. Może być duży";
String sFooter = "To jest zakończenie. Może być duże";
// To będą dane, które się zmieniają od pliku do pliku
byte[][] dane = { { 1, 2, 3}, // dane 1-go pliku
{ 9, 10, 11, 12 }, // dane 2-go pliku
{ 100, 101} // dane 3-go pliku
};
Charset charset = Charset.forName("windows-1250");
ByteBuffer header = charset.encode(CharBuffer.wrap(sHeader)),
footer = charset.encode(CharBuffer.wrap(sFooter));
// Drugi element tablicy buforów będzie dynamicznie się zmienial
// na razie = null
ByteBuffer[] contents = { header, null, footer };
for (int i = 0; i<dane.length; i++) {
FileChannel fc = new FileOutputStream("plik"+i).getChannel();
contents[1] = ByteBuffer.wrap(dane[i]); // podstawienie zmiennych danych
fc.write(contents); // zapis danych ze wszystkich buforów!
fc.close();
header.rewind();
footer.rewind();
}
}
}
Czytanie i pisanie "po wielu buforach" od razu, nazywane także wektorowym wejściem-wyjściem (vectored I/O) jest atomistyczne
nie tylko w tym sensie, że realizowane jest przez jedno odwołanie z poziomu
programu. Na nowoczesnych platformach systemowych daje ono dużą poprawę efektywności,
bowiem następuje znaczna redukcja liczby niskopoziomowych (na poziomie jądra
systemu) operacji we/wy oraz ich optymalizacja (w skrajnym przypadku do atomistycznej
- pojedynczej - calkowicie zoptymalizowanej niskopoziomowej operacji we/wy).
5.21. NIO: mapowanie plików
Mapowanie pliku polega na odzwierciedleniu całości lub części pliku w pamięci, bezpośrednio dostępnej dla programu.
Mapowanie pliku w NIO uzyskujemy poprzez uzyskanie kanału podłączonego do
pliku (niech oznacza go zmienna channel), a następnie użycie metody map z
klasy FileChannel. Metoda ta zwraca referencję do obiektu klasy MappedByteBuffer
(pochodnej od Buffer), który jest bajtowym buforem bezpośrednim (direct),
zawierającym bajty pliku lub jego wybranego segmentu.
FileChannel channel = ....; // podłączenie kanału do pliku
MappedByteBuffer buf = channel.map( tryb, pozycja, rozmiar);
gdzie:
- buf - bufor bajtowy, reprezentujący zwartość pliku lub jego segmentu
- tryb - jedna z opcji będących stałymi statycznymi klasy FileChannel.MapMode o nazwach
- READ_ONLY - możliwe tylko odczytywanie bufora
- READ_WRITE - możliwe zmiany w buforze i ew. automatycznej propagacji
zmian do pliku i ew. widocznośi tych zmian przez inne programy mapujące ten
sam plik,
- PRIVATE - możliwe zmiany w buforze, ale bez propagowania ich do
pliku (będą więc niewidoczne dla innych programów mapujących ten plik)
- pozycja - pzocucja pliku, od ktorej zaczyna się mapowanie
- rozmiar - rozmiar mapowanego segmentu pliku
Po zmapowaniu pliku, na uzyskanym buforze bajtowym i jego widokach możemy
wykonywać operacje, znane nam z klas buforowych, a operacje te de-facto będą
operacjami na zawartości pliku.
Dostępne tryby dzialania zależą od trybów podłączenia (otwarcia) kanału.
Tryb READ_ONLY może być użyty tylko dla kanałów otwartych do czytania, natomiast
tryby READ_WRITE oraz PRIVATE wymagają kanałów otwartych w trybie odczytu-zapisu.
Odpowiedniość między trybami kanałów, a rodzajami plików/strumieni pokazuje poniższa tablica.
Tryb mapowania
|
Tryb otwarcia kanału
|
Rodzaj pliku,
do którego podłączamy kanał
metodą getChannel()
|
READ_ONLY
|
tylko do odczytu
|
FileInputStream
RandomAccesFile otwarty w trybie tylko do odczytu ("r")
|
READ_WRITE
PRIVATE
|
do odczytu-zapisu
|
RandomAccesFile otwarty w trybie pisania-czytania ("rw")
|
Mapowanie plików może być wygodne z punktu widzenia programowania (działanie
na pliku jak na buforze i jego widokach - lub w niektórych przypadkach jak
na tablicy).
Ale nie tylko to jest zaletą mapowania plików.
Otóż uzyskiwany bufor mapujący (obiekt klasy MappedByteBuffer) jest buforem bezpośrednim, czyli alokowanym poza przestrzenią adresową programu .
W nowoczesnych systemach operacyjnych może on prawie w ogóle nie zajmować
pamięci systemu! Oczywiście jakieś fragmenty pamięci są używane dla prowadzenia
odwzorowania, ale generalnie strony pamięci wirtualnej nie są zajęte. Obszarem
stronicowanie jest sam plik!
Sprawia to, iż wiele programów naraz może mapować ten sam duży plik i działać
na nim, nawet jeśli sumaryczna wielkość wszystkich odwzorowań przekracza
wielkość dostępnej pamięci operacyjnej.
Z drugiej strony, to że bufory dla mapowanych plików są bezpośrednie (czyli
alokowane gdzieś poza programami) sprawia, że mapowane pliki mogą stanowić
wygodną realizację pamięci dzielonej (przez różne procesy) i sposobem na komunikwoanie się tych procesów.
Zobaczmy przykłady.
Pierwszy program ilustracyjny zapisuje najpierw plik testowy liczbami całkowitymi,
następnie uzyskuje kanał i mapuje plik, po czym wykonuje zmiany w tym pliku,
operując wylącznie na mapującym buforze.
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
class MapFiles1 {
String fname = "test";
public MapFiles1() throws Exception {
init(); // inicjacja pliku testowego
mapAndChange(); // mapowanie i zmiana danych pliku
checkResult(); // sprawdzenie wyników
}
void init() throws IOException {
int[] data = { 10, 11, 12, 13 };
DataOutputStream out = new DataOutputStream(
new FileOutputStream(fname)
);
for (int i=0; i<data.length; i++) out.writeInt(data[i]);
out.close();
}
void mapAndChange() throws IOException {
// Aby dokonywać zmian musimy przyłączyć kanal
// do pliku otwartego w trybie "read-write"
RandomAccessFile file = new RandomAccessFile(fname, "rw");
FileChannel channel = file.getChannel();
// Mapowanie pliku
MappedByteBuffer buf;
buf = channel.map(
FileChannel.MapMode.READ_WRITE, // tryb "odczyt-zapis"
0, // od początku pliku
(int)channel.size() // cały plik
);
// Uzyskujemy widok na bufor = zmapowany plik
IntBuffer ibuf = buf.asIntBuffer();
// Dla ciekawości: jakie charakterystyki widoku
System.out.println(ibuf + " --- Direct: " + ibuf.isDirect());
int i = 0;
while (ibuf.hasRemaining()) {
int num = ibuf.get(); // pobieramy kolejny element
ibuf.put(i++, num * 10); // zapisujemy jego wartość*10 na jego pozycji }
}
// Zapewnia, że zmiany na pewno zostaną odzwierciedlone w pliku
buf.force();
channel.close();
}
void checkResult() throws IOException {
DataInputStream in = null;
try {
in = new DataInputStream(new FileInputStream(fname));
while(true) System.out.println(in.readInt());
} catch(EOFException exc) {
return;
} finally {
in.close();
}
}
public static void main(String[] args) throws Exception {
new MapFiles1();
}
}
Program wyprowadzi:
java.nio.DirectIntBufferS[pos=0 lim=4 cap=4] --- Direct: true
100
110
120
130
Na przykładzie tego programu warto zwrócić uwagę na następujące kwestie:
- program praktycznie nie konsumuje pamięci dla przechowywania pliku
(zarówno MappedByteBuffer, jak i jego widok jako IntBuffer są buforami bezpośrednimi)
- po zmapowaniu pliku, pozycja bufora bajtowego jest ustawiona na
0, a jego pojemność i limit na liczbę zmapowanych bajtów; widoki "dziedziczą"
te ustawienia, dokonując jedynie przeliczeń limitu i pojmeności z uwzględnieniem
rozmiaru typu elementów,
- użycie metody force() z klasy MappedByteBuffer zapewnia fizyczny
zapis do pliku; sposób i częstostliwość fizycznego zapisu zmienianych w buforze
danych zależy od systemu operacyjnego, może się okazać, że nawet po zamknięciu
kanału, systemowe czy sprzętowe cache/bufory nie są wymiatane.
Drugi program ma trochę bardziej praktyczne zastosowanie. Pozwala on "w miejscu"
zmienić kodowanie podanego jako argument pliku z Windows1250 na ISO-8859-2
jednocześnie zamieniając wszystkie litery na duże.
Przy okazji zobaczymy, że:
- dekodowanie bufora bajtowego tworzy nowy bufor znakowy, który opakowuje
tablicę elementów typu char[]. Ta tablica istnieje (ona własnie, praktycznie,
jest tym buforem znakowym) i możemy mieć do niej dostęp poprzez odwołanie
array() z klasy CharBuffer (dla wszystkich klas buforowych metoda array zwraca tablicę opakowaną przez bufor, ale tylko wtedy, gdy takie opakowanie miało miejsce),
- czasem potrzebne może być zastosowanie klasy CharsetEncoder (jej
metody encode używamy po to by dokonać kodowania do już istniejącego bufora
bajtowego, w naszym przypadku - mapującego plik). Analogicznie (ale niejako
w drugą stronę) może być zastosowana klasa CharsetDecoder.
Oto tekst programu. Proszę go przetestowac na jakimś samodzielnie utworzonym
pliku html, zapisanym w stronie kodowej Windows 1250).
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
class MapFiles2 {
public static void main(String[] args) throws Exception {
Charset inCharset = Charset.forName("windows-1250"),
outCharset = Charset.forName("ISO-8859-2");
RandomAccessFile file = new RandomAccessFile(args[0], "rw");
FileChannel fc = file.getChannel();
// Mapowanie pliku
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,
0, (int) fc.size());
// Utworzenia bufora znakowego ze zdekodowanymi znakami
// z bufora bajtowego (mapujące plik). Konwersja: win1250->unicode
CharBuffer cbuf = inCharset.decode(mbb);
// Okazuje się, że ten nowo utworzony bufor opakowuje tablicę
// zatem możemy ją uzyskać i działać na jej elementach
// to dzialanie oznacza dzialanie na elementach bufora
char[] chArr = cbuf.array();
for (int i=0; i < chArr.length; i++)
chArr[i] = Character.toUpperCase(chArr[i]);
// Po dekodowaniu bufor bajtowy musi być przewinięty do początku
// aby koder (zob. dalej) mógł w nim zapisywać kodowane dane
mbb.rewind();
// Utworzenie kodera, zamieniającego Unicode na wyjściową stronę kodową
CharsetEncoder encoder = outCharset.newEncoder();
// Koder zapisuje istniejący bufor mbb (ten który mapuje plik)
// ostatni argument - true oznacza zakończenie pracy kodera na tym wywołaniu
encoder.encode(cbuf, mbb, true);
fc.close();
}
}
5.22. NIO: bezpośrednie transfery kanałowe
Bezpośredni transfer kanałowy polega na przeslaniu wszystkich danych
jednego kanału do drugiego kanału za pomocą jednego odwołania z poziomu programu,
z możliwym (w zależności od systemu operacyjnego i platformy sprzętowej)
pominięciem systemowych operacji wejścia-wyjścia, a zrealizowanym jako bardzo
szybki trarnsfer wykonywany przez sam sprzęt ze wsparciem ze strony jądra
systemu
Obecnie w NIO bezpośrednie transfery kanałowe dotyczą tarnsferów do/z plików.
Klasa FileChannel dostarcza dwóch metod, które pozwalają na:
- bezpośredni transfer kanałowy z dowolnego kanalu do kanalu plikowego (metoda transferTo)
- bezpośredni transfer kanalowy z kanalu plikowego do dowlonego kanału (metoda transferFrom)
Bezpośrednie transfery kanałowe w systemach, które w swoim jądrze zapewniają
wsparcie takich transferów, mogą być bardzo szybkie i efektywne.
W innych systemach (jak np. Windows), poprawa efektywności (w porównaniu
z tradycyjnymi sposobami kopiowania danych) może być różna, nawet dość nieznaczna.
Zawsze jednak zaletą stosowania metod transfer... z klasy FileChannel
pozostaje możliwośc uniknięcia potrzeby programowania (w kliku wierszach,
ale zawsze to trochę pracy) procedur kopiowania danych z/do plików.
Oto przykładowy program, porównujący dwa sposoby kopiowania plików: za pomocą
bezpośredniego transferu kanałowego i poprzez buforowane strumienie.
import java.nio.*;
import java.nio.channels.*;
import java.io.*;
class DirectTransfer {
String inFileName;
String outFileName;
DirectTransfer(String infn, String outfn) throws Exception {
inFileName = infn;
outFileName = outfn;
directTransfer();
copyByStream();
}
void directTransfer() throws Exception {
FileInputStream in = new FileInputStream(inFileName);
FileOutputStream out = new FileOutputStream(outFileName);
FileChannel fcin = in.getChannel();
FileChannel fcout = out.getChannel();
long size = fcin.size();
System.out.println("Copying file " + size + "B.");
long start = System.currentTimeMillis();
// Bezpośredni transfer
fcout.transferFrom(fcin, 0, size);
long end = System.currentTimeMillis();
System.out.println("Direct transfer time " + (end - start));
}
final int BUFSIZE = 5000000;
void copyByStream() throws Exception {
FileInputStream fin = new FileInputStream(inFileName);
BufferedInputStream in = new BufferedInputStream(fin, BUFSIZE );
FileOutputStream fout = new FileOutputStream(outFileName);
BufferedOutputStream out = new BufferedOutputStream(fout, BUFSIZE);
byte[] b = new byte[BUFSIZE];
long start = System.currentTimeMillis();
while (true) {
int n = in.read(b);
if (n == -1) break;
out.write(b, 0, n);
}
in.close();
out.close();
long end = System.currentTimeMillis();
System.out.println("Stream time " + (end - start));
}
public static void main(String[] args) throws Exception {
new DirectTransfer(args[0], args[1]);
}
}
Możliwy wynik:
Copying file 51793936B.
Direct transfer time 2470
Stream time 6150
5.23. Podsumowanie
Po lekturze tego rozdziału wiemy już bardzo dużo o operacjach-wejścia w Javie.
Dzięki abstrakcji strumieniowej możemy pisać bardzo uniwersalne i elastyczne
programy, zaś poznanie nowych środków wejścia-wyjścia (NIO) daje możliwość
pisania programów, działających bardzo efektywnie przy dużym obciążeniu operacjami
wejścia-wyjścia.
5.24. Zadania i ćwiczenia
-
Stworzyć klasę, która pozwala na zliczanie wystąpień w podanym strumieniu podanego napisu.
- Jak 1, ale użyć kanałów plikowych
- Napisać program, w którym występują dwa wątki. Wątek 1 przegląda system
plikowy w poszukiwaniiu plików html. Nazwy odnalezionych plików przekazuje
za pomocą potoku drugiemu wątkowi, który zmienia kodowanie w przekazanych
plikach z Windows-1250 na ISO-8859-2 (jesli taka konieczność występuje).
- Napisać dwa programy, które wymieniają dane za pomocą zmapowanego pliku.