Java (18) - Proudy, serializace

Doposud naše aplikace pracovaly vesměs s daty, která jsme jim interně zadali. V dnešním dílu si představíme datové proudy (streamy), pomocí kterých může naše aplikace komunikovat se svým okolím.

Proudy pro nás ale nejsou žádnou novinkou, již jsme se s nimi mnohokrát setkali. Pro výpis na konzoli jsme volali metody proudu System.out, a když jsme ve 14. dílu získávali číslo ze standardního vstupu, tak jsme jej četli z proudu System.in.

Bylo by ovšem mylné se domnívat, že vstupem a výstupem programů je pouze klávesnice a konzole. Proudy mohou stejně dobře pracovat s výstupními zařízeními (tiskárnou), obrázky, soubory a mnoha dalšími sériovými zdroji/cíly dat.

Typy proudů

Proudy můžeme dělit dle jejich směru na vstupní (přenášejí data do aplikace) a na výstupní (přenášejí data z aplikace). Proudy také rozlišujeme na binární a znakové. Jak již názvy napovídají, tak zatímco binární proudy využijeme pro libovolná binární data (tj. data vkládáme je po bajtech), tak znakové proudy jsou určeny pouze pro text (znaky).

Vybrané binární proudy
Vybrané binární proudy

Decorator

S proudy je v Javě neodmyslitelně spojen návrhový vzor decorator (dekorátor). Jak jsme si již naznačili, existuje velké množství proudů pro různé situace a zařízení. Tyto proudy ale neobsahují veškerou možnou funkcionalitu, primárně protože kombinace jednotlivých vlastností nemusí být kompatibilní, a ve většině případů s sebou nese i určitou režii.

Pokud bychom zároveň vytvářeli zvláštní třídu pro každý různě obohacený proud, tak bychom jich brzy měli tisíce. Příkladem by pak mohla být třída Proud pro zápis do souboru s vyrovnávací pamětí, bez kontrolního součtu, šifrovaný, bez hashe (a pětatřiceti dalšími přívlastky).

Základní myšlenka vzoru decorator je velmi jednoduchá – ať je proud obohacený jakýmkoliv způsobem, tak musí mít pořád stejné rozhraní.


Příklad

Pro případ vstupního (de)šifrujícího souborového proudu proto stačí vytvořit nejprve proud pro přístup k souboru (základní, nešifrovaný) a ten vložit jako zdroj do (de)šifrujícího proudu. Veškerá volání dešifrujícho proudu nejprve zpracujeme a poté delegujeme patřičným metodám obaleného proudu.

Pokud tedy budeme chtít použít metodu, která se šifrování nijak netýká – například uzavření proudu – tak (de)šifrující proud volání pouze přepošle. Pokud budeme chtít přečíst nějaká data, tak je (de)šifrující proud přečte z obaleného proudu a nám navrátí výstup interního dešifrovacího procesu.

        //Z tohoho souboru budeme cist
        InputStream is = new FileInputStream("/tmp/file.txt");
        //Ale soubor je sifrovan, predame (de)sifrovacimu proudu instanci puvodniho vstupniho proudu
        //NullCipher nedela nic :-), je to identita
        CipherInputStream cis = new CipherInputStream(is, new NullCipher());
        //precteme jeden byte desifrovanych dat
        int i = cis.read();
        
        //uzavreme proud (uvolnime zdroje systemu)
        cis.close();

Binární proudy

Binární proudy umožňují přenést libovolná data. Vstupní proudy dědí z třídy InputStream (dokumentace), výstupní z OutputStream (dokumentace). Základní operací definovanou v InputStreamu je metoda read, pomocí které můžeme z proudu přečíst jeden bajt. Analogicky výstupní proud definuje metodu write.

Čtení a zápis po jednotlivých bajtech je velmi pomalé, zvláště pokud uvážíme, že na druhé straně proudu může být disk – a každý dotaz může velmi snadno znamenat nutnost nového vystavení hlaviček.

BufferedStream

Třídy BufferedInputStream (dokumentace) a BufferedOutputStream (dokumentace) za nás tento nedostatek řeší. Tyto proudy obsahují pole, které slouží jako vyrovnávací paměť. V případě čtení z disku bufferovaný proud načte celý blok dat a uloží jej do pole, ze kterého data dále posílá našemu programu. V okamžiku, kdy se pole vyprázdní, učiní dotaz na další blok. Tímto způsobem dochází k eliminaci velkého množství zbytečných a drahých volání.

Obdobně pro zápis na disk použijeme BufferedOutputStream. Zde se ovšem můžeme dostat do situace, kdy zapíšeme do bufferu méně dat, než je potřebné k jeho vyprázdnění (zápisu do obaleného proudu). V tomto případě můžeme zavolat metodu flush, která vyprázdní všechny buffery (tj. zajistí okamžitý zápis všech dat). Před uzavíráním proudu – uzavření dealokuje veškeré zdroje nutné pro udržení spojení – není nutné flush použít, metoda close to udělá sama.

Serializace

Velmi zajímavou možností využití proudů je serializace (ukládání objektů do formátu, který může být uložen nebo poslán po síti). K serializování objektů můžeme použít standardní mechanismus využívající třídy ObjectOutputStream (dokumentace) a rozhraní Serializable (dokumentace).

Pro serializaci objektu ovšem musí být splněno několik podmínek:

  • Třída objektu implementuje rozhraní Serializable (nemá metody, slouží pouze jako marker).
  • Každá nadtřída, která neimplementuje Serializable musí mít veřejný bezparametrický konstruktor. Serialiyovaná třída musí zajistit serializaci datových složek všech nadtříd, které nejsou serializovatelné.
  • Všechny datové složky objektu musí být buď primitivního typu, serializovatelné nebo přechodné – klíčové slovo transient (značí, že daná položka nebude serializována).

První podmínku jsme schopni zaručit téměř vždy – stačí pouze objekt oddědit a implementovat rozhraní (výjimkou jsou konečné třídy (mají v hlavičce modifikátor final)). Zbylé výjimky můžeme obejít implementací rozhraní Externalizable (dokumentace), pomocí kterého si nadefinujeme vlastní serializační protokol.

Pokud chceme při serializaci a deserializaci pouze provést nějaké dodatečné akce, ale vystačíme si s automatickým mechanismem, tak musíme na serializovatelném objektu pouze implementovat metody readObject a writeObject (musí být soukromé).


/**
 * Demonstrace serializacnich mechanismu
 * @author Pavel Micka
 */
public class Main {

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException {
        ObjectOutputStream s = new ObjectOutputStream(new FileOutputStream("c:/temp/object.txt"));
        A a = new A();
        a.setBar("bar");
        a.setFoo("foo");
        s.writeObject(a); //zapiseme a


        B b = new B();
        b.setFoo("fooB");
        b.setBar("barB");
        s.writeObject(b); //zapiseme b - vypise "Serializating"

        C c = new C();
        c.setFoo("fooC");
        c.setBar("barC");

        s.writeObject(c);

        s.close(); //zavreme vystupni stream


        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("c:/temp/object.txt"));
        A readA = (A)inputStream.readObject();
        System.out.println(readA.getFoo()); //foo
        System.out.println(readA.getBar()); //null

        B readB = (B) inputStream.readObject(); //vypise "Deserializating"
        System.out.println(readB.getFoo()); //fooB
        System.out.println(readB.getBar()); //null

        C readC = (C) inputStream.readObject();
        System.out.println(readC.getFoo()); //fooC
        System.out.println(readC.getBar()); //barC

        inputStream.close(); //zavreme vstupni stream

    }
}

/**
 * Demonstrace automaticke serializace
 * @author Pavel Micka
 */
class A implements Serializable {

    protected String foo;
    /**
     *
     */
    protected transient String bar;

    /**
     * @return the foo
     */
    public String getFoo() {
        return foo;
    }

    /**
     * @param foo the foo to set
     */
    public void setFoo(String foo) {
        this.foo = foo;
    }

    /**
     * @return the bar
     */
    public String getBar() {
        return bar;
    }

    /**
     * @param bar the bar to set
     */
    public void setBar(String bar) {
        this.bar = bar;
    }
}

/**
 * Demonstrace explicitniho pouziti metod readObject a writeObject
 * @author Pavel Micka
 */
class B extends A {

    private void writeObject(ObjectOutputStream out) throws IOException {
        //zapise automaticky, co lze automaticky zapsat
        out.defaultWriteObject();
        System.out.println("Serializating");
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        // our "pseudo-constructor"
        in.defaultReadObject();
        System.out.println("Deserializating");

    }
}

/**
 * Pouziti rozhrani Externalizable
 * @author Pavel Micka
 */
class C extends A implements Externalizable {

    public C() {
    }
    

    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(this.foo);
        out.writeObject(this.bar);
    }

    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        this.foo = (String) in.readObject();
        this.bar = (String) in.readObject();
    }
}

Metoda reset

Kdybychom chtěli do výstupního proudu ukládat jeden a ten samý objekt vícekrát (byť s pozměněnými datovými složkami), tak by nám k tomu prosté opakované volání writeObject nestačilo. Výstupní proud si totiž interně pamatuje, že daný objekt již byl zapsán a podruhé jej neuloží. Tuto vlastnost ale můžeme obejít vyčištěním interní paměti proudu pomocí volání metody reset.

Serial version UID

Java si v rámci serializovatelných tříd udržuje konstantu serialVersionUID, která udává verzi objektu. Pokud změníme jeho definici, tak se změní i zmíněná konstanta a původní objekt již nepůjde deserializovat.

Existují ale i operace, které uložený objekt nezneplatní – například přidání členské proměnné, její odmazání, přidání či odebrání metody objektu. V tento okamžik se hodí mít u původní i zastaralé (uložené) entity stejné, explicitně zadané, serialVersionUID, aby deserializace proběhla bez problémů (přidaná/odebraná proměnná byla ignorována).

Mezi změny, které nejsou kompatibilní, patří odebrání rozhraní Serializable a obecně jakákoliv změna hierarchie předků dané třídy.

class A implements Serializable {
    private static final long serialVersionUID = 10275539472837495L;
    .
    .
    .
}

Znakové proudy

Znakové proudy fungují stejným způsobem jako ty binární, pouze operují s textem (a jsou odvozeny od tříd Reader (dokumentace) a Writer (dokumentace)).

Poměrně důležitou poznámkou je, že Java interně uchovává řetězce ve znakové sadě Unicode. Z toho plyne, že při každém zápisu a čtení ze znakového proudu dochází k překódování daného řetězce (znaků).


Příklad

Abychom si demonstrovali použití znakových proudů, tak vytvoříme řádkový nástroj, který v zadaném textovém souboru spočítá počet řádků, slov a písmen.

/**
 * Radkova utilita, ktera spocita pocet znaku, slov a radku v zadanem souboru
 * @author Pavel Micka
 */
public class Main {

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) throws FileNotFoundException, UnsupportedEncodingException, IOException {
        //dva parametry prikazove radky -> 1) cesta k souboru 2) kodovani souboru
        if(args.length != 2){
            System.out.println("Usage: program file_name file_encoding");
            System.exit(1); //ukonci beh programu (virtualniho stroje) s kodem 1, kod jiny nez 0 == chyba
        }
        String filePath = args[0];
        String fileEncoding = args[1];

        int wordCount = 0;
        int lineCount = 0;
        int characterCount = 0;

        //vytvorime novou bufferovanou ctecku s danym kodovanim z FileInputStreamu
        BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(filePath), fileEncoding));
        String line = null;
        while((line = reader.readLine()) != null){ //dokud mame k dispozici dalsi radek
            characterCount += line.length();
            lineCount++;

            //tokenizer rozlozi text na jednolive symboly (tokeny) dle uvedenych oddelovacu (delimiter)
            StringTokenizer tokenizer = new StringTokenizer(line, " \\u0009"); //oddelovace jsou mezera a \\u0009 == tabulator
            wordCount += tokenizer.countTokens();
        }
        
        reader.close(); //i Readery (a Writery) musime zavrit
        
        System.out.println("Pocet znaku: " + characterCount);
        System.out.println("Pocet slov: " + wordCount);
        System.out.println("Pocet radku: " + lineCount);
    }
}

Abychom mohli program otestovat z vývojového prostředí Netbeans, musíme vybrat kontextovém menu projektu volbu Properties, zvolit položku Run a vyplnit pole Arguments (tj. umístit zde cestu ke zvolenému textovému souboru a název jeho kódování).

Také si můžeme zkusit program spustit z příkazové řádky. Nejprve vstupíme do složky projektu a podsložky build/classes. Zde program spustíme příkazem "java balicek_tridy.jmeno_tridy parametry_prikazove_radky".

Zjištění kódování textového souboru

Pokud nevíte, jakým způsobem zjistit kódování textového souboru, tak si stáhněte textový editor PSPad (aktuálně ve verzi 4.5.4). Otevřete v něm zvolený soubor a podívejte se na dolní lištu (zde uvidíte napsáno například Kódování UTF-8). Pokud byste chtěli znakovou sadu změnit, tak můžete zvolit jinou v menu Formát.

Literatura

  • HORTON, Ivor. Java 5. Praha : Neocortex spol s.r.o., 2005. 1443 s. ISBN 80-86330-12-5.
  • GREANIER, Todd. Sun Developer Network (SDN) [online]. July 2000 [cit. 2010-12-09]. Discover the secrets of the Java Serialization API. Dostupné z WWW: <http://java.sun.com/developer/technicalArticles/Programming/serialization/>.







Doporučujeme

Internet pro vaši firmu na míru