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 47154×
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.
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.
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.
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.
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.
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í).
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.