Ćwiczenia 6


Temat zajęć: Programowanie gniazd na platformie Java.

Literatura:

Uwaga 1: Opis większości klas i metod, z których będziemy korzystać na zajęciach znajduje się w dokumentacji JDK: http://java.sun.com/javase/6/docs/api/ (pakiety java.net i java.io).

Uwaga 2: W niektórych przypadkach zabezpieczenia lokalnej maszyny wirtualnej mogą uniemożliwiać korzystanie z lokalnych portów i/lub wykonywanie połączeń. W takim przypadku należy zmienić zabezpieczenia maszyny wirtualnej na czas wykonywania danego programu korzystającego z gniazd. Można to zrobić za pomocą odpowiedniego pliku policy (zasady zabezpieczeń). Uruchamiając program należy wówczas podać dodatkowy parametr przy starcie maszyny wirtualnej:
java -Djava.security.policy=mojplik.policy klasa_do_uruchomienia
Plik java.policy (pobierz) umożliwiający dostęp do wszystkich portów powyżej 1024 i wykonywanie dowolnych połączeń wygląda następująco:
grant {
permission java.net.SocketPermission "*:1024-65535", "accept";
permission java.net.SocketPermission "*:1-65535", "connect";
};


Wstęp

Z poziomu języka Java i pakietu JDK dużo łatwiej jest korzystać z gniazd TCP niż UDP (i są one używane częściej), dlatego skupimy się głównie na nich. Do komunikacji sieciowej w Javie wykorzystujemy zwykle klasy:
Dodatkowo w przypadku TCP korzystamy z java.io.InputStream, java.io.OutputStream oraz z ich klas potomnych (dla wygody), np. java.io.DataInputStream i java.io.DataOutputStream (a także z różnego typu klas buforujących). Należy zaznaczyć, że praktycznie wszystkie metody klas obsługujących połączenia sieciowe mogą wyrzucić wyjątki w przypadku błędów; zwykle jest to IOException lub jedna z jej klas potomnych. Wyjątki te należy przechytywać (za pomocą odpowiednich konstrukcji try { ... } catch (...) { ... }) i obsługiwać.

Gniazda UDP

Zajmiemy się najpierw UDP. Pierwszą czynnością jest konstrukcja obiektu klasy java.net.DatagramSocket. Do konstruktora tej klasy możemy podać numer portu, co spowoduje powiązanie tworzonego gniazda z danym numerem portu na lokalnej maszynie. Zatem możemy postąpić np. tak:
DatagramSocket s = new DatagramSocket(); // dowolny numer lokalnego portu nadany przez system 
lub tak:
DatagramSocket s = new DatagramSocket(4750); // gniazdo UDP powiązane z lokalnym portem 4750 
Zwykle w przypadku aplikacji, która czeka na dane od klientów korzystać będziemy z tego drugiego konstruktora, a implementując klienta (gdy nasz lokalny numer portu nie jest dla nas istotny) - z pierwszego.

Klasa DatagramSocket posiada dwie podstawowe metody służące do wysyłania i odbierania datagramów UDP:
Kluczem jest tutaj klasa DatagramPacket. Jeśli chcemy skonstruować pakiet przeznaczony do wysłania, to korzystamy z konstruktora
DatagramPacket(byte[] buf, int len, InetAddress address, int port) 
Czyli podajemy tablicę bajtów do wysłania, liczbę bajtów, jaka ma być z tej tablicy przesłana, adres odbiorcy (obiekt klasy InetAddress - za moment wrócimy do tej klasy) oraz numer portu odbiorcy. Jeśli tak skonstruowany datagram przekażemy następnie metodzie send klasy DatagramSocket, zostanie on wysłany pod wskazany adres, na podany numer portu, za pomocą protokołu UDP.

Jeśli interesuje nas odbiór datagramu, to również musimy najpierw go skonstruować. Używamy do tego zazwyczaj innego konstruktora (nie podajemy adresu, bo nie wiemy przecież skąd datagram nadejdzie):
DatagramPacket(byte[] bufor, int maxlen) 
Konstruujemy zatem "pusty" pakiet, zawierający podany bufor danych oraz maksymalną liczbę bajtów, jaką chcemy odebrać (oczywiście nie powinna ona przekraczać rozmiaru bufora, ale może być mniejsza).

Tak skonstruowany pakiet podajemy następnie metodzie receive, która czeka na nadejście datagramu i jego zawartość umieszcza w podanym przez nas obiekcie klasy DatagramPacket.

Klasa DatagramPacket posiada (między innymi) dwie użyteczne metody:
Umożliwiają one wydobycie adresu zwrotnego i numeru portu nadawcy, tak aby możliwe było np. odesłanie potwierdzenia czy kontynuowanie wymiany danych zgodnie z zadanym protokołem.

Klasa InetAddress reprezentuje adresy IP. Co ciekawe, nigdy nie konstruujemy obiektów klasy InetAddress bezpośrednio (klasa ta nie ma publicznego konstruktora). Zamiast tego, korzystamy ze statycznych metod klasy InetAddress, które zwracają obiekty tej klasy. Jeśli chcemy np. stworzyć obiekt reprezentujący adres hosta o nazwie atos, to korzystamy z konstrukcji:
InetAddress a = InetAddress.getByName("atos"); 
Jeśli chcemy użyć adresu IP, np. 150.254.78.2, korzystamy z tej samej metody:
InetAddress a = InetAddress.getByName("150.254.78.2"); 
(wiemy już, że funkcja gethostbyname(...) zwraca dane o hoście niezależnie od tego, czy podamy jako parametr nazwę domenową, czy tekstową reprezentację adresu IP - metoda InetAddress.getByName(...) zachowuje się dokładnie tak samo).

Klasa InetAddress posiada również metodę getByAddress, jednak wymaga ona podania tablicy bajtów, zawierającej adres IP w formacie sieci (czyli 4 bajty w formacie MSB).

Aby stworzyć obiekt klasy InetAddress reprezentujący adres naszego lokalnego hosta, używamy konstrukcji
InetAddress myhost = InetAddress.getLocalHost(); 
Jeśli mamy już obiekt klasy InetAddress, to możemy skorzystać z metody String getHostName() aby poznać kanoniczną nazwę domenową odpowiadającą danemu adresowi.

Podane informacje wykorzystamy w następującym przykładzie, ilustrującym sposób wykorzystania gniazd w Javie do komunikacji UDP.

 Przykład 1

Serwer UDP czeka na podanym porcie na nadejście datagramu. Zakładamy, że w datagramie będzie zakodowany łańcuch tekstowy. Serwer wyświetla otrzymany łańcuch na konsoli, po czym odsyła do nadawcy datagramu datagram z zakodowanym łańcuchem "Odebrano.".
Klient tworzy datagram z zakodowanym łańcuchem "SIK420", wysyła go na podany adres (lub nazwę) i podany numer portu, po czym czeka na datagram od serwera. Po odebraniu datagramu wyświetla zawarty w nim tekst na konsoli.

Plik c5p1a.java pobierz
import java.io.*;
import java.net.*;

public class c5p1a {

private DatagramSocket socket; // gniazdo UDP

public c5p1a(int aport) throws IOException {
// utworzenie i powiazanie z portem
socket = new DatagramSocket(aport);
}

void dataExchange() {
byte[] bufor = new byte[256];
// "pusty" pakiet do odbioru danych
DatagramPacket p = new DatagramPacket(bufor, 256);
try {
socket.receive(p); // czekaj na datagram

// napisz kto jest nadawca
System.out.println(
"Od: "+p.getAddress().toString()+
" ("+p.getAddress().getHostName()+")");

// utworz lancuch z tablicy bajtow
String s = new String(p.getData());

// wypisz wiadomosc
System.out.println(s);

// utworz datagram zwrotny
// korzystajac z adresu nadawcy
String response = "Odebrano.";
DatagramPacket p2 = new DatagramPacket(
response.getBytes(), response.length(),
p.getAddress(), p.getPort());

socket.send(p2); // wyslij odpowiedz
socket.close(); // koniec protokolu

// jesli cos poszlo nie tak
// wypisz stan stosu wywolan
} catch (Exception e) {
e.printStackTrace();
}
}

// args[0] - numer portu w wierszu polecen
public static void main(String[] args) {
if (args.length < 1) {
System.out.println(
"Podaj numer portu jako parametr"
);
return;
}
try {
c5p1a server = new c5p1a(
Integer.parseInt(args[0]));
System.out.println("Czekam...");
server.dataExchange();

// ew. wyjatek wyrzucany przez konstruktor
// c5p1a
} catch (Exception e) {
e.printStackTrace();
}
}
}

Plik c5p1b.java pobierz
import java.io.*;
import java.net.*;

public class c5p1b {

// args[0] - nazwa hosta (lub IP)
// args[1] - numer portu
public static void main(String[] args) {
if (args.length < 2) {
System.out.println(
"Podaj nazwe hosta i numer portu"
);
return;
}
try {
// utworzenie gniazda - lokalny port niewazny
DatagramSocket socket = new DatagramSocket();

// ustalenie adresu na podstawie args[0]
InetAddress addr = InetAddress.getByName(args[0]);

// utworzenie datagramu z wiadomoscia
String s = "SIK 420";
DatagramPacket p = new DatagramPacket(
s.getBytes(), s.length(),
addr, Integer.parseInt(args[1]));

// wyslanie wiadomosci
socket.send(p);

// utworzenie "pustego" datagramu
byte[] bufor = new byte[256];
DatagramPacket p2 = new DatagramPacket(bufor, 256);

// czekaj na wiadomosc zwrotna
socket.receive(p2);

// utworz lancuch z tablicy bajtow
String response = new String(p2.getData());

// wyswietl wiadomosc zwrotna
System.out.println("Serwer powiedzial: "+response);

// koniec protokolu
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}

Kompilacja:
javac c5p1a.java
javac c5p1b.java
Uruchomienie:

Konsola 1:
java c5p1a numer_portu 
Konsola 2:
java c5p1b nazwa_hosta numer_portu 

TCP

W przypadku komunikacji TCP mamy do dyspozycji dużo wygodniejsze narzędzia. Po pierwsze jednak, rozróżniamy tutaj stronę nasłuchującą i nawiązującą połączenie. Strona nasłuchująca korzysta z ServerSocket (nasłuch) i Socket (komunikacja z klientem po nawiązaniu przez niego połączenia). Klient korzysta tylko z klasy Socket.

Obiekty klasy ServerSocket tworzymy zwykle następująco:
ServerSocket ssocket = new ServerSocket(3455); // tworzy gniazdo nasłuchujące 
// i wiąże je z portem 3455
choć możliwe jest skorzystanie z konstruktora bezparametrowego i późniejsze wywołanie metody bind. Kluczową metodą klasy ServerSocket jest accept, która czeka na nawiązanie przez połączenia przez inny program, po czym zwraca obiekt klasy Socket, którego używamy do wymiany danych z tym jednym konkretnym klientem. Metoda ta jest zatem odpowiednikiem funkcji accept, która również zwraca nowo utworzone gniazdo po odebraniu przychodzącego połączenia.

Strona aktywna (klient) używa klasy Socket, konstruując jej obiekty np. tak:
Socket s = new Socket("atos", 3000); 
Podanie nazwy hosta (lub adresu IP lub obiektu klasy InetAddress) i numeru portu powoduje automatycznie nawiązanie połączenia z wybranym hostem i numerem portu (czyli automatycznie wywoływane jest connect). Możliwe jest również wywołanie connect na wcześniej utworzonym gnieździe, np. tak:
s.connect(new InetSocketAddress("atos",3000));
Istnieje jeszcze drugi wariant metody connect, który ma postać
void connect(SocketAddress endpoint, int timeout);
i który pozwala na ustawienie maksymalnego czasu oczekiwania (parametr timeout, wyrażony w milisekundach) na nawiązanie połączenia. W przypadku upłynięcia tego czasu bez poprawnego nawiązania połączenia, metoda connect wyrzuca SocketTimeoutException.

W obu przypadkach (i serwera, i klienta), jeśli mamy już poprawny obiekt klasy Socket, możemy korzystać ze strumieni wejściowych i wyjściowych. Klasa Socket udostępnia następujące metody:
Ponieważ klasy InputStream i OutputStream są dość ubogie (pozwalają na czytanie i pisanie bajtów lub tablic bajtów), zwykle "nadbudowujemy" na obiektach tych klas jakieś bardziej użyteczne strumienie. Możemy np. skorzystać z klas DataInputStream i DataOutputStream, gdzie przekazujemy odp. strumienie do konstruktorów. Przykład:
DataInputStream dis = new DataInputStream(socket.getInputStream()); 
Teraz można już korzystać np. z dis.readLong(), dis.readDouble() czy dis.readUTF() aby odczytywać ze strumienia (czyli z gniazda) dane konkretnego typu (tutaj odp. long, double i String). W przypadku strumieni wyjściowych sytuacja jest analogiczna - DataOutputStream posiada metody writeT (gdzie T jest nazwą jednego z podstawowych typów Javy).


 Przykład 2

Prosty przykład ilustrujący podstawowe techniki stosowane w przypadku gniazd TCP w Javie. Wykorzystane zostały strumienie DataInputStream i DataOutputStream, a klient przesyła do serwera kolejno liczbę całkowitą, łańcuch tekstowy i liczbę rzeczywistą.

Plik c5p2a.java pobierz
import java.io.*;
import java.net.*;

public class c5p2a {

private ServerSocket ssocket; // do nasluchu
private int port; // nr portu

public c5p2a(int aport) throws IOException {
port = aport;

// utworzenie gniazda nasluchu
// i powiazanie go z portem (bind)
ssocket = new ServerSocket(port);
}

public void startListening() {
Socket socket = null; // dla jednego klienta
int ivalue;
String svalue;
double dvalue;

try {
// czekaj na polaczenie
socket = ssocket.accept();

// pobierz strumienie i nadbuduj
// na nich "lepsze" strumienie
DataInputStream dis = new DataInputStream(
socket.getInputStream());
DataOutputStream dos = new DataOutputStream(
socket.getOutputStream());

// czytaj kolejno int, String i double
ivalue = dis.readInt();
svalue = dis.readUTF();
dvalue = dis.readDouble();

// wypisz co odebrano
System.out.println("Odebrano:");
System.out.println(ivalue);
System.out.println(svalue);
System.out.println(dvalue);

// odpisz klientowi
dos.writeUTF("ODEBRANO POPRAWNIE");

// zamknij strumienie
dis.close();
dos.close();

// zamknij gniazdo
socket.close();

// zamknij gniazdo nasluchujace
ssocket.close();

// jesli cos pojdzie zle, wypisz
// stos wywolan
} catch (Exception e) {
e.printStackTrace();
return;
}
}

// args[0] - numer portu
public static void main(String[] args) {
if (args.length < 1) {
System.out.println(
"Podaj numer portu"
);
return;
}
try {
// ustal port
int port = Integer.parseInt(args[0]);
c5p2a server = new c5p2a(port);
System.out.println("Czekam...");
server.startListening();

// moze byc albo wyjatek z konstruktora
// c5p2a albo z Integer.parseInt jesli parametr
// nie jest liczba
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Serwer zakonczyl dzialanie.");
}
}

Plik c5p2b.java pobierz
import java.io.*;
import java.net.*;

public class c5p2b {

// args[0] - nazwa hosta
// args[1] - numer portu
public static void main(String[] args) {
if (args.length < 2) {
System.out.println(
"Podaj nazwe hosta i port"
);
return;
}
try {
// ustal adres serwera
InetAddress addr = InetAddress.getByName(args[0]);

// ustal port
int port = Integer.parseInt(args[1]);

// utworz gniazdo i od razu podlacz je
// do addr:port
Socket socket = new Socket(addr, port);

// pobierz strumienie i zbuduj na nich
// "lepsze" strumienie
DataOutputStream dos = new DataOutputStream(
socket.getOutputStream());
DataInputStream dis = new DataInputStream(
socket.getInputStream());

// zapisz kolejno int, String i double
dos.writeInt(1000);
dos.writeUTF("Hello World!");
dos.writeDouble(3.14159);

// czytaj odpowiedz
String s = dis.readUTF();

// wypisz odpowiedz
System.out.println("Serwer powiedzial: "+s);
dis.close();
dos.close();

// koniec rozmowy
socket.close();

// moga byc wyjatki dot. gniazd,
// getByName, parseInt i strumieni
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Klient zakonczyl dzialanie");
}
}

Kompilacja:
javac c5p2a.java
javac c5p2b.java

Uruchomienie:

Konsola 1:
java c5p2a numer_portu 
Konsola 2:
java c5p2b nazwa_hosta numer_portu 

Przesyłanie obiektów za pośrednictwem gniazd sieciowych

Zajmiemy się teraz problemem przesyłania obiektów za pośrednictwem gniazd sieciowych. Najwygodniejszym sposobem jest wykorzystanie obiektów klas ObjectInputStream i ObjectOutputStream, które posiadają specjalizowane metody writeObject i readObject, służące odp. do zapisu obiektów do strumienia i ich odczytu ze strumienia. Obiekty wymienionych klas możemy, podobnie jak w przypadku DataInputStream i DataOutputStream, "nadbudować" na obiektach klas odp. InputStream i OutputStream, które to obiekty możemy pobrać bezpośrednio z gniazda (za pomocą Socket.getInputStream() i Socket.getOutputStream()). Przykładowo, dla strumienia wyjściowego konstrukcja może wyglądać np. tak:
ObjectOutputStream oos = new ObjectOutputStream(gniazdo.getOutputStream());
Istnieją dwie fundamentalne zasady dotyczące przesyłania obiektów za pomocą strumieni (dotyczą one również przesyłania ich za pośrednictwem sieci):
  1. Przesyłane obiekty muszą być serializowalne, co w praktyce oznacza, że muszą implementować interfejs java.io.Serializable. Jeśli projektowana przez nas klasa zawiera wyłącznie pola typów serializowalnych, to wystarcza jawna deklaracja implementacji wspomnianego interfejsu w nagłówku klasy. W przeciwnym wypadku należy zaimplementować metody readObject i writeObject aby poinstruować maszynę wirtualną w jaki sposób obiekty naszej klasy zapisywać w strumieniu i z niego odczytywać.
  2. Przesyłany jest wyłącznie stan obiektów i informacja o ich klasie, nie jest natomiast przesyłany sam bytecode klasy. W praktyce oznacza to, że jeśli przesyłamy obiekt klasy X, to po stronie czytającej (gdzie de facto następuje utworzenie nowego obiektu i odtworzenie jego stanu na podstawie danych ze strumienia) musi być dostępny bytecode klasy X, czyli plik X.class musi być dostępny w ścieżce poszukiwań maszyny wirtualnej (jest to pewien skrót myślowy - bytecode klasy nie musi być koniecznie czytany z pliku, są inne metody jego uzyskania przez JVM, jednak w przypadku typowym sprowadza się to do dostępności odpowiedniego pliku w jednym z katalogów wskazanych w zmiennej CLASSPATH).

 Przykład 3

Rozważmy następujący problem. Chcielibyśmy zaimplementować uniwersalny serwer obliczeniowy, który wykonywałby dostarczone przez klientów zadania nie mając "świadomości" o ich merytorycznej zawartości. Jedyne, co serwer powinien wiedzieć, to że dostanie od klienta obiekt reprezentujący zadanie i inny obiekt, reprezentujący parametry tego zadania. Serwer powinien wykonać określoną metodę otrzymanego obiektu-zadania, która zwróci wynik będący pewnym obiektem. Tak uzyskany wynik serwer obliczeniowy powinien odesłać klientowi, który zlecił wykonanie zadania. Zatem serwer obliczeniowy udostępnia moc obliczeniową maszyny, na której działa, w celu wykonywania dowolnych (różnej natury) zadań, tak długo, jak zadania te są zgodne ze zdefiniowanym interfejsem.

Realizację projektu zaczniemy od określenia interfejsu Zadanie, który określał będzie funkcjonalność obiektów-zadań. Od razu podamy, że rozszerza on java.io.Serializable, ponieważ z założenia obiekty klas implementujących interfejs Zadanie będą przesyłane za pośrednictwem sieci (a więc strumieni, a więc muszą być serializowalne).

Plik Zadanie.java pobierz
public interface Zadanie
extends java.io.Serializable {

public Object Wykonaj(Object params);
}
Intefejs definiuje tylko jedną metodę - Wykonaj, która przyjmuje pewien (zależny od zadania) obiekt jako parametry i zwraca pewien (również zależny od zadania) obiekt jako wynik.

Na potrzeby przykładu podamy dwie implementacje interfejsu Zadanie (dwa istotnie różne zadania): pierwsze z nich generuje liczbę pseudolosową i zwraca jako wynik (nie potrzebuje żadnych parametrów), drugie zaś sortuje podaną tablicę liczb całkowitych i zwraca jako wynik posortowaną tablicę.

Plik ZLiczbaLosowa.java pobierz
public class ZLiczbaLosowa
implements Zadanie {

public Object Wykonaj(Object params) {
System.out.println("*** Tu zadanie liczba losowa.");
return new Double(Math.random());
}
}

Plik ZSortuj.java pobierz
public class ZSortuj
implements Zadanie {

public Object Wykonaj(Object params) {
int[] tablica = (int[]) params;
int i, j;
System.out.println("*** Tu zadanie sortowania.");
for (i=1; i<tablica.length; i++) {
for (j=0; j<tablica.length-1; j++) {
if (tablica[j]>tablica[j+1]) {
int tmp = tablica[j];
tablica[j] = tablica[j+1];
tablica[j+1] = tmp;
}
}
}
return tablica;
}
}

Możemy teraz przystąpić do implementacji serwera obliczeniowego. Będzie on działał wg następującego schematu:
  1. zainicjuj gniazdo nasłuchujące TCP
  2. powtarzaj w nieskończoność
    1. czekaj na połączenie od klienta
    2. czytaj ze strumienia obiekt-zadanie
    3. czytaj ze strumienia obiekt-parametry
    4. uruchom Wykonaj z otrzymanego zadania, podając metodzie otrzymane parametry
    5. wynik Wykonaj odeślij (jako obiekt) klientowi
    6. zakończ sesję z bieżącym klientem
Jak widać schemat nie jest bardzo skomplikowany, co daje w efekcie dość prosty kod serwera przedstawiony poniżej.

Plik SerwerObliczen.java pobierz
import java.net.*;
import java.io.*;

public class SerwerObliczen {
private int port;
private ServerSocket ss;

public SerwerObliczen(int aport) {
super();
port = aport;
ss = null;
}

public void InicjujGniazdo() {
try {
ss = new ServerSocket(port);
} catch (Exception e) {
e.printStackTrace();
System.exit(1);
}
}

public void WykonujZadania() {
Socket s;
ObjectInputStream ois;
ObjectOutputStream oos;

while (true) {
System.out.println("Czekam na zadanie...");
try {
s = ss.accept();
System.out.println("Polaczenie z "+
s.getInetAddress().getHostName());
System.out.println("Odczytywanie zadania ...");
oos = new ObjectOutputStream(s.getOutputStream());
ois = new ObjectInputStream(s.getInputStream());
Zadanie z = (Zadanie) ois.readObject();
System.out.println("Odebrano zadanie typu "+
z.getClass().getName());
System.out.println(
"Odczytywanie parametrow ...");
Object par = ois.readObject();
System.out.println(
"Parametry odczytane. Wykonuje zadanie ...");
Object wynik = z.Wykonaj(par);
System.out.println(
"Zadanie wykonane. Wysylam wyniki ...");
oos.writeObject(wynik);
System.out.println("Gotowe. Zamykam sesje z "+
s.getInetAddress().getHostName());
ois.close();
oos.close();
s.close();
} catch(Exception e) {
e.printStackTrace();
System.exit(1);
}
}
}
public static void main(String[] args) {
int port = 0;
BufferedReader klawiatura;

try {
klawiatura = new BufferedReader(
new InputStreamReader(
System.in
)
);
System.out.print("Podaj numer portu: ");
port = Integer.parseInt(
klawiatura.readLine()
);

} catch(Exception e) {
e.printStackTrace();
System.exit(1);
}
SerwerObliczen serwer = new SerwerObliczen(port);
serwer.InicjujGniazdo();
serwer.WykonujZadania();
}
}

Do przetestowania naszego uniwersalnego serwera wykorzystamy prostego klienta, który na życzenie użytkownika zleca serwerowi wykonanie albo zadania ZLiczbaLosowa, albo zadania ZSortuj. Jest oczywiste, że program kliencki nie może być w pełni uniwersalny, ponieważ jego zadaniem jest m.in. przygotowanie parametrów zadania i prezentacja wyników, zatem siłą rzeczy musi on być związany z konkretnym rodzajem zadania.

Plik Klient.java pobierz
import java.net.*;
import java.io.*;

public class Klient {
private Socket s;
private InetAddress adresSerwera;
private int portSerwera;
private ZLiczbaLosowa zadanie1;
private ZSortuj zadanie2;
private int[] tablica;
private ObjectInputStream ois;
private ObjectOutputStream oos;

public Klient(InetAddress sa, int p) {
super();
adresSerwera = sa;
portSerwera = p;
tablica = new int[5];
for (int i = 0; i < 5; i++) {
tablica[i] = 5-i;
}
zadanie1 = new ZLiczbaLosowa();
zadanie2 = new ZSortuj();
}

public void OtworzPolaczenie() {
try {
System.out.println("Nawiazuje polaczenie ...");
s = new Socket(adresSerwera, portSerwera);
oos = new ObjectOutputStream(s.getOutputStream());
ois = new ObjectInputStream(s.getInputStream());
System.out.println("Polaczenie nawiazane.");
} catch (Exception e) {
e.printStackTrace();
System.exit(1);
}
}

public void ZamknijPolaczenie() {
try {
System.out.println("Zamykam polaczenie ...");
oos.close();
ois.close();
s.close();
System.out.println("Polaczenie zamkniete.");
} catch(Exception e) {
e.printStackTrace();
System.exit(1);
}
}

public Object WykonajZadanieNaSerwerze(
Zadanie z, Object par
) {
Object wynik = null;
try {
oos.writeObject(z);
oos.writeObject(par);
wynik = ois.readObject();
} catch(Exception e) {
e.printStackTrace();
System.exit(1);
}
return wynik;
}

public void Menu() {
while (true) {
System.out.println();
System.out.println();
System.out.println(
"1. Wykonaj na serwerze zadanie 1 (liczba losowa)");
System.out.println(
"2. Wykonaj na serwerze zadanie 2 (sortowanie)");
System.out.println(
"3. Koniec");
try {
BufferedReader klawiatura = new BufferedReader(
new InputStreamReader(
System.in
)
);
System.out.print("Wybor: ");
int opcja = Integer.parseInt(
klawiatura.readLine());
if (opcja == 3) System.exit(0);
if (opcja == 1 || opcja == 2) {
OtworzPolaczenie();
if (opcja == 1) {
Double wynik = (Double)
WykonajZadanieNaSerwerze(
zadanie1,
new Double(0.0)
);
System.out.println(
"Wynik zadania 1: "+wynik.doubleValue());
} else {
int[] wynik = (int[])
WykonajZadanieNaSerwerze(
zadanie2,
tablica
);
System.out.print("Wynik zadania 2: [ ");
for (int i = 0; i < wynik.length; i++) {
System.out.print(wynik[i]+" ");
}
System.out.println("]");
}
ZamknijPolaczenie();
}
} catch (Exception e) {
System.out.println("NIE POWIODLO SIE");
}
}
}

public static void main(String[] args) {
String host;
int port;
try {
BufferedReader klawiatura = new BufferedReader(
new InputStreamReader(
System.in
)
);
System.out.print("Host serwera obliczen: ");
host = klawiatura.readLine();
System.out.print("Numer portu serwera obliczen: ");
port = Integer.parseInt(klawiatura.readLine());
Klient k = new Klient(
InetAddress.getByName(host),
port
);
k.Menu();
} catch (Exception e) {
e.printStackTrace();
System.exit(1);
}
}
}

Kompilacja:
javac *.java
Uruchomienie:

Konsola 1:
java SerwerObliczen
Konsola 2:
java Klient