Rozdział 13. Klienty sieciowe

Przy użyciu języka Java można tworzyć różne rodzaje programów korzystających z sieci. W tradycyjnych programach posługujących się gniazdami programista odpowiada za cały proces interakcji pomiędzy klientem i serwerem; „gniazda” TCP zapewniają tylko tyle, że dane, które zostały zapisane na wejściu, będzie można odczytać na wyjściu. W programach działających na wyższym poziomie abstrakcji, wykorzystujących takie technologie jak RMI, CORBA lub EJB, używane rozwiązania programistyczne mają znacznie większe znaczenie i kontrolę nad działaniem programu. Gniazda są często wykorzystywane w przypadkach komunikowania się ze „starymi” serwerami; jeśli od podstaw tworzymy zupełnie nową aplikację, to lepiej wykorzystać rozwiązania działające na wyższym poziomie.

Przydatne może być porównanie gniazd z systemem telefonicznym. Telefony służyły początkowo do przekazywania sygnałów analogowych, które nie mają żadnej określonej struktury. Następnie sieci telefoniczne zaczęto wykorzystywać w aplikacjach „warstwowych”; pierwszą z nich było wysyłanie faksów. Czym byłyby faksy bez ogólnej dostępności zwyczajnej telefonii głosowej? Drugą bardzo popularną aplikacją warstwową są komutowane połączenia TCP/IP. W połączeniu z World Wide Web stały się one popularną usługą o ogromnym zasięgu. Jednak czym byłyby komutowane sieci IP bez rozbudowanej sieci zwyczajnych łączy telefonicznych? A czym byłby dzisiejszy internet bez dostępu komutowanego?

Gniazda dobrze odpowiadają temu porównaniu. WWW, RMI, JDBC, CORBA oraz EJB to wszystko przykłady technologii wykorzystujących gniazda, lecz operujących na „wyższym poziomie abstrakcji”. Obecnie najczęściej używanym protokołem jest TCP/IP — i to zazwyczaj on powinien być używany, kiedy chcemy przekazać dane z punktu A do punktu B.

Od momentu pojawienia się pierwszej wersji języka Java w maju 1995 roku (język ten stanowił wtedy dodatek do przeglądarki HotJava) był on popularnym narzędziem tworzenia aplikacji sieciowych. Bardzo łatwo można się przekonać, dlaczego tak się stało, zwłaszcza jeśli wcześniej mieliśmy okazję pisać programy sieciowe w języku C. Przede wszystkim programiści używający języka C muszą zwracać uwagę na wykorzystywany system operacyjny. W systemie Unix używane są gniazda synchroniczne, które pod względem operacji zapisu i odczytu przypominają obsługę zwyczajnych plików dyskowych. Z kolei w systemach operacyjnych firmy Microsoft wykorzystywane są gniazda asynchroniczne, w których informacje o zakończeniu operacji odczytu lub zapisu są przekazywane za pomocą funkcji zwrotnych. W Javie różnice te nie istnieją. Poza tym niezbędna ilość kodu, jaki trzeba napisać w języku C, aby utworzyć gniazdo, jest deprymująca. Na Przykład 13-1 przedstawiłem „typowy” kod napisany w języku C służący do utworzenia gniazda w programie klienckim. Należy przy tym pamiętać, że jest to kod nadający się do użycia jedynie w systemach Unix. A co więcej, służy on wyłącznie do nawiązania połączenia. Aby można go było użyć w systemie MS Windows, trzeba by wykorzystać dodatkowy kod warunkowy (dołączany przy użyciu mechanizmu #ifdef języka C). Mechanizmy dołączania plików (#include) stosowane w języku C wymagają, aby były dołączane tylko odpowiednie pliki, i to w ściśle określonej kolejności (w porównaniu z nimi mechanizm importowania klas dostępny w języku Java jest znacznie bardziej elastyczny).

W pierwszej recepturze zamieszczonej w niniejszym rozdziale pokażę, w jaki sposób w języku Java można nawiązać połączenie sieciowe z serwerem za pomocą jednego wiersza kodu (i kilku dodatkowych wierszy związanych z obsługą wyjątków). W dalszej kolejności zajmiemy się obsługą błędów oraz przekazywaniem danych za pośrednictwem gniazd. Następnie przyjrzymy się datagramom i protokołowi UDP — w tym celu stworzyłem klienta implementującego znaczną część protokołu TFTP (ang. trivial file transfer protocol — bardzo prosty protokół przesyłu plików), używanego przez blisko dwie dekady przez stacje robocze, które nie były wyposażone w dyski twarde. Na samym końcu rozdziału przedstawię program, który nawiązuje interaktywne połączenie z serwerem tekstowym, takim jak serwer Telnet lub serwer poczty elektronicznej.

Wszystkie programy przedstawione w tym rozdziale łączy jedna cecha — wykorzystanie istniejących serwerów, dzięki czemu nie musimy jednocześnie tworzyć zarówno klienta, jak i serwera. Z jednym wyjątkiem są to usługi dostępne w każdym standardowym systemie Unix. Jeśli nie jesteś w stanie znaleźć żadnego uniksowego serwera, z którego mógłbyś skorzystać, to radzę Ci poszukać dowolnego starego komputera PC (być może zbyt „słabego”, aby można w nim uruchomić system Windows) i zainstalować w nim dowolny bezpłatny system operacyjny Unix. Moim ulubionym jest system OpenBSD, natomiast na rynku dominują różne wersje systemu Linux. Wszystkie te systemy są dostępne na CD-ROM-ach lub można je bezpłatnie zainstalować poprzez internet. Udostępniają one także wszystkie standardowe usługi używane w przykładach przedstawionych w tym rozdziale, w tym także serwer czasu oraz serwer TFTP. Dostępne są również wersje języka Java przeznaczone do użycia w systemach Unix i Linux.

W niniejszej książce zamieściłem również podstawowe informacje na temat usług internetowych (ang. web services). Termin „usługa internetowa” oznacza zazwyczaj „komunikację pomiędzy programami prowadzoną przy użyciu protokołu HTTP”. Usługi te można podzielić na dwie podstawowe kategorie: usługi SOAP oraz usługi REST. Usługi REST są bardzo proste — aby z nich skorzystać, należy przesłać żądanie HTTP, a usługa zwraca odpowiedź w formie zwykłego tekstu, danych w formacie JSON (patrz Rozdział 19.) lub danych w formacie XML (patrz Rozdział 20). Usługi SOAP wymagają szczegółowej specyfikacji zapisanej w postaci kodu XML i szczegółowo opisującej wszystkie aspekty komunikacji; oprócz tego zmuszają one do prowadzenia całej interakcji z wykorzystaniem stosunkowo rozbudowanego kodu XML. Udostępniają one pewne możliwości, które są wspólne dla SOAP i REST, są to jednak rozwiązania zaawansowane, którymi nie będziemy się zajmować w tej książce. Więcej informacji na temat tworzenia oprogramowania sieciowego można znaleźć w książce Java Network Programming (wydanej przez wydawnictwo O’Reilly). W niniejszej książce nie przedstawię także interfejsów programistycznych służących do tworzenia aplikacji serwerowych — JAX-RS oraz JAX-WS — gdyż są one szczegółowo opisywane w kilku innych książkach wydawnictwa O’Reilly.

W zasadzie na temat nawiązywania połączeń sieciowych w języku Java nie można powiedzieć zbyt dużo. Tworząc gniazdo, należy przekazać nazwę komputera oraz numer portu. Konstruktor klasy java.net.Socket wywołuje funkcje systemowe gethostbyname() oraz socket(), zapisuje niezbędne dane w strukturze sockaddr_in serwera, po czym wywołuje systemową funkcję connect(). Jedyne, co nam pozostaje do zrobienia, to przechwytywanie wyjątków będących obiektami klas potomnych klasy IOException. Program przedstawiony na Przykład 13-2 nawiązuje klienckie połączenie sieciowe, choć nie używa go do przeprowadzenia jakichś faktycznych operacji wejścia-wyjścia. Wykorzystuje on technikę umieszczania operacji na zasobach wewnątrz instrukcji try (więcej informacji na ten temat można znaleźć w punkcie „Instrukcja try zarządzająca zasobami” w podrozdziale „Nowości wprowadzone w wersji Java 7” Dodatek B), aby wymusić prawidłowe i automatyczne zamknięcie gniazda, kiedy już nie będzie ono potrzebne.

Ta wersja programu nie zawiera żadnego kodu, który pozwalałby na raportowanie problemów; taki kod można natomiast znaleźć w programie ConnectedFriendly.java, który został przedstawiony w „13.3. Obsługa błędów sieciowych”.

W języku Java dostępne są także inne sposoby wykorzystywania aplikacji sieciowych. Można również otworzyć połączenie z zasobem o określonym adresie URL i odczytać jego zawartość (patrz „13.9. URI, URL czy może URN?”). Można też napisać kod, który w razie otworzenia w przeglądarce będzie pobierany z miejsca określonego adresem URL, a w innych przypadkach zostanie uruchomiony jako aplikacja.

Obiekt klasy InetAddress reprezentuje internetowy adres danego komputera lub komputera głównego. Klasa ta nie ma żadnego publicznego konstruktora — obiekt InetAddress można utworzyć, wywołując statyczną metodę getByName(), do której przekazywana jest bądź to nazwa komputera (na przykład: www.darwinsys.com), bądź też adres sieciowy zapisany w formie łańcucha znaków (na przykład: "1.23.45.67"). Wszystkie „informacyjne” metody klasy InetAddress mogą zgłaszać sprawdzany wyjątek UnknownHostException, który należy przechwycić lub zadeklarować w nagłówku metody. Żadna z tych metod nie wymienia informacji ze zdalnym komputerem, a zatem nie są zgłaszane żadne inne wyjątki związane z obsługą połączeń sieciowych.

Metoda getHostAddress() zwraca adres IP komputera (zapisany w formie łańcucha znaków) odpowiadający danemu obiektowi InetAddress. W odwrotny sposób działa metoda getHostName(), która zwraca nazwę odpowiadającą obiektowi InetAddress. Korzystając z tych metod, można zatem wyświetlić nazwę komputera, jeśli dysponuje się jego adresem IP (lub na odwrót):

/network/InetAddrDemo.java

public class InetAddrDemo {
    public static void main(String[] args) throws IOException {
        String hostName = "www.darwinsys.com";
        String ipNumber = "8.8.8.8"; // powszechnie znany serwer DNS


        // Odnajdujemy komputer na podstawie nazwy.
        System.out.println("Komputer " + hostName + " ma adres " +
            InetAddress.getByName(hostName).getHostAddress());

        // Odnajdujemy komputer na podstawie adresu.
        System.out.println("Komputer o adresie " + ipNumber +
            " nosi nazwę " + InetAddress.getByName(ipNumber).getHostName());

        // Odnajdujemy adres skojarzony z nazwą localhost.
        final InetAddress localHost = InetAddress.getLocalHost();
        System.out.println("Adres localhost to: " + localHost);

        // Sposób uzyskiwania obiektu InetAddress przy użyciu
        // otworzonego gniazda (Socket).
        String someServerName = "www.google.com";
        // Zakładamy, że na komputerze o podanej nazwie działa
        // serwer WWW:
        Socket theSocket = new Socket(someServerName, 80);
        InetAddress remote = theSocket.getInetAddress();
        System.out.printf("Adres InetAddress komputera %s to %s%n",
            someServerName, remote);
    }
}

Obiekt InetAddress można także uzyskać, wywołując metodę getInetAddress() obiektu klasy Socket. Z kolei obiekt Socket można utworzyć, używając obiektu InetAddress zamiast nazwy komputera. A zatem poniższy fragment kodu pozwala nawiązać połączenie z portem o numerze myPortNumber w komputerze, z którym jest już nawiązane połączenie sieciowe reprezentowane przez obiekt Socket:

InetAddress remote = theSocket.getInetAddress();
Socket anotherSocket = new Socket(remote, myPortNumber);

Wreszcie, można także określić wszystkie adresy skojarzone z pewnym komputerem — ostatecznie serwer może działać w kilku sieciach. Służy do tego statyczna metoda getAllByName(host) zwracająca tablicę obiektów InetAddress, z których każdy reprezentuje jeden adres IP skojarzony z komputerem o podanej nazwie.

Dostępna jest również statyczna metoda getLocalHost() zwracająca obiekt InetAddress odpowiadający komputerowi o nazwie localhost lub o adresie 127.0.0.1. Obiekt InetAddress zwrócony przez tę metodę można wykorzystać do nawiązania połączenia z serwerem działającym w tym samym komputerze co klient.

W razie korzystania z protokołu IPv6 zamiast klasy InetAddress można używać klasy Inet6Address.

Warto się także przyjrzeć interfejsowi NetworkInterface zamieszczonemu w „16.11. Znajdowanie interfejsów sieciowych”, który pozwala na uzyskiwanie wielu informacji na temat możliwości sieciowych komputera, na którym została uruchomiona aplikacja.

Na razie nie ma jeszcze sposobu na odnajdywanie usług. Na przykład nie można stwierdzić, że serwer HTTP działa na porcie 80. Pełne implementacje protokołu TCP/IP zawsze zawierały dodatkowe funkcje informacyjne; w języku C wywołanie funkcji getservbyname("http", "tcp"); spowodowałoby odnalezienie podanej usługi[46] i zwróciło strukturę servent (strukturę z informacjami o usłudze), której pole s_port miałoby wartość 80. Numery konkretnych usług nie zmieniają się — jeśli jednak trzeba zdobyć informacje o nowych usługach lub usługach zainstalowanych w niestandardowy sposób, to możliwość zmiany numeru usługi we wszystkich programach na danym komputerze lub w danej sieci (niezależnie od języka, w jakim programy te zostały napisane) poprzez zmianę definicji tej usługi jest bardzo przydatna i wygodna. Kolejne wersje języka Java także powinny dawać tę możliwość.

Klasa Socket dysponuje metodami, które pozwalają na pobranie obiektów InputStream lub OutputStream. Za ich pomocą można odpowiednio odczytywać i zapisywać dane w gnieździe. Nie ma żadnych metod pozwalających na uzyskanie obiektów Reader lub Writer; po części wynika to z faktu, że niektóre usługi sieciowe operują wyłącznie na znakach ASCII, niemniej jednak głównym powodem jest to, iż klasa Socket została zaprojektowana przed stworzeniem klas Reader i Writer. Zawsze można stworzyć obiekt Reader, jeśli dysponuje się obiektem InputStream, lub obiekt Writer, gdy ma się obiekt OutputStream — wystarczy się posłużyć odpowiednimi klasami konwertującymi. Poniżej przedstawiłem najczęściej spotykane rozwiązanie:

BufferedReader is = new BufferedReader(
    new InputStreamReader(sock.getInputStream()));
PrintWriter os = new PrintWriter(sock.getOutputStream(), true);

Poniżej, na Przykład 13-4, przedstawiłem kod programu odczytującego wiersze tekstu wysyłane przez usługę „daytime” dostępną w rozbudowanych pakietach obsługi protokołu TCP/IP (takich jak te używane w większości systemów Unix). Do serwera usługi Daytime nie trzeba wysyłać żadnych danych, wystarczy nawiązać z nim połączenie i odczytać jeden wiersz tekstu. Serwer generuje pojedynczy wiersz tekstu zawierający aktualną datę i czas, a następnie zamyka połączenie.

Poniżej pokazałem sposób wykorzystania omawianego programu. W pierwszej kolejności wyświetliłem datę i czas na lokalnym komputerze, a następnie uruchomiłem program DaytimeText, aby przekonać się, jaka jest aktualna data i godzina na serwerze (moim lokalnym serwerem jest komputer o nazwie „darian”):

C:\javasrc\network>date
The current date is: 2002-10-12
Enter the new date: (yy-mm-dd)
C:\javasrc\network>time
The current time is: 21:36:59,31
Enter the new time:
C:\javasrc\network>java network.DaytimeText darian
Czas na komputerze darian to Sat Oct 12 13:34:11 2002

Kod źródłowy klasy DaytimeText został przedstawiony na Przykład 13-4.

Kolejny program, przedstawiony na Przykład 13-5, pokazuje, w jaki sposób można odczytywać i zapisywać dane w tym samym gnieździe. Serwer Echo działa w bardzo prosty sposób — przesyła z powrotem nadesłane do niego wiersze tekstu. Choć nie jest to wyjątkowo „inteligentny” serwer, to jest całkiem przydatny — pomaga bowiem podczas testowania sieci oraz klientów, takich jak te przedstawiane w niniejszym rozdziale!

Metoda converse() przeprowadza krótką wymianę informacji z serwerem Echo działającym na komputerze o podanej nazwie; jeśli nazwa komputera nie została podana, to program próbuje nawiązać połączenie z komputerem localhost. „Localhost” to uniwersalna nazwa zastępcza[47] „komputera, na którym został uruchomiony dany program”.

Ciekawym ćwiczeniem mogłoby być wydzielenie z metody converse() kodu wysyłającego i odbierającego dane i zaimplementowanie go w formie klasy NetWriter dodającej obsługę znaków \r\n oraz opróżnianie bufora. Prawdopodobnie powinna to być klasa potomna klasy PrintWriter.

Poniżej przedstawiłem najprostszy sposób utworzenia potrzebnych obiektów:

DataInputStream is = new DataInputStream(sock.getInputStream());
DataOutputStream os = new DataOutputStream(sock.getOutputStream());

Jeśli ilość przekazywanych danych może być duża, to warto wykorzystać strumienie buforowane. W tym przypadku obiekty DataOutputStream oraz DataInputStream mają następującą postać:

DataInputStream is = new DataInputStream(
    new BufferedInputStream(sock.getInputStream()));
DataOutputStream os = new DataOutputStream(
    new BufferedOutputStream(sock.getOutputStream()));

Program przedstawiony poniżej, na Przykład 13-6, korzysta z kolejnej usługi zwracającej czas, jednak w tym przypadku czas zwracany jest w postaci binarnej — jako liczba całkowita wyrażająca ilość sekund, jakie upłynęły od początku 1900 roku. Ponieważ w języku Java punktem odniesienia wykorzystywanym przy wyznaczaniu dat jest rok 1970, od odczytanej liczby odejmujemy ilość sekund odpowiadających różnicy pomiędzy latami 1900 i 1970. Gdy przedstawiłem ten przykład na prowadzonym przeze mnie kursie, większość słuchaczy chciała dodawać tę różnicę, rozumując, że rok 1970 jest późniejszy. Jeśli jednak dokładnie to przemyśleć, to okazuje się, że ilość sekund pomiędzy latami 1999 i 1970 jest mniejsza od ilości sekund pomiędzy latami 1999 i 1900, a zatem poprawny wynik uzyskujemy, odejmując obliczoną ilość sekund. Ponieważ konstruktor klasy Date wymaga podania czasu wyrażonego jako ilość milisekund, obliczoną wcześniej liczbę sekund mnożymy razy 1000.

Różnica pomiędzy czasami odniesienia jest obliczana poprzez pomnożenie liczby lat przez 365. Następnie dodawana jest do niej liczba dni przestępnych pomiędzy obiema datami (czyli dodatkowych dni w latach przestępnych, np. 1904, 1908..., 1998) — a zatem w sumie 19 dni.

Liczba całkowita odczytywana z serwera jest typu unsigned int i została zapisana w sposób charakterystyczny dla języka C. Jednak Java nie udostępnia typu liczbowego bez znaku (unsigned); zazwyczaj, aby używać liczb bez znaku, wykorzystywany jest kolejny, „większy” typ liczbowy, czyli w naszym przypadku byłby to typ long. Niemniej jednak Java 2 nie udostępnia także żadnego sposobu odczytywania liczb całkowitych bez znaku ze strumienia danych binarnych. Metoda readInt() klasy DataInputStream odczytuje liczby całkowite ze znakiem stosowane w języku Java. Dostępne są także metody readUnsignedByte() oraz readUnsignedShort(), nie ma jednak metody readUnsignedInt(). W związku z tym symulujemy możliwość odczytania liczby całkowitej bez znaku (zapisywanej w liczbie typu long, gdyż w innym przypadku zostałby utracony bit znaku), odczytując kolejne bajty i łącząc je przy użyciu operatorów przesunięć bitowych.

Na samym końcu kodu tworzę obiekt LocalDateTime i używam go do wyświetlenia daty i godziny na lokalnym (klienckim) komputerze; korzystam przy tym z nowego API do obsługi dat i godzin (opisanego w Rozdział 6.).

$ java network.DaytimeBinary darian
Czas na komputerze zdalnym: 3600895637
BASE_DIFF = 2208988800
Różnica czasów == 1391906837
Czas na komputerze darian to Sat Feb 08 19:47:17 EST 2014
Lokalna data/godzina = 2014-02-08T19:47:17.703
$

Patrząc na przedstawione powyżej wyniki działania programu, można zauważyć, że różnica czasów pomiędzy komputerem lokalnym i serwerem wynosi kilkanaście sekund; można zatem przypuszczać, że sposób obliczania daty zastosowany na Przykład 13-6 jest poprawny.

Przykład 13-6. /network/RDateClient.java

public class RDateClient {
    /** Port TCP używany przez usługę zwracającą czas w postaci binarnej. */
    public static final short TIME_PORT = 37;
    /** Liczba sekund pomiędzy 1970, punktem odniesienia przy wyznaczaniu
     * dat Date(long) oraz czasu Time w języku Java.
     * Uwzględnia lata przestępne (aż do 2100).
     * Odejmuje 1 dzień z roku 1900, dodaje pół dnia dla lat 1969 - 1970.
     */
    protected static final long BASE_DAYS =
        (long)((1970-1900)*365 + (1970-1900-1)/4);

    /* Liczba sekund od początku roku 1970. */
    public static final long BASE_DIFF = (BASE_DAYS * 24 * 60 * 60);

    /** Stała używana przy konwersji sekund na milisekundy. */
    public static final int MSEC = 1000;

    public static void main(String[] argv) {
        String hostName;
        if (argv.length == 0)
            hostName = "localhost";
        else
            hostName = argv[0];

        try {
            Socket sock = new Socket(hostName, TIME_PORT);
            DataInputStream is = new DataInputStream(new
                BufferedInputStream(sock.getInputStream()));
            // Musimy odczytać 4 bajty z sieci jako liczby bez znaku.
            // Musimy wykonać to sami, gdyż nie ma metody readUnsignedInt().
            // Typ long w Javie ma wielkość 8 bajtów, jednak
            // my używamy istniejącego protokołu daytime korzystającego
            // z liczb całkowitych o długości 4 bajtów.
            long remoteTime = (
                ((long)(is.readUnsignedByte()) << 24) |
                ((long)(is.readUnsignedByte()) << 16) |
                ((long)(is.readUnsignedByte()) <<  8) |
                ((long)(is.readUnsignedByte()) <<  0));
            System.out.println("Czas na komputerze zdalnym: " + remoteTime);
            System.out.println("BASE_DIFF = " + BASE_DIFF);
            System.out.println("Różnica czasów == " +
                                                   (remoteTime - BASE_DIFF));
            Date d = new Date((remoteTime - BASE_DIFF) * MSEC);
            System.out.println("Czas na komputerze " + hostName + " to " +
                                                               d.toString());
            System.out.println("Lokalna data/godzina = " +
                                                         LocalDateTime.now());
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

Serializacja obiektów to możliwość przekonwertowania obiektów przechowywanych w pamięci komputera na zewnętrzną postać, którą można wysłać w sposób szeregowy (czyli bajt po bajcie). Zagadnienia związane z transmisją szeregową zostały omówione w „10.20. Zapisywanie i odczytywanie obiektów”.

Przedstawiony tu program (oraz współpracujący z nim serwer) stanowią jedyną prezentowaną w tym rozdziale usługę, która nie jest dostępna w pakietach implementujących protokół TCP/IP, usługa ta jest bowiem charakterystyczna dla języka Java. Sam kod przypomina program DaytimeBinary przedstawiony w poprzedniej recepturze, jednak w tym przypadku serwer przesyła utworzony już obiekt Date. Kod klienta został przedstawiony poniżej, na Przykład 13-7, natomiast kod serwera — w „16.2. Zwracanie odpowiedzi (łańcucha znaków bądź danych binarnych)”.

Najpierw wyświetliłem datę i czas na lokalnym komputerze, a następnie uruchomiłem program podający informacje o dacie i czasie na zdalnym komputerze:

C:\javasrc\network>date /t
2000-01-23
C:\javasrc\network>time /t
2:52:34.43p
C:\javasrc\network>java network.DaytimeObject aragorn
Komputer, na którym działa serwer, to: aragorn
Czas na serwerze: Sun Jan 23 14:52:25 GMT 2000
C:\javasrc\network>

Transmisja datagramów lepiej odpowiada możliwościom niższych warstw sieciowych — Ethernet oraz IP (ang. Internet Protocol). W odróżnieniu od połączeń wykorzystujących strumienie, takich jak TCP, protokoły transmisji datagramów, na przykład UDP, wysyłają poszczególne „pakiety” (lub fragmenty danych) jako niezależne jednostki[48]. Bardzo często protokół TCP porównuje się z rozmowami telefonicznymi, natomiast protokół UDP — z wysyłaniem kartek pocztowych lub faksów.

Różnice pomiędzy tymi dwoma rodzajami transmisji sieciowych uwidaczniają się głównie w obsłudze błędów. Pakiety, podobnie jak kartki pocztowe, mogą się gubić. Kiedy ostatni raz zdarzyło się, że listonosz przyszedł i poinformował, że urząd pocztowy zgubił jedną z kilku kartek? Coś takiego nigdy się nie zdarzyło ani się nie zdarzy, a to dlatego, że urząd pocztowy nie rejestruje przesyłanych kartek. Z drugiej strony, jeśli podczas rozmowy telefonicznej pojawią się jakieś zakłócenia — takie jak szumy lub nawet problemy z połączeniem — to zawsze można poprosić rozmówcę o powtórzenie tego, co przed chwilą powiedział.

W przypadku połączeń strumieniowych, jak poprzez gniazda TCP, to sieciowa warstwa transportowa odpowiada za obsługę błędów — prosi o powtórzenie transmisji. W przypadku protokołów rozsyłających datagramy, takich jak UDP, za obsługę transmisji odpowiada programista. To coś w stylu numerowania wysyłanych kartek pocztowych, aby mieć możliwość ponownego wysłania tych, które nie doszły; może to dobra wymówka, aby wrócić do miejsca, w którym się spędziło wakacje.

Kolejna różnica polega na tym, iż w przypadku rozsyłania datagramów zachowywane są granice poszczególnych wiadomości. W przypadku wykorzystania protokołu TCP, jeśli serwer prześle 20 bajtów, a następnie kolejne 10 bajtów, to program odczytujący te dane nie będzie wiedzieć, czy została wysłana jedna wiadomość składająca się z 30 bajtów, dwie składające się z 15 bajtów, czy też 30 niezależnych znaków. W przypadku wykorzystania połączeń operujących na datagramach (DatagramSocket) dla każdej „porcji” (bufora) danych tworzony jest obiekt DatagramPacket, który jest następnie przesyłany jako pojedyncza, niezależna jednostka; jego zawartość nie zostanie połączona z zawartością jakiejkolwiek innej „porcji” danych. Obiekt DatagramPacket udostępnia między innymi takie metody jak getLength() oraz setPort().

A zatem dlaczego mielibyśmy w ogóle używać protokołu UDP? Otóż protokół ten ma znacznie mniej narzutów niż TCP, co może być szczególnie przydatne w przypadkach przesyłania bardzo dużych ilości danych przez niezawodną sieć lokalną bądź przez internet pomiędzy komputerami oddalonymi od siebie o niewielką liczbę segmentów sieci. Jednak w przypadku przesyłania na duże odległości preferowany będzie protokół TCP, gdyż obsługuje on ponowne przesyłanie utraconych pakietów. Poza tym jeśli zachowywanie granic rekordów może nam ułatwić życie, to może to stanowić ważny argument za wykorzystaniem protokołu UDP.

Przykład 13-8 przedstawia krótki program, który nawiązuje połączenie UDP z serwerem daty i czasu wykorzystanym wcześniej w „13.4. Odczyt i zapis danych tekstowych”. Ponieważ w protokole UDP nie występuje pojęcie „połączenia”, klient zazwyczaj inicjuje „konwersację” (czasami oznacza to przesłanie pustego pakietu), a serwer UDP używa umieszczonych w niej informacji o adresie, by odesłać odpowiedź.

Uruchomiłem ten program, korzystając z mojego serwera, aby mieć pewność, że działa poprawnie:

$ java network.DaytimeUDP dalai
Wysłano żądanie.
Odebrano pakiet o długości 26
Data na komputerze dalai to Sat Feb  8 20:22:12 2014
$

Program przedstawiony w tej recepturze jest klientem protokołu TFTP — doskonale znanej usługi wykorzystywanej w systemach Unix do uruchamiania stacji roboczych za pośrednictwem sieci, która pojawiła się na długo przed systemem Windows 3.1. Wybrałem ten protokół, gdyż jest on dobrze obsługiwany po stronie serwera, a zatem bez problemu można znaleźć serwer do testowania naszego przykładowego programu.

Protokół TFTP jest trochę dziwny. Klient nawiązuje połączenie z serwerem, korzystając z dobrze znanego portu UDP o numerze 69, przy czym sam używa portu o wygenerowanym numerze[49], a serwer odpowiada podobnie (korzystając z portu o wygenerowanym numerze).

Zajmijmy się teraz szczegółami. Jak widać na Rysunek 13-1, klient rozpoczyna od wysłania żądania odczytu, w którym podawana jest nazwa pliku, a następnie odczytuje pierwszą porcję danych. Żądanie odczytu składa się z dwóch bajtów zawierających kod żądania (jest to krótka liczba całkowita o wartości 1 zdefiniowana jako OP_RRQ), dwóch bajtów zawierających numer żądania w sekwencji, nazwy pliku zawierającej znaki ASCII i zakończonej znakiem o kodzie 0 oraz kolejnym łańcuchem znaków zakończonych znakiem o kodzie 0. Serwer odczytuje żądanie przesłane przez klienta, sprawdza, czy może otworzyć wskazany plik, a jeśli może, to wysyła pierwszy pakiet danych (OP_DATA) i odczytuje kolejny pakiet. Teraz klient odczytuje nadesłaną odpowiedź i jeśli jest prawidłowa, przekształca pakiet w pakiet potwierdzenia i wysyła go. Taka wymiana danych z potwierdzaniem jest kontynuowana aż do momentu odczytania całego pliku. Należy zauważyć, że każdy pakiet, za wyjątkiem ostatniego, ma długość 516 bajtów (512 bajtów danych, 2 bajty określające typ pakietu oraz 2 bajty wskazujące numer kolejnego pakietu). Ostatni pakiet może mieć dowolną długość z zakresu od 4 (taki pakiet nie zawiera żadnych danych) do 515 (511 bajtów danych). Jeśli podczas przesyłania pakietu pojawią się jakieś problemy, to zostanie on ponownie przesłany. Jeśli jakiś pakiet zostanie zgubiony, to zarówno klient, jak i serwer mają odczekać odpowiedni czas. Klient przedstawiony w tej recepturze nie zachowuje się w ten sposób, jednak serwer, z którego korzysta, działa zgodnie z nim. Takie opóźnienia można dodać bądź to przy użyciu wątków (patrz „22.4. Spotkania i ograniczenia czasowe”), bądź też poprzez wywołanie na rzecz gniazda metody setSoTimeout(); w takim przypadku, jeśli pakiet faktycznie został zgubiony, należy przechwycić wyjątek SocketTimeoutException i ponownie przesłać potwierdzenie (lub żądanie odczytu); czynności te być może trzeba będzie powtórzyć, przy czym liczba tych powtórzeń jest ograniczona. Implementację tego rozwiązania pozostawiam jako ćwiczenie dla Czytelnika. Aktualna wersja kodu klienta została przedstawiona na Przykład 13-9.

Przykład 13-9. /network/RemCat.java

public class RemCat {
    /** Numer portu UDP. */
    public final static int TFTP_PORT = 69;
    /** Używany tryb pracy - zawsze "octet". */
    protected final String MODE = "octet";

    /** Przesunięcie kodu/odpowiedzi w bajtach */
    protected final int OFFSET_REQUEST = 1;
    /** Przesunięcie numeru pakietu w bajtach */
    protected final int OFFSET_PACKETNUM = 3;

    /** Flaga testowania */
    protected static boolean debug = false;

    /** Kody operacji protokołu TFTP dla żądania odczytu. */
    public final int OP_RRQ = 1;
    /** Kody operacji protokołu TFTP dla żądania odczytu. */
    public final int OP_WRQ = 2;
    /** Kody operacji protokołu TFTP dla żądania odczytu. */
    public final int OP_DATA = 3;
    /** Kody operacji protokołu TFTP dla żądania odczytu. */
    public final int OP_ACK    = 4;
    /** Kody operacji protokołu TFTP dla żądania odczytu. */
    public final int OP_ERROR = 5;

    protected final static int PACKET_SIZE = 516;    // == 2 + 2 + 512
    protected String host;
    protected InetAddress servAddr;
    protected DatagramSocket sock;
    protected byte buffer[];
    protected DatagramPacket inp, outp;

    /** Program główny obsługujący klienta sieciowego.
     * @param argv[0] nazwa komputera, na którym działa serwer TFTP
     * @param argv[1..n] nazwy plików (musi być przynajmniej jedna)
     */
    public static void main(String[] argv) throws IOException {
        if (argv.length < 2) {
            System.err.println("Sposób użycia: rcat komputer nazwaPliku[...]");
            System.exit(1);
        }
        if (debug)
            System.err.println("Uruchamiamy program RemCat.");
        RemCat rc = new RemCat(argv[0]);
        for (int i = 1; i<argv.length; i++) {
            if (debug)
                System.err.println("-- Rozpoczynamy pobieranie pliku " +
                    argv[0] + ":" + argv[i] + "---");
            rc.readFile(argv[i]);
        }
    }

    RemCat(String host) throws IOException {
        super();
        this.host = host;
        servAddr = InetAddress.getByName(host);
        sock = new DatagramSocket();
        buffer = new byte[PACKET_SIZE];
        outp = new DatagramPacket(buffer, PACKET_SIZE, servAddr, TFTP_PORT);
        inp = new DatagramPacket(buffer, PACKET_SIZE);
    }

    /* Tworzymy pakiet żądania odczytu protokołu TFTP. To nieco
     * złożone, gdyż długości pól są zmienne. Poszczególne bajty
     * liczb muszą być zapisywane w kolejności "sieciowej";
     * na szczęście Java wydaje się dość "inteligentna",
     * gdyż sama używa właśnie tej kolejności zapisu.
     */
    void readFile(String path) throws IOException {
        buffer[0] = 0;
        buffer[OFFSET_REQUEST] = OP_RRQ;        // Żądanie odczytu
        int p = 2;            // Liczba znaków w buforze

        // Konwersja łańcucha znaków zawierającego nazwę pliku na
        // bajty (zapisane w buforze). Zmienna "p" służy jako
        // wskaźnik przesunięcia precyzyjnie umieszczający wszystkie
        // bajty żądania w odpowiednim położeniu.
        byte[] bTemp = path.getBytes();    // Na przykład ASCII
        System.arraycopy(bTemp, 0, buffer, p, path.length());
        p += path.length();
        buffer[p++] = 0;        // Bajt o wartości 0 kończy łańcuch znaków

        // Podobnie konwertujemy tryb (MODE - "octet"), zapisując go
        // w buforze w postaci bajtów.
        bTemp = MODE.getBytes();    // Na przykład ASCII
        System.arraycopy(bTemp, 0, buffer, p, MODE.length());
        p += MODE.length();
        buffer[p++] = 0;            // Bajt o kodzie 0 kończy łańcuch

        /* Wysyłamy żądanie odczytu do serwera tftp */
        outp.setLength(p);
        sock.send(outp);

        /* Pętla odczytująca pakiety danych z serwera aż do
         * momentu odnalezienia pakietu krótszego niż standardowy;
         * taki pakiet oznacza koniec pliku.
         */
        int len = 0;
        do {
            sock.receive(inp);
            if (debug)
                System.err.println(
                    "Pakiet # " + Byte.toString(buffer[OFFSET_PACKETNUM])+
                    "KOD ODPOWIEDZI " +
                                      Byte.toString(buffer[OFFSET_REQUEST]));
            if (buffer[OFFSET_REQUEST] == OP_ERROR) {
                System.err.println("BŁĄD: " +
                    new String(buffer, 4, inp.getLength()-4));
                return;
            }
            if (debug)
                System.err.println("Odebrano pakiet wielkości " +
                    inp.getLength());

            /* Wyświetlenie danych odczytanych z pakietu */
            System.out.write(buffer, 4, inp.getLength()-4);

            /* Potwierdzenie odbioru pakietu.
             * Numer pakietu, który chcemy potwierdzić, jest już
             * w buforze, a zatem wystarczy zmienić kod.
             * Potwierdzenie jest wysyłane na port, z którego
             * serwer wysłał dane, a nie na port TFTP_PORT.
             */
            buffer[OFFSET_REQUEST] = OP_ACK;
            outp.setLength(4);
            outp.setPort(inp.getPort());
            sock.send(outp);
        } while (inp.getLength() == PACKET_SIZE);

        if (debug)
            System.err.println(
                   "** GOTOWE ** Koniec pętli, wielkość ostatniego pakietu " +
                   inp.getLength());
    }
}

Do przetestowania tego programu konieczny będzie serwer protokołu TFTP. Jeśli korzystasz z komputera z systemem Unix, którym sam administrujesz, to uruchomienie serwera TFTP będzie wymagało jedynie modyfikacji pliku /etc/inetd.conf i ponownego uruchomienia serwera inetd (lub jego przeładowania przy użyciu polecenia kill -HUP). inetd to program, który oczekuje na różnego typu połączenia, a jeśli jakieś połączenie od klienta zostanie odebrane, to uruchamia odpowiedni serwer (jest to pewien rodzaj „przetwarzania leniwego”[50]). Ja stworzyłem tradycyjny katalog /tftpboot, dodałem następujący wiersz tekstu do pliku inetd.conf i ponownie uruchomiłem serwer inetd:

tftp dgram udp wait root /usr/libexec/tftpd -s /tftpboot

Następnie w katalogu /tftpboot umieściłem kilka plików, w tym jeden o nazwie foo. Wykonanie polecenia:

$ java network.RemCat localhost foo

powoduje utworzenie czegoś, co wygląda jak plik. Aby jednak mieć pewność, wyniki działania programu RemCat porównałem z oryginalnym plikiem, używając w tym celu polecenia diff. Brak wiadomości jest dobrą wiadomością:

$ java network.RemCat localhost foo | diff - /tftpboot/foo

Jak na razie wszystko jest w porządku. Nie umieszczajmy jednak tego programu w sieci bez wcześniejszego, choćby pobieżnego, przetestowania obsługi błędów:

$ java network.RemCat localhost takiegoPlikuNieMa
BŁĄD: File not found
$

URL jest tradycyjną nazwą adresów sieciowych zawierających schemat (na przykład http), adres (czyli nazwę witryny) oraz określenie zasobu lub ścieżkę dostępu. Jednak w sumie istnieją trzy odrębne terminy:

Informacje umieszczone pod koniec dokumentacji nowej klasy wyjaśniają, jakie są relacje pomiędzy URI, URL oraz URN. URI reprezentują grupę wszystkich identyfikatorów, natomiast adresy URL i nazwy URN stanowią jej podgrupy.

Identyfikatory URI są najbardziej ogólne; analizuje się je zgodnie z prostą składnią bez zwracania uwagi na schemat (jeśli taki w ogóle został określony) i nie muszą się one odwoływać do żadnego konkretnego serwera. Adresy URL zawierają nazwę komputera, schemat oraz inne komponenty; są one analizowane zgodnie z regułami określanymi przez podany w adresie schemat. W przypadku tworzenia adresu URL automatycznie tworzony jest także obiekt InputStream. Z kolei URN określają nazwy zasobów, natomiast nie informują, jak należy je odnajdywać; typowymi przykładami nazw URL są mailto: oraz news:.

Podstawowe operacje udostępniane przez klasę URI obejmują normalizację (czyli usunięcie nadmiarowych segmentów ścieżki, w tym podwójnych znaków kropki ..) oraz relatywizację (choć właściwie operację tę należałoby określać jako „przekształcenie do postaci względnej”, jednak ktoś chciał mieć jedno słowo, by móc wygodnie nazwać metodę). Obiekt URI nie dysponuje żadnymi metodami służącymi do otwierania zasobów określanych przez dany identyfikator; do tego celu są zazwyczaj używane obiekty URL tworzone za pomocą łańcuchowej reprezentacji URI w sposób przedstawiony poniżej:

URL x = new URL(theURI.toString());

Program zaprezentowany na Przykład 13-10 pokazuje przykłady normalizacji, określania ścieżek względnych oraz tworzenia adresów URL na podstawie identyfikatorów URI.

Choć biblioteka HttpClient stanowiąca jeden z projektów rozwijanych przez Fundację Apache zapewnia bardzo dużą elastyczność, to jednak w sytuacjach gdy chcemy jedynie korzystać z prostych usług internetowych REST, stosowanie jej jest sporą przesadą. W takich przypadkach zależy nam zazwyczaj jedynie na możliwości otworzenia adresu URL oraz odczytania zawartości zasobu określanego przez ten adres. W naszym prostym przykładzie wykorzystamy bezpłatną usługę freegeoip.net. Geolokalizacja adresu IP polega na określeniu położenia geograficznego odbieranego połączenia IP, przy czym zazwyczaj jest to adres klienta lub serwera (bądź jego serwera proxy, jeśli taki jest używany).

Usługa FreeGeoIP udostępnia trzy formaty, w których mogą być zwracane dane wynikowe: CVS (patrz „3.13. Przetwarzanie danych rozdzielonych przecinkami”), JSON (patrz Rozdział 19.) oraz XML (patrz Rozdział 20.). Ponieważ jest ona typową usługą REST udostępniającą wiele formatów wynikowych, interesujący nas format wybiera się poprzez podanie go jako jednego z elementów adresu URL. Dokumentacja usługi określa, że stosowany adres URL ma następującą postać:

http://freegeoip.net/{format}/{komputer}

Z usługi można korzystać zarówno przy użyciu protokołu HTTP, jak i HTTPS, a dostępnymi formatami są: csv, json lub xml (wszystkie zapisywane małymi literami). Segment „komputer” jest opcjonalny: jeśli nie zostanie podany, to usługa określi położenie adresu IP komputera, który nawiązał połączenie; w przeciwnym razie można podać adres IP komputera lub jego nazwę.

A oto kod tej usługi:

/weserviceclient/RestClientFreeGeoIp.java

public class RestClientFreeGeoIp {
    public static void main(String[] args) throws Exception {
        URLConnection conn = new URL(
            "http://freegeoip.net/json/www.oreilly.com")
            .openConnection();
        try (BufferedReader is =
            new BufferedReader(new InputStreamReader(conn.getInputStream()))){

            String line;
            while ((line = is.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

Wyniki są zwracane w wybranym formacie. W razie wskazania formatu JSON wyniki są zwracane w postaci jednego, długiego wiersza tekstu, zawierającego wszystkie informacje na temat podanego komputera (poniższy przykład pokazuje informacje o serwerze O’Reilly Media). Zamieszczone poniżej przykładowe dane zostały podzielone w miejscach występowania przecinków, dzięki czemu mogłem dostosować je do szerokości strony:

{"ip":"207.152.124.48","country_code":"US","country_name":"United States",
"region_code":"CO","region_name":"Colorado","city":"Englewood",
"zipcode":"80111","latitude":39.6237,"longitude":-104.8738,
"metro_code":"751","areacode":"303"}

Jak wszystkie duże, komercyjne organizacje, także O’Reilly używa rozproszonej sieci rozpowszechniania treści (CDN), dlatego uzyskiwane wyniki mogą się zmieniać; poniżej przedstawiłem zupełnie inne wyniki uzyskane po wykonaniu tego samego programu kilka minut później:

{"ip":"69.31.106.26","country_code":"US","country_name":"United States",
"region_code":"MA","region_name":"Massachusetts","city":"Cambridge",
"zipcode":"02142","latitude":42.3626,"longitude":-71.0843,
"metro_code":"506","areacode":"617"}

Więcej informacji na temat usług internetowych REST (w tym także sposoby implementacji komponentów serwerowych tych usług) można znaleźć w książce RESTful Java with JAX-RS 2.0, 2nd Edition (http://shop.oreilly.com/product/0636920028925.do) napisanej przez Billa Burke’a i wydanej przez wydawnictwo O’Reilly.

SOAP jest protokołem komunikacyjnym używanym przez usługi internetowe przesyłające dane w formacie XML. SOAP nie jest akronimem, początkowo był to skrót pochodzący od słów „Simple Object Access Protocol” (prosty protokół dostępu do obiektów); jednak w momencie kiedy zaczął nad nim pracować międzynarodowy komitet standaryzacyjny, nie można było już o nim powiedzieć, że jest prosty, a jeśli chodzi o obiekty, to tak naprawdę nigdy nie był on z nimi związany. Dlatego też zdecydowano się porzucić rozwinięcie skrótu, lecz zostawić samą nazwę. A... i jeszcze jedno: właściwie nie jest to także „protokół” — SOAP korzysta bowiem z protokołów HTTP i HTTPS. A zatem SOAP to obecnie jedynie SOAP, choć użycie w jednym zdaniu słów „simple” (prosty) oraz „SOAP” wywołuje jedynie śmiech u zwolenników usług REST.

Pierwszą czynnością jest pobranie kopii dokumentu WSDL (ang. Web Service Description Language — język opisu usług internetowych). Dokument ten przetwarza się przy użyciu narzędzia wsimport (dostarczanego wraz z JDK). Program ten generuje klasę wytwórczą usługi, która z kolei pozwala na utworzenie obiektu namiastki tej usługi. Dopiero teraz można wywołać metodę namiastki, aby uzyskać dostęp do zdalnej usługi.

WSDL opisuje usługę ze wszelkimi szczegółami. Można go sobie wyobrażać jako interfejs Javy na sterydach. Interfejs przekazuje informacje o dostępnych metodach, ich argumentach, deklarowanych wyjątkach i tak dalej; nie daje jednak możliwości przekazywania informacji o tym, gdzie znaleźć daną usługę w internecie ani jak konwertować dane na format, który usługa będzie mogła zaakceptować. Z kolei WSDL zapewnia wszystkie te możliwości, a co więcej, sposoby korzystania z nich są standaryzowane (choć jednocześnie złożone).

Całe to rozwiązanie ma tę zaletę, że klient i serwer nie muszą być napisane w tym samym języku programowania ani działać w tym samym systemie operacyjnym lub na komputerach o tej samej architekturze procesorów. Usługa może działać na serwerze aplikacji Java EE, a korzystające z niej klienty — na urządzeniach mobilnych (z systemami Android, iOS, BlackBerry, Windows itd.); choć mogą także być napisane w językach Perl, Python lub Ruby i działać na komputerze z systemami Linux, Mac OS lub BSD. WSDL opisuje wszystkie zagadnienia związane z formatowaniem, dzięki czemu problemy z wielkością słów lub kolejnością zapisu bitów są rozwiązywane w sposób niewidoczny dla programisty. Nawet w świecie języka Java istnieje wiele pakietów narzędziowych służących do generowania klientów i usługi SOAP. W tym przykładzie skorzystamy z JAX-WS (ang. Java API for XML Web Services), gdyż jest on standardowym elementem platformy Javy (i to zarówno w wersji standardowej, Java SE, jak i korporacyjnej, Java EE).

Są osoby, które preferują ręczne generowanie plików WSDL. Nazywają to „programowaniem w oparciu o kontrakt”. Ja jednak do nich nie należę. Zacznę od istniejącego pliku WSDL bądź wygeneruję plik WSDL na podstawie istniejącego interfejsu Javy, a następnie ręcznie wprowadzę w nim niezbędne poprawki. Także interfejs języka Java jest określonym kontraktem, choć być może nie aż tak kompletnym (ani nie wypełnionym po brzegi legalnym bełkotem; co można by dodać, posuwając analogię jeszcze o krok dalej).

Na potrzeby usługi prezentowanej w tej recepturze przygotuję jeszcze inną wersję mojego prostego kalkulatora Calc. Warto zwrócić uwagę, że wszystkie kody prezentowane w tej recepturze pochodzą z repozytorium javasrcee (https://github.com/IanDarwin/javasrcee).

Aby przybliżyć nas do analizy kodu klienta, bardzo pobieżnie przedstawię, jak napisałem usługę, gdyż tworząc ją, można także utworzyć dokument WSDL. W realnych sytuacjach zazwyczaj będziemy chcieli komunikować się z już istniejącymi usługami, a więc można przyjąć, że już dysponujemy lub będziemy dysponować ich dokumentami WSDL. W takim przypadku można pominąć tę część rozdziału i kontynuować lekturę od kolejnego podpunktu, „Generowanie artefaktów klienta i pisanie jego kodu”.

Usługa Calc powstała jako implementacja klasy o tej samej nazwie i została umieszczona w pakiecie jaxwsservice:

/jaxwsservice/Calc.java

import javax.jws.WebService;
@WebService(targetNamespace="http://toy.service/")
public class Calc {

    public int add(int a, int b) {
        System.out.println("CalcImpl.add()");
        return a + b;
    }
    // Pozostałe trzy metody są bardzo podobne.

Aby przekształcić tę klasę na usługę w celach testowych oraz aby wygenerować odpowiedni plik WSDL, JDK udostępnia zadziwiające narzędzie o nazwie javax.xml.ws.Endpoint, które pozwala wybrać dowolną klasę (o ile tylko zostanie do niej dodana adnotacja @WebService) i opublikować ją jako usługę internetową!

/jaxwsservice/ServiceMain.java

// Tworzymy "namiastkę usługi".
Calc impl = new Calc();
// Uruchamiamy usługę.
Endpoint ep =
    Endpoint.publish("http://localhost:9090/calc", impl);
System.out.println("Usługa uruchomiona: " + ep);

Zakładając, że wcześniej na głównym poziomie kodów z repozytorium javasrcee wykonaliśmy polecenie mvn compile, to teraz powyższą usługę będziemy mogli wykonać, używając polecania:

$ java -cp target/classes javaxwsservice.ServiceMain
Usługa uruchomiona:
 com.sun.xml.internal.ws.transport.http.server.EndpointImpl@f0f7074

A co jest takiego niesamowitego w tym wszystkim? Otóż jeśli ktoś nie zetknął się jeszcze ze złożonością wdrażania niektórych usług internetowych, to zapewne uzna, że nie ma tu nic ciekawego. Ale uwierzcie mi, to naprawdę jest ciekawe. Otóż nie tylko udało się nam uruchomić serwer i udostępnić implementację prostego, starego obiektu Javy (POJO) jako usługę internetową, lecz jednocześnie został wygenerowany plik WSDL, i to w całości bez naszego udziału! No dobrze, została przy tym wykorzystana adnotacja, którą wcześniej dodaliśmy do klasy Calc. Dzięki temu mogę teraz pokazać zawartość dokumentu WSDL. Kiedy serwis zostanie już uruchomiony, należy wpisać w przeglądarce adres URL usługi, dodając do niej parametr HTTP o nazwie wsdl (nie trzeba podawać jego wartości — wystarczy dodać do adresu ?wsdl). A zatem adres URL pozwalający na pobranie dokumentu WSDL będzie mieć postać: http://localhost:9090/calc?wsdl.

Choć w zasadzie kopiowanie dokumentu WSDL nie jest potrzebne do szczęścia, to jednak można go zapisać na dysku. Jeśli dysponujemy sensownym klientem FTP, takim jak program ftp w systemie BSD lub program wget, to możemy zapisać go bezpośrednio w pliku, używając następującego polecenia:

$ ftp -o calc.wsdl http://localhost:9090/calc?wsdl
Requesting http://localhost:9090/calc?wsdl
100% |**************************************************| 3745       00:00
3745 bytes received in 0.00 seconds (9.18 MB/s)
$ ls -l calc.wsdl
-rw-r--r-- 1 ian wheel 3745 Jan 3 09:53 calc.wsdl
$

Oczywiście warto zajrzeć do pobranego pliku WSDL — przede wszystkim po to, by przekonać się o tym, że faktycznie jest on tak złożony (a przynajmniej rozwlekły), jak twierdziłem. Jednym z argumentów wysuwanych przez zwolenników tworzenia dokumentów WSDL w pierwszej kolejności jest to, że dokumenty te pisane ręcznie przez programistów są zazwyczaj mniejsze i prostsze.

Teraz możemy już ponownie zająć się generowaniem kodu klienta. Program wsimport odczytuje dokument WSDL — bądź to podany w formie adresu URL, bądź też z pliku na dysku — i na jego podstawie generuje cały zestaw tak zwanych artefaktów:

$ mkdir jaxwsclient
$ wsimport -d jaxwsclient -keep 'http://localhost:9090/calc?wsdl'
parsing WSDL...

Generating code...

Compiling code...

$ ls jaxwsclient/service/toy/*.java
Add.java                   Divide.java                ObjectFactory.java
AddResponse.java           DivideResponse.java        Subtract.java
Calc.java                  Multiply.java              SubtractubtractResponse.java
CalcService.java           MultiplyResponse.java      package-info.java
$

Koniecznie trzeba przejrzeć wygenerowany kod interfejsu Calc oraz klasy CalcService (jeśli Czytelnik nie buduje ich, czytając tę recepturę, to ich kody można znaleźć w przykładach dołączonych do książki oraz w repozytorium javasrcee w katalogu jaxwsclient). W przypadku usług korzystających z argumentów będących obiektami trzeba będzie przeanalizować inne wygenerowane artefakty, gdyż to właśnie one będą określać faktyczne typy, które klient będzie musiał przekazywać w wywołaniach metod namiastki usługi. Analiza klasy CalcService pozwoli określić nazwę metody, którą należy wykonać w celu nawiązania połączenia z usługą. W naszym przypadku jest to:

/jaxwsclient/TinyClientMain.java — pierwszy wiersz kodu metody main()

Calc client = new CalcService().getCalcPort();

Analiza kodu interfejsu Calc pozwoli nam określić argumenty, jakie należy przekazywać w wywołaniach jego metod. Od tego momentu mamy udawać, że nigdy nie widzieliśmy serwerowej wersji usługi, i musimy bardzo uważać, by nie zaimportować żadnych klas należących do „serwerowego pakietu”! Teraz możemy już napisać drugi wiersz kodu naszego klienta:

/jaxwsclient/TinyClientMain.java — drugi wiersz kodu metody main()

System.out.println(client.add(2, 2));

Możesz być bardzo zawiedziony, jeśli zostanie wyświetlony wynik inny niż 4. Ale, jak widać, to prawda — faktycznie można napisać kompletnego klienta usługi SOAP w dwóch wierszach kodu, oczywiście zakładając, że argumenty są typów prostych, i nie wspominając o wszystkich plikach wygenerowanych przez program wsimport oraz dodatkowej pracy wykonanej przez pakiet JAX-WS. A oto pełny kod źródłowy klienta:

/jaxwsclient/TinyClientMain.java

// Nie ma żadnych instrukcji import!

/** Pełny, kompletny kod klienta usługi Calc, który
 * mieści się w dwóch wierszach. */
public class TinyClientMain {
    public static void main(String[] args) {
        Calc client = new CalcService().getCalcPort();
        System.out.println(client.add(2, 2));
    }
}
$ java -cp target/classes jaxwsclient.TinyClientMain
4
$

Faktyczny klient umieszczony w pakiecie jaxwsclient udostępnia interaktywny kalkulator, który pozwala na wprowadzanie wierszy kodu, takich jak 2 + 2, i wyświetlenie wyników.

$ java -cp target/classes jaxwsclient.ClientMain
Interaktywny kalkulator. Oddzielaj operatory znakami odstępu.
>> 2 + 2
2 + 2 = 4
>> 22 / 7
22 / 7 = 3
>> ^D
$

Genialne, prawda? Tak się składa, że w pakiecie klienta usługi dostępne są także test jednostkowy oraz test integracyjny (który używa statycznej metody Endpoint.publish() do uruchamiania i zatrzymywania usługi).

Poniżej przedstawiłem podsumowanie efektów naszej pracy:

  1. Stworzyliśmy usługę (ale tę zazwyczaj otrzymamy gotową).

  2. Pobraliśmy plik WSDL usługi, wykorzystując sztuczkę z dodaniem do adresu usługi końcówki ?wsdl.

  3. Przetworzyliśmy plik WSDL przy użyciu programu wsimport w celu wygenerowania artefaktów klienta.

  4. Przeanalizowaliśmy pliki Calc i CalcService (ich nazwy będą w każdej usłudze tworzone według tego samego wzorca) — nazwa usługi będzie nazwą interfejsu, natomiast nazwą klasy wytwórczej będzie nazwą usługi z dodanym słowem Service.

  5. Wywołaliśmy metodę getCalcService() klasy wytwórczej, aby pobrać obiekt namiastki usługi Calc.

  6. Wywoływaliśmy metody obiektu namiastki.

Program przedstawiony w tej recepturze jest prostym klientem usługi Telnet. Jak wiadomo, Telnet jest najstarszym i wciąż używanym programem do zdalnego logowania się do komputerów przez internet. Usługa ta była początkowo stosowana w sieciach ARPANET, a później została przeniesiona i przystosowana do użycia w internecie. Wciąż stosowany jest uniksowy klient tej usługi obsługiwany z poziomu wiersza poleceń, a dodatkowo jest także dostępnych kilka programów przeznaczonych do użycia w systemach operacyjnych z graficznym interfejsem użytkownika. Ze względów bezpieczeństwa wykorzystanie usługi Telnet w celu logowania się do zdalnych komputerów zostało w znacznym stopniu zastąpione przez protokół SSH (http://www.openssh.com/), niemniej wciąż nie można się obyć bez klientów usługi Telnet — są one wykorzystywane na przykład podczas logowania się do lokalnych komputerów lub testowania serwerów, z którymi nawiązuje się połączenia za pomocą gniazd i które stosują protokoły tekstowe, oraz do dokładnego poznawania takich „tekstowych” protokołów. Na przykład często spotykanym rozwiązaniem jest stosowanie klienta usługi Telnet do nawiązywania połączenia z serwerem SMTP (poczty elektronicznej). Serwer SMTP można z dobrymi wynikami obsługiwać na wyczucie, nawet jeśli nie cała sesja obsługi poczty jest wykonywana interaktywnie.

Gdy mniej więcej w tym samym czasie dane mają być kopiowane w dwóch kierunkach — z klawiatury do zdalnego programu oraz ze zdalnego programu na ekran — to problem ten można rozwiązać na dwa sposoby. Niektóre biblioteki obsługujące operacje wejścia-wyjścia w języku C udostępniają funkcje poll() lub select(), które pozwalają sprawdzić grupę plików, aby przekonać się, który z nich jest gotowy do odczytu lub zapisu. Język Java nie obsługuje takiego sposobu postępowania. Drugie rozwiązanie, wykorzystywane w większości systemów i języków programowania, w tym także w Javie, polega na użyciu dwóch niezależnych wątków[51], z których każdy obsługuje transmisję danych w jednym kierunku. Takie właśnie rozwiązanie wykorzystamy w niniejszej recepturze. Klasa Pipe reprezentuje wątek i dysponuje dodatkowo kodem umożliwiającym kopiowanie danych w jednym kierunku. Nasz program wykorzystuje dwa obiekty tej klasy, z których każdy obsługuje transfer danych w jednym kierunku i działa niezależnie od drugiego.

Przedstawiony program pozwala na nawiązanie połączenia z dowolną usługą sieciową wykorzystującą protokół tekstowy. Na przykład za jego pośrednictwem można prowadzić „konwersację” z lokalnym serwerem SMTP (prosty protokół przesyłania poczty elektronicznej) bądź z serwerem Daytime (na porcie 13) wykorzystywanym w kilku przykładach przedstawionych we wcześniejszej części tego rozdziału.

$ java network.Telnet darian 13
Komputer darian; port 13
Połączenie nawiązano.
Sat Apr 28 14:07:41 2001
^C
$

Kod źródłowy programu zaprezentowałem na Przykład 13-11.

Oczywiście powyższemu programowi wiele brakuje do „prawdziwego” klienta Telnet, jednak do jakichkolwiek faktycznych działań w internecie zamiast programu Telnet należałoby używać SSH — patrz http://openssh.com.

W tej recepturze przedstawiłem przykład prostego klienta służącego do prowadzenia pogawędek internetowych. Za jego pomocą nie można prowadzić pogawędek z osobami korzystającymi z takich programów jak ICQ lub AIM, gdyż każdy z nich używa własnych protokołów[52]. Przedstawiony tu program zapisuje i odczytuje dane z serwera, który jest odnajdywany przy użyciu stosowanej w apletach metody getCodeBase(). Serwer, z którym komunikuje się ten program, zostanie przedstawiony w Rozdział 16. A jak nasz przykładowy program wygląda po uruchomieniu? Na Rysunek 13-2 przedstawiłem przykład pogawędki, którą pewnego dnia odbyłem sam ze sobą.

Kod programu jest na tyle prosty, że nie wymaga żadnych dodatkowych komentarzy. Dane wejściowe odczytujemy z serwera, tak wykorzystując niezależny wątek, że odczyt i wysyłanie danych nie kolidują ze sobą; rozwiązanie to zostało przedstawione w Rozdział 22. Zagadnienia związane z odczytywaniem i wysyłaniem danych przez sieć zostały omówione w niniejszym rozdziale. Kod źródłowy tego programu przedstawiłem na Przykład 13-12.

Przykład 13-12. /chat/ChatClient.java

public class ChatClient extends JFrame {

    private static final long serialVersionUID = -3686334002367908392L;
    private static final String userName =
        System.getProperty("user.name", "Bezimienny użytkownik");
    /** Czy użytkownik jest zalogowany? */
    protected boolean loggedIn;
    /** Główna ramka aplikacji. */
    protected JFrame cp;
    /** Domyślny numer portu. */
    protected static final int PORTNUM = ChatProtocol.PORTNUM;
    /** Używany numer portu. */
    protected int port;
    /** Gniazdo */
    protected Socket sock;
    /** Obiekt BufferedReader służący do odczytu danych z gniazda */
    protected BufferedReader is;
    /** Obiekt PrintWriter służący do wysyłania wierszy tekstu */
    protected PrintWriter pw;
    /** TextField - pole wejściowe */
    protected TextField tf;
    /** TextArea - pole do wyświetlania pogawędki */
    protected TextArea ta;
    /** Przycisk Login */
    protected Button loginButton;
    /** Przycisk Logout */
    protected Button logoutButton;
    /** Tytuł wyświetlany na pasku nagłówka */
    final static String TITLE = "ChatClient: Prosty klient pogawędek Iana Darwina";

    /** Przygotowanie graficznego interfejsu użytkownika. */
    public ChatClient() {
        cp = this;
        cp.setTitle(TITLE);
        cp.setLayout(new BorderLayout());
        port = PORTNUM;

        // Interfejs użytkownika
        ta = new TextArea(14, 80);
        ta.setEditable(false);        // tylko do odczytu
        ta.setFont(new Font("Monospaced", Font.PLAIN, 11));
        cp.add(BorderLayout.NORTH, ta);

        Panel p = new Panel();

        // Przycisk do logowania.
        p.add(loginButton = new Button("Logowanie"));
        loginButton.setEnabled(true);
        loginButton.requestFocus();
        loginButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                login();
                loginButton.setEnabled(false);
                logoutButton.setEnabled(true);
                tf.requestFocus();    // Określenie miejsca wprowadzania!
            }
        });

        // Przycisk do wylogowania
        p.add(logoutButton = new Button("Wyloguj się"));
        logoutButton.setEnabled(false);
        logoutButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                logout();
                loginButton.setEnabled(true);
                logoutButton.setEnabled(false);
                loginButton.requestFocus();
            }
        });

        p.add(new Label("Treść wiadomości:"));
        tf = new TextField(40);
        tf.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (loggedIn) {
                    pw.println(ChatProtocol.CMD_BCAST+tf.getText());
                    tf.setText("");
                }
            }
        });
        p.add(tf);

        cp.add(BorderLayout.SOUTH, p);

        cp.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        cp.pack();
    }

    protected String serverHost = "localhost";

    /** Podłączamy się do pogawędki */
    public void login() {
        showStatus("Logujemy się!");
        if (loggedIn)
            return;
        try {
            sock = new Socket(serverHost, port);
            is = new BufferedReader(new InputStreamReader(sock.getInputStream()));
            pw = new PrintWriter(sock.getOutputStream(), true);
            showStatus("Mamy połączenie");

            // Udajemy logowanie - na razie nie potrzeba hasła.
            pw.println(ChatProtocol.CMD_LOGIN + userName);

            loggedIn = true;

        } catch(IOException e) {
            showStatus("Nie można nawiązać połączenia z " +
                serverHost + "/" + port + ": " + e);
            cp.add(new Label("Nie można uzyskać gniazda: " + e));
            return;
        }

        // Tworzymy i uruchamiamy czytelnika: z serwera do wielowierszowego
        // pola tekstowego. Aby uniknąć blokowania, używamy wątku.
        new Thread(new Runnable() {
            public void run() {
                String line;
                try {
                    while (loggedIn && ((line = is.readLine()) != null))
                        ta.append(line + "\n");
                } catch(IOException e) {
                    showStatus("POŁĄCZENIE ZOSTAŁO UTRACONE!!");
                    return;
                }
            }
        }).start();
    }

    /** Wychodzimy stąd, Scotty, nie ma tu żadnych inteligentnych form
     * życia! */
    public void logout() {
        if (!loggedIn)
            return;
        loggedIn = false;
        try {
            if (sock != null)
                sock.close();
        } catch (IOException ign) {
            // No i co z tego?
        }
    }

    public void showStatus(String message) {
        System.out.println(message);
    }

    /** Metoda główna umożliwiająca uruchomienie programu jako
     * normalnej aplikacji.
     */
    public static void main(String[] args) {
        ChatClient room101 = new ChatClient();
        room101.pack();
        room101.setVisible(true);
    }
}

Istnieje wiele innych sposobów tworzenia klientów pogawędek charakteryzujących się lepszą strukturą. Można do nich zaliczyć takie rozwiązania jak gniazda sieciowe (WebSocket), RMI (wywoływanie zdalnych metod) oraz Java Messaging Services (usługi rozsyłania komunikatów). RMI jest interfejsem RPC (ang. Remote Procedure Call — wywoływanie zdalnych procedur) w języku Java, który wchodzi w skład zarówno standardowej, jak i korporacyjnej wersji języka Java. Nie opisywałem go w tym wydaniu książki, choć poprzednie wydanie zawierało rozdział na jego temat. Pozostałe technologie wchodzą w skład Java EE — korporacyjnej wersji języka Java — a więcej informacji na ich temat można znaleźć w książce Java EE 7 Essentials napisanej przez Aruna Guptę i wydanej przez wydawnictwo O’Reilly.

Jeśli konieczne jest szyfrowanie informacji przesyłanych za pośrednictwem gniazd, można wykorzystać bibliotekę JSSE firmy Sun (ang. Java Secure Socket Extension — rozszerzenie do obsługi bezpiecznych gniazd języka Java). (Jeśli Czytelnik skorzystał z wcześniejszej rady i użył standardowego protokołu HTTP, to teraz może zaszyfrować pogawędki, zmieniając adresy URL tak, by korzystały z protokołu HTTPS).

Doskonałe omówienie zagadnień programowania sieciowego z wykorzystaniem języka C można znaleźć w książce Unix Network Programming W. Richarda Stevensa. Wbrew temu, co sugeruje tytuł książki, omawia ona zagadnienia związane z wykorzystaniem gniazd oraz obsługą protokołów TCP/IP/UDP i w sposób zadziwiająco szczegółowy prezentuje wszystkie fragmenty interfejsu programistycznego służącego do obsługi sieci (w wersji dostępnej w systemach Unix) oraz protokoły (na przykład takie jak TFTP).

Sprawdzanie poprawności odnośników jest ciągłym problemem dla wszystkich właścicieli witryn WWW, jak również dla osób piszących dokumentacje techniczne zawierające odwołania do materiałów zewnętrznych (takich jak autor tej książki). Programy do sprawdzania odnośników (ang. link checkers) są narzędziami, których takie osoby nieustannie używają do sprawdzania poprawności odnośników umieszczanych na swoich stronach, i to niezależnie od tego, czy są to strony witryn WWW, czy książek. Implementacja narzędzia tego typu sprowadza się do: a) pobrania odnośników oraz b) próby ich otworzenia. Dokładnie tak samo działa program przedstawiony w tej recepturze. Nadałem mu nazwę KwikLinkChecker, gdyż można uznać, że jest to rozwiązanie „szybkie i dalekie od ideału” — nie sprawdza on zawartości odnośnika, by upewnić się, że wciąż zawiera on to, co zawierał niegdyś; a zatem jeśli na przykład zapomnę odnowić rejestrację domeny i zostanie ona przejęta przez jakąś witrynę pornograficzną, to mój program się o tym nie dowie. Pomimo tej niedoskonałości program wykonuje to, co do niego należy, i to całkiem dobrze i całkiem szybko:

/com/darwinsys/tools/KwikLinkChecker.java

/**
 * Metoda sprawdza jeden odnośnik HTTP. Nie działa rekurencyjnie.
 * Zwraca obiekt LinkStatus z logicznym polem określającym status
 * wykonanego sprawdzenia i nazwą pliku lub komentarzem o błędzie
 * w polu message. Końcówka tej metody jest jednym z niewielu
 * miejsc, w których konieczna jest cała sekwencja różnych
 * klauzul catch, niezbędnych do prawidłowego działania programu.
 */
public LinkStatus check(String urlString) {
    URL url;
    HttpURLConnection conn = null;
    HttpURLConnection.setFollowRedirects(false);
    try {
        url = new URL(urlString);
        conn = (HttpURLConnection) url.openConnection();
        switch (conn.getResponseCode()) {
        case 200:
            return new LinkStatus(true, urlString);
        case 403:
            return new LinkStatus(false,"403: " + urlString );
        case 404:
            return new LinkStatus(false,"404: " + urlString );
        }
        conn.getInputStream();
        return new LinkStatus(true, urlString);
    } catch (IllegalArgumentException | MalformedURLException e) {
        // JDK firmy Oracle zgłasza wyjątek IllegalArgumentException,
        // jeśli na podstawie adresu URL nie uda się określić
        // komputera docelowego
        return new LinkStatus(false,
                                "Nieprawidłowy adres URL: " + urlString);
    } catch (UnknownHostException e) {
        return new LinkStatus(false,
                   "Nieprawidłowy lub nieaktywny komputer: " + urlString);
    } catch (FileNotFoundException e) {
        return new LinkStatus(false, "NIE ODNALEZIONO (404) " + urlString);
    } catch (ConnectException e) {
        return new LinkStatus(false, "Serwer nie działa: " + urlString);
    } catch (SocketException e) {
        return new LinkStatus(false, e + ": " + urlString);
    } catch (IOException e) {
        return new LinkStatus(false, e.toString()); // Dołączamy URL.
    } catch (Exception e) {
        return new LinkStatus(false, "Nieoczekiwany wyjątek! " + e);
    } finally {
        if (conn != null) {
            conn.disconnect();
        }
    }
}

Oczywiście dostępne są bardziej wymyślne narzędzia do sprawdzania odnośników, jednak jak na moje potrzeby ten sprawdza się bardzo dobrze.



[46] Informacje o usługach są przechowywane w różnych miejscach. W systemach Unix może to być plik /etc/services, a w systemach MS Windows plik services zapisywany w katalogach \windows lub \winnt; informacje te mogą być także przechowywane w scentralizowanych rejestrach, takich jak Network Information Services firmy Sun (w skrócie NIS, wcześniej określana jako YP), lub innych miejscach zależnych od używanego systemu operacyjnego bądź sieci.

[47] A przynajmniej była uniwersalna w czasach, gdy większość komputerów podłączonych do sieci była administrowana przez wykwalifikowane osoby zatrudnione na cały etat lub wynajmowane w celu zarządzania komputerami. Obecnie na bardzo wielu komputerach podłączonych do internetu „localhost” nie jest prawidłowo skonfigurowany.

[48] W niektórych sieciach pakiety UDP będą musiały zostać podzielone na mniejsze fragmenty, jednak na poziomie UDP nie ma to większego znaczenia, gdyż na drugim końcu połączenia pakiet zostanie ponownie scalony.

[49] Jeśli aplikacja nie zwraca uwagi na numer używanego portu, to jest on zazwyczaj wyznaczany przez system operacyjny. Na przykład jeśli dzwonisz do firmy, to zazwyczaj nie interesuje jej numer telefonu stacjonarnego lub komórkowego, z którego dzwonisz, a jeśli informacja ta ma dla firmy znaczenie, to istnieją sposoby, aby ją zdobyć. Generowane numery portów zwykle należą do zakresu od 1024 (pierwszy numer portu, który nie jest uprzywilejowany; patrz Rozdział 16.) do 65 535 (największa wartość, jaką można zapisać w 16 bitach).

[50] Jednocześnie należy mieć świadomość potencjalnych zagrożeń bezpieczeństwa — nie powinno się uruchamiać serwera TFTP bez wcześniejszego przeczytania dobrej książki na temat zabezpieczania komputerów, takiej jak Building Internet Firewalls wydanej przez wydawnictwo O’Reilly.

[51] Wątek to (prawdopodobnie) jeden z wielu możliwych przepływów sterowania w obrębie danego procesu; patrz „24.1. Uruchamianie zewnętrznego programu”.

[52] Programem o dostępnym kodzie źródłowym, który pozwala na komunikowanie się z programami ICQ oraz AIM, jest Jabber (http://www.jabber.org/).