Java (19) - síťová komunikace II.

Různé druhy komunikací na internetovém protokolu - to bude téma tohoto článku. Budeme komunikovat protokoly UDP a TCP, podíváme se také na vyšší vrstvy komunikačního modelu.

12.10.2005 07:00 | Lukáš Jelínek | přečteno 46765×

Odeslání datagramu

Začneme tím nejjednodušším. Potřebujeme poslat samostatný paket (datagram) nějakému příjemci, jehož adresu (jmennou nebo číselnou) a port známe. Bude to, jak je pro protokol UDP typické, nezabezpečený přenos dat - datagram se může poškodit, zcela ztratit, může přijít několikrát, pořadí příchodu datagramů k příjemci se může lišit od pořadí odeslání. Veškerá režie proto leží na uživatelské aplikaci (popis však přesahuje prostorové možnosti tohoto článku).

Zkusme si tedy vytvořit datagram, který příjemci pošleme. V nám již důvěrně známém balíku java.net je třída DatagramPacket, která představuje v podstatě pole bajtů připravené k odeslání. Instance tohoto objektu obsahuje také cílovou adresu a číslo portu (nemusí být nastaveny). Jak vytvořit takový datagram, napoví příklad:

String s = "testovací datagram";

byte ba[];

try {
    ba = s.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
  ...
}

DatagramPacket dgp1 = new DatagramPacket(ba, ba.length);
DatagramPacket dgp2 = new DatagramPacket(ba, 2, 3);

DatagramPacket dgp3 = null;
DatagramPacket dgp4 = null;

try {
    dgp3 = new DatagramPacket(ba, ba.length,
            InetAddress.getByName("pocitac.domena.cz"), 12345);
    dgp4 = new DatagramPacket(ba, ba.length,
            new InetSocketAddress("pocitac.domena.cz", 12345));
} catch (Exception e) {
    System.err.println(e.getMessage());
}

První dva uvedené pakety se vytvoří bez přiřazené adresy a portu. Jeden pojme celý textový řetězec (protože se, jak je zřejmé, datagram vytvoří z celého pole), zatímco druhý se vytvoří od druhého bajtu s délkou 3 bajty. Pozor ovšem, takový postup je u textových dat (které jsem zde použil pro názornost) nepříliš vhodný, protože zejména pro kódování UTF-8 obvykle předem nevíme, jak se znaky zakódují - proto je lepší znaky vybrat předem. Pro obecná (binární) data ovšem můžeme klidně s úsekem pole pracovat.

Druhé dva pakety obsahují adresu a port - v obou případech stejné, liší se pouze způsob vytvoření. Všimněte si, že je potřeba zachytávat výjimky, případně je předávat výš. Abych byl přesný, výjimky vyhazuje pouze konstruktor přijímající objekt typu SocketAddress, u InetAddress se zde jedná o ošetření výjimky z metody getByName().

Máme datagram, ale co s ním? Poslat, samozřejmě. Na to ale potřebujeme prostředek, který to zajistí. Tím prostředkem je UDP socket, v Javě reprezentovaný třídou DatagramSocket. Socket obsadí určitý port na některé z adres počítače (viz minulý díl - rozhraní a adresy). Adresu a port si můžeme zvolit, ale také si je můžeme nechat přidělit automaticky. Můžeme také socket "připojit" na adresu příjemce - odesílat pakety pak bude možné jen tomuto příjemci (totéž se týká příjmu). Datagram odešleme zavoláním metody send(). Viz příklad:

try {
    DatagramSocket sock1 = new DatagramSocket();
    
    sock1.send(dgp1);  // chyba - datagram ani socket nemá cíl. adresu a port
    sock1.send(dgp3);  // funguje
    
    DatagramSocket sock2 = new DatagramSocket(55555);
    sock2.connect(InetAddress.getByName("pocitac.domena.org"), 44444);
    
    sock2.send(dgp2);  // funguje
    sock2.send(dgp4);  // chyba - výjimka kvůli neshodě adres
} catch (Exception e) {
    e.printStackTrace();
}

Příklad využívá datagramy vytvořené výše (v reálném programu se ale samozřejmě musí zajistit, aby pakety skutečně existovaly). Nyní tedy vytvoříme dva sockety - první z nich bude mít adresu a port přidělené automaticky (což v našem případě nehraje roli) a nebude omezen na konkrétní cílovou adresu. Z toho vyplývá, že první datagram by odmítl odeslat (chybí mu cílová adresa a port), druhý by odeslal bez problémů.

Druhý socket má ručně zvolený místní port 55555 a je pevně nastaven na komunikaci s určenou adresou a portem. Proto první paket odešle, zatímco druhý nikoli, protože se cílová adresa a port v datagramu liší od cílové adresy a portu socketu. Všechny chybové stavy se hlásí výjimkami - ovšem pozor, některé stavy závisí na bezchybné funkci ICMP komunikace (kterou mohou někteří příliš horliví správci sítí blokovat), a ani pak nevyhození výjimky automaticky neznamená úspěch.

Na druhém konci

Datagram tedy úspěšně odešel z našeho programu (a pokud nemá lokální adresu příjemce, pak i z počítače). Na druhém konci by na něj měl někdo čekat - nemusí, ale pak samozřejmě posílání dat nemá smysl. Vytvoříme tedy úplně stejný socket jako při posílání datagramu. Paket se přijímá metodou receive(). Toto volání vždy blokuje do přijetí paketu nebo vypršení časového limitu (v tom případě ovšem končí vyhozením výjimky SocketTimeoutException). Následující příklad ukazuje implementaci "UDP echo" - program čeká na data, a když přijdou, pošle je zpět odesílateli.

try {
    DatagramSocket sock = new DatagramSocket(20000);
    DatagramPacket dgp = new DatagramPacket(new byte[1000], 1000);
    
    sock.setSoTimeout(600000);  // timeout 10 minut
    
    while (true) {
        sock.receive(dgp);
        sock.send(dgp);
    }
    
} catch (Exception e) {
    ...
}

Je to opravdu velmi jednoduché. Vytvoříme socket na portu 20000, připravíme datagram s bufferem pro 1000 bajtů, a nastavíme socketu časový limit 10 minut. To bude také jediný způsob ukončení (skrze výjimku) tohoto primitivního prográmku, pokud nepočítám stisknutí Ctrl-C a podobné způsoby. Metoda receive() pracuje poněkud "nejavovským" způsobem - předáváme jí již vytvořený objekt, který metoda pouze naplní. Zde je nutno zdůraznit, že buffer datagramu musí být dostatečně velký, aby se do něj data vešla (jinak se oříznou). Platnou délku metoda zapíše do datagramu, odkud ji zjistíme zavoláním getLength(). Velikost bufferu volíme podle velikosti očekávaných dat, nemá smysl vytvářet zbytečně velké buffery pro data o několika bajtech.

Pozor - použití čísel portů podléhá omezením práv na konkrétním systému. Obvykle lze bez omezení používat porty od 1024, nižší vyžadují administrátorská práva.

TCP klient

Spojově orientovaná komunikace je v Javě ještě jednodušší než ta paketová. Pracuje se totiž se streamy - úplně stejně, jako při práci se soubory. Dá se říci, že díky této abstrakci se nemusí program vůbec zabývat, jak je komunikace řešena. Prostě získá příslušný stream a hotovo. Pro sestavení spoje se používá opět socket. Vše je vidět na následujícím příkladu:

try {
    Socket sock1 = new Socket();            // vytvoření socketu
    sock1.connect("www.linuxsoft.cz", 80);  // připojení
    sock1.close();                          // zavření socketu

    Socket sock2 = new Socket("www.linuxsoft.cz", 80); // hned se připojíme
    
    BufferedReader br = new BufferedReader(
            new InputStreamReader(sock2.getInputStream()));
    BufferedWriter bw = new BufferedWriter(
            new OutputStreamWriter(sock2.getOutputStream()));
    
    bw.write(request);  // zapíšeme předem připravený požadavek
    bw.flush();         // odeslání z bufferu
    
    String line = "";
    
    // dokud jsou data, opakuj
    while (line != null) {
        line = br.readLine();
        if (line != null)
            System.out.println(line);  // platná data vypisuj
    }
    
    sock2.close(); // zavření socketu
    
} catch (Exception e) {
    ...
}

Tento kus kódu představuje velmi primitivního HTTP klienta. Nejprve k vytvoření socketu - v prvním případě vytvoříme nepřipojený a připojíme ho teprve ve druhém kroku. Po použití (resp. zde bez použití) se socket zavře, čímž se zruší (korektním způsobem) TCP spoj mezi počítači.

Ve druhém případě již socket využijeme. Zde se vytváří s okamžitým připojením. Ze socketu získáme streamy, ty jsou napojeny na další tak, že pak máme k dispozici textové bufferované streamy. Do toho výstupního zapíšeme předem připravený (není součástí příkladu) HTTP požadavek, a ze vstupního streamu můžeme číst data. Ta se v tomto případě vypisují na standardní výstup. Po použití socket opět zavřeme.

Parametry socketů (timeouty, přenosové třídy, buffery apod.) můžeme různě nastavovat - je samozřejmě potřeba vědět, k čemu nám změna výchozího nastavení bude.

Jednoduchý server

Typické chování serveru je, že čeká na připojení klienta, a až ten se připojí, obslouží ho - a současně čeká na připojení případných dalších klientů. V Javě to vypadá tak, že si vytvoříme instanci třídy ServerSocket a zde metodou accept() nasloucháme na příslušném portu. V okamžiku, kdy se připojí klient, metoda vrátí instanci třídy Socket. Z ní, úplně stejně jako na klientské straně, získáme vstupní a výstupní stream, a přes tyto streamy již normálně komunikujeme.

Zbývá ještě vyřešit, jak během obsluhy klienta zajistit čekání na další klienty. Java je výrazně multithreadově orientovaná - a z toho vyplývá i řešení tohoto problému. Prostě vytvoříme nové vlákno, které obsluhuje klienta, zatímco původní vlákno opět zavolá metodu accept() a čeká na další připojení. Ještě se nelze nezmínit, že počet klientů čekajících na obsloužení je omezený. Výchozí hodnota je 50, v konstruktoru lze určit jinou - ovšem s rozumem, protože je lepší klienta odmítnout, než obsluhovat neúnosně pomalu.

boolean quit = false;
    
try {
    ServerSocket ss = new ServerSocket(22222);

    while (!quit) {
        final Socket sock = ss.accept();
        Thread t = new Thread() {
            public void run() {
                try {
                    InputStream is = sock.getInputStream();
                    OutputStream os = sock.getOutputStream();
                     
                    ...
                    
                    sock.close();
                } catch (IOException e) {
                    ...
                }
            }
        };
        t.setDaemon(true);
        t.start();
    }
} catch (Exception e) {
    ...
}

Příklad ukazuje řešení pomocí anonymní třídy. Na místě použití se předefinuje metoda run() třídy Thread, vlákno se nastaví jako démon (tzn. jeho běh není překážkou ukončení programu) a spustí se. Všimněte si, že je proměnná sock deklarována s modifikátorem final - to je nutné, protože se s ní pracuje v metodě vytvořené anonymní třídy. Program běží, dokud je (v okamžiku připojení klienta) proměnná quit nastavena na false (odkud se hodnota změní, teď není důležité); poslední klient již samozřejmě není obsloužen (a obsluhování jiných klientů může být také přerušeno). Není to úplně nejlepší, ale vhodné řešení ukončování serverů je poněkud složitější - budeme se jím zabývat někdy později.

HTTP klient

Sliboval jsem vyšší vrstvy komunikace, zde je jedna z nich. Protokol HTTP je jeden z nejpoužívanějších, proto mám v Javě k dispozici snadno použitelnou implementaci HTTP klienta. Je to třída HttpURLConnection (jejím potomkem je velice podobná třída HttpsURLConnection pro připojení přes SSL).

HttpURLConnection je abstraktní třída, její instance tedy nemůžeme vytvářet. Vzhledem k charakteru HTTP spojení na tom není nic překvapivého. Instanci získáme např. metodou openConnection() na instanci třídy URL - tato metoda však vrátí instanci URLConnection, proto si ji (po potřebné kontrole) musíme přetypovat. Důležitou vlastností HttpURLConnection je sdílení prostředků a jejich efektivní správa. Proto je výhodné tento mechanismus používat. Další výhodou je opět abstraktní přístup, kdy stačí mít URL a o nízkoúrovňové operace se nemusíme starat.

try {
    URL url = new URL("http://www.linuxsoft.cz/");    
    HttpURLConnection con = (HttpURLConnection) url.openConnection();
    
    System.out.println("Response code: " + con.getResponseCode());
    System.out.println("Content type: " + con.getContentType());
    
    BufferedReader br = new BufferedReader(
            new InputStreamReader(con.getInputStream()));
    
    String s = "";
    while (s != null) {
        s = br.readLine();
        if (s != null)
            System.out.println(s);
    }
    
    con.disconnect();
} catch (Exception e) {
    ...
}

Příklad ukazuje jednoduché získání dokumentu z HTTP serveru. Na základě URL vytvoříme spojení na server, příslušná instance třídy HttpURLConnection sama odešle požadavek (při přetypování v příkladu se mlčky předpokládá, že je objekt této třídy skutečně vrácen - pokud by to tak nebylo, skončí to výjimkou ClassCastException). Příklad vypíše kód odpovědi serveru a MIME typ dat. Pak se získá vstupní stream a data z něj se vypisují na standardní výstup. To je jedna z cest, jak s daty naložit. Existují i další cesty, ale ty si necháme na jindy.

Využití proxy serveru

V některých případech není k dispozici přímé připojení do Internetu nebo jiné sítě. Jediné spojení zajišťuje proxy server. Je to sice nepříjemné, nicméně řešitelné, a to poměrně snadno, zejména od Javy 5.0. Tam je totiž přímo třída Proxy, jejíž instanci předáme metodě openConnection(). Celé je to poněkud rozsáhlejší, ale v tomto okamžiku stačí vědět, že proxy serverů je několik typů, z nichž zde vybereme HTTP (ještě se může hodit DIRECT, což značí přímé spojení bez proxy). Opět bude nejlepší ukázat si to na příkladu:

try {
    URL url = new URL("http://www.linuxsoft.cz/");
    Proxy p = new Proxy(Proxy.Type.HTTP,
            new InetSocketAddress("10.0.0.200", 3128));
    HttpURLConnection con = (HttpURLConnection) url.openConnection(p);
    
    ...
    
} catch (Exception e) {
    ...
}

Je to úplně stejné jako v předchozím příkladu - s tím rozdílem, že použijeme proxy server. Ten běží na počítači s adresou 10.0.0.200 na (obvyklém) portu 3128. Pokud proxy server pracuje, jak má, nebude se chování od předchozího příkladu lišit (pouze bychom měli přítomny HTTP hlavičky specifické pro tento druh připojení).

Bez vláken ani ránu

Komunikační činnosti nyní na chvíli opustíme (vrátíme se k nim ještě později), a přejdeme k tomu, bez čeho se například při síťové komunikaci prakticky nelze obejít - k vláknům a práci s nimi. Vlákna se již několikrát objevila v příkladech, ale byla to vždy jen malá ukázka toho, co se s nimi dá dělat.

Online verze článku: http://www.linuxsoft.cz/article.php?id_article=977